Evidence
Adversarial findings
Before mainnet, a dedicated pass tried to drain the vault or get a free position. Each attack is a test that plays the attacker (test/adversarial/Attacks.t.sol, 23 tests), plus a sandwich against the real Robinhood Chain pool on a fork. Each test asserts that the attack fails or that its gain is bounded. Full write-up: research/ADVERSARIAL.md.
What broke, and was fixed before deployment
A dead or permanently stale feed could have locked LP money.
- LP withdrawals required both price checks to pass. If the Chainlink feed stopped updating (deprecation, delisting) or started reverting, withdrawals were refused for good, even with every position closed and all USDG idle.
- A reverting feed also made
totalAssets()revert, which bricked the vault. - A position its owner abandoned could never be knocked out without a feed, so its loan was stuck too.
The fixes:
- No revert on a broken feed. The feed is read with
try/catch; a reverting or non-positive answer reads as "no price". - LPs can always leave when no loans are open. NAV is then exactly idle cash, so withdrawals need no price check.
- Emergency
unwind(id)once the feed is dead, after 7 days without a usable print (the longest normal freeze measured is 78 hours). Anyone may unwind at the pool's 30-minute average less 1%; the vault is repaid first. - A dead-feed clock for a reverting feed.
reportFeedDown()starts it, and any healthy read (open,close,knockOut, or the report itself) clears it.
Tested by test_attack_deadFeedCannotLockIdleLpFunds, test_attack_staleFeedWithOpenPositionUnwindsAfterSevenDays, test_attack_revertingFeedNeedsSevenDaysFromReport, test_attack_staleDownReportClearedByActivity and test_attack_unwindIntoCrashedSpotReverts.
What held
| Attack | Result |
|---|---|
| Reentrancy through a hook-calling token, on every external call | Every market re-entry reverts. Removing the guards from open and close makes the test fail |
| First-depositor inflation | The victim loses at most dust; the attacker cannot redeem more than they put in |
| Share-rounding farm, fuzzed round trips | Never ends with more USDG than it started with |
mint() rounding past the $20 cap | Refused: the cap is checked twice |
| Knock-out by crashing the pool's spot price | NotKnockable: the trigger is Chainlink, not the pool |
| Crashing spot under a real knock-out to buy the forced sale cheap | Slippage: the sale must fill within 1% of Chainlink |
| Sandwich on the real pool (fork) | Front-running $1k, $10k or $100k cost the attacker $1.00, $10.00 and $99.95; the victim filled 0.10–0.17% from Chainlink (2026-10-03 run) |
| Closing your own position into a crashed spot | Underwater: a close that would short the vault reverts |
| LP running ahead of a known loss in the same block | Refused while the pool average lags; once it agrees, NAV carries the loss |
| LP and trader as one account, all in one block | Cannot come out ahead: the open fee is a cost |
| Free position: fuzzed margin and leverage, immediate close | An immediate round trip always costs the fee |
Double claim, closing someone else's position, calling the swap callback, lend, setMarket | All refused |
Residual risks we accept
These are stated on the Limits page: upstream trust in Paxos and Robinhood, capacity griefing of the open-interest cap, and the LP exit lag after a weekend move.