Validation gap

Rhea Finance's $18.4M Loss and How Olympix Would Have Prevented It

Loss
Loss
Exploit date
April 16, 2026

On April 16, 2026, a handful of freshly created NEAR accounts opened margin positions on Rhea Finance within seconds of each other. Each one borrowed real assets from Rhea's lending reserves, swapped them through a route of tokens nobody had heard of the day before, and handed back a few cents of USDC as the position. Rhea's margin engine accepted every one of them, because by the time the swap came back it had already decided the trade was safe.

The losses climbed fast. CertiK's first alert put them at about $7.6 million, and the next day Rhea's post-mortem, as reported by The Block set the total at $18.4 million, drained from the reserve pool through positions that were underwater from the moment they opened. Most of that money has since come back or been frozen, which is a story of its own, but the bug that let it out was a single line in the code that decided how much a margin trade was supposed to return.

A Lending Market With a Swap Inside It

Rhea Finance, formerly Burrow, is a lending protocol on NEAR. Users deposit assets to earn yield and borrow against collateral, and the core contract that runs all of it is Burrowland, deployed as contract.main.burrow.near. In July 2024, according to Rekt's write-up, Burrowland gained a margin trading module that let users open leveraged positions without leaving the protocol.

Opening a margin position on Rhea works in three moves. The user posts collateral, Burrowland lends them a debt token from its reserves, and that debt token is swapped through Ref Finance, NEAR's main DEX, into the position token the user wants to hold. The user describes the swap themselves, as a Ref message listing one or more swap actions, and each action carries its own token in, token out and minimum acceptable output.

Because the user writes the route, Burrowland has to decide whether the route is reasonable before it lends anything. It reads the message, works out what the swap promises to deliver, and compares that promise against oracle prices for the debt and position tokens. If the promise is fair, the protocol borrows on the user's behalf and sends the debt token off to Ref. NEAR contracts call each other asynchronously, so the result of that swap comes back later, in a separate callback.

That split is the whole story. One function decides what the trade should return, a different function receives what it actually returned, and the two never compare notes.

The Line That Added Up the Wrong Numbers

The function that reads the swap promise is get_token_out(). It takes the last action in the route, treats its output token as the position token, and computes the minimum amount the route guarantees. The Burrowland source on GitHub shows how that minimum is calculated:

let action = actions.last().unwrap();
let token_out = action.get_token_out();
let min_amount_out = actions.iter().filter(|a| a.get_token_out() == token_out).map(|a| a.get_min_amount_out()).sum();

In plain terms, the code walks through every swap action in the route, keeps the ones whose output token matches the final output token, and adds up their minimums. For a route that splits one trade across several pools that all end in USDC, that's a sensible way to total the parallel legs. It stops being sensible the moment a route passes through the final token more than once in a row.

A route like debt token to fake token to USDC to fake token to USDC has two actions that output USDC, but only the last one delivers anything to the protocol. The first USDC leg's output is immediately spent as the input for the next hop. get_token_out() counts both minimums anyway, so the promised total grows with every pass through USDC, even though the USDC itself is the same money moving in a circle.

QuillAudits traced the root cause to exactly this summation in margin_trading.rs, describing it as a check that double-counts any output token reused as the next step's input. Their simplified example has a first step promising 999 units and a second promising 1, for a validated total of 1,000, while the protocol actually receives about 1 USDC.

The inflated minimum then went into is_min_amount_out_reasonable(), the check that compares the promise against oracle prices. A promise that large looks generous, not suspicious, so the check passed and Burrowland borrowed.

The Callback That Believed Everything

When Ref finished the swap, the result came back to on_open_trade_return(). This is the function that records the new position, and the verified source shows what it does with the amount the swap produced:

asset_position.margin_position += amount;
mt.token_p_amount += amount;
mt.is_locking = false;

Whatever arrived became the position. According to Rekt's analysis, the callback didn't compare that amount against the minimum it had validated a moment earlier, didn't recompute the position's health and didn't check leverage. It only rolled back if the swap failed outright. A swap that succeeded and returned almost nothing was recorded as a perfectly normal margin position with a debt far larger than its assets.

Rekt quantified one of these trades. Burrowland validated a min_token_p_amount of 32,595,520,035,000,000,000,000 in raw units, while the USDC the route actually returned to the protocol was 7,925 raw units, less than a cent.

Two Days of Setup, Seconds of Execution

The attack was built over about two days. According to QuillAudits, the attacker started staging on April 13, funded a subject wallet through NEAR Intents on April 15 and spread roughly 71,000 NEAR across 423 intermediary wallets. Late on April 15, 21 of those intermediary accounts were deleted within 30 seconds, each naming the subject wallet as its beneficiary.

The attack infrastructure went up next. The attacker deployed fake fungible-token contracts with no standard metadata methods, and Rekt counted 25 consecutive Ref Finance pools, IDs 8514 to 8538, layered so that ZEC paired with first-stage fakes, fakes paired with other fakes, and the last layer paired fakes with USDC. QuillAudits counted 123 fake tokens across the whole operation.

Five worker accounts did the actual draining. Each one followed the same script, as Rekt reconstructed it: seed liquidity into the fake pools, receive collateral from a master account, deposit it into Burrowland, open a poisoned margin position, withdraw, pull the liquidity back out of the fake pools, and send the proceeds to the master.

NearBlocks shows two of those workers calling open_position at the same moment. At 09:47:12 UTC on April 16, worker d4e8e7...0b03 signed transaction 4H5kQ4...r1AN and worker 5a7695...3040 signed transaction 44tWhQ...Du1x, both successful. Each margin open sent borrowed reserve assets into a route that bounced through USDC over and over, and each one came back with a sliver of USDC recorded as a full position.

