How Rain Vaults is protected, who has reviewed it, what they found, and exactly what changed. We publish the uncomfortable parts too — a security page that only lists wins is marketing, not security.
Every one of the four original Criticals, and eight of the twelve Highs, descended from a single design decision in V1–V3: the vault computed a NAV by marking open positions at the best resting bid on the Rain order book. On thin prediction-market books, that mark can be moved for the cost of one resting order — so deposits, withdrawals and fees were all priced off a number an attacker could set.
| Flow | V1–V3 | V4 / 4.1 |
|---|---|---|
| Deposit | Mint shares at NAV (manipulable) | USDT waits in a pending pool; converts to shares only when the vault is flat (all cash), at cash-per-share. Cancel any time; auto-refund after 14 days. Pending funds can never be traded. |
| Withdraw | Paid at NAV if cash available, else FIFO queue + 48h force-unwind | Exit by execution, same transaction: (1) resolved positions claimed to shared cash; (2) your pro-rata slice of every open position is merged (matched YES/NO pairs at face value) then sold; (3) the amount actually sold is measured as the share-balance delta; (4) anything unsold is reserved to you — still yours, sellable later or redeemable 1:1 at resolution. Payout = your share of cash before the sales + your own proceeds. Remaining holders' cash-per-share is provably unchanged. |
| Fees | On NAV, continuously | Only on realised cash at flat moments, above a high-water mark. Management fee crystallised before any pricing in withdraw. |
| Liveness | Any market revert could freeze the vault | Every market view or call the vault's liveness depends on goes through a guarded call. A broken market can make one position "not flat"; it can never block anyone's exit, force-unwind or vault close. |
| Force-unwind | After 48h queue wait | Permissionless after 30 days without a flat state; bound to a specific (market, option); caller sizes the sale (≤ 20% of free shares) and sets slippage. |
All 28 findings across both rounds. Status reflects rev 4.1. "Design" means we made a deliberate, documented choice that differs from the auditor's recommendation — see §6.
| ID | Sev | Finding | Rev 4.1 | How |
|---|---|---|---|---|
| C01 | Critical | Manager below 5% floor jams withdrawal queue | FIXED | No queue exists. Floor is a stock invariant (§M03/M04). |
| C02 | Critical | Trade key drains vault via self-registered fake market | FIXED | registerMarket manager-only; must be a Rain-factory pool with USDT base token. Exact per-trade approval, reset to 0. |
| C03 | Critical | NAV manipulable via order-book mark | FIXED | No mark exists. |
| C04 | Critical | NAV deflation lets manager take cash | FIXED | Cash never divided against a mark; manager exits by execution like everyone. |
| C05 | Critical · new | Rain pools without getClaimableAmount freeze the vault | FIXED 4.1 | All liveness-critical market reads are guarded staticcalls with conservative fallbacks. registerMarket probes the view and rejects pools lacking it. Verified: 144 of 552 live pools lack it (§7). |
| H01 | High | Dust deposit resets lock-up | FIXED | Only deposits ≥1% of holding extend lock-up. |
| H02 | High | Unpriceable position marked 0 | FIXED | No marking. |
| H03 | High | Cancelled queue entries consume request cap | FIXED | No queue. |
| H04 | High | Deposit 1:1 fallback when NAV = 0 | FIXED | Conversion reverts if shares exist and cash is zero. |
| H05 | High | Oversized market valuation overflows NAV | FIXED 4.1 | No NAV. Additionally every market-reported quantity is clamped (MAX_REPORTED) so an absurd value reads as 0, not as a revert. |
| H06 | High | Prune drops live position | FIXED | Prune only when provably flat. |
| H07 | High | Close over zero-marked live positions | FIXED | closeVault requires flat. |
| H08 | High | Escrow shares double-counted | FIXED | No NAV. |
| H09 | High | Matched YES/NO pairs sold leg-by-leg instead of merged | FIXED 4.1 | Exit merges matched pairs at face value first, on every market type, then sells the remainder. |
| H10 | High | tradingModel() used as phase indicator (wrong after AMM → order-book) | FIXED 4.1 | tradingModel removed from all routing. Verified on-chain: it stays 0 after optionEndTime on every AMM pool. |
| H11 | High | Gross top-of-book overstates NAV | FIXED | No NAV. |
| H12 | High | Dispute → both cranks fail; no permissionless exit | FIXED 4.1 DESIGN | Claim is guarded and never reverts the caller. During a dispute window an exiter receives a reservation (not cash) for that position — see §6. |
| H13 | High · new | Zero/partial order-book fill burns exiter's shares | FIXED 4.1 | Sales measured by share-balance delta, not by "did not revert". Unsold shares are reserved per (market, option, side, user); the vault's trading can never touch them; owner sells later or redeems at resolution. |
| M01 | Medium | Factory key pulls grantor's USDT | FIXED | Seed pulled from the key (msg.sender). |
| M02 | Medium | Force-unwind gated on age only / no solvency trigger | PARTIAL DESIGN | Gated on 30 days without flat and positions exist; caller-sized, slippage-protected. No solvency trigger by design — there is no NAV to be insolvent against. §6. |
| M03 | Medium | 5% floor diluted by deposits | FIXED 4.1 | A deposit conversion that would push the manager below 5% reverts. Floor is now a hard stock invariant, fuzz-checked after every operation. |
| M04 | Medium | Sole-holder exemption on the floor | FIXED 4.1 | Exemption removed. A manager who wants out entirely must closeVault() (requires flat). |
| M05 | Medium | Wrong approval unit for buy orders | FIXED | Exact USDT approval per trade. |
| M06 | Medium | Losing positions exhaust slots | FIXED | 32 slots; resolved/flat positions pruned. |
| M07 | Medium | Zero payout burns stake | FIXED | Reverts unless a reservation was created. |
| L02 | Low | Cash read before fee accrual in withdraw | FIXED 4.1 | Management fee crystallised first. |
| L03 | Low | No way to retire a market | FIXED 4.1 | retireMarket stops new spend; exit, cancel, merge, claim, force paths stay open forever. |
| L05 | Low | Fee clock reset on zero accrual via poke | FIXED 4.1 | Clock advances only when shares were actually minted. |
| R02 | Rec. | Force-unwind by index is race-prone | FIXED 4.1 | forceUnwind(idx, market, option, …) reverts StalePositionIndex on mismatch. |
L01, L04, R01 and I-class informational items were confirmed fixed at retest and are omitted for brevity; the full list is in the reports (§11).
Both new findings share one root cause: our fuzz harness tested against a mock Rain market that behaved the way we assumed Rain behaves. Deployed Rain differs in two places that matter.
getClaimableAmountV4 called getClaimableAmount directly in the code path that decides whether a position is flat. 144 of the 552 pools created by the Rain factory (140 of them USDT) were deployed with an older facet set that does not have this function; the call reverts with FunctionNotFound. Register one of those pools, let an option resolve, and every entry point of the vault — including the escape routes — reverts forever. A regression versus V3, which had containment here. Our mock always implemented the function, so the fuzzer could not see it.
Rain's sellOption on an empty or shallow order book returns successfully after selling nothing, or only what depth exists. V4 treated "did not revert" as "sold", burned the exiter's shares and paid only what came back. Our mock reverted on an empty book, so the fuzzer never produced that state.
optionEndTime, dispute windows, merge refused after close. Every retest finding now has a fuzz operation that exercises it, and the invariants that would have caught C05 and H13 (I10, I11) run after every operation. We also run a read-only probe against all live Rain pools before each release (§7).Three items where we chose differently from the recommendation. We document them here so depositors can judge for themselves.
Recommendation: a permissionless path that pays exiters cash even while a Rain option is under dispute. Our choice: Rain refuses both claim and merge once a pool is closed, so no on-chain cash path exists — anyone promising one is pretending. Instead the exiter is never blocked: they receive their full share of every other position in cash, plus a reservation on the disputed one, redeemable the moment Rain settles. The vault cannot freeze; the exiter cannot be diluted; they wait exactly as long as Rain makes everyone wait.
Recommendation: trigger force-unwind when the vault is "insolvent". Our choice: V4 has no NAV, so there is no solvency metric to test — inventing one would reintroduce the manipulable mark that caused C03/C04. Force-unwind is instead gated on 30 days without a flat state, and the exit-by-execution path is available at all times regardless. We accept "partially fixed" on this item.
Consequence we accept: because deposits that would dilute the manager below 5% now revert, a vault's capacity is ~20× the manager's own stake. A manager who wants more AUM must add more of their own capital. We consider this a feature — it is what "skin in the game" means — but it is a real product constraint and depositors should know it.
Before publishing rev 4.1 we ran a read-only probe from an Arbitrum One archive node against every pool created by the Rain factory 0x38B3Ba1ee001E6785224E31b3031ae96CA06C677:
getClaimableAmount (C05)optionEndTime · DISPUTE_WINDOWtradingModel changes phase (H10)Findings: the auditor's C05 count is exact; DISPUTE_WINDOW is 3,600 s on all pools; tradingModel() returns 0 on every AMM pool including those long past optionEndTime, confirming H10. The probe scripts (probe-live.js, probe-shape.js) ship with the contracts so the auditor can re-run them.
fuzz.js): seeded random sequences of 35 operations — deposits, exits, every trade type, liquidity changes, partial-fill depth, pool close, dispute open/resolve, legacy-facet toggle, AMM→order-book phase, market retirement, compromised-key attacks — on a local Anvil chain against the realistic mock. 12 invariants checked after every operation, including: accounting closes exactly (I1); remaining holders' cash-per-share unchanged by any exit (I3); no operator/attacker ever gains USDT (I4); pending funds never traded (I5); reservations always backed by real shares on Rain (I10); no exit ever reverts for a market-side reason (I11); manager floor holds after every op (I12).registerMarket.audit-tests.js): 27 scenarios on an Arbitrum One fork against real Rain markets.minAmountOut.| Item | Location |
|---|---|
| V4.1 contracts, mocks, fuzz harness, probes, audit response | github.com/agentbase-prod/rainvaults-com · contracts/v4 |
| Rain market interface compiled against | contracts/IRainMarket.sol |
| Compile & verify | cd contracts/v4 && npm i && node compile.js (solc 0.8.26, viaIR, runs=1) |
| Audit reports | Composable Security — Initial (2026-09-01) and Retest (2026-09-16), ID RAIN-5e2b9934. Published here on completion of the final round. |
| Responsible disclosure | Found something? Open an issue on the repository or contact the team via the Rain community. We will credit you. |