Back to Blog
Anatomy of the proton.swaps Exploit: ~2.1B XPR Drained, Pools 100% Restored, and the Bugs Fixed for Good

Anatomy of the proton.swaps Exploit: ~2.1B XPR Drained, Pools 100% Restored, and the Bugs Fixed for Good

15 September 202622 min read
SecurityIncident ReportXPR NetworkDeFiForensics

Updated 15 September 2026 with the full timeline, the recovery multisigs, exact token reconciliation and the pool-restoration plan.

On 12 September 2026, between 17:19 and 17:35 UTC, an attacker drained the MetalX AMM contract proton.swaps of roughly 2.1 billion XPR plus its token reserves — one of the largest single incidents in XPR Network's history. Within six hours the block producers had frozen the attacker's accounts, fixed a second broken system contract that was blocking the recovery, and seized the accounts by multisig. By the next morning about 87% of the XPR — and 100% of the stablecoins — was sitting in a governance-controlled treasury. By 14 September the XPR Consortium had approved the grant that makes the pools whole.

This is the complete, on-chain account: how the exploit worked, where the money went, exactly how the recovery happened (including the bug that nearly stopped it), and — the part that matters most for the network's future — the contract fixes submitted upstream so the same class of failure can't recur.

Everything below is reproducible from public chain history. We've cross-checked it against two independent forensic write-ups (Alvosec and others); the on-chain facts line up to the digit.