The value didn't vanish. The debt tokens Burrowland lent out flowed into the attacker's own fake pools, so when the workers removed their liquidity afterward, they pulled the real assets back out alongside their seed capital. Behind them, every poisoned position sat deep underwater, and as Rhea explained in its post-mortem, liquidations of those positions drained the reserve pool further. QuillAudits counted about $18.6 million across 1,142 transactions. Rhea's own figure is $18.4 million, and that's the number used here.

Following the Money

Rekt traced the proceeds from the workers to a master account and then to a collector wallet, with confirmed transfers of 1.7 million NEAR, 460,455 USDC, 446,582 USDT, 799,903 wNEAR and 7,095 ZEC. From there the attacker moved funds through NEAR Intents, converted USDT into USDC mid-stream, bridged USDC to Ethereum and parked about $3.4 million of it in Aave, and pushed roughly 12,095 ZEC into shielded Zcash addresses.

The freezes started almost immediately. Tether froze about 3.29 million USDT in the attacker's wallet, and NEAR Intents froze another roughly 1.05 million USDT, according to QuillAudits. Aurora Labs co-founder Alex Shevchenko sent the attacker an on-chain message saying they'd been identified and asking for the remaining assets back.

They came back in pieces. The attacker returned about 3.36 million USDC and 1.56 million NEAR to the Rhea lending contract, and QuillAudits later reported a return of about 13,500 ZEC and an Ethereum-side return of about $3.49 million. By April 22, QuillAudits put the remaining shortfall at about $0.4 million, which Rhea committed to covering in full.

Rhea's Response

Rhea paused its lending contracts within hours of the first alert and paused Rhea DEX as a precaution, though the DEX itself wasn't affected. It removed the fraudulent tokens and pools from the DEX before reopening it, engaged an external security team for forensics and opened traces with centralized exchanges.

The larger decision came after. According to QuillAudits, Rhea permanently discontinued margin trading on NEAR and planned a phased reopening of lending starting in the first week of May 2026, tied to resolving the frozen funds, contract upgrades, real-time monitoring and a full protocol audit.

Why It Slipped Past Everyone

Rekt notes that Burrowland was audited twice by BlockSec before its March 2022 launch, while margin trading arrived in a later version in July 2024. Code added after a review naturally sits outside that review's scope, and the margin module is exactly the kind of addition that changes what older code means.

get_token_out() looks correct on its own. Summing the minimums of parallel legs that end in the same token is reasonable, and that's the case the summation handles well. The bug only appears when the function meets a route shape nobody planned for, and it only becomes dangerous because of a second, separate decision in on_open_trade_return() to trust whatever amount the swap produced. Neither function is wrong in isolation. Together they let a user write a promise the protocol believes and then deliver almost nothing.

That's also why a lot of normal testing wouldn't touch it. Tests for a margin open usually use realistic routes, a direct swap or a clean two-hop path, and those routes never reuse the output token mid-path. The attacker's route was valid input by every rule the parser enforced, and it took deliberately weird input to expose the gap.

How Olympix Would Have Caught the Inflated Promise

Olympix reads a protocol as states, transitions and entry points, and it reads the whole lifecycle of an operation, including the callbacks that finish it. For Burrowland's margin module, the state is each position's debt and position amounts plus the reserves behind them. The transitions are open_position and the callback that completes it. The entry point that matters is the one where a user supplies their own swap route.

From that structure, the engine infers the invariants the system depends on. One is about the open itself: once a margin position opens, the position amount recorded must be at least the minimum the protocol validated before lending. Another is broader: a position should never open with its debt already exceeding the value of its assets.

Static analysis would have surfaced the signals here. A minimum that's computed in one call and never re-checked in the callback that receives the real amount is a classic split-validation pattern, and the summation over every action whose output matches the final token is a signal on its own when the route is user-supplied. Signals like these let the engine narrow from every possible route to the feasible ones, and from there to the few that touch the invariant.

BugPoCer starts from an invariant like that and searches for a sequence of state changes that breaks it, working from the system outward. Had Rhea run it on the margin module, the search would have produced a route that passes through the position token more than once, an open_position call that passes the oracle check on the inflated minimum, and a callback that credits a fraction of that minimum as the position. BugPoCer would have returned that sequence as a counterexample, with the minimal conditions that trigger it and a generated test that reproduces it, showing the reserves before the open and an underwater position after it.

That counterexample would have pointed at two small fixes. get_token_out() can count only the actions that actually deliver to the protocol, or reject routes that reuse the output token as an input. on_open_trade_return() can compare the amount it receives against the validated minimum and roll back when it falls short. Either one alone would have closed the path, and together they cost a few lines of code against $18.4 million in reserves.

Olympix runs at every merge and before release. The margin module, the route parser and the callback would have been analyzed together each time any of them changed, which is where a bug that lives between two functions gets found.

Check the Promise When It's Delivered

Any protocol that validates an asynchronous operation up front and finalizes it in a callback is trusting that the two halves agree. The safest version checks the result where the result arrives. To see how Olympix maps the paths through your validation and settlement logic and returns a working counterexample for each one that breaks, book a technical walkthrough.

On-Chain References

Poisoned margin open (Apr 16, 2026, 09:47:12 UTC): 4H5kQ4...r1AN

Second worker's margin open (same block): 44tWhQ...Du1x

Worker account: d4e8e7...0b03

Attacker collector wallet: 31ac7a...a540

Burrowland (Rhea Lend) contract: contract.main.burrow.near

Attacker Ethereum wallet: 0xbb5f...11f6

See what Olympix finds on your own code

Send us a repo that's already been audited, and we'll run Olympix at the same commit and show you what comes back.