Originally published at Arcus Perps Exploit: Subaccount Bug Drains $2.23M
On the evening of 9 November 2026 (about 03:07 UTC on 10 November), a newly created account on Arcus Perps, a perpetuals and stock-token exchange on Robinhood Chain, found a bug in how the exchange handled transfers between a user's own subaccounts. Under specific conditions, those transfers made the account's recorded balance far larger than what it actually held. The account then asked to withdraw against that balance, and 2,234,391 USDG left the exchange on-chain before withdrawals were stopped.
It could have been much worse. A further $3 million of withdrawal requests was held back by the bridge's on-chain rate limit, never paid out, and later reversed. Arcus patched the bug, blocked the exploiter's addresses and covered the full shortfall from the Arcus Labs company treasury, saying no other user's balance, position or order was affected.
This article walks through what is known, why internal transfers are such a common source of "free money" bugs, and why the rate limit deserves as much attention as the bug itself.
What Happened
Arcus published an incident report on 10 November. Its key facts, which match the 0xCERT advisory, DefiLlama's hacks database and independent press coverage:
- Attacker: one newly created account. Arcus says no other accounts were involved.
- Bug class: accounting error in transfers between a user's own subaccounts.
- Effect: the account recorded a balance "far larger than it actually held".
- Withdrawn: 2,234,391 USDG, paid out on-chain.
- Blocked: about $3M of further requests, held by the bridge's rate limit and reversed.
- Loss to users: none, per Arcus; the shortfall was covered from the company treasury.
Arcus published the exploiter's initial address, 0xeFbf442599310E5CeE1A4EDd5c66334c5f30b3Dd, and asked exchanges and service providers to flag funds received from it.
Timeline (UTC)
Arcus gave its timeline in US Eastern time. Converted to UTC, it reads:
| Time (UTC) | Event |
|---|---|
| 10 Nov 03:07 | Exploit activity begins |
| 03:10 | Bridge rate limit caps payouts and holds back about $3M of requests |
| 03:15 | Monitoring flags the withdrawals; investigation starts |
| 04:07 | Platform-wide withdrawals paused |
| 04:37 | Bug fix deployed |
| 04:43 | Exploiter's addresses blocked from trading, transferring and withdrawing |
| 09:26 | Withdrawals re-enabled for everyone |
| 10:02 | Exploiter's pending withdrawals reversed |
| 17:28 | Shortfall covered from the Arcus Labs treasury |
Two things stand out. First, the rate limit acted within about three minutes, before any human was looking. Second, it took about an hour from first activity to the platform-wide pause, and about 90 minutes to a deployed fix. Altcoin Reporter pointed out that this sits uneasily with the report's "within one hour" claim for detection and fix. Either way, the automated control did its job well before the people did.
Where the money went
Arcus has not described the laundering route. Altcoin Reporter, citing Blockscout data, reported that the same address received about 1,052 ETH on Ethereum between 03:15 and 04:00 UTC on 10 November, roughly 922 ETH of it through a contract labelled as a Relay router, and later sent about 53 ETH into THORChain's router. The outlet reported that the address still held about 475 ETH on 11 November. Treat these figures as third-party on-chain tracing, not as Arcus's own numbers. The treasury top-up made users whole; it did not recover the funds.
The Root Cause: Internal Transfers That Create Value
Arcus has not published code or a technical post-mortem, and its report does not say whether the faulty logic ran in a smart contract or in the exchange's matching and accounting engine. Altcoin Reporter describes it as the exchange's own accounting of internal transfers rather than a smart contract hack in the usual sense. What the report does say is enough to place it in a well-known bug class: a transfer between two accounts owned by the same user changed the total amount of money in the system.
A correct internal transfer is a zero-sum move. If subaccount A sends 100 to subaccount B, A goes down by 100, B goes up by 100, and the sum of all balances is unchanged. When a transfer breaks that rule, a user can loop it and print balance out of nothing. Because the debit and credit sit on accounts the same person controls, nobody else is harmed by the transfer itself, so the bug is easy to miss in testing. The damage only shows up when the inflated balance is withdrawn against real collateral.
Common ways this goes wrong
We don't know which of these applied at Arcus. These are patterns we see repeatedly in audits of perps, lending and exchange accounting:
Self-transfer aliasing. Code reads both balances into memory, subtracts from one copy, adds to the other, then writes both back. If source and destination are the same account, the second write overwrites the first and the debit disappears.
Debit and credit computed differently. The credit is sized in one unit (for example, notional value or including unrealized PnL) while the debit is sized in another (raw collateral). Each transfer leaks the difference.
Unchecked margin on the sender. A subaccount moves out collateral that is still backing open positions. The receiver gets free collateral, and the sender is left undercollateralized, so the system's "real" equity is now counted twice.
Rounding in the user's favour on both legs. Small on each call, but a script can repeat it thousands of times.
Ordering and caching. A cached "free collateral" value is updated on the credit but not invalidated on the debit, so the next withdrawal check reads a stale, larger number.
A simplified illustration
The Solidity below is simplified and illustrative only. It is not Arcus's code, which has not been published. It shows the self-transfer aliasing pattern:
// Simplified, illustrative example. Not Arcus code.
function transferBetweenSubaccounts(uint256 from, uint256 to, uint256 amount) external {
require(owner[from] == msg.sender && owner[to] == msg.sender, "not owner");
uint256 fromBal = balance[from];
uint256 toBal = balance[to];
require(fromBal >= amount, "insufficient");
balance[from] = fromBal - amount;
balance[to] = toBal + amount; // if from == to, this overwrites the debit
}
With from == to, every call adds amount to the balance. The fix is either to reject from == to, or to update storage in place (balance[from] -= amount; balance[to] += amount;) and then assert that the total is unchanged. The second approach also catches bugs nobody has thought of yet.
Why the Rate Limit Mattered
Most incident write-ups focus on the bug. Here, the more instructive part is the control that limited it. The bridge's on-chain withdrawal rate limit capped outflows about three minutes into the attack and held back roughly $3M of further requests. Without it, the paid-out loss could have been more than twice as large before anyone paused the platform.
Rate limits work because they don't need to understand the bug. They only assume that legitimate outflows have a normal speed and that anything faster deserves a delay. That makes them useful against the bugs your tests missed, which are the only ones that matter in production. The pending queue also gave Arcus something to act on: once the bug was understood, the exploiter's held requests could be reversed instead of chased across chains.
The trade-off is user experience during genuine spikes. Good designs size limits per asset and per time window, let small withdrawals through, and queue large ones visibly rather than failing them.
Lessons for Builders
Make conservation an invariant, not an assumption. Any internal transfer, rebalance or margin move must leave total balances unchanged. Check it in code where you can, and in fuzz and invariant tests everywhere. "Sum of all subaccount balances equals total deposits minus withdrawals, plus realized PnL" is the kind of property that would catch this whole class.
Test the degenerate inputs. Same source and destination, zero amounts, maximum amounts and freshly created accounts are where accounting code breaks. We saw a zero-amount input defeat a stake check in the b14g withdrawal exploit, and a sign error do similar damage in the WAX bridge negative fee exploit.
Reconcile off-chain books against on-chain reality. If accounting runs off-chain, run a continuous check that the exchange's ledger never claims more than the contracts hold, and halt withdrawals automatically when it drifts.
Put a rate limit on every exit. Bridges, vault withdrawals and settlement paths should all have one. It turned a potential $5M+ event into a $2.23M one here, and it bought time in the Ankr bridge forged-receipt exploit style of incident where the root cause takes hours to find.
Treat brand-new accounts with large withdrawals as a signal. A fresh account that withdraws millions within minutes is rare in normal use. Heuristics on account age and withdrawal size are cheap and catch exploit patterns early.
Close the gap between alert and pause. Monitoring flagged the withdrawals at 03:15 UTC, but the platform-wide pause came at 04:07 UTC. An automatic or one-click pause tied to the alert would have narrowed that window.
What Users Should Know
Arcus says no user balances, positions or orders were affected, the shortfall was covered from its treasury, and trading and withdrawals are operating normally. Use only official Arcus channels for updates, and treat unsolicited "refund" or "recovery" messages as phishing. This analysis is technical commentary, not financial advice.
Sources
- Arcus incident report: timeline, amounts, response and exploiter address
- 0xCERT-2026-0338: advisory summarising the report, with UTC timing
- DefiLlama hacks database: classification as a withdrawal logic flaw on Robinhood Chain
- Altcoin Reporter: UTC timeline and Blockscout-based tracing of the exploiter address
- The Crypto Times: coverage of the report and the withdrawn versus blocked amounts
Closing Thoughts
The Arcus incident needed no flash loan, oracle or key compromise. It needed one transfer path that did not conserve value, plus a withdrawal path that trusted the resulting number. The bug was the exchange's; the reason the loss stopped at $2.23M was a rate limit that nobody had to trigger. If you run a perps venue, lending market or anything with internal ledgers and subaccounts, talk to our team or see our audit pricing to have your accounting invariants and exit controls reviewed before someone tests them for you.
Top comments (0)