Balancer STA Deflationary-Token / Gulp Pool Drain (2020-06-28)
- Name: Balancer STA deflationary-token / gulp pool drain
- Date: 2020-06-28
- Protocol: Balancer v1
- Chain: Ethereum
- Category: AMM reserve-accounting mismatch / fee-on-transfer token integration failure / flash-loan-assisted pool drain
- Loss: more than $500,000 across two Balancer pools containing STA and STONK; the primary STA pool lost WETH, WBTC, LINK, and SNX reserves
- Summary: The attacker used a dYdX flash loan to trade against a Balancer pool that held STA, a fee-on-transfer token that burned 1% on each transfer. Balancer’s v1 pool logic updated internal reserve records from the nominal
tokenAmountInit expected to receive, while STA delivered less than that amount. After repeatedly cycling WETH into STA until the pool’s real STA balance was driven down to dust, the attacker called Balancer’s publicgulp(STA)function to sync the recorded reserve to the tiny real balance. That made STA’s internal spot price explode. The attacker then repeatedly swapped1 weiSTAback out for WETH and other assets; because the full dust-sized STA input was burned away, the pool effectively received no STA while still paying out valuable tokens priced against an almost-zero STA reserve. - Trigger / entry point:
BPool::swapExactAmountIn()updated internal balance records for the input token and assumed_pullUnderlying()would collect the full amount.- STA and STONK were non-standard fee-on-transfer tokens, so the pool’s real token balance decayed relative to Balancer’s internal
_records[token].balance. BPool::gulp(token)was publicly callable and let the attacker choose when Balancer would resync internal accounting to the contract’s actual token balance.
- Root cause:
- Balancer v1 was designed around vanilla ERC-20 semantics where the nominal transfer amount is also the amount received.
- The pool therefore priced swaps from cached reserve records that could become false when a token burned part of every transfer.
gulp()was not the original source of the mismatch, but it let the attacker crystallize the mismatch into Balancer’s price model at attacker-chosen times.- Once the pool’s actual STA balance was near zero, even dust-sized STA inputs could be priced as if they were extraordinarily valuable relative to the other pool assets.
- Exploit path:
- The attacker borrowed 104,331 WETH from dYdX in transaction
0x013be97768b702fe8eccef1a40544d5ecb3c1961ad5f87fee4d16fdc08c78106and targeted the primary STA pool at0x0e511aa1a137aad267dfe3a6bfca0b856c1a3682. - Using repeated
swapExactAmountIn()calls at or near Balancer’sMAX_IN_RATIO, the attacker cycled WETH into STA roughly two dozen times to push the pool’s real STA balance down to about1 wei. - Because STA burned 1% on each transfer, Balancer’s internal reserve tracking and the token balance actually sitting in the pool diverged as the attack progressed.
- The attacker then called
gulp(STA), forcing Balancer to reset its recorded STA reserve to the actual dust balance. With STA now recorded at roughly1 wei, Balancer’s spot price math treated STA as extraordinarily expensive relative to WETH and the other pool assets. - Next the attacker swapped
1 weiSTAback into the pool for WETH. On a token this small, STA’s transfer fee burned the entire input, so Balancer effectively received zero additional STA while still paying out WETH against the distorted reserve ratio. - Before each subsequent STA-to-asset swap, the attacker called
gulp(STA)again so the pool kept re-anchoring its accounting to the near-zero actual STA balance rather than allowing the internal reserve to rise. - The same pattern was repeated to drain WETH first, then WBTC, SNX, and LINK from the pool. A second exploit transaction minutes later used the same mechanics against a STONK-containing pool:
0xeb008786a7d230180dbd890c76d6a7735430e836d55729a3ff6e22e254121192. - After repaying the flash loan, the attacker converted stolen STA through Uniswap and sent proceeds to collection address
0xbf675c80540111a310b06e1482f9127ef4e7469a.
- The attacker borrowed 104,331 WETH from dYdX in transaction
- What the attacker needed to be true:
- The pool had to admit a non-standard fee-on-transfer token directly rather than behind a wrapper or allowlist gate.
- Balancer’s internal reserve accounting had to trust nominal transfer amounts instead of actual balance deltas.
gulp()or an equivalent reserve-sync path had to remain callable at attacker-controlled times.- The pool needed enough high-value assets on the other side of STA for the distorted price to be monetizable.
- Flash-loaned capital had to be sufficient to push the fee-charging token reserve into a dust state within one transaction.
- Technical anatomy worth remembering:
- This was not fundamentally a flash-loan bug. The flash loan only made the reserve-depletion loop fast and capital-efficient.
- The decisive invariant failure was simple: Balancer treated
tokenAmountInas received inventory even when STA’s transfer semantics guaranteed the pool received less. gulp()mattered because it turned a latent accounting gap into an immediately actionable pricing distortion.- Dust-state transfers are especially dangerous with fee-on-transfer tokens because the whole nominal input can disappear to fees or burns while protocol math still credits it.
Key transaction hashes
- Flash-loan-funded primary exploit transaction:
0x013be97768b702fe8eccef1a40544d5ecb3c1961ad5f87fee4d16fdc08c78106 - Follow-on STONK-pool exploit transaction:
0xeb008786a7d230180dbd890c76d6a7735430e836d55729a3ff6e22e254121192
Key contracts and addresses
- Primary STA pool:
0x0e511aa1a137aad267dfe3a6bfca0b856c1a3682 - Attacker execution address reported by CertiK:
0x81D73c55458f024CDC82BbF27468A2dEAA631407 - Attacker collection address:
0xbf675c80540111a310b06e1482f9127ef4e7469a
Response / mitigation
- Balancer publicly stated that the protocol was designed for compliant ERC-20s and reiterated prior warnings about tokens with transfer fees.
- The team moved to blacklist transfer-fee tokens in the UI and added more explicit documentation around token-behavior risk.
- The incident became a standard reference point for why permissionless token admission does not remove the need for hard assumptions about token semantics in accounting code.
Why this incident matters
- It is the clean canonical example of an AMM being drained because internal reserve accounting trusted a non-standard token’s nominal transfer amount.
- It shows that housekeeping functions like
gulp()can become exploit multipliers when they interact with already-broken accounting. - It is also a durable reminder that
token integrationis part of a protocol’s adversarial surface, not a neutral compatibility detail. - The sharper preserved healthy-side boundary is that
Balancer tried to transfer this much STAshould not have been treated asthat much reserve backing now exists for swap pricing and LP claims. Balancer needed one stronger reserve-adoption boundary between nominal input, actual receipt, and publishable reserve truth.
Three reserve-truth promotions this incident collapsed
-
A token transfer path reported a STA-shaped input event
- Repeated
swapExactAmountIn()calls made Balancer believe STA was flowing through the pool, but STA’s 1% recipient-side burn meant the pool often received less than the nominaltokenAmountIn, and eventually dust-sized inputs could burn away entirely. - That only proved
a transfer attempt happenedandsome live balance effect occurred. It did not yet mean Balancer had actually adopted that much fresh STA as reserve backing for pricing.
- Repeated
-
Balancer promoted nominal or dust-state STA into canonical reserve truth
- First, the pool’s internal
_records[STA].balanceadvanced from the nominal amount it expected to receive rather than from the actual post-transfer balance delta. - Later,
gulp(STA)let the attacker republish the real near-zero STA balance as the new canonical reserve at exactly the moment most favorable to price distortion. - Those are two sides of the same broken promotion boundary: Balancer collapsed
transfer-shaped event,recognized reserve backing, andlive price-setting STA inventoryinto one mutable story.
- First, the pool’s internal
-
That published reserve story still inherited immediate payout authority over the rest of the pool
- Once the pool believed STA reserves were effectively dust,
1 weiSTAcould be treated as extraordinarily valuable against WETH, WBTC, SNX, and LINK. - The fatal step was not merely the bad quote. The rest of Balancer’s swap paths still honored that reserve story as money-good and released real assets even when the pool had received zero economically meaningful new STA.
- Once the pool believed STA reserves were effectively dust,
The durable lesson is that a swap tried to send STA, this much STA now counts as recognized reserve backing, and that recognized reserve may already price withdrawals from the rest of the pool are three different promotions. Balancer let the attacker control all three.
Pattern tags:
- Nominal-input versus actual-receipt drift: Balancer priced against the amount it expected to receive, not the amount the fee-on-transfer token actually delivered.
- Attacker-timed reserve publication:
gulp()let the attacker choose exactly when the dust-sized real STA balance would become canonical pricing truth. - Dust-state null-input payout: once transfers got small enough that STA’s fee burned the whole input, Balancer could still pay out valuable assets against effectively zero newly received STA.
- Design companions: fee-on-transfer-accounting-mismatch, reserve-backed-claim-truth-security-object, and sync-harvest-and-cache-write-authority-discipline.
Why the exploit worked
- Balancer’s pool math credited incoming STA from the nominal
tokenAmountInpath even though STA’s transfer semantics guaranteed the contract often received less. - The protocol never forced a stricter
actual balance deltareceipt check before that nominal amount became reserve truth inside swap pricing. - Repeated WETH→STA swaps let the internal STA reserve record and the real STA balance drift farther apart until the pool’s true inventory was near dust.
gulp()then let the attacker collapse the stale reserve record onto that dust-sized real balance at the exact moment they wanted Balancer’s price model to trust it.- Once the pool believed STA reserves were effectively zero, even
1 weiSTAlooked enormously valuable. Because the fee-on-transfer semantics could burn the entire dust-sized input, Balancer could pay out WETH, WBTC, SNX, and LINK against reserve fiction rather than newly received inventory.
Detection and audit pivots
- Compare internal reserve updates with the contract’s post-transfer balance delta for every supported token. If pricing or LP accounting can advance on nominal transfer amounts alone, the pool is too trusting.
- Treat sync helpers like
gulp(),skim(),sync(), or reserve-refresh writes as solvency surfaces, not housekeeping. Ask who can call them and what attacker-shaped state they are allowed to publish. - Fuzz dust-state fee-on-transfer behavior, including cases where the nominal input rounds to a nonzero amount but the actual received balance delta is zero.
- Ask what exact event turns
token movement happenedintorecognized reserve backing exists. If the answer is effectivelytransfer was attemptedorpublic sync ran, reserve adoption is too weak. - Keep asset-semantics admission explicit. Tokens that burn on transfer, rebate on transfer, tax recipients, or otherwise violate vanilla ERC-20 receipt assumptions should not sit directly inside reserve math that expects conservation.
Failure-prevention checklist
- Measure reserve adoption from actual post-transfer balance deltas, not from the nominal
tokenAmountInor other caller-supplied amount fields. - Keep token-semantics admission explicit at pool creation and listing time. If pool math assumes vanilla ERC-20 conservation, fee-on-transfer and deflationary assets should be rejected or isolated behind wrappers rather than admitted directly.
- Treat reserve-refresh helpers such as
gulp()as security-sensitive publication gates. If they remain callable, they should not be able to canonize attacker-shaped dust-state balances into immediate pricing truth. - Fuzz repeated near-
MAX_IN_RATIOloops, fee-on-transfer burns, and dust-state rounding until the smallest nonzero input sometimes produces zero actual receipt. Reserve math should still fail closed there. - Test that spot pricing, LP accounting, and redemption logic never pay out against assets the pool did not actually receive, even after many sequential swaps and syncs.
- Add policy or invariant checks around abnormal reserve decay so a pool cannot keep quoting normally after a transfer-semantic mismatch has already pushed one side toward dust.
- Prefer route-local pause, asset disablement, or sync restrictions that can stop one semantically broken token from poisoning the whole pool’s pricing surface.
Audit prompts
- Which exact line or helper turns
requested transfer amountintorecognized reserve balance, and where is the real post-transfer balance delta compared before pricing advances? - If a token burns, taxes, or redirects part of the transfer, can internal reserves, LP shares, or swap quotes still advance as if the full nominal amount arrived?
- Can a public
gulp(),sync(),skim(), or reserve-refresh path be called at the most adversarial moment to publish a manipulated live balance as canonical price-setting truth? - What happens when the token balance is driven near dust and the next nominal input rounds to a nonzero amount while the actual receipt is zero?
- Does the pool have any explicit token-semantics denylist, wrapper requirement, or invariant monitor for fee-on-transfer / rebasing / non-conserving assets, or is compatibility assumed by default?
- If one reserve goes sharply out of line with recent swap flow, what concrete control stops the pool from continuing to quote and pay out against that broken accounting state?
- Could the same accounting mismatch leak into LP share minting, exit math, or downstream oracle integrations even if the obvious swap path were patched?
Lessons that should already be standard
- Pool accounting should trust custody reality, not transfer intent.
We asked for this many tokensis not reserve backing. - Permissionless pool creation does not remove semantic assumptions. If the AMM math expects conservative ERC-20 transfers, unsupported token behavior is a balance-sheet risk, not just a UX footnote.
- Sync helpers are not harmless housekeeping when they decide when latent accounting drift becomes live price truth.
- Dust-state behavior deserves explicit adversarial review. Many integrations only become catastrophically wrong when repeated burns and rounding push a reserve near zero.
- The exploit did not require a new pricing formula or exotic oracle bug. Ordinary pool math became dangerous once reserve publication was allowed to outrank actual receipt measurement.
- Token admission, reserve publication, and payout authority are one joined security surface. Treating them as separate implementation details is how a weird token becomes a pool-wide drain.
Internal linkages
- Core recurring pattern: fee-on-transfer-accounting-mismatch.
- Positive-design companion: reserve-backed-claim-truth-security-object — the healthier question is what exact reserve-backed object Balancer swaps and LP claims were supposed to trust after each token transfer, and when nominal input should have become recognized reserve backing at all.
- Asset-admission lens: interface-conformance-semantic-compatibility-failure — the deeper integration mistake was letting a fee-on-transfer asset sit directly inside pool math written for vanilla ERC-20 reserve semantics.
- Publication-authority pattern: state-publication-authority-failure — the drain only became live once
gulp()blessed Balancer’s attacker-shaped dust-state reserve reality as canonical pricing truth. - Publication-authority lens: sync-harvest-and-cache-write-authority-discipline —
gulp()mattered because Balancer treated a public reserve-refresh helper as harmless bookkeeping even though it let the attacker choose exactly when dust-state reality became live pricing truth. - Reserve-spend lens: internal-mark-publication-quarantine-and-reserve-spend-separation — the deeper healthy-side question is how Balancer should have kept
dust-state STA balance is now visibleseparate fromthat dust-state reserve may already set live swap prices that pay out WETH, WBTC, SNX, and LINK. - Adjacent comparison: spartan-protocol-current-balance-liquidity-share-inflation-drain-2021-05-01 — both incidents let real balances and economically recognized reserves drift apart, but Balancer mismeasured what arrived from a fee-on-transfer asset while Spartan misattributed ambient balances as already-recognized LP backing.
- Adjacent pattern: oracle-manipulation — once Balancer’s reserve accounting was false, its own spot-price logic behaved like a self-poisoned oracle.
Control surface
-
The decisive leverage sat in Balancer v1’s internal reserve bookkeeping, permissionless admission of fee-on-transfer assets, and the public
gulp()sync path that let the attacker decide exactly when false accounting would become the active pricing truth. -
The deeper control surface was reserve adoption itself: Balancer lacked one canonical step that said
this much STA actually arrived, and only this much may now become price-setting reserve backing. -
In other words,
gulp()was a reserve-publication surface, not neutral housekeeping: it let the attacker collapse Balancer’s stale internal reserve record to a dust-sized real balance at the exact block and sequence point most favorable for draining the other assets. -
The protocol did not merely
support weird tokens. It priced and paid out against reserves it never truly received. -
Sources:
- https://medium.com/balancer-protocol/incident-with-non-standard-erc20-deflationary-tokens-95a0f6d46dea
- https://medium.com/1inch-network/balancer-hack-2020-a8f7131c980e
- https://diligence.security/blog/2020/11/token-interaction-checklist/
- https://www.certik.com/blog/little-pains-great-gains-balancer-defi-contract-was-drained
- https://etherscan.io/tx/0x013be97768b702fe8eccef1a40544d5ecb3c1961ad5f87fee4d16fdc08c78106
- https://etherscan.io/tx/0xeb008786a7d230180dbd890c76d6a7735430e836d55729a3ff6e22e254121192
- https://github.com/balancer-labs/balancer-core/blob/140df49361a58e6c79b395964be98387702a7c0d/contracts/BPool.sol#L334-L340
- https://github.com/balancer-labs/balancer-core/blob/140df49361a58e6c79b395964be98387702a7c0d/contracts/BPool.sol#L423-L470
-
Last reviewed: 2026-08-03 UTC