i posted a thread on this yesterday, but 280 characters at a time doesn't do it justice. this one needs room to breathe because the mechanism is genuinely novel. i haven't seen this pattern before.
maya protocol lost ~20.83 BTC. the attacker didn't find a reentrancy bug. didn't exploit a price oracle. didn't even need a flash loan. they made maya's own slashing mechanism โ the system designed to protect the protocol โ drain the reserve into a pool they controlled.
the security system was the exploit.
maya is a thorchain fork. mid-2025, they ported over a feature called Trade Accounts โ essentially off-pool deposit slots where users can park assets for faster trading. assets in trade accounts sit outside the main vault accounting structure.
this is what caught my attention. when you add a new asset custody mechanism to a protocol, every security system that references asset balances needs to know about it. solvency checkers, slashing logic, outbound validation โ all of it.
maya ported the feature. they did not update the security systems.
eight steps. i'll walk through each one because the sequencing matters โ this wasn't a smash-and-grab, it was choreographed.
attacker deposits 8 ETH and 2 LINK into Trade Accounts. these assets are now held by the vault but stored outside vault.Coins โ the data structure that the solvency checker reads.
// handler_solvency.go โ what gets checked
// vault.Coins is the ONLY source of truth
// trade account balances? not referenced. not once.
// grep the file yourself โ zero hits for "trade"
the vault has the assets. the solvency checker can't see them. already a problem, but it gets worse.
attacker donates 27,789 CACAO to the ARB.LINK pool. this pool was near-empty โ low liquidity, negligible volume. by donating into it, the attacker is setting up the pool they'll extract from later. nobody noticed because donating to a pool is a normal, permissionless operation.
this is the core of the exploit. one transaction. 23 MsgDeposit messages packed inside it.
| messages | purpose |
|---|---|
| 22 | trade account withdrawals |
| 1 | DONATE:ARB.LINK |
the 22 withdrawals are the interesting part. each withdrawal amount is incremented by approximately 263 units from the previous one. this isn't random โ it prevents maya's transaction batching logic from combining them into a single outbound. each withdrawal must be processed as a separate L1 transaction.
22 individual outbound transactions, all originating from the vault.
here's where the security system breaks. nodes observe 22 L1 outbound transactions leaving the vault. they check these against the scheduled TxOutItems โ the list of outbounds the protocol authorized.
trade account withdrawals aren't tracked in TxOutItems.
so the nodes see 22 outbounds that don't match any scheduled item. the logic in handler_observed_txout.go has a name for outbounds that leave the vault without authorization: theft.
// handler_observed_txout.go โ slashObservedOutbound()
// outbound observed but not in TxOutItems?
// โ classified as unauthorized
// โ trigger SlashVaultToLP(subsidize=true)
the protocol thinks it's being robbed. it isn't. these are legitimate withdrawals from a feature the security system doesn't know about. but the slashing logic doesn't ask questions. it acts.
SlashVaultToLP fires. the vault's bond gets slashed for the "theft." then comes the subsidize=true flag.
when a vault is slashed for an unauthorized outbound, the protocol tries to make LPs whole. it calls subsidizePoolWithSlashBondV92() in helpers.go. this function transfers CACAO from the Reserve module to affected pools.
the function has no cap.
// helpers.go โ subsidizePoolWithSlashBondV92()
// line 175
//
// calculates subsidy amount based on slash value
// transfers from Reserve to pool
// NO maximum transfer limit
// NO check against reserve balance
// NO rate limiting
22 "unauthorized" outbounds. 22 slash events. each one pumping CACAO from the Reserve into pools. the Reserve held roughly 50M CACAO. by the time the slashing finished, 49.42M CACAO had been transferred to pools โ with the ARB.LINK pool (the one the attacker pre-positioned) receiving a massive share.
the attacker adds 100 CACAO as liquidity to the now-bloated ARB.LINK pool. then withdraws 99% of their LP position.
| direction | amount |
|---|---|
| LP deposit | 100 CACAO |
| LP withdrawal | 48,869,502 CACAO |
| multiplier | 488,695x |
100 CACAO in, 48.8M CACAO out. the pool ratio was so distorted from the subsidy flood that this withdrawal was mathematically valid.
10 consecutive CACAO-to-BTC swaps. 20.83 BTC extracted to an external wallet. clean, sequential, no rush.
# attacker addresses
maya: maya1dl3yrfpedyr5jfr0r86s2apjltnjqgszmwsv8x
btc: bc1q0hsgwunccczelq05ucpmfz268eyy5jr2y5l646
no single bug here is catastrophic on its own. it's the combination that kills.
handler_solvency.go checks vault solvency by comparing vault.Coins against actual on-chain balances. trade account assets exist on-chain but aren't in vault.Coins. the solvency checker has no reference to trade accounts โ the string "trade" doesn't appear in the file.
in BTS terms, this is an L1 verification failure โ the system's model of reality doesn't match reality. the vault holds assets that the verification layer can't see.
handler_observed_txout.go classifies any observed outbound not matching a scheduled TxOutItem as unauthorized. trade account withdrawals are legitimate but unscheduled. the handler can't distinguish "feature i don't know about" from "someone is stealing funds."
this is the bridge equivalent of a guard who shoots anyone leaving the building who isn't on today's visitor log โ including employees who entered through a door the guard doesn't monitor.
subsidizePoolWithSlashBondV92() in helpers.go transfers CACAO from Reserve to pools with no maximum limit. no percentage cap. no per-event ceiling. no rate limiter. the function assumes that slash events are rare and legitimate โ that the amount being transferred is proportional to an actual theft.
when the input is 22 fake "theft" events in rapid succession, the function drains the Reserve.
BTS L5 circuit breaker โ nonexistent. no rate limiting on reserve outflows. 49.42M CACAO moved in one sequence without any mechanism hitting the brakes.
trade accounts were a thorchain feature ported to maya mid-2025. the feature itself worked correctly โ deposits, withdrawals, accounting, all fine. what wasn't ported was the awareness of trade accounts across the security stack.
this pattern shows up more than people admit. protocol forks a feature from upstream. feature code works. security code doesn't know the feature exists. the gap sits there until someone finds it.
| system | trade account awareness |
|---|---|
| deposit handler | yes |
| withdrawal handler | yes |
| trade accounting | yes |
| solvency checker | no |
| outbound classifier | no |
| subsidy calculator | not applicable (no cap regardless) |
three systems that needed updating. zero were updated.
as of writing:
| network | globally halted (HALTCHAINGLOBAL: 1) |
| reserve | 419,808 CACAO (was ~50M) |
| CACAO price | down ~89% |
| extracted | 20.83 BTC |
i've been building the bridge trust score framework around the idea that bridge security is multiplicative โ one zero makes the product zero. maya is a clean case study.
| BTS layer | maya score | why |
|---|---|---|
| L1 verification | failed | solvency model incomplete โ trade accounts invisible |
| L5 circuit breaker | absent | no cap on reserve subsidy, no rate limiting |
the attack surface wasn't in the trade account code. it was in the gap between the feature and the security systems that should have known about it. this is the kind of thing BTS is designed to catch โ not code bugs, but model completeness. does the verification layer's view of the world match the actual world?
at maya, it didn't. the solvency checker saw a vault that looked correct while the vault held assets the checker couldn't account for. the outbound classifier saw "theft" where there was a legitimate withdrawal. the subsidy function saw a crisis and emptied the treasury.
every system did exactly what it was programmed to do. the bug wasn't in any one system. it was in the fact that they were programmed against an incomplete model of the protocol.
when you fork a feature, you're not just porting code. you're porting a set of assumptions that every other system in the protocol needs to share. trade accounts assumed the solvency checker would know they exist. the solvency checker assumed vault.Coins was complete. the outbound classifier assumed every legitimate outbound would be scheduled. the subsidy function assumed slash events reflected real theft.
every assumption was reasonable in isolation. together, they created a pipeline that converted legitimate withdrawals into a reserve drain.
the attacker understood this better than the developers did. that's usually how it goes.
expanded from the thread on x. the thread had the what โ this has the why. bridge-security-toolkit is being updated to flag model-completeness gaps like this.
written while the hawthorne bridge was up for a boat and my coffee was getting cold. โ 0xrivet