ZRC2 interop exploit: PlunderSwap pools drained (2026-10-22/23)

From 2026-10-22 21:57 UTC, four PlunderSwap V3 pools holding ZRC2-backed tokens were drained of their real assets. The attacker paid for swaps with ZRC2 tokens whose transfers were visible during the transaction but discarded when the transaction committed, so the same tokens could be "spent" again and again. The cause was a bug in the Zilliqa node (zq2 v0.21.10), not in the PlunderSwap contracts. Each claim below can be checked on-chain or in Zilliqa's public source code.

Status (checked 2026-11-12, block 40,209,093)

What Zilliqa says, and what the chain shows

Zilliqa's incident page On-chain and in Zilliqa's public code
Cause "An attacker used a flaw in the ERC-20 interface of some legacy ZRC-2 tokens to create tokens without backing and sold them into liquidity pools on PlunderSwap for ZIL." The ZRC2 transfers did happen inside each transaction; the node then dropped them at commit (apply_delta_scilla in zq2 v0.21.10). No tokens were minted. The proxies only pass calldata to Zilliqa's own precompiles.
Protocol "The native ZIL token and the Zilliqa protocol were not affected." The fix was a node release and a protocol hard fork (block 39,242,023), which changed how EVM and Scilla state are committed.
Response "We are working with the affected token issuers on a fix and with the exchanges where the funds ended up." Status: "Under investigation". The fork disabled interop for three ZRC2 contracts. Nothing in it restores the pools.
Losses Not stated ≈ 42.9M ZIL (≈ $89k) of real assets, below
Restoration or compensation Not mentioned None on-chain as of 2026-11-12

How it worked

A ZRC2 transfer made from an EVM contract through the interop precompile (scilla_call, 0x5a494c53) is visible for the rest of the transaction. In zq2 v0.21.10 it is thrown away at commit if the ZRC2 contract's address is also present in the transaction's EVM state. The attacker put it there with a single BALANCE(<zrc2 address>):

sequenceDiagram
  participant A as Attacker contract
  participant P as V3 pool
  participant T as ZRC2 proxy (e.g. SEED)
  participant N as Node at commit
  A->>P: swap()
  P->>A: pays out the real token (kUSD, WZIL)
  P->>A: uniswapV3SwapCallback
  A->>T: transfer(pool, 3000 SEED) via scilla_call
  P->>T: balanceOf(pool): +3000, check passes
  A->>A: BALANCE(SEED ZRC2): address enters EVM state
  N-->>N: EVM delta skips the untouched ZRC2 account
  N-->>N: Scilla delta skips it because the EVM "has" it
  Note over N: The 3000 SEED transfer is lost. The attacker still holds it

In every transaction checked, the same 3000 SEED was "spent" again. Example: tx 0x90e5ec2b…d631d0 (block 38,747,068). The pool's SEED balance is unchanged across the block, the swap event and the ZRC2 TransferSuccess event both say +3000 SEED, the attacker contract still holds its 3000 SEED, and the pool paid out 9.244 kUSD of real tokens.

The bug (zq2 v0.21.10, exec.rs). After an EVM transaction the node applies the EVM delta, then the Scilla delta produced by interop calls. apply_delta_scilla skips any account the EVM state contains:

// Modified addresses by EVM are skipped
if dont_overwrite_evm_accounts_from_stale_scilla_state
    && let Some(evm) = evm_state
    && evm.contains_key(&address)
{
    continue;
}

evm_state holds every account the EVM loaded, not only the ones it changed, so BALANCE, EXTCODESIZE and EXTCODEHASH all add an entry. apply_delta_evm skips accounts that weren't modified. So neither delta writes the ZRC2 account, and the transfer disappears. The comment says "modified", which suggests the intended check was is_touched().

Why the safety net didn't fire. apply_transaction_evm has a deltas_overlap check that would fail exactly this transaction, but it only runs while the precompile-caller allowlist is empty:

Mainnet height Fork
27,081,150 dont_overwrite_evm_accounts_from_stale_scilla_state = true: the skip logic above
27,546,174 tighten_precompile_rules = true: the overlap check
29,108,584 allow_scilla_call_precompile_to_be_called_from_addresses = [... SEED, zUSDT, ...]: allowlist non-empty, so the overlap check stops running
39,242,023 unified_scilla_state = true (the fix)
39,242,024 disable_permanently_scilla_precompiles_v1 for SEED, gZIL and 0x173ca677…

From block 29,108,584 until the fix, every allowlisted ZRC2 proxy could be exploited this way. Normal users were not affected: EIP20ZRC2Proxy only passes the ZRC2 address as calldata and never touches it from the EVM. A contributing factor: the proxy's transfer/transferFrom ignore the precompile result and always return true.

Losses

Real assets that left the four pools, from each pool's last block before its first phantom-credited swap to block 38,814,118 (2026-10-24 01:20 UTC). ZIL at $0.00207 (Binance, 22:00 UTC on 10-22); zUSDT at its $1 peg; SEED and kUSD at pre-exploit on-chain prices.

Pool First phantom swap (UTC) Real tokens lost ≈ ZIL ≈ USD
zUSDT/WZIL 0.01% 0xd9969fac… 10-22 21:57 13,419,781 WZIL
55,808 zUSDT
40,336,100 $83,632
SEED/kUSD 1% 0x91fddad1… 10-23 00:32 251,419 SEED
1,371 kUSD
1,130,766 $2,345
WZIL/SEED 0.25% 0xaa7d53db… 10-22 23:19 353,397 WZIL
106,178 SEED
712,055 $1,476
ZOGE/WZIL 0.25% 0xb254a308… 10-23 00:40 58,752 WZIL 58,752 $122
Total 42,237,673 $87,575

