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)
- The bug is closed. A hard fork at block 39,242,023 (2026-10-30 07:52 UTC), shipped in zq2 v0.22, turned on
unified_scilla_state. One block later it permanently disabled the interop precompiles for three ZRC2 contracts: SEED (0xfa254e12…), gZIL (0xa845c103…) and0x173ca677…. SEED's ERC-20 proxy now reverts ontotalSupply(). Mainnet reportszilliqa2/v0.22.1. - None of the four drained pools was restored. Their token balances and prices are exactly as they were on 2026-10-24. The mainnet fork list (
zq2-mainnet.toml) has no restore entry for any PlunderSwap pool, although zq2 has used targeted restore forks before (restore_xsgd_contract,restore_ignite_wallet_contracts,restore_denylisted_accounts_v1). - LP positions in those pools now mostly resolve to tokens the pools don't hold. The first LPs to withdraw take whatever real tokens are left; the rest get nothing.
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).
- Iterating live. A fresh wallet, about $43 of seed money, and 16 contracts in five hours before a working exploit. Nothing was tested on mainnet beforehand. Whether the attacker already knew where the bug was can't be told on-chain; the zq2 code is public.
- An experienced on-chain trader. The 17.8KB contract is a general trading and flash-swap bot, redeployed rather than written that afternoon.
- Little laundering effort. The proceeds went straight back to the same KYC exchange the attack was funded from, through one or two throwaway hops.
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
- 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. - 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.