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 tokenAmountIn it 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 public gulp(STA) function to sync the recorded reserve to the tiny real balance. That made STA’s internal spot price explode. The attacker then repeatedly swapped 1 weiSTA back 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:
    1. The attacker borrowed 104,331 WETH from dYdX in transaction 0x013be97768b702fe8eccef1a40544d5ecb3c1961ad5f87fee4d16fdc08c78106 and targeted the primary STA pool at 0x0e511aa1a137aad267dfe3a6bfca0b856c1a3682.
    2. Using repeated swapExactAmountIn() calls at or near Balancer’s MAX_IN_RATIO, the attacker cycled WETH into STA roughly two dozen times to push the pool’s real STA balance down to about 1 wei.
    3. 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.
    4. The attacker then called gulp(STA), forcing Balancer to reset its recorded STA reserve to the actual dust balance. With STA now recorded at roughly 1 wei, Balancer’s spot price math treated STA as extraordinarily expensive relative to WETH and the other pool assets.
    5. Next the attacker swapped 1 weiSTA back 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.
    6. 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.
    7. 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.
    8. After repaying the flash loan, the attacker converted stolen STA through Uniswap and sent proceeds to collection address 0xbf675c80540111a310b06e1482f9127ef4e7469a.
  • 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 tokenAmountIn as 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 integration is 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 STA should not have been treated as that 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

  1. 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 nominal tokenAmountIn, and eventually dust-sized inputs could burn away entirely.
    • That only proved a transfer attempt happened and some live balance effect occurred. It did not yet mean Balancer had actually adopted that much fresh STA as reserve backing for pricing.
  2. Balancer promoted nominal or dust-state STA into canonical reserve truth

    • First, the pool’s internal _records[STA].balance advanced 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, and live price-setting STA inventory into one mutable story.
  3. 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 weiSTA could 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.

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 tokenAmountIn path even though STA’s transfer semantics guaranteed the contract often received less.
  • The protocol never forced a stricter actual balance delta receipt 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 weiSTA looked 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 happened into recognized reserve backing exists. If the answer is effectively transfer was attempted or public 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 tokenAmountIn or 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_RATIO loops, 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 amount into recognized 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 tokens is 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

Control surface