All incidents

OCAToken SwapHelper Recycle Drains PancakeSwap OCA/USDC Pair

Share
Feb 13, 2026 17:48 UTCAttackLoss: 422,645.21 USDCManually checked1 exploit txWindow: Atomic
Estimated Impact
422,645.21 USDC
Label
Attack
Exploit Tx
1
Addresses
1
Attack Window
Atomic
Feb 13, 2026 17:48 UTC → Feb 13, 2026 17:48 UTC

Exploit Transactions

TX 1BSC
0xcd5979352d9b42ccb7780d5344fac08d1d46591a592ab284a588e2156cf44906
Feb 13, 2026 17:48 UTCExplorer

Victim Addresses

0x5779bf44cd518b05651ae38fcc066247cce21504BSC

Loss Breakdown

422,645.21USDC

Similar Incidents

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 swapHelper address 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 to contract (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 swapHelper to move OCA out of the pair without going through pair.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 Swap events; USDC is transferred out of the pair).
  • SwapHelper then calls OCAToken.recycle(...) to transfer (reclaim) OCA out of the pair to SwapHelper and force sync().

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 Transfer from the pair to SwapHelper at logIndex 0x0e (OCA Transfer topic 0xddf252ad..., from=0x5779... to=0xe0d5...).
  • Pair Sync events (pair log topic 0x1c411e9a...) around the same region.
  • OCAToken PairRecovered(to=swapHelper, amount=...) at logIndex 0x10.

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 (before 427369506331114918872080, after 4724300398572271508372) for holder 0x5779... (balance diff).
  • Attacker EOA USDC delta: +422645205932542647363708 for holder 0xdddf... (balance diff).
  • Receipt shows the final USDC transfer to the EOA at logIndex 0x44 for exactly 422645205932542647363708 raw units.

The transaction also has a large native (BNB) cost:

  • Sender native delta: -42894013882404242882 wei (-42.894013882404242882 BNB).
  • Counterparty native delta: +42891523771404242882 wei (+42.891523771404242882 BNB) to 0x4848489f0b2bedd788c696e2d79b6b69d7484848.

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... transfers 8,704,860.148366532708908952 USDC 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 calls sync(), cementing manipulated reserves.
  • Receipt logs show multiple PairRecovered(to=swapHelper) events and repeated pair Sync events 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.2059 USDC to the attacker EOA 0xdddf....
  • 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.205932542647363708 USDC was drained from the OCA/USDC PancakeSwapV2 pair 0x5779... (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