Popsicle Finance Sorbetto Fragola PLP Transfer Fee-Checkpoint Omission / Reward Double-Claim Drain (2021-08-04)

  • Name: Popsicle Finance Sorbetto Fragola PLP transfer fee-checkpoint omission / reward double-claim drain
  • Date: 2021-08-04
  • Protocol: Popsicle Finance Sorbetto Fragola (Uniswap V3 optimizer)
  • Chain: Ethereum
  • Category: reward-distribution bug / transferable receipt-token checkpoint omission / fee double-claim / flash-loan amplified accounting drain
  • Loss: about $20.7M, with public tallies also rounding the incident to about $20M-$25M
  • Summary: Popsicle’s Sorbetto Fragola vaults let PLP receipt tokens move between addresses without moving the fee-accounting checkpoints that were supposed to describe how much of the global fee accumulator each holder had already earned. collectFees() settled rewards from per-address values like token0PerSharePaid and token1PerSharePaid, but plain PLP transfers left those checkpoints stale. The attacker flash-loaned large balances, deposited into eight Fragola pools, transferred the same PLP position across helper contracts, and called collectFees() after each transfer. Each helper address was treated as if its newly received PLP had been present for the full prior fee-accrual window, so the same historical rewards were crystallized multiple times. The attacker then returned the PLP to the original address, withdrew the principal liquidity, repaid Aave, and kept the duplicated fee payouts.
  • Trigger / entry point:
    • Popsicle’s fee system tracked user reward state per address rather than binding it tightly to the transferable PLP balance itself.
    • collectFees() relied on the current PLP balance plus stored checkpoints such as token0PerSharePaid / token1PerSharePaid.
    • PLP transfer() / transferFrom() did not first settle or reset the receiver’s checkpoints to the current accumulator state.
    • That let a fresh helper contract receive real PLP and then claim historical fees it never economically earned.
  • Root cause:
    • Fragola used a global tokenPerShare-style fee accumulator and per-user paid checkpoints, but only some state-changing paths synchronized the user checkpoints correctly.
    • The decisive invariant failure was: historical fee entitlement did not move correctly with PLP ownership.
    • After a PLP transfer, the receiver inherited the balance but not the economically correct already-accounted-for checkpoint, so collectFees() treated the incoming balance as if it had been present since an older accumulator state.
    • Because rewards were stored in side mappings rather than tied inseparably to the live PLP holder, one helper could claim and keep rewards, pass the same PLP onward, and let the next helper claim again.
    • Certora later summarized the bug cleanly: Popsicle updated user accounting on deposit and withdrawal but not on token transfer, so transferring PLP could increase the parties’ total assets instead of merely moving them.
  • Exploit path:
    1. The attacker used Aave flash loans to fund a large multi-asset position, with public reconstructions citing roughly 30M USDT, 30M USDC, 3M DAI, 13k WETH, 1.4k WBTC, and 200k UNI across the full attack.
    2. They created three helper contracts / addresses, commonly reconstructed as A, B, and C.
    3. Using one pool at a time, A deposited large balances into a Sorbetto Fragola vault and received PLP shares.
    4. A transferred those PLP shares to B.
    5. B called collectFees(), and because B’s checkpoints did not reflect the transferred balance’s true prior accounting state, Popsicle credited B with fees that belonged to the whole position’s past accrual window.
    6. B transferred the same PLP shares to C, which repeated collectFees() and harvested the same historical fee window again under its own stale checkpoint state.
    7. The attacker transferred the PLP back to A, which withdrew the underlying principal liquidity from the vault.
    8. The cycle was repeated across eight Fragola pools, including a widely cited USDT/WETH example.
    9. After principal withdrawal and reward harvesting, the attacker repaid the flash loans, swapped much of the stolen inventory to ETH, and sent a large portion through Tornado Cash.
  • What the attacker needed to be true:
    • PLP had to be transferable between helper addresses.
    • Reward entitlement had to depend on per-address checkpoints rather than on non-transferable position state.
    • collectFees() had to trust those checkpoints after transfer without first resetting the receiver to the current accumulator state.
    • Claimed rewards had to remain with the helper address even after the PLP moved onward.
    • Vault fee inventory and underlying liquidity had to be deep enough that duplicated reward claims across several pools were economically meaningful.
  • Technical anatomy worth remembering:
    • This was not a price-oracle hack, AMM invariant break, or token-callback reentrancy. It was pure reward accounting failure on top of a transferable share token.
    • The exploit did not require stealing the underlying LP principal directly. The attacker used honest deposits and honest withdrawals, while the theft happened in the side-pocketed fee distribution math.
    • BlockSec described the issue as a double-claiming attack and preserved the core reward formula explicitly: user.token0Rewards += PLP.balanceOf(account) * (fee0PerShare - user.token0PerSharePaid) / 1e18 (with the same structure for token1). The dangerous detail was that token0Rewards / token1Rewards were stored per address and remained withdrawable even after the PLP moved away.
    • Certora’s later write-up sharpened the design lesson: every yield-bearing receipt token should satisfy the property that plain transfers cannot increase the combined economic value of the sender and receiver, and the concrete fix was to override transfer() and transferFrom() rather than inheriting raw ERC-20 behavior.
    • REKT’s practical exploit loop is worth preserving because it is so compact: deposit -> transfer -> collectFees -> transfer -> collectFees -> transfer back -> withdraw.
    • REKT’s USDT/WETH walkthrough also makes the value flow vivid: after depositing about 30M USDT and 5.467k WETH for roughly 10.51 PLP, helper B reportedly harvested about 2.15M USDT + 392 WETH and helper C about 2.15M USDT + 402 WETH before the principal liquidity was withdrawn and the loop moved on to the next pool.
  • Representative transactions and addresses:
    • Main exploit transaction: 0xcd7dae143a4c0223349c16237ce4cd7696b1638d116a72755231ede872ab70fc
    • Attacker address highlighted by REKT: 0xf9E3D08196F76f5078882d98941b71C0884BEa52
    • Publicly highlighted attack functions and accounting surfaces: collectFees(), updateVault, _fee0Earned, _fee1Earned, token0PerSharePaid, token1PerSharePaid, and PLP transfer() / transferFrom() behavior
    • Publicly cited example pool flow: the Sorbetto Fragola USDT/WETH pool, where a large PLP position was moved across helper addresses and claimed twice before principal withdrawal
  • Response / mitigation:
    • Popsicle locked deposits to affected pools and limited withdrawals to pools believed not to be impacted.
    • In the same-day postmortem, the team said roughly 85% of Sorbetto Fragola pool funds had been drained and identified the then-withdrawable unaffected set as AXS/ETH, YGG/USDC, LINK/ETH, and the EURt pools.
    • The team also offered the attacker a $1M whitehat-style return bounty.
    • The long-term fix is straightforward in retrospect: share transfers must settle or move reward checkpoints, not merely move balances.
    • Certora later used the incident as a case study for a generic invariant: token transfers should never increase the total assets of the participating parties.

Pattern tags

Why the exploit worked

  • Popsicle treated who holds PLP right now and who has already consumed the fee history attached to that PLP as separable stories. Deposits and withdrawals updated user-side checkpoints, but plain PLP transfers did not.
  • That meant the attacker never had to forge liquidity, fake fees, or break Uniswap V3 math. They only had to move one real PLP balance across fresh helper addresses whose token0PerSharePaid / token1PerSharePaid state still lagged the balance they had just inherited.
  • collectFees() then converted that stale checkpoint gap into real token payouts, and those payouts stayed behind in the helper even after the PLP moved onward. One economic fee window therefore became several sequentially collectible fee windows.
  • The flash loans only amplified a transfer-accounting bug that was already balance-sheet fatal. The real control failure was letting inherited ERC-20 mobility outrank the protocol’s own rule for who had already been paid.

Detection and audit pivots

  • Replay the exact economic loop deposit -> transfer to fresh address -> collectFees -> transfer again -> collectFees -> transfer back -> withdraw and assert that aggregate claimable + withdrawable value across all helpers never exceeds what one honest holder could realize.
  • Diff sender-plus-receiver total assets before and after a pure PLP transfer. If the combined economic value rises without new swap fees entering the vault, the transfer path is already a mint surface.
  • Trace every balance-movement path, not only the happy-path transfer() call: transferFrom(), permit-funded pulls, migration helpers, liquidation flows, rescue paths, or any wrapper that can move reward-bearing shares must settle the same checkpoints.
  • Check whether a receiver of old shares is initialized to the live accumulator state for those incoming shares or instead inherits zero / stale fee-debt state that can be cashed out immediately.
  • Treat reward-bearing ERC-20 compatibility claims skeptically. If deposit and withdraw have custom accounting but inherited token verbs still move the same economic entitlement, reviewers should assume entrypoint drift until transfer invariants are proven explicitly.

Failure-prevention checklist

  • Settle or remap fee checkpoints on every share-movement path, not only on deposit and withdrawal. If PLP is transferable, then transfer() and transferFrom() must either first realize accrued fees for sender and receiver or move the historical-fee baseline with the shares so the incoming balance starts from the live accumulator state.
  • Treat share balance and already-paid fee history as one accounting object. If the protocol cannot keep them fused under ordinary ERC-20 movement, make the receipt token non-transferable or wrap transfers in protocol-owned migration logic rather than inheriting generic token verbs blindly.
  • Prove the Certora-style balance-sheet invariant directly: a pure transfer of reward-bearing shares must not increase the combined assets of sender and receiver when no new fees entered the vault.
  • Reuse one checkpoint-update primitive across mint, burn, transfer, transferFrom(), permit-assisted pulls, migration helpers, liquidations, rescue paths, and any wrapper that can move yield-bearing shares. Transfer-time omissions are usually entrypoint-drift bugs, not math bugs.
  • Initialize receivers of transferred shares to the current accumulator for the incoming balance unless the protocol explicitly and atomically transfers the sender’s unclaimed fee state along with the balance.
  • Keep claimed fees, pending fees, and withdrawable principal economically coherent under multi-address hop tests such as deposit -> transfer -> claim -> transfer -> claim -> transfer back -> withdraw; the total outcome should match one honest holder’s outcome, not exceed it.
  • Stress test with flash-loan-sized positions, because reward-checkpoint bugs that look tiny at ordinary size become treasury-scale once one transaction can transiently own most of the vault.
  • Add monitoring for repeated high-value fee claims following recent share transfers; in this family, fresh holder claims old history immediately after receiving shares is the onchain smell.

Audit prompts

  • When reward-bearing PLP moves from A to B, what exact state transition ensures B cannot immediately claim fee history that economically accrued before B owned the shares?
  • Do transfer() and transferFrom() override inherited ERC-20 behavior to synchronize fee checkpoints, or is the protocol assuming token mobility is harmless because the custom accounting lives elsewhere?
  • If two collaborators alternate custody of the same shares, does the sum of claimable + already claimed + withdrawable principal across both addresses stay invariant absent new vault fees?
  • Are sender and receiver both checkpointed against the same live accumulator on transfer, or can one side keep old rewards while the other side reopens the same historical fee window?
  • Does any helper path — migrations, zaps, vault wrappers, liquidations, rescue functions, permit-funded pulls — move PLP without invoking the same checkpoint-settlement logic as the ordinary transfer path?
  • Can a receiver with stale or zero token0PerSharePaid / token1PerSharePaid state collect against a balance that was minted under a later accumulator value?
  • If the protocol claims to support transferable yield-bearing shares, where is the explicit proof that plain balance movement cannot mint extra economic value for the participating parties?
  • Would a flash loan merely scale the theft, or is there some size threshold above which transfer-time checkpoint drift first becomes economically meaningful? If scale is the only variable, the bug is already fatal.

Lessons that should already be standard

  • A yield-bearing ERC-20 is not an ordinary ERC-20 with a side spreadsheet. Once fees, rewards, or rebasing rights ride on the balance, transfer semantics become part of the money-good accounting boundary.
  • Historical accrual must either travel with the shares or be settled away before the shares move. Letting balances move while the fee baseline stays behind is just a quiet duplicate-claim rail.
  • Deposit and withdraw correctness is not enough. Many of the most expensive accounting bugs live in inherited standard entrypoints that teams treated as plumbing rather than as custody logic.
  • The right transfer invariant is economic, not cosmetic: sender plus receiver should not become richer because a token moved.
  • Collaborator-hop attacks are often enough. If moving shares across fresh addresses reopens old fee windows, the protocol does not need reentrancy or oracle manipulation to lose treasury-scale value.
  • Flash loans are rarely the real bug in this family. They only let the attacker temporarily own enough of the receipt-token supply for a transfer-accounting mistake to matter immediately.
  • Reward-bearing share systems should be reviewed as if balance, historical entitlement, and claim-consumed state were one security object, because economically they are.

Why this incident matters

  • Popsicle preserves a very clean transferable receipt token + stale reward checkpoint = repeated historical accrual exploit family.
  • It is especially useful because the core bug is small and easy to miss: the dangerous path was not deposit, withdraw, or claim in isolation, but the missing accounting logic inside ordinary token transfer behavior.
  • The incident is also a reminder that a yield-bearing ERC-20 is not an ordinary ERC-20. Once fee entitlement rides on the token balance, inherited transfer behavior becomes part of the protocol’s solvency boundary.

Internal linkages

Control surface