Zilliqa X-Bridge drain: on-chain trace and link analysis (2026-10-08)

On 2026-10-08 03:29:23 UTC (Ethereum block 26,144,997) the Ethereum side of Zilliqa's X-Bridge was emptied by four MessageBus.dispatch calls. Each carried one signature from the bridge's only validator key, 0x29cd9c758239259a15792591a9aa64667a409572. Zilliqa says that key was compromised. This report traces where the funds went and asks one question: were the wallets involved funded by, or linked to, Zilliqa?

Everything below was checked on-chain (Ethereum archive state, Blockscout for Ethereum and Zilliqa, mempool.space for Bitcoin). Snapshot taken 2026-11-07, updated 2026-11-12. Each claim is labelled as either an on-chain fact or an inference.

Answer in short

  1. The drain key was a Zilliqa ops key, funded and authorised by Zilliqa wallets. It was added as the sole validator by the bridge owner 0xb9366e40…. It was gas-funded only by the previous validator 0x054973fb… and by Zilliqa's ops/treasury wallet 0xaaf33a7e…, which sent it 0.1 ETH on 2026-09-22, 16 days before the drain. This is true under either theory (stolen key or insider).

  2. The drain recipient and its laundering wallets were never funded by any Zilliqa wallet. The recipient 0x72852ca9… was new. Its only funding was 0.086 ETH that the drain key itself sent it in the drain block. After that it paid its own gas from the stolen funds, plus 0.001 ETH top-ups from two public gas-refuel wallets.

  3. A second Zilliqa key, the X-Bridge deployer 0xd89421cd…, was used on drain day. At 06:07 UTC a Binance withdrawal sent it 0.58 ETH. Within 4.5 minutes the deployer forwarded that to a new wallet. On 2026-11-06 that money was laundered the same way as the bridge proceeds, through the same gas-refuel wallet, with the same Bitcoin wallet fingerprint, five minutes before the bridge proceeds started moving. Inference: one operator controlled both the drain key and the deployer key. This is the strongest link in the data. Binance can identify the account behind withdrawal tx 0xd16c3efa…, and that would settle it.

  4. THORChain did not hide anything. Every swap memo names its Bitcoin destination in plain text. 9.70 BTC arrived at five known addresses. 3.06 BTC has since gone into Wasabi-style CoinJoins. 6.68 BTC is still unmixed at five known addresses (table below), which should be flagged to exchanges now.

  5. Nothing in the bridge could slow the drain. Withdrawals cannot be paused by design, and there are no caps or delays. Only the single-key owner could stop outflows, and the setup is unchanged today (what would have prevented it). Separately, Zilliqa's own relayer kept using the "compromised" key for 18 days. On Ethereum the key was rotated 28 hours after the drain. On Zilliqa it stayed the only validator until 2026-10-26 10:20 UTC. On Oct 22 and Oct 26 Zilliqa's relayer used it to deliver deposits. The Ethereum TokenManager was never paused (paused() == false today).

  6. The drain was run with Foundry forge script, very likely from Zilliqa's own public relay script. The gas limits of all five drain txs match Foundry's gas-search formula exactly. So do Zilliqa's own manual admin txs, including the Oct 9 key rotation and earlier manual transfers out of the same validator key. Only 1 in 171,911 sampled Ethereum transfers carries that fingerprint. The drain used the raw private key, not a hardware wallet. The script is public, so this cannot tell an insider from an outsider who had the key (details).

  7. The ops wallet pays into a Binance account that is visible on-chain on both chains. 0xd6c86912… has Etherscan's public tag "Binance Deposit". The ops wallet sent it a 100 USDT test and then 749,900 USDT on Ethereum (2026-09-11), and 207,010,000 ZIL on Zilliqa (2026-10-06 07:54, tx 0x412458f3…). The chain cannot say whose account it is. Binance can, and can say whether it is the account behind the 0.58 ETH withdrawal to the deployer on drain day (lead 1). The wider treasury around the ops wallet is traced in treasury report: ≈ $1.49M of ZIL went to exchanges in the 30 days before the drain (almost all on Oct 6) and ≈ $355k in the 30 days after, and nothing from it went back into the bridge beyond the ops wallet's ≈ $17k on Oct 22.