Another ≈ 0.68M ZIL (≈ $1.4k) was lost on net in pools that stayed solvent, through arbitrage against the manipulated prices: SEED fell from 3.378 to 0.070 ZIL. Total ≈ 42.9M ZIL ≈ $89.0k.

On 10-22, before the exploit, zUSDT and zUSDC halved in price on-chain while Binance ZIL stayed flat. The pool was fully backed through that slide, which came from two sell-offs paid in real zUSDT by two arbitrage bots with no link to the attacker. That is why zUSDT is valued at its $1 peg here and not at the dislocated ~$0.50.

Why the pools stay broken

Uniswap-V3-style pools keep no reserve accounting an admin could correct. LP positions are claims on liquidity at the pool price, and the attacker moved each pool's price with tokens that never arrived. WZIL/SEED, for example, owes its LPs about 2.04M SEED and holds 4.5. The pools can't be redeployed either. A V3 pool's address is fixed by (factory, token0, token1, fee), and createPool reverts once it exists. Sending tokens back alone wouldn't help, because the prices and fee growth were moved by the phantom swaps. Putting LPs back exactly needs the pools' storage and balances restored at chain level, which only takes 4–5 storage slots per pool plus their balances. The 2026-10-30 fork did not do this.

The attacker

flowchart TD
  MEXC[("Exchange hot wallet 0x4224ed94
(very likely MEXC)")] -- "4 withdrawals, 52.8k ZIL" --> F["Funder 0xedfbf2d3
(new on 10-22)"] F -- "deploys 16 contracts, then the exploit" --> C["Attack contracts
0x8dccf8d1, 0x69b5ec91, 0x151897e2"] F --> E["Attack EOA 0x380c44a5"] C -. "real WZIL, kUSD, zUSDT out of the pools" .-> F F --> I["Intermediates
0xef1aa242, 0xcf15d3a9, 0xb628dc5b"] E --> I E --> D["6 deposit addresses"] I --> D F --> D D -- "≈ 14.04M ZIL" --> MEXC

Funded from, and cashed out to, one exchange. The funder 0xedfbf2d3… was unused before 2026-10-22 16:40 UTC, 5h17m before the first phantom swap. Every inbound transfer it ever received came from one exchange hot wallet, 0x4224ed94…: about 20.6k ZIL (≈ $43) in three withdrawals before the attack, and 32.1k after. About 14.04M ZIL of proceeds went back into that same hot wallet through six single-use deposit addresses:

Deposit address Fed by ZIL swept to 0x4224ed94…
0xb41d5fc0ad52f80d0190d13db0ce1d46a77aa68e attack EOA, intermediate 0xef1aa242… 8,374,090
0x5af2e48ff0b027e0db78e2348d5f93ac0958c7c3 intermediate 0xef1aa242… 2,002,950
0xdada5b8323051aa5a09a6a2bf8a929a49efc0af9 attack EOA, intermediate 0xcf15d3a9… 1,917,728
0xce0c09df6ef557848a66c94757180f3c11ce32b4 funder, directly 830,212
0xfc1c1cc707a89e48c628a317fe74d5cdd0289010 funder via 0xb628dc5b… 787,305
0x8aff85310489a7df950b12963ec5440da7f3e97f attack EOA via 0xb628dc5b… 127,174
Total 14,039,460

Why MEXC (circumstantial). 0x4224ed94… had three transactions before 2026-09-08, and its real deposit sweeps begin at 10:16 UTC that day. Public reporting of MEXC's announcements puts the resumption of ZIL deposits and withdrawals on its new Zilliqa EVM integration at 2026-09-08 10:00 UTC. MEXC holds the account records for both ends: the withdrawals that funded the attack and the deposits of the proceeds.

Timeline (UTC, Oct 22–23).

When What
16:40 Funder receives its first funds: 3,452.82 ZIL from 0x4224ed94…
16:50 – 21:50 16 contracts deployed. Small buys of zUSDT, gZIL, stZIL, zETH, SEED and GARY; a 17.8KB general trading bot; test contracts trying ZRC2 transfers into V2 pairs and V3 callbacks. BALANCE first appears at 19:04
21:54 0x8dccf8d1… deployed, the first contract with the working BALANCE trick
21:57:12 First phantom swap: a 2 zUSDT probe into zUSDT/WZIL
22:10 → Main zUSDT/WZIL drain. WZIL is unwrapped to ZIL and moved on
22:26 → 07:34 ≈ 14.04M ZIL deposited to the exchange through six deposit addresses
04:51 One more exchange withdrawal to the funder: 32,140.79 ZIL

What the behaviour suggests (inference).

Links to other incidents. The attacker cluster has no transfers with, and no shared counterparties (other than the exchange and DEX contracts) with, the X-Bridge deployer, owner, validators, ops/treasury wallet or drain chain, or the treasury wallets. None of its addresses is active on Ethereum. Its tooling also differs from the X-Bridge drainer's. That drainer used Foundry forge script gas limits (130% of the estimate); the ZRC2 attacker used a wallet with estimated limits and a script with hand-picked round limits (300k / 400k / 700k / 900k / 1.2M) at 3× the minimum gas price.

Leads

  1. MEXC: the account or accounts behind the six deposit addresses and the four withdrawals to 0xedfbf2d3…, with KYC and login records, and a freeze on anything left.
  2. Restoration: the pools' pre-hack storage and balances are fully determined by the chain (4–5 slots per pool plus token balances). A targeted restore fork, of the kind zq2 has done before, would return every LP position exactly.