TL;DR

  • ~2.1B XPR drained from proton.swaps (~$5.4M at drain-time prices): ~1.556B directly, plus 563.5M borrowed from lending.loan against stolen stablecoin collateral.
  • Pools 100% restored on 15 September: every token the contract owes is back, matched to the last unit against its own ledger, and every LP position is intact. Trading reopened the same day.
  • 1,855,642,289 XPR (87%) recovered to the BP-controlled recoverfunds treasury, plus 100% of the stolen stablecoins (1.37M XUSDC + 571K XMD), 12.87M METAL and every other pool token.
  • ~277M XPR + 310K METAL escaped to KuCoin, MEXC and Gate.io before the freeze — now the subject of law-enforcement action, with strong KYC leads.
  • The recovery needed a second bug fixed first: eosio.wrap, the BP account-recovery tool, was broken. The producers diagnosed it together in the BP channel, ProtonNZ wrote and built the fix, and 16 block producers deployed it to mainnet the same night.
  • The XPR Consortium granted 290,793,873.4965 XPR to cover what escaped; the pools will be restored to the exact figures the contract's own ledger says it owes.
  • Permanent fixes to XPRNetwork/proton.contracts: inline eosio.wrap (PR #8), blocklist enforcement in eosio.token (PR #9), and removal of the last dead deferred-transaction code in eosio.system (PR #10).

1. The bug: one missing check

proton.swaps let users deposit tokens, track an internal balance, and withdraw. The withdraw action was supposed to check "does this account have at least this much?", subtract it, and send the tokens.

The check was written as "balance ≥ requested amount." Nobody wrote "and the requested amount is greater than zero."

Because an Antelope asset carries a signed 64-bit amount, a negative quantity is perfectly valid input. A negative amount sails through a balance ≥ amount guard (zero is greater than any negative number), and then — because subtracting a negative adds — it inflates the attacker's internal balance out of thin air. A follow-up positive withdraw then pays out the contract's real reserves.

The whole drain was a single atomic transaction (1fee7e10eb0e…, block 403,087,440): a negative withdraw of -1,526,084,924.3129 XPR immediately followed by a positive one of the same size.

This wasn't the first attempt. Three months earlier, on 18 June 2026, an account named gatedepostf ran the exact same negative-withdraw against proton.swaps — but with the wrong decimal precision (-10000 XPR instead of -10000.0000 XPR). It left a phantom, unpayable ledger entry and paid out nothing. A public probe that found the door, tried the handle wrong, and left. A full scan of every withdraw call in the contract's life — 117,530 of them, from deployment in December 2020 to today — shows only three accounts ever passed a negative amount: that June probe, and the two September exploiters. Nothing else was ever taken.

2. The drain and the money trail

The attacker's account, nobreed (created that morning, funded with 12,300 XPR from a KuCoin withdrawal), drained ~1.556B XPR plus every pool token, then used the stolen XUSDC/XMD as collateral on lending.loan to borrow another 563.5M XPR — squeezing maximum liquid value out of the position. The first exchange cash-out (7.5M XPR to MEXC) left at 17:58, thirty-four minutes after the drain.

From there the funds moved out through a same-day mule account (sal2i4qb5byz, created by nobreed seventeen minutes after the drain) and straight into exchange deposit wallets. A second exploiter — winterbloom4 — hit the same bug at 20:56–21:12, sold its share (18,778 XMT) through the Alcor DEX, and deposited the proceeds to the same exchanges.

While the pool was insolvent, it kept quoting as if nothing was wrong: 19,921 swaps executed against phantom reserves in the four-hour window between the drain (17:24) and the emergency pause (21:34) — a reminder that a drained AMM doesn't stop trading on its own.

The cover image above is the shape of it: one source, the accounts the funds passed through, exchange endpoints on the right, and — in teal — the recovery converging back into a single controlled treasury.

3. Where the money went

DestinationAmountStatus
Recovery treasury (recoverfunds)1,284,672,456 XPR + 12,871,556 METAL + 1,374,130 XUSDC + 571,762 XMD + wrapped BTC/ETH/DOGE/ADA/LTC/EOS + ecosystem tokens (SNIPS, LOAN, MINT, STRX)✅ secured
lending.loan (debt repaid)565,000,000 XPR sent, 1,357,182 refunded as over-payment✅ protocol made whole
KuCoin (kcdeposit4in)~269.4M XPR⚠️ escaped — KYC lead
MEXC (mexcloanloan)7.5M XPR + 310K METAL⚠️ escaped — KYC lead
Gate.io (gatedeposit)33 XPR (test)⚠️ escaped — KYC lead

On the incident day, the attacker's deposits were 99.7% of all XPR that hit KuCoin's deposit wallet — not a needle in a haystack, they were the haystack. Both exploiters reused the same KuCoin deposit memo (a unique customer ID), which ties them to a single exchange account — the strongest deanonymization lead of the whole case. Exchange freeze/KYC requests and law-enforcement filings are underway for the escaped ~277M.

4. The recovery, minute by minute

All times UTC. Every row is a public transaction.

WhenWhatWho
12 Sep 17:19–17:35nobreed probes with -1 XPR, then drains 1.526B XPR and every pool tokenattacker
17:35–17:37Borrows 563.57M XPR from lending.loan against the stolen XUSDC/XMDattacker
17:58First cash-out: 7.5M XPR → MEXCattacker
19:04nobreed added to the network blocklist — its METAL/XUSDC/XMD can no longer move (native XPR can)Metallicus
19:07 / 19:54sal2i4qb5byz blocklisted, and unionchainpi — later identified as an exchange deposit wallet (see §8)Metallicus
20:56–21:12Second exploiter winterbloom4 hits the same bugattacker
21:32proton.swaps patched (setcode)Metallicus
21:34proton.swaps paused — trading stopsMetallicus
22:19First key-recovery multisig executes through eosio.wrap… and silently does nothing (see §5)BPs
22:43 → 22:49BP multisig freezeatk proposed and executed in six minutes (15 of 21 producers) — actor-level freeze on the clusterBPs
23:25Patched eosio.wrap deployed by BP multisig wrapinline1 (15 of 21)ProtonNZ (fix) + BPs (signatures)
23:26Multisig recovall (proposed by Alvosec) executes — attacker accounts' keys rotated to the recovery keyAlvosec, BPs
23:37Multisig recovall2 — remaining cluster accounts seizedProtonNZ, BPs
13 Sep 06:37BP multisig unfreezeatk restores CPU/NET to the seized accounts so they can transactMetallicus, BPs
07:10–08:20All six seized accounts swept into recoverfunds (sal2i4qb5byz 616.0M, nobreed 620.9M, crymaverick 44.09M, unionchainpo 3.6M, winterbloom4, unionchainpi). Three of the six turned out to be an exchange's wallets — see §8recovery key
07:13lending.loan debt repaid (565M XPR; 1.36M excess auto-refunded); collateral unwound and redeemedrecovery key
14 Sep 21:17XPR Consortium grant 290,793,873.4965 XPR → recoverfunds (admin.proton committee, 3 of 6)XPR Consortium
15 Sep 00:0318,991,937.9886 XPR → metalpayroll, exchanged for the 310,000 METAL + 18,778 XMT the pools are shortrecovery key
15 Sep 03:17310,000 METAL delivered to recoverfunds from xprtreasury (tx)Metallicus
15 Sep 04:1518,778 XMT delivered to recoverfunds (tx) — every token the pools are owed is now staged in one accountMetallicus
15 Sep 17:44proton.swaps updated with the restoration path (tx)Metallicus
17:4510 XPR test restoration (tx) — accepted, nothing bookedrecovery key
17:46Full restoration: 15 tokens, 1,556,474,381.8415 XPR + all pool tokens, in one transaction (tx). Every token now held = owed, to the last unitrecovery key
15 Sep 18:42proton.swaps unpaused — trading reopens (tx); deposit withdrawals held paused for a staged reopeningMetallicus

One important correction to that timeline: three of the six accounts seized that night — crymaverick, unionchainpi and unionchainpo — were not attacker accounts. They are a centralised exchange's own deposit and treasury wallets, which had received the attacker's deposits like any other customer transfer. Nothing on chain identified them as such. Their keys were returned on 15 September and the exchange's own funds are being repaid in full; the detail is in §8.

From the first negative withdraw to the attacker's keys being rotated: six hours seven minutes. From the first blocklist entry to keys rotated: four and a half hours — most of that spent discovering and fixing the eosio.wrap bug below.

5. The bug that almost blocked the recovery — and how the BPs fixed it

Block producers froze the attacker cluster within hours. But seizing compromised accounts requires eosio.wrap — the privileged contract BPs use to execute recovery actions by multisig — and eosio.wrap was broken.

Its exec scheduled the recovery as a deferred transaction. Producers running modern Antelope (Leap 5+/Spring) no longer process deferred transactions at all. The first single-account recovery multisig at 22:19 "executed" successfully… and then silently did nothing. Alvosec's bundled recovall reached 15 of 15 approvals and then refused to execute at all — "deferred transaction with the same sender_id and payer already exists" — because three wrapped calls in one transaction collided on the deferred sender ID. Nobody had needed eosio.wrap since the network upgraded, so nobody knew.

The diagnosis came out of the BP channel in real time: Danemark and ProtonUK pinned it down — the contract was still the pre-v3.2.0 deferred implementation and Leap 5+ producers simply drop deferred transactions — and Danemark traced the duplicate-sender-ID failure to the way the old contract derived IDs. EOS Amsterdam called it: "while we're here, we can upgrade the wrap contract." ProtonNZ wrote the fix to make eosio.wrap run the wrapped actions inline (matched exactly to the AntelopeIO reference contract, at the group's request), built it, and proposed it as BP multisig wrapinline1. Sixteen producers signed within four minutes and it was deployed to mainnet at 23:25. Sixty seconds later Alvosec's recovall went through the fixed contract and the attacker's keys were rotated. That patch is now a proper, reviewed pull request against the canonical repo (PR #8) — and the idea behind PR #9 (make native XPR honour the blocklist so mobilising 15 BPs is never this time-critical again) was raised in the same channel, in the same hour.

Credit where it's due: the block producers turned around three separate 15-of-21 multisigs in under an hour on a Saturday night — freezeatk, wrapinline1, recovall — and a fourth (unfreezeatk) the next morning. For the European producers that meant signing between roughly half past midnight and two in the morning on a Sunday, then getting up again before dawn for unfreezeatk; the BP channel was still working the problem at 3 a.m. Central European time. Nobody was on call for this. They just showed up. Metallicus patched and paused the swap contract, ran the token-level freezes, and are writing the restoration change. This was a genuinely coordinated response, and every signature is on-chain:

MultisigPurposeProposed byApprovals (in signing order)Executed
recovallRotate the attacker accounts' keys via eosio.wrapAlvosec17 — alvosec, protonnz, eosamsterdam, saltant, snipverse, storexbp, protonuk, bloxprod, eosusa, xprnodeonebp, catsvote, madblocks, cerebroai, totalproton, cafe, brotonbp, cryptolions (21:05–21:51)23:26 by protonnz, once eosio.wrap was fixed
freezeatkActor-level freeze of the attacker clusterMetallicus15 in 3 min 39 s — bloxprod, storexbp, snipverse, madblocks, xprnodeonebp, brotonbp, totalproton, protonnz, protonuk, eosusa, cryptolions, catsvote, alvosec, protonmadrid, eosamsterdam (22:45:09–22:48:48)22:49 by protonnz
wrapinline1Deploy the inline eosio.wrap fixProtonNZ16 in 3 min 57 s — storexbp, saltant, cryptolions, madblocks, xprnodeonebp, catsvote, protonuk, snipverse, eosamsterdam, protonmadrid, protonnz, alvosec, bloxprod, brotonbp, danemarkbp, cafe (23:21:00–23:24:57)23:25 by protonnz
recovall2Seize the remaining cluster accountsProtonNZ17 in 4 min 28 s — storexbp, brotonbp, danemarkbp, snipverse, catsvote, bloxprod, xprnodeonebp, madblocks, protonnz, protonuk, alvosec, protonmadrid, cryptolions, eosamsterdam, eosusa, saltant, cafe (23:33:16–23:37:44)23:37 by saltant
unfreezeatkRestore CPU/NET to the seized accounts so they can be sweptMetallicus15 — protonind, protonnz, saltant, snipverse, storexbp, bloxprod, blocksforge, eosusa, alvosec, xprnodeonebp, cafe, cerebroai, brotonbp, catsvote, madblocks (13 Sep 01:09–06:37)13 Sep 06:37 by madblocks
swapsgrantXPR Consortium grant, 290,793,873.4965 XPR → recoverfundsProtonNZ3 of 6 committee — protonnz, alvosec, cryptolions (14 Sep 20:14–21:06)14 Sep 21:17 by protonnz

Twenty different producer accounts signed at least one of these. Several — alvosec, protonnz, snipverse, storexbp, bloxprod, xprnodeonebp, catsvote, madblocks, brotonbp — signed all five BP multisigs.

6. What was recovered — exact figures

The contract's own ledger (pools reserves plus the deposits table) says what it owes per token. Comparing that to the contract's actual balances gives the deficit; comparing the deficit to what was swept into recoverfunds proves nothing else ever left:

TokenDeficit in proton.swapsRecovered to recoverfundsStill shortExplained by
XPR1,556,474,391.84151,284,672,456.3336271,801,935.5079escaped to exchanges
METAL13,181,556.4982020312,871,556.49820203310,000escaped to MEXC
XMT18,778.00000000018,778winterbloom4 sold via Alcor
XUSDC1,374,114.6300831,374,129.643548—fully recovered (15 surplus)
XMD571,767.317950571,761.8483325.47dust
SNIPS, LOAN, MINT, STRX, XDOGE, XADA, XEOS, XLTC, XETH, XBTC= recoveredexact0fully recovered

Eleven of fifteen tokens match to the last unit. The only unexplained deficit on the entire contract is about $5 of XMD.

7. Making the pools whole

The 271.8M XPR, 310K METAL and 18,778 XMT that escaped are with law enforcement and the exchanges; that process takes months and may or may not return anything. Liquidity providers shouldn't wait for it.

On 14 September the XPR Consortium approved a grant of 290,793,873.4965 XPR from xprgrants — the exact XPR deficit (271,801,935.5079) plus the XPR needed to obtain the missing METAL and XMT (18,991,937.9886, priced by walking the live MetalX order book so it could be justified to the unit). The METAL and XMT are being sourced over-the-counter from Metallicus rather than on the open market, so there's no price impact on either token.

One more piece of engineering was needed: as written, proton.swaps treats any token sent to it as either a user deposit (which would book a new liability, leaving the contract exactly as insolvent as before) or a swap — and rejects both while paused. A special modification had to be made to the contract so that the restoration funds can be deposited back without being credited to anyone, restoring solvency without touching a single pool balance or LP share. Metallicus deployed it on 15 September at 17:44 UTC, verified byte-for-byte against the build that had passed the same test on testnet.

The restoration executed at 17:46 UTC — one transaction from recoverfunds (5157f47d…), fifteen tokens, after a 10 XPR test a minute earlier, plus a final 0.5 XMD at 18:20 to back one open limit order that had also been drained (74a2729d…). Final reconciliation against the contract's own ledger:

TokenOwed (pools + deposits + open orders)Held after restoreDifference
XPR1,559,844,174.16981,559,844,174.16980
METAL13,181,556.4982927013,181,556.498292700
XMT188,435.14294895188,435.142948950
XUSDC1,374,114.6300921,374,114.6300920
XMD571,767.817953571,767.8179530
SNIPS, LOAN, MINT, STRX, XDOGE, XADA, XEOS, XLTC, XETH, XBTC==0

Every token the contract owes is now backed to the last unit. No deposit row was created by the restoration, pool reserves and LP supplies are unchanged, and every liquidity provider's position is intact. Of the 290.79M XPR grant, nothing is unspent; the only remainder in recoverfunds is 15.01 XUSDC and a fraction of an XMD that the pools weren't owed. Metallicus reopened trading at 18:42 UTC, less than an hour after the restoration, keeping deposit withdrawals paused for a staged reopening. Solvency checks taken while live trading resumed read held = owed on every token.

8. Attribution

The strongest attribution evidence is not on the blockchain at all — it's in the exchange deposit memos, because a memo is the receiving exchange's own customer identifier.

Two exploiters, one exchange account. nobreed drained the contract at 17:19–17:35 UTC; winterbloom4 hit the same bug independently at 20:56–21:12. On chain they look unrelated: no shared keys at the time, no transfers between them, separate funding. But both deposited to the same exchange account under the same customer identifier. Only the exchange can confirm it, and we've asked — but on the face of it, the "second, independent exploiter" is the same customer.

The attack was funded by an exchange withdrawal. Six minutes before the first exploit call, an exchange's withdrawal wallet sent 12,300.4995 XPR to nobreed, which had been created that morning and had no other source of funds. Whoever requested that withdrawal is identifiable from that exchange's records.

It was probed three months earlier. A separate account ran the same negative-amount withdraw on 18 June 2026 and failed on a decimal-precision error. It also mints counterfeit tokens — including a fake "XPR" — and deposited 500,000 of them to an exchange, apparently testing whether forged tokens get credited. Whether that account is connected to September's attackers is a question only the exchanges can settle.

A correction worth making explicitly. An earlier version of this article described several accounts that received stolen funds as attacker infrastructure, because they were created in advance by a common key and forwarded funds onward under numeric memos. That was wrong: they are an exchange's own deposit and treasury wallets, and the memos are its customer payment IDs. Block producers seized them during the response because nothing on chain identified them, and control was returned once that became clear. The lesson is in §9 — exchanges should register the accounts they operate, so responders can tell a deposit wallet from a mule at three in the morning.

We're deliberately not naming individuals. Attribution is an exchange-KYC and law-enforcement matter, and detailed reports have gone to every exchange involved.

9. The permanent fixes

Recovering the funds was the emergency. Making sure the network is structurally better afterwards is the job. This incident exposed gaps in the system contracts, and we've submitted upstream fixes:

1. eosio.wrap — inline execution (PR #8). The account-recovery tool was left on the pre-Leap-5 deferred implementation — broken before it was ever needed. The emergency mainnet patch is now a reviewed, testnet-verified pull request so the canonical eosio.wrap works on modern Antelope. Privileged system contracts should never be a surprise dependency in the middle of an incident.

2. eosio.token — enforce the network blocklist (PR #9). XPR already has a shared on-chain blocklist contract that the wrapped tokens honour — a blocklisted account can't move METAL, XUSDC, etc. But eosio.token (native XPR) never checked it — the token contract has been unchanged since 2020. So on 12 September the attacker's METAL was frozen at 19:04 while his XPR kept moving until the BPs could assemble a 15-of-21 key takeover. PR #9 makes eosio.token read the same shared blocklist (sender-side, exactly matching xtokens), with core system accounts explicitly exempt so a blocklist entry can never halt staking, RAM, or rewards chain-wide. We built it with the same compiler that produced the live contract — the current on-chain eosio.token rebuilds byte-for-byte from the repository, and the fix differs from it by 42 lines — and verified the full freeze/unfreeze flow on testnet. It is a real power the native token didn't have before, and it is being put to the block producers as an explicit decision, not slipped in.

3. eosio.system — remove the last dead deferred-transaction code (PR #10). The same bug class as eosio.wrap turned out to be live in the system contract itself: XPR unstake refunds are scheduled as deferred transactions that never fire.

We can date the day it broke: 1 November 2025. Pairing every unstakexpr with the refundxpr that follows it shows a clean cliff. Unstake on 30 October 2025 and your refund arrived at the 24-hour mark 95% of the time; unstake on 1 November and it never did. That is when XPR's producers finished moving to Leap 5+/Spring, which silently discards deferred transactions. Nothing errored, so nobody noticed — the unstake still succeeds, the refund is just scheduled into a void. The ~30% of refunds that still land near 24 hours are people and wallets claiming manually at the first opportunity.

So this has been quietly broken for ten months, and the exploit response is simply what uncovered it. Measured over every unstakexpr of the last 540 days — 25,167 unstakes by 7,652 accounts:

376 accounts are holding 121,236,971 XPR (~US$295,000) that matured past the 24-hour window and was never paid out. Median wait: 102 days. The oldest has been sitting for 536 days. Roughly a third of all unstakes are never followed by a claim.

Nobody's XPR is lost — it's sitting in the refundsxpr table waiting for a refundxpr call that the chain was supposed to make automatically. Users on wallets that prompt them get it; everyone else doesn't know it exists. (The largest single case, ~32M XPR stuck 65 days, belongs to an account that had called refundxpr manually 119 times before and then stopped.) The same code will also start failing outright the day the network activates the standard DISABLE_DEFERRED_TRXS protocol features. PR #10 removes the dead code and makes matured refunds claimable by anyone, so a keeper can deliver them automatically again.

This one doesn't have to wait for a contract change: any wallet that reads refundsxpr and shows a "claim" button would clear most of that backlog today, and ProtonNZ is happy to run a keeper once #10 lands. Pooled funds are not affected — in all 25,167 unstakes the receiver was always the staker, and no staking protocol appears in the list. It's 376 individual wallets.

All three are pending human review, testnet runs and audit before mainnet, as privileged system-contract changes should be.

10. What proton.swaps needs (and what everyone can learn)

  • Assert quantity.amount > 0 on every token-moving action. A balance ≥ amount guard is not a positive-amount check; signed asset amounts make negative inputs a real attack surface. Audit every deposit/withdraw ledger for signed-amount handling.
  • Invariant tests: a contract's real token balance must always equal the sum of its internal ledger balances. That single test would have caught this in CI — and it is exactly the check that let us prove above that nothing else ever left the contract.
  • A fast circuit-breaker. A four-hour gap between drain and pause is a lot of phantom swaps. Emergency pause should be reachable quickly, not only through a slow multisig.
  • Keep privileged system contracts current. eosio.wrap being on a deprecated deferred model turned a bad day into a much longer one. Rehearse recovery on testnet before you need it.
  • Watch the probes. The June gatedepostf attempt was visible on-chain for three months. Monitoring for negative or malformed amounts on ledger contracts is cheap — and in this case it didn't even need to be clever: withdraw had not been called once in the 46 months before that probe. An alert on "any withdraw at all" would have fired on 18 June with zero false positives and given 86 days' warning.

Closing

No user lost funds to a stolen private key here — this was a contract bug, and the response was a coordinated block-producer, Metallicus and XPR Consortium effort that got most of the value back, is making liquidity providers whole, and is now closing the gaps that made it possible. That's the system working: fast containment, honest post-mortem, and permanent fixes in the open.

If you build on XPR Network and want a second set of eyes on your contracts before they hold real value, that's exactly the kind of review this incident argues for. Reach out.

Transaction IDs, account names, and figures in this article are verifiable on explorer.xprnetwork.org. ProtonNZ is an XPR Network block producer.