No direct path was found from any other Zilliqa wallet (owner, earlier validators, ops wallet, the 3-of-5 Safe) into the attacker's wallets. No link was found to the Oct 22 ZRC2 exploit wallets.

What Zilliqa says, and what the chain shows

Zilliqa's incident page On-chain
Cause "The X-Bridge was drained using a compromised key." One signature from the bridge's only validator key, 0x29cd9c75…, released 100% of its USDT, USDC, WBTC and XCAD. The same operator also used the deployer key that day (below)
Amount Not stated 343,801 USDT, 79,371 USDC, 5.16 WBTC and 10.6M XCAD, ≈ $855k
Response "The bridge is paused and its interface is suspended. The compromised key no longer has any role on Zilliqa, and the incident has been reported to the authorities." The key was removed on Ethereum 28 h after the drain, but stayed the only validator on Zilliqa for 18 days, and Zilliqa's relayer used it on Oct 22 and 26. The TokenManager was never paused
Compensation Not mentioned The TokenManager has received ≈ $17k since the drain, all from the ops wallet on Oct 22. Two backlogged withdrawals failed for lack of USDT

Timeline (UTC)

When What
2024-12-18 Deployer 0xd89421cd… first funded by withdrawals from 0x21a31ee1… and 0x28c6c062…1d60 (Etherscan labels Binance 15 / Binance 14; Blockscout has no tags for them)
2025-01-07 3-of-5 Safe 0x69cfa6d0… sends the deployer the bridge's starting liquidity (1.20M USDT, 10.0M XCAD, 10.9 WBTC, 121.8 ETH, TRAXX, BRKL, OPUL); deployer deposits it into the TokenManager
2025-01-11 Bridge deployed by the deployer. Validator set = [0x250572ed…], one key
2025-04-17 Validator → 0x054973fb…; ownership of all bridge contracts → 0xb9366e40…
2026-01-30 / 02-25 Validator → 0x29cd9c75… (two validators for 24 minutes on Jan 30, otherwise always one)
2026-09-11 08:40, 19:13 Ops wallet sends 100 USDT, then 749,900 USDT, to Binance deposit address 0xd6c86912…
2026-09-22 10:24 Ops wallet 0xaaf33a7e… sends the validator 0.1 ETH
2026-10-06 07:13–07:14 Last regular Zilliqa→Ethereum batch (nonces 301–312), signed and sent by 0x29cd…
2026-10-06 07:54, 08:50 On Zilliqa the ops wallet deposits 207,010,000 ZIL to Binance via 0xd6c86912… and 203,878,000 ZIL to another exchange via 0x682be515…, ≈ $1.49M together, the treasury's largest exchange day
2026-10-07 11:49–15:38 New wallet 0x86a877d8… bridges 1 USDT in twice and 1 zUSDT out twice. Who owns it is unresolved
2026-10-07 15:14 Last Ethereum→Zilliqa delivery before a 15-day gap
2026-10-08 03:28:48–51 The four forged messages use nonces 1,791,430,128–131, which are Unix timestamps of these seconds; legitimate nonces are sequential (…311, 312)
2026-10-08 03:29:23 Drain, block 26,144,997: 343,801.12 USDT, 79,371.00 USDC, 5.1634 WBTC, 10,614,127.58 XCAD to 0x72852ca9…. The validator key also sends its own 0.086 ETH there
03:31–03:39 Recipient sells all USDT, USDC and WBTC through UniswapX for 331.09 ETH. XCAD is never sold
06:07:11 Binance 14 (0x28c6c062…1d60) withdraws 0.57985 ETH to the deployer
06:11:47 Deployer sends it all to new wallet 0x7426360c…
07:10:35 0x7426360c… also gets 0.64 ETH via an Across fill from BNB Chain (depositor 0x144c5fb3…)
2026-10-09 07:40:59 Owner removes 0x29cd… and adds 0x092a47c0… on Ethereum only
2026-10-13 17:16 Treasury sends 101,566,299 ZIL to Binance through two pass-through wallets
2026-10-15 15:31 Treasury's main holder 0xd46b12a5… unstakes 250,000,000 ZIL from the LTIN validator (claimed Oct 23 14:19, unmoved since)
2026-10-22 12:36, 17:48 0x29cd… key still relays deposits on Zilliqa, including the ops wallet's own 5,000 USDT and ~12,046 USDC
2026-10-22 16:49–16:55 Those deposits: the ops wallet withdraws 47,375.5 USDT from Binance 15 (0x21a31ee1…) and within 6 minutes bridges 5,000 USDT and 12,046 USDC into the TokenManager. The only money put back into the drained TokenManager
2026-10-25 First public notice from Zilliqa ("8:19 PM", as shown on X)
2026-10-26 08:52 Ops wallet 0xaaf33a7e… gas-funds the new validator
2026-10-26 09:14–09:23 New key pays 21 backlogged withdrawals on Ethereum (nonces 317–339; 313–316, 332 and 337 skipped; 321 and 334 fail for lack of USDT). In the same minutes the old key 0x29cd… delivers the backlog on Zilliqa
2026-10-26 10:20:02 Zilliqa-side validator finally rotated 0x29cd… → 0x092a… (block 38,963,027)
2026-10-28 Zilliqa: X-Bridge suspended, rotating all operational keys
2026-11-06 01:22–01:37 Deployer-path ETH: two hops, Uniswap → USDC, MAYAChain → BTC bc1qeh0j…, then peeled to a new taproot address
2026-11-06 01:32 → 11-07 10:43 Drain ETH: test sends, two hops each, then THORChain ×4 and MAYAChain ×1 → BTC → peeled to taproot → Wasabi-style CoinJoins

