A technical breakdown of the BCE/USDT liquidity pool exploit on PancakeSwap, March 2026
The 90-Second Version
On March 23, 2026, attackers deployed two malicious contracts on BNB Chain that exploited a flaw in the BCE token's burn mechanism to drain $679,000 from a PancakeSwap BCE/USDT pool. The attack didn't touch PancakeSwap's router — the vulnerability lived entirely inside the token's custom transfer logic.
Root cause: BCE's _transfer() function triggered automatic burns that modified the pool's reserve ratio without going through the AMM's swap path. Attackers manipulated this to create artificial arbitrage, then drained the pool.
Loss: ~$679,000 in USDT
Chain: BNB Chain (BSC)
Protocol: PancakeSwap V2 (BCE/USDT pool)
Flaw class: Defective fee-on-transfer / burn-on-transfer token logic
Why This Matters Beyond BCE
Custom token transfer logic — burns, reflections, taxes, rebases — is the single most common source of AMM pool exploits in 2026. The pattern is always the same:
- Token modifies its own supply during
transfer() - AMM pool's
reserve0/reserve1ratio shifts without a corresponding swap - Attacker arbitrages the desynchronized price
BCE is a textbook case. Understanding it prevents the next one.
The Attack: Step by Step
Phase 1: Reconnaissance
The attacker identified that BCE's _transfer() function contained an automatic burn mechanism:
// Simplified reconstruction of BCE's vulnerable transfer logic
function _transfer(address from, address to, uint256 amount) internal {
uint256 burnAmount = amount * burnRate / 100;
uint256 transferAmount = amount - burnAmount;
_balances[from] -= amount;
_balances[to] += transferAmount;
_totalSupply -= burnAmount; // ← Supply reduced but pool doesn't know
// Burns from totalSupply but doesn't call pool.sync()
emit Transfer(from, to, transferAmount);
emit Transfer(from, address(0), burnAmount);
}
The critical flaw: when tokens are burned during a transfer involving the liquidity pool, the pool's cached reserves (reserve0, reserve1) become stale. The pool thinks it holds X tokens, but it actually holds X minus the burned amount.
Phase 2: Deploying the Attack Infrastructure
The attacker deployed two contracts:
- Contract A: Bypassed BCE's per-transaction buy/sell limits by splitting operations into many sub-limit transactions
- Contract B: Orchestrated the actual drain by triggering burns at precise moments
// Attacker's limit-bypass pattern (reconstructed)
contract LimitBypasser {
IBEP20 bce = IBEP20(BCE_ADDRESS);
IRouter router = IRouter(PANCAKE_ROUTER);
function fragmentedBuy(uint256 totalAmount, uint256 chunks) external {
uint256 perChunk = totalAmount / chunks;
for (uint i = 0; i < chunks; i++) {
// Each chunk stays under the per-tx limit
router.swapExactTokensForTokens(
perChunk, 0,
path, address(this), block.timestamp
);
}
}
}
Phase 3: The Burn-Drain Cycle
The attacker executed a repeated cycle:
- Buy BCE through Contract A (fragmented to stay under limits)
- Transfer BCE between Contract A and Contract B — each transfer triggers burns
- Burns reduce total supply but pool reserves stay cached at old values
-
Pool's
getAmountOut()now returns inflated values because it uses stale reserves - Sell BCE back to pool at the inflated rate
- Repeat until pool is drained
Cycle 1: Buy 1000 BCE → Transfer (burns 50) → Pool thinks 1000, actually 950
Cycle 2: Buy 1000 BCE → Transfer (burns 50) → Pool thinks 2000, actually 1900
...
Cycle N: Accumulated reserve desync → massive arbitrage → drain
Phase 4: Extraction
After accumulating sufficient reserve desynchronization, the attacker executed a final large swap that extracted $679,000 in USDT from the pool at a price that didn't reflect the actual BCE supply.
The Root Cause: Token Supply Changes Outside AMM Awareness
PancakeSwap (and Uniswap V2 forks) use the constant product formula:
x * y = k
This formula assumes that reserves only change through:
- Swaps (which call
swap()) - Liquidity additions/removals (which call
mint()/burn()) - Explicit syncs (which call
sync())
BCE's burn mechanism violated this assumption. It changed the token balance inside the pool without calling sync(), creating a persistent gap between cached reserves and actual balances.
// How PancakeSwap V2 tracks reserves
function getReserves() public view returns (uint112 _reserve0, uint112 _reserve1) {
_reserve0 = reserve0; // Cached value — NOT live balanceOf()
_reserve1 = reserve1; // Only updated on swap/mint/burn/sync
}
Every burn widened the gap. Every gap was profit for the attacker.
The Broader Pattern: Fee-on-Transfer Token Attacks in 2026
BCE isn't alone. Here are the token mechanics that create the same class of vulnerability:
- Burn-on-transfer: Reduces supply without pool sync → BCE ($679K)
- Reflection tokens: Redistributes to holders including pool → SafeMoon clones
-
Transfer tax: Recipient gets less than
amount→ Multiple BSC tokens - Rebase tokens: Changes all balances simultaneously → Ampleforth-style
- Max-tx limits: Creates predictable trading patterns → BCE (exploited to fragment)
4 Burn-Safe Patterns for Token Developers
Pattern 1: Exempt Pool Addresses from Burns
The simplest fix — don't burn tokens when the sender or receiver is a known liquidity pool:
mapping(address => bool) public isLiquidityPool;
function _transfer(address from, address to, uint256 amount) internal {
uint256 burnAmount = 0;
// Don't burn on pool interactions
if (!isLiquidityPool[from] && !isLiquidityPool[to]) {
burnAmount = amount * burnRate / 100;
}
uint256 transferAmount = amount - burnAmount;
_balances[from] -= amount;
_balances[to] += transferAmount;
if (burnAmount > 0) {
_totalSupply -= burnAmount;
emit Transfer(from, address(0), burnAmount);
}
emit Transfer(from, to, transferAmount);
}
Trade-off: Requires maintaining an allowlist of pool addresses.
Pattern 2: Auto-Sync After Burns
If you must burn on pool transfers, force a sync:
function _transfer(address from, address to, uint256 amount) internal {
uint256 burnAmount = amount * burnRate / 100;
uint256 transferAmount = amount - burnAmount;
_balances[from] -= amount;
_balances[to] += transferAmount;
_totalSupply -= burnAmount;
emit Transfer(from, to, transferAmount);
emit Transfer(from, address(0), burnAmount);
// Force pool to recognize the balance change
if (isLiquidityPool[from] || isLiquidityPool[to]) {
IPancakePair(poolAddress).sync(); // ← Critical
}
}
Pattern 3: Burn-to-Dead-Address Instead of Supply Reduction
Instead of reducing totalSupply, send burned tokens to a dead address. This keeps balanceOf(pool) accurate:
address constant DEAD = 0x000000000000000000000000000000000000dEaD;
function _transfer(address from, address to, uint256 amount) internal {
uint256 burnAmount = amount * burnRate / 100;
uint256 transferAmount = amount - burnAmount;
_balances[from] -= amount;
_balances[to] += transferAmount;
_balances[DEAD] += burnAmount; // No supply change, no pool desync
emit Transfer(from, to, transferAmount);
emit Transfer(from, DEAD, burnAmount);
}
Why it works: The pool's balanceOf() remains consistent with its cached reserves.
Pattern 4: Per-Block Volume Limits
BCE's per-transaction limits were bypassed by splitting into fragments. Use per-block cumulative tracking:
mapping(address => mapping(uint256 => uint256)) private _blockVolume;
uint256 public maxVolumePerBlock;
function _transfer(address from, address to, uint256 amount) internal {
_blockVolume[from][block.number] += amount;
require(
_blockVolume[from][block.number] <= maxVolumePerBlock,
"Block volume limit exceeded"
);
// ... rest of transfer logic
}
Detection: Semgrep Rule for Vulnerable Burn Logic
rules:
- id: burn-without-pool-sync
message: >
Token burns inside _transfer() without calling pool.sync().
This can desynchronize AMM reserves and enable drain attacks.
severity: ERROR
languages: [solidity]
patterns:
- pattern: |
function _transfer(...) {
...
_totalSupply -= $BURN;
...
}
- pattern-not: |
function _transfer(...) {
...
$POOL.sync();
...
}
Detection: Foundry Invariant Test
function invariant_poolReservesMatchBalances() public {
(uint112 reserve0, uint112 reserve1,) = pair.getReserves();
uint256 actualBalance0 = token0.balanceOf(address(pair));
uint256 actualBalance1 = token1.balanceOf(address(pair));
assertLe(uint256(reserve0), actualBalance0, "Reserve0 > actual balance");
assertLe(uint256(reserve1), actualBalance1, "Reserve1 > actual balance");
if (actualBalance0 > 0) {
uint256 desync = (uint256(reserve0) - actualBalance0) * 10000 / actualBalance0;
assertLe(desync, 10, "Reserve0 desync > 0.1%");
}
}
Solana Parallel: SPL Token-2022 Transfer Fees
Solana's Token-2022 program includes a transfer fee extension that functions similarly to burn-on-transfer. The withheld fees accumulate in the recipient's token account, creating the same reserve desync risk for AMM pools:
// Anchor: Check for transfer fee extension before trusting amounts
use anchor_spl::token_2022::spl_token_2022::extension::transfer_fee::TransferFeeConfig;
pub fn safe_swap(ctx: Context<Swap>, amount_in: u64) -> Result<()> {
let mint = &ctx.accounts.token_mint;
if let Ok(fee_config) = mint.get_extension::<TransferFeeConfig>() {
let fee = fee_config
.calculate_epoch_fee(Clock::get()?.epoch, amount_in)
.ok_or(ErrorCode::FeeCalculationFailed)?;
let actual_received = amount_in - fee;
update_reserves(ctx.accounts.pool, actual_received)?;
}
Ok(())
}
Audit Checklist: Token-AMM Interaction Safety
Before deploying any token with custom transfer logic to an AMM:
- [ ] Burns: Does
_transfer()reducetotalSupply? If yes, are pool addresses exempted or issync()called? - [ ] Fees/taxes: Does the recipient receive less than
amount? Is the AMM pool aware? - [ ] Reflections: Do holder balances change outside of transfers?
- [ ] Rebases: Does
balanceOf(pool)change between blocks without transfers? - [ ] Transaction limits: Per-transaction or per-block cumulative? Can they be bypassed by splitting?
- [ ] Pool sync: After any supply-modifying operation involving a pool, is
pair.sync()called? - [ ] Invariant tests: Is there a test that
reserve ≤ balanceOf(pool)holds after every operation?
Key Takeaway
The BCE exploit reveals a fundamental tension in tokenomics design: custom transfer logic and AMM invariants are often incompatible by default. Every token that modifies balances, burns supply, or redistributes during transfer() is potentially breaking the x*y=k assumption that AMM pools depend on.
The fix isn't to avoid custom token mechanics — it's to ensure that every supply-modifying operation either (a) excludes pool addresses, (b) calls sync() immediately after, or (c) uses dead-address burns that don't affect balanceOf().
$679K is a cheap lesson. The next burn-mechanism exploit targeting a larger pool won't be.
DeFi Security Research — DreamWork Security
Top comments (0)