What happened
On 1 and 2 November 2026, about $126 million in USDC left accounts at Kraken, Crypto.com and OKX, according to Bitquery's revised count from 5 November (17 wallets over 28 hours). Earlier reports gave $108.8 million and 15 wallets. Nothing on chain shows an exchange was breached; the money left through ordinary withdrawals. How the attackers got access has not been made public.
Two details from the reporting matter for anyone who writes withdrawal logic:
- Ledger Insights describes addresses that first received a $1,000 test payment, then the full balance a minute later, typically $5 million to $10 million.
- Bitquery found that 12 of the 17 receiving wallets had been paid by the same exchanges before, two of them since May. Those familiar wallets took 79% of the total.
Then the funds moved fast. Bitquery puts the median wait before funds went on to a fresh wallet at five minutes, and $62.98 million of stablecoins were swapped for 27,553 ETH between 23:44 UTC on Sunday and 04:02 UTC on Monday. Issuers can freeze USDC and USDT; nobody can freeze ether. By 5 November Tether had frozen 1.14 million USDT and Circle none of the 1.71 million USDC still in the attacker's wallets.
Why the usual check passes it
A withdrawal allowlist answers one question: is this destination approved? For every withdrawal above, the answer was yes. An address with a payment history going back months is exactly the kind of destination an allowlist trusts most.
So the check that would have helped is not about the address. It is about how this withdrawal compares to what the account normally does.
A policy check that looks at behaviour
Here is a small, dependency-free version you can run with Node 18+. It keeps the allowlist and adds three rules: a size jump against the largest earlier payment to the same address, an hourly cap, and a large payment shortly after a small test.
const HOUR = 60 * 60 * 1000; export function review(w, history, policy) { const reasons = []; if (!policy.allowlist.has(w.to)) reasons.push('destination is not on the allowlist'); const toThis = history.filter((h) => h.to === w.to); const largestBefore = Math.max(0, ...toThis.map((h) => h.amount)); if (largestBefore > 0 && w.amount > largestBefore * policy.maxJump) { reasons.push(`amount is ${(w.amount / largestBefore).toFixed(0)}x the largest earlier payment to this address`); } const lastHour = history.filter((h) => w.at - h.at < HOUR).reduce((s, h) => s + h.amount, 0); if (lastHour + w.amount > policy.hourlyCap) { reasons.push(`hourly total would reach ${(lastHour + w.amount).toLocaleString('en-US')} (cap ${policy.hourlyCap.toLocaleString('en-US')})`); } const small = toThis.filter((h) => h.amount <= policy.testSize && w.at - h.at < 10 * 60 * 1000); if (small.length && w.amount > policy.testSize * 100) reasons.push('large payment minutes after a small test payment'); return {hold: reasons.length > 0, reasons}; } Feed it the reported pattern: an approved address with an older, normal payment, a $1,000 test, and $6 million a minute later. Then an ordinary payment to another approved address the next day.
const policy = {allowlist: new Set(['addr-A', 'addr-B']), maxJump: 10, hourlyCap: 2_000_000, testSize: 1_000}; const t0 = Date.UTC(2026, 10, 1, 20, 0); const history = [ {to: 'addr-A', amount: 40_000, at: t0 - 90 * 24 * HOUR}, {to: 'addr-A', amount: 1_000, at: t0}, ]; const queue = [ {to: 'addr-A', amount: 6_000_000, at: t0 + 60 * 1000}, {to: 'addr-B', amount: 30_000, at: t0 + 30 * HOUR}, ]; for (const w of queue) { const r = review(w, history, policy); console.log(`${w.to} ${w.amount.toLocaleString('en-US')}: ${r.hold ? 'HOLD' : 'pass'}${r.reasons.length ? '\n - ' + r.reasons.join('\n - ') : ''}`); if (!r.hold) history.push(w); } Output:
addr-A 6,000,000: HOLD - amount is 150x the largest earlier payment to this address - hourly total would reach 6,001,000 (cap 2,000,000) - large payment minutes after a small test payment addr-B 30,000: pass The allowlist line never fires. The other three do, and the ordinary payment still goes through.
What a hold buys you
A hold does not have to be long to matter here. The money became unfreezable once it was swapped into ether, and that happened within hours. A rule that parks an unusual withdrawal for a second approver, even for an hour, turns a five-minute exit into a decision a person makes.
The thresholds above are placeholders. Real ones come from each account's own history, and a production version would also persist state, alert someone, and log why a payment was held.
Sources
- Bitquery, revised investigation, figures verified 5 Nov 2026: https://www.bitquery.io/investigations/eu-106m-crypto-theft
- Ledger Insights, 3 Nov 2026: https://www.ledgerinsights.com/eu-institutions-suspected-133m-theft-was-via-ordinary-exchange-withdrawals-mostly-at-kraken/
- CoinDesk, 4 Nov 2026: https://www.coindesk.com/business/2026/11/04/zachxbt-names-french-bank-credit-mutuel-arkea-as-suspected-victim-of-usd133-million-crypto-theft
- GNcrypto, 3 Nov 2026: https://www.gncrypto.news/news/crypto-theft-exposes-limits-of-exchange-withdrawal-allowlists/