How the drain worked (fact)

ChainGatewayUpgradeable (verified source, out/contracts) lets anyone call dispatch. It only checks that the signatures over toEthSignedMessageHash(abi.encode(sourceChainId, block.chainid, target, call, gasLimit, nonce)) form a supermajority of ValidatorManager.getValidators(), which means count * 3 > validators * 2. With one validator, one signature is enough.

The four drain messages call TokenManager.accept(...) as if they came from Zilliqa's token managers (0x4fa6148c… for USDT/WBTC/XCAD, 0xaaf7f44c… for USDC). No such messages were ever emitted on Zilliqa. Their nonces are wall-clock timestamps, not the bridge's sequential counter. Each signature recovers to 0x29cd…, which was the only validator at block 26,144,996. The amounts equal the TokenManager's full balances of each token at the block before, so the drain took 100% of USDT, USDC, WBTC and XCAD.

Loss, at the prices the attacker got on UniswapX (≈ $2,570/ETH): 343.8k USDT + 79.4k USDC + 5.16 WBTC (≈ $428k) + 10.6M XCAD (≈ $4k at $0.0004, unsold) ≈ $855k.

Who controlled the drain key: the validator lineage (fact)

flowchart TD
  B[("Binance withdrawals")] --> D["Deployer 0xd89421cd"]
  D -- "0.2 ETH" --> V1["Validator #1 0x250572ed"]
  V1 -- "0.10 ETH" --> V2["Validator #2 0x054973fb"]
  V2 -- "0.053 ETH" --> V3["Validator #3 0x29cd9c75
signed the drain"] D -- "ownership (Apr 2025)" --> O["Owner 0xb9366e40"] O -- "add/removeValidator" --> V3 OPS["Ops wallet 0xaaf33a7e"] -- "0.1 ETH (Sep 22)" --> V3 OPS -- "0.1 ETH (Oct 26)" --> V4["Validator #4 0x092a47c0"] V3 -- "0.086 ETH (drain block)" --> R["Recipient 0x72852ca9"]

Where the money went (fact)

flowchart TD
  R["Recipient 0x72852ca9"] -- "UniswapX, Oct 8" --> E["331.09 ETH
idle 29 days"] E -- "10 ETH" --> L1["0x2bacf2a8 → 0xcdc8a768"] --> M1["MAYAChain"] --> B1["bc1qa2y8… → bc1prfdk…
CoinJoin"] E -- "50 ETH" --> L2["0xef453faa → 0x8b94b5b9
Uniswap → USDT"] --> T2["THORChain"] --> B2["bc1qc3q9… → bc1pzcg9…
CoinJoin"] E -- "50 ETH" --> L3["0x18813037 → 0x4f9ad58b"] --> T3["THORChain"] --> B3["bc1qh9s5… → bc1pk92q…
CoinJoin, 0.18 left"] E -- "100 ETH" --> L4["0x60ef8938 → 0xb7863acb"] --> T4["THORChain"] --> B4["bc1qcyrm… → bc1pv965…
unspent"] E -- "121.7 ETH" --> L5["0xe0426048 → 0xb763b3cd"] --> T5["THORChain"] --> B5["bc1qtd8p… → bc1pzjhw…
CoinJoin, 2.31 left"]

Each hop wallet forwards its whole balance within 7–9 minutes. Two 0.1 ETH "test" wallets sent their 0.1 ETH back. Each router deposit's memo is =:BTC.BTC:<address>:<limit>/<interval>/0, which names the Bitcoin address outright. On Bitcoin, each destination peels to one new taproot address in 1–3 txs, and those addresses then join ~200-input CoinJoins. The CoinJoins are version 1, locktime 0, at 0.15–1.1 sat/vB, with WabiSabi standard denominations (2ⁿ, 2·3ⁿ/3ⁿ, 1-2-5 sats).

BTC not yet mixed (2026-11-07):

Address BTC Origin
bc1pv965tsrz00qwazje92hsvctsgedeavsn4dt05juhucsaqnp0pzwsjzdjyq 2.92488457 100 ETH leg
bc1pzjhw0kdf3mr5favyx2lhcvl9c6dxmjfnpcu20tmxgufktv9tkv9q8v4jjv 2.31183980 121.7 ETH leg
bc1qtd8pl6wuhx2wae738cdtrzj22s0svqjf5x9d69 1.22582564 121.7 ETH leg
bc1pk92qwml7yhujryd6jphyalxc9vcm54h8hsug7c9skd0vltya7vdsz6dpgm 0.18453159 50 ETH leg
bc1pr7mun2q9e983tnfd7nl63a0xzrg8ujlsp09knsq3j9ckupptn37svurqd4 0.03548818 deployer path
Total 6.68256978

The 10.6M XCAD is still in 0x72852ca9….

The deployer-key link

Facts:

Inference: the person laundering the bridge proceeds also controlled the deployer key on 2026-10-08. The Oct 8 withdrawal could have been Zilliqa topping up its own deployer, swept by someone watching the key, or the key holder funding it themselves. Either way, the attacker controlled two Zilliqa ops keys, not one relayer key. That fits Zilliqa's Oct 28 "rotating all operational keys". Weak points: 0xa568… is a gas-refuel distributor that serves ~190 addresses, and the BTC fingerprint is common to some wallet software. Neither alone proves anything. Together with the deployer-key sweep and the five-minute timing, the link is strong.

What would settle it: Binance KYC for the account behind withdrawal 0xd16c3efa…. Also the Across depositor 0x144c5fb3… on BNB Chain (deposit to 0x7426360c…, fill tx 0x19a4922d…). It is a busy EOA (nonce 3,908) and probably a service.

How the keys were held: tx fingerprints

A signature carries no trace of the device that made it, so the chain cannot show whether a key was on a Ledger. The software that built each tx does leave traces: tx type, gas limit and fee policy.

The drain was run as a Foundry script

The drain's 0.086 ETH sweep used a gas limit of 29,005. A plain ETH transfer needs exactly 21,000, and every RPC tested (dRPC, PublicNode, Zilliqa) estimates 21,000 for it. So the number comes from the sending software.

Where 29,005 comes from (fact). Foundry forge script --broadcast simulates each tx and runs GasSearch (crates/script/src/runner.rs). That is a binary search from gas_used up to 3 × gas_used, which stops when two successive successful limits differ by less than 10% in integer maths. It then sets gas_limit = result × 130 / 100 (crates/script/src/transaction.rs, default --gas-estimate-multiplier 130). For a transfer the search goes 42,000 → 31,500 → 26,250 → 23,625 → 22,312 and stops; × 1.3 = 29,005. Re-implementing this predicts the exact gas limit of each tx from its gas used and minimum gas:

Key Txs whose gas limit matches forge script exactly
Validator #3 (drain key) The 5 drain txs (4 × dispatch + the ETH sweep) and its 3 manual ETH transfers to validator #2 (2026-01-30, 2026-02-04). None of its relayer batches match
Deployer Ownership transfers, addValidator, pause, registerToken, its ETH transfers to the owner
Owner addValidator calls, including the 2026-10-09 rotation, and registerToken
Validators #1, #2 Their manual ETH transfers only
Validator #4, relayer batches, attacker laundering wallets None

The same keys' manual transfers on Zilliqa also used 29,005.

How rare it is (fact). In 1,500 evenly spaced Ethereum blocks from Oct 2025 to Nov 2026, 1 of 171,911 plain ETH transfers used a 29,005 gas limit, sent by an unrelated MEV-style address. Wallets use 21,000 or round multiples of it (31,500, 23,100, 25,200, 27,300). Foundry is a common developer tool, but almost nobody moves ETH with it.

The template is Zilliqa's own, and it is public (fact). Zilliqa/xbridge (public, archived 2026-05-07) contains smart-contracts/script/bsc/validators/dispatchRelay.s.sol and script/zq/validator/dispatchRelay.s.sol. These Foundry scripts read PRIVATE_KEY_VALIDATOR from the environment, build abi.encode(sourceChainId, block.chainid, target, call, gasLimit, nonce).toEthSignedMessageHash(), sign it with vm.sign, and broadcast dispatch from the same validator key. The drain is that flow with two edits: message gas limit 2,000,000 instead of the script's fixed 1,000,000 (which every legitimate relay used), and nonces set to the current Unix time. It also added a sweep of the key's own ETH. (oscar01428/Universal-Cross-Chain-Bridge, created 2026-07-08, is a copy of Zilliqa/xbridge with recent commit authors rewritten; its script is byte-identical.)

What this means (inference).

How widely the key was exposed

How Zilliqa stored and passed keys (fact; full history of the public repo Zilliqa/xbridge):

Human use of the keys (fact). Every rotation was done by hand with Foundry, and each session used several keys within minutes:

Session Keys used
2025-01-11/13 (launch) Deployer deploys and sets validator #1, then funds it. Validator #1's first two relays were sent by hand, not by the relayer
2025-04-17/25 Deployer rotates to validator #2, hands ownership to the owner, and sends it ETH and ZIL. Validator #1 sends its ETH and ZIL to validator #2
2026-01-30 → 02-04 Owner swaps validator #2 → #3 and back. ETH moves #2 → #3 → #2 (twice), and again on Feb 4
2026-02-25 → 27 Owner swaps #2 → #3 (Feb 25 11:19). Validator #2 sends ETH (11:21) and later ZIL to #3. #3 starts relaying (Feb 26 10:09). Deployer sweeps ETH and ZIL to the owner (Feb 27 15:08). Owner calls registerToken (15:12)

By Feb 2026 the owner, deployer, validator #2 and validator #3 keys were all used from one operator environment. The attacker controlled validator #3 and the deployer. There is no sign they had the owner key: they swept the 0.086 ETH on validator #3 and the 0.58 ETH that reached the deployer, but the owner's 0.055 ETH and 93 ZIL are untouched (a weak signal).

Who could have had the drain key (inference):

The drain key first appears on 2026-01-30. Anyone whose access ended before then could not have copied it, unless they kept access to a vault, machine, .env or backup where it was later stored. Whoever drained the bridge also held the deployer key, which sat in the same operator environment.

Fits / does not fit:

Explanation Fit with the evidence
Malware or phishing on an operator machine holding a .env with the operational keys Good: explains validator #3 and the deployer together, and the raw-key Foundry use
Former staff with a copy Only if they left after 2026-01-30, or kept access to the vault, a machine or a backup after leaving
Current insider Fits as well; on-chain data cannot separate it from the two above
Leak through the public repo Ruled out for these keys: none was ever committed
Full 1Password vault compromise Weaker: the owner key would probably have been taken too, and it shows no use

What would settle it: 1Password access logs for xbridge_validator0, the deployer and owner items since Jan 2026; cluster audit logs (secret reads, pod exec) in production; offboarding dates compared with 2026-01-30; forensics on the machines used in the Jan 30 – Feb 27, 2026 sessions; and the Binance account behind the Oct 8 withdrawal to the deployer.

Incident response facts that need explaining

What would have prevented it

What the verified contracts allow (fact):

Controls, and whether each would have stopped this drain:

Control Applies to Stops this drain? Change needed
Several independent validators, e.g. 4-of-5 on separate infrastructure and operators Validator set Yes. One stolen key is useless Config only: the contract already enforces count * 3 > validators * 2
Validator keys in a non-exportable HSM / cloud KMS with signing policy and audit logs Validator keys Very likely. The drain used the raw key in a Foundry script; a non-exportable key cannot be copied into one Ops
Per-token outflow limit per period, plus a delay queue for large withdrawals that a guardian can cancel TokenManager Yes, loss capped and time to react. Must fit real traffic: the largest legitimate withdrawal seen was 300,000 USDT to the ops wallet (2026-03-18), so large amounts need a delay, not a hard block Contract upgrade
Guardian-pausable accept/dispatch, plus a watcher checking each dispatch against a Relayed event on Zilliqa TokenManager / gateway Only with a delay. All four drain txs were in one block Contract upgrade + monitoring bot
Owner as a Safe multisig, with Zodiac Delay on upgrades, setValidatorManager and validator changes, and Zodiac Roles giving an on-call operator only brake powers (pause, remove a validator) Owner Not this drain. It removes a single point of failure just as large: today one EOA can upgrade the TokenManager or replace the validators and take everything. A brake role also allows reacting in minutes, not 28 h Ops: transferOwnership to the Safe
Retire the deployer key Deployer No; but it was still live and used on drain day Ops
Ops/treasury wallet as a Safe with Zodiac Roles allowances (operator may spend up to X per day/week) 0xaaf33a7e… No; it protects 42.8M ZIL and the stablecoin flows from the same single-key exposure Ops
Rotate both chains' validator sets together Process Would have closed the 18-day window on Zilliqa Process

A rate limit for the bridge itself has to live in the contracts. The money sits in the TokenManager and moves on signed messages, not on Safe transactions, so a Safe or Zodiac cannot cap it. Safe and Zodiac belong on the admin keys and the treasury.

What did not link

Corrections to the Bearby post

Leads, most useful first

  1. Binance: account behind withdrawal 0xd16c3efac8021cac2afa6905848efcf015aa9bbdd6d03c7cebfc4aff676ad1cb (0.57985 ETH to the deployer, 2026-10-08 06:07:11). Also the Dec 2024 withdrawals that first funded the deployer, to check whether they came from the same account. And whether either is the account behind deposit address 0xd6c869121021de9a8dea0771e1f79ae8099e4cd9, which the ops wallet pays into (749,900 USDT on 2026-09-11, 207M ZIL on 2026-10-06).
  2. Flag the 6.68 BTC above with exchanges and analytics firms before it is mixed.
  3. CoinJoin outputs from txs 334dc0bb…, 5bdd9bb2…, a837ea24…, c2b79f8f…: watch for post-mix consolidation (timing and amounts often survive mixing).
  4. Across: deposit behind fill 0x19a4922d… (BNB Chain depositor 0x144c5fb3…).
  5. Zilliqa: ask how the relayer still held and used 0x29cd… on Oct 22–26, who has access to the deployer key, and why the Zilliqa-side validator and the Ethereum TokenManager were left live.