OCAToken SwapHelper Recycle Drains PancakeSwap OCA/USDC Pair
Exploit Transactions
0xcd5979352d9b42ccb7780d5344fac08d1d46591a592ab284a588e2156cf44906Victim Addresses
0x5779bf44cd518b05651ae38fcc066247cce21504BSCLoss Breakdown
Similar Incidents
BIGFI Burn Bug Drains PancakeSwap
36%SOF Sell-Hook Reserve Manipulation Drains PancakeSwap V2 USDT Liquidity
36%Public Mint Drains USDT Pair
35%AI IPC destroy-sync mechanism drains IPC-USDT pair USDT reserves
34%CS Pair Balance Burn Drain
34%OceanLife Reflection Drain on PancakeSwap
33%Root Cause Analysis
OCAToken SwapHelper Recycle Drains PancakeSwap OCA/USDC Pair
1. Incident Overview TL;DR
On BNB Chain (chainid 56) in block 81020478, transaction 0xcd5979352d9b42ccb7780d5344fac08d1d46591a592ab284a588e2156cf44906 drained USDC from the PancakeSwap V2-style OCA/USDC pair 0x5779bf44cd518b05651ae38fcc066247cce21504 by combining a large USDC flashloan with a permissionless SwapHelper entrypoint that repeatedly (1) swapped OCA into USDC and (2) reclaimed the "sold" OCA back out of the pair via OCAToken.recycle(...), forcing the pair to sync() manipulated reserves.
The transaction resulted in a net 422,645.205932542647363708 USDC transfer to the adversary EOA 0xdddfb3d6fa42e66cf78efa21166b8ef2d26c1ba5 (USDC Transfer logIndex 0x44 in the receipt), while the pair's USDC balance dropped by the same amount (balance diff). The sender also paid 42.894013882404242882 BNB in the same transaction (native balance delta), including a direct transfer of 42.891523771404242882 BNB to 0x4848489f0b2bedd788c696e2d79b6b69d7484848.
Using PancakeSwap's USDC/WBNB pool reserves at block 81020478 as a price proxy (~617.648717866481 USDC per BNB), the 42.894013882404242882 BNB paid corresponds to ~26,493.432678614017 USDC. Under that approximation, the net profit is ~396,151.773253928630 USDC-equivalent (see artifacts/auditor/iter_0/bnb_usdc_price_at_B.json).
The root cause is a design flaw in OCAToken (0xe0dafd4592205067299a6ae269f68aa804f95419): it exposes a recycle(address to, uint256 amount) primitive callable by a configured SwapHelper that can transfer OCA directly out of the AMM pair and then call pair.sync(). Because SwapHelper (0xe0d5ec0f754c442f37fbdf18266053309d5f6f55) exposes an externally callable path (selector 0x9c1dad28 observed in the trace), any searcher can use it to violate the AMM reserve accounting assumptions and drain the pair.
2. Key Background
PancakeSwap V2 pairs are UniswapV2-style constant product pools that maintain internal "reserves" and rely on a key accounting assumption: pool balances only change through the pair's swap/mint/burn flow (or, if balances change externally, the pool is corrected via skim/sync). sync() updates the stored reserves to match the pair's current token balances. If an external actor can arbitrarily change a pair's balance of one side and force sync(), the pair's price/reserve state can be set to an artificial value that subsequent swaps will use.
OCAToken is an ERC-20-like token with custom transfer logic, including:
- A "100% buy tax" path when the pair transfers OCA to a user (pair -> user), unless the transfer is classified as "remove liquidity".
- A remove-liquidity heuristic
_isRemoveLiquidity()that treats a pair -> user transfer as liquidity removal when both token balances are<=their reserves. - A special
swapHelperaddress configured by the token owner, intended to run a "deflationary/recycle" mechanism.
The incident exploit relies primarily on the recycle() mechanism. A secondary enabling factor is _isRemoveLiquidity() misclassifying flash-swap-ordered transfers as liquidity removal, which can bypass the intended 100% buy tax and allow acquisition of OCA at scale.
3. Vulnerability Analysis & Root Cause Summary
- Root cause category:
ATTACK. - Core invariant violated (economic/accounting): a UniswapV2-style pool should not allow external actors to remove one reserve asset (here OCA) and then finalize that change into reserves via
sync()in a way that makes swaps pay out the other reserve asset (here USDC) without receiving the corresponding input value.
In OCAToken, recycle(address to,uint256 amount) is callable by swapHelper and can transfer OCA from the AMM pair to an arbitrary recipient, immediately followed by pair.sync(). This gives SwapHelper a privileged capability over LP-owned assets in the pair and breaks the AMM's reserve accounting assumptions. During the incident, an attacker used a USDC flashloan to acquire OCA and then repeatedly called SwapHelper (selector 0x9c1dad28) to swap OCA for USDC while reclaiming the swapped OCA back out of the pair via recycle(). Receipt logs show multiple PairRecovered(to=swapHelper) events and repeated Sync events emitted by the pair, indicating that reserves were forcibly updated after OCA was moved out of the pair. This loop drained USDC from the pool, ultimately transferring 422,645.2059 USDC to the attacker EOA.
Separately, OCAToken._isRemoveLiquidity() uses a balance-vs-reserve heuristic that can be satisfied during flash-swap ordering (token out happens before token in), allowing the attacker to receive OCA from the pair without triggering the intended 100% buy tax.
4. Detailed Root Cause Analysis
4.1 Key On-Chain Components
- OCAToken (OCA):
0xe0dafd4592205067299a6ae269f68aa804f95419 - USDC (BSC):
0x8ac76a51cc950d9822d68b83fe1ad97b32cd580d - PancakeSwapV2 Pair (OCA/USDC):
0x5779bf44cd518b05651ae38fcc066247cce21504 - SwapHelper:
0xe0d5ec0f754c442f37fbdf18266053309d5f6f55 - PancakeSwap Router:
0x10ed43c718714eb63d5aa57b78b54704e256024e - Flashloan provider (USDC lender):
0x8f73b65b4caaf64fba2af91cc5d4a2a1318e5d8c - Seed exploit tx:
0xcd5979352d9b42ccb7780d5344fac08d1d46591a592ab284a588e2156cf44906
Adversary-related accounts observed in the incident transaction:
- Profit recipient / tx sender EOA:
0xdddfb3d6fa42e66cf78efa21166b8ef2d26c1ba5 - In-tx orchestrator contract:
0xa297a53b5554f4feba4077f4cb13da220387deaa - Tx
tocontract (deployed/used by attacker):0xfe43ac1924e64b33135a7756e3f78bf4710dd458
4.2 Code-Level Breakpoint: OCAToken.recycle() Transfers From the Pair and Calls sync()
The victim token contract implements a privileged recycle(...) that can remove OCA directly from the LP pair and then update reserves:
function recycle(address to, uint256 amount) external {
require(msg.sender == swapHelper, "Only SwapHelper");
require(to != address(0), "Cannot recover to zero address");
require(amount > 0, "Amount must be greater than zero");
require(balanceOf(uniswapV2Pair) >= amount, "Insufficient pair balance");
super._transfer(uniswapV2Pair, to, amount);
// Sync Pair reserves
IUniswapV2Pair(uniswapV2Pair).sync();
emit PairRecovered(to, amount);
}
Source: artifacts/collector/seed/56/0xe0dafd4592205067299a6ae269f68aa804f95419/src/Contract.sol
This is the decisive breakpoint for the exploit:
- It allows
swapHelperto move OCA out of the pair without going throughpair.swap()accounting. - It then finalizes the changed balances into stored reserves by calling
pair.sync().
In the incident, receipt logs show PairRecovered(to=swapHelper) emitted multiple times (logIndex 0x10, 0x22, 0x34), and the call trace shows calls into OCAToken with selector 0x5d36d182 (the selector of recycle(address,uint256)) originating from the SwapHelper address. This ties the on-chain execution directly to the code-level mechanism above.
4.3 How SwapHelper Enables a Swap + Recycle Drain Loop
The SwapHelper contract at 0xe0d5ec0f754c442f37fbdf18266053309d5f6f55 is invoked by the attacker's contract using selector 0x9c1dad28 (observed repeatedly in the call trace). In each round, the observed on-chain pattern is:
- SwapHelper causes OCA to be sold for USDC through PancakeSwap Router flows (pair emits
Swapevents; USDC is transferred out of the pair). - SwapHelper then calls
OCAToken.recycle(...)to transfer (reclaim) OCA out of the pair to SwapHelper and forcesync().
This means the pair pays out USDC for a sale, but does not retain the OCA that should have remained in the pair as "payment" for that USDC. Instead, the sold OCA is pulled back out of the pair by recycle() and the manipulated balances are locked into reserves via sync().
Receipt evidence for the recycle() side-effects (first round shown):
- OCA
Transferfrom the pair to SwapHelper at logIndex0x0e(OCATransfertopic0xddf252ad..., from=0x5779...to=0xe0d5...). - Pair
Syncevents (pair log topic0x1c411e9a...) around the same region. - OCAToken
PairRecovered(to=swapHelper, amount=...)at logIndex0x10.
Call trace evidence for the root-cause call:
- Multiple internal calls into OCAToken with selector prefix
0x5d36d182(recycle).
Together, these show the mechanism is not "just trading": it relies on a privileged post-swap reserve mutation that is not part of the AMM's standard swap accounting.
4.4 Secondary Enabler: _isRemoveLiquidity() Misclassifies Flash Swap Ordering and Bypasses the 100% Buy Tax
The token's transfer logic classifies pair -> user transfers as "remove liquidity" when both token balances are <= reserves:
function _isRemoveLiquidity() private view returns (bool) {
(uint112 reserve0, uint112 reserve1,) = IUniswapV2Pair(uniswapV2Pair).getReserves();
address token0 = IUniswapV2Pair(uniswapV2Pair).token0();
address token1 = IUniswapV2Pair(uniswapV2Pair).token1();
uint256 balance0 = IERC20(token0).balanceOf(uniswapV2Pair);
uint256 balance1 = IERC20(token1).balanceOf(uniswapV2Pair);
return balance0 <= reserve0 && balance1 <= reserve1;
}
Source: artifacts/collector/seed/56/0xe0dafd4592205067299a6ae269f68aa804f95419/src/Contract.sol
And the pair -> user transfer path uses it to decide whether to apply the 100% buy tax:
- If
_isRemoveLiquidity()is true, it performs a normal transfer (no buy tax). - Otherwise, it executes
_handleBuy(...)for the 100% tax path.
In a flash-swap-ordered flow, the pair transfers the output token (here OCA) before receiving the input token (USDC). At that moment, both balances can be <= their reserves: the output balance decreased and the input balance has not yet increased, satisfying the heuristic and incorrectly treating the transfer as liquidity removal. The receipt ordering supports this pattern: OCA transfers from the pair to the attacker contract are followed immediately by USDC transfers from the attacker contract back into the pair in multiple rounds.
This bypass is not the primary drain primitive (that is recycle()+sync()), but it helps the attacker acquire large OCA amounts in-tx despite the token's buy-tax design.
4.5 Deterministic Profit and Pool Depletion
The impact is directly measurable from state diffs and receipt logs:
- Pair USDC delta:
-422645205932542647363708(before427369506331114918872080, after4724300398572271508372) for holder0x5779...(balance diff). - Attacker EOA USDC delta:
+422645205932542647363708for holder0xdddf...(balance diff). - Receipt shows the final USDC transfer to the EOA at logIndex
0x44for exactly422645205932542647363708raw units.
The transaction also has a large native (BNB) cost:
- Sender native delta:
-42894013882404242882wei (-42.894013882404242882BNB). - Counterparty native delta:
+42891523771404242882wei (+42.891523771404242882BNB) to0x4848489f0b2bedd788c696e2d79b6b69d7484848.
5. Adversary Flow Analysis
This incident is realized in a single adversary-crafted transaction.
Stage 1: Temporary capital via USDC flashloan:
- The flashloan provider
0x8f73...transfers8,704,860.148366532708908952USDC to the attacker contract, and the same amount is repaid later in-tx (receipt logs; call trace).
Stage 2: Acquire OCA via flash swap ordering to bypass buy tax:
- The attacker contract triggers pair operations that cause the pair to transfer OCA out first, then receive USDC input second, satisfying
_isRemoveLiquidity()and avoiding the 100% buy tax path.
Stage 3: Drain loop using SwapHelper + recycle():
- The attacker contract repeatedly calls SwapHelper (selector
0x9c1dad28) with varying amount arguments. - Each round causes USDC to be paid out from the pair due to OCA sales, then
recycle()reclaims OCA back out of the pair to SwapHelper and callssync(), cementing manipulated reserves. - Receipt logs show multiple
PairRecovered(to=swapHelper)events and repeated pairSyncevents across rounds.
Stage 4: Final extraction + repayment + profit realization:
- Remaining OCA is sold for USDC, the flashloan is repaid, and the attacker contract transfers
422,645.2059USDC to the attacker EOA0xdddf.... - The tx also makes a large BNB transfer to
0x4848...and pays gas, consistent with high inclusion cost.
6. Impact & Losses
- Direct loss (pool depletion):
422,645.205932542647363708USDC was drained from the OCA/USDC PancakeSwapV2 pair0x5779...(balance diff + receipt log). - Secondary effects: the pool's OCA balance/reserves were forcibly reduced and repeatedly re-synced via
recycle()+sync(), causing severe price distortion and liquidity loss for LPs.
7. References
- Seed tx metadata:
artifacts/collector/seed/56/0xcd5979352d9b42ccb7780d5344fac08d1d46591a592ab284a588e2156cf44906/metadata.json - Seed tx balance diffs (native + ERC20):
artifacts/collector/seed/56/0xcd5979352d9b42ccb7780d5344fac08d1d46591a592ab284a588e2156cf44906/balance_diff.json - Full cast trace (very verbose):
artifacts/collector/seed/56/0xcd5979352d9b42ccb7780d5344fac08d1d46591a592ab284a588e2156cf44906/trace.cast.log - Tx receipt (logs):
artifacts/auditor/iter_0/tx_receipt.eth_getTransactionReceipt.json - Tx internal call trace (callTracer):
artifacts/auditor/iter_0/debug_traceTransaction.callTracer.json - OCAToken source (verified via collector):
artifacts/collector/seed/56/0xe0dafd4592205067299a6ae269f68aa804f95419/src/Contract.sol - BNB->USDC price approximation at block B:
artifacts/auditor/iter_0/bnb_usdc_price_at_B.json