Security & Risk
Rayyan is a fork of Compound V3 (Comet) pinned at d5a30b0, Solidity 0.8.15, with roughly 200 changed or added lines. Everything outside the murabaha accounting path runs upstream code unmodified: collateral management, health factor, buyCollateral, the Chainlink integration, the utilization curve, the pause guardian, and ERC-20 accounting. absorb() is upstream except one line, which credits the borrower against the amount owed after ibra'.
The contracts have not been audited. Two independent audits are scheduled before mainnet. What follows is what has been tested, what has been found and fixed, and what is known to be open.
Test coverage
| Suite | Count | What it establishes |
|---|---|---|
RayyanMurabaha.t.sol | 33 | Exact-value assertions on the murabaha math: rate lock at origination, linear accrual to the wei, ibra' rebate, cap enforcement, LP and treasury split accounting, expiration-queue semantics, governance bounds, rate-manipulation replay, EMA fold behaviour, maxRatePerSecond reverts, ibra' on liquidation, and a fair-value liquidation that leaves the protocol nothing |
RayyanCloser.t.sol | 16 | Fork tests against live Arbitrum state with real USDC, WETH, WBTC, Chainlink feeds and Uniswap V3 pools: full, hybrid and collateral-only closes, WBTC-only positions, keeper settlement with no borrower pre-authorization and the agency fee (including its 1 USDC floor), flash-callback and permission guards |
RayyanInvariants.t.sol | 6 | Stateful fuzzing, detailed below |
SolvencyRepro.t.sol | 1 | Regression replay of the post-expiry streaming bug |
The invariants:
| Invariant | Property held |
|---|---|
invariant_solvency | Pool assets ≥ all claims, zero tolerance |
invariant_supplyIndexNonDecreasing | The supply index never moves backwards |
invariant_debtCappedAndIbraBounds | Owed never exceeds the contract price; ibra' stays within the unearned remainder |
invariant_borrowCapRespected | No borrow completes above 80% utilization |
invariant_aggregateConsistencyAndPositionCompleteness | The aggregate stream rate equals the sum of live positions |
invariant_noStalePositionData | Closed positions leave no residue |
The repo default in foundry.toml is 64 runs at depth 50. The campaign reported here is 256 runs at depth 100, about 153,600 randomized operation sequences:
FOUNDRY_INVARIANT_RUNS=256 FOUNDRY_INVARIANT_DEPTH=100 \
forge test --match-contract RayyanInvariantTestSolvency runs strict, with no overstream allowance.
Contract sizes
| Contract | Runtime bytes | Margin to the 24,576 limit |
|---|---|---|
| Comet | 24,471 | 105 |
| CometExt | 4,370 | 20,206 |
| RayyanCloser | 8,745 | 15,831 |
Live drills
Keeper settlement of a $700k position on an Arbitrum One fork, taking the exact settlement fee then in force (1.5% of collateral) to treasury. POL band re-tightened from 47.1% to 68.0% utilization on Arbitrum Sepolia.
protocolClose cannot be exercised on a public testnet, since it requires a position to reach its 360-day settlement date, so it is covered by the fork suite rather than by a public transaction.
Spec-conformance review
A line-by-line review of the implementation against the governing specification, run from scratch with no build context, found the implementation faithful to spec with no fund-loss, accounting or access-control defect. It produced two findings: a missing setIbraFactor governance setter, since implemented and tested, and the rate-lock manipulation vector described below. This was an internal review and does not substitute for a third-party audit.
Findings and fixes
Post-expiry streaming
Fuzzing found a gap the specification did not anticipate. A position past its settlement date kept streaming markup into the supply index until somebody settled it, so the pool could credit LP claims against markup no borrower still owed.
The fix is an on-chain FIFO expiration queue. Every position shares a 360-day tenor, so positions expire in the order they were opened, which makes the queue amortized O(1) and lets accrual be segmented at expiry boundaries. Each stream now stops at its own settlement date with no keeper involvement, and late repay or absorb skips the aggregate decrement the queue already performed. This is what allows the solvency invariant to run with zero tolerance rather than an overstream allowance.
Rate-lock manipulation
A borrower's rate is sampled once and then fixed for 360 days, which makes the sampling instant worth attacking. An attacker could supply a large amount of base in the same block, crush utilization, borrow at the artificially low rate, and withdraw, locking a cheap rate for a year at the cost of one block of capital.
New positions now price at max(spot, EMA) utilization. The EMA is time-weighted with τ = 4 hours and a single fold capped at 50%, so an idle gap cannot snap it to a manipulated spot and intra-block flows fold nothing. Only utilization that persisted can lower a quote, while organic demand spikes still reprice immediately, because the spot leg of the max is unbounded upward.
borrowMurabaha also takes a maxRatePerSecond argument and reverts with RateAboveMax above it, closing the mirror attack of pushing utilization up between quote and execution. Attack replay, EMA convergence and bound-revert tests are in the unit suite, and the strict solvency invariant re-passed the 256×100 campaign unchanged.
Collateral routing and execution bounds
The unified close originally worked only from WETH, which left WBTC-only borrowers unable to use the collateral leg. Each listed collateral now carries a Uniswap execution route, with WETH and WBTC resolved at deploy and further assets added through a governor-gated setRoute.
Every flash swap is bounded against the protocol's own Chainlink valuation, so a manipulated pool makes the close revert rather than sell a borrower's collateral cheaply. The bound is asymmetric on purpose: 5% on voluntary closes, where the borrower has no deadline and other options, and 3.95% on expiry settlement. Slippage on the collateral sold plus the 0.25% agency fee then never exceeds the 4.2% cost of the absorb() fallback, so the borrower-friendly path fills whenever it beats liquidation and yields when it does not. (The live Sepolia deployment uses the earlier 6.5% bound, set against its earlier 7% absorb cost.)
Solvency does not depend on an AMM fill. Health-based liquidation is oracle-priced and internal, as upstream, and expired debt is capped, so a failed settlement can be retried at no cost. Surplus collateral and USDC dust return to the borrower in the same transaction.
Open items
Code-size margin is 105 bytes. Comet's runtime is 24,471 bytes against the EIP-170 limit of 24,576, after trimming collateral asset slots to six, keeping queue storage internal, and removing the native approveThis hook. That removal also revoked a governor power to grant ERC-20 allowances out of the pool, which narrows the trusted surface. The margin constrains what can be added to Comet without a byte plan.
Bad-debt physics. A collateral gap violent enough to cross the liquidation margin inside a single block socializes losses to the pool. This is inherited from pooled lending generally and is not specific to Rayyan. The threat model is written into the fuzz handler rather than assumed: price ticks bounded at 5% against Chainlink's 0.5% deviation threshold, keeper absorb on the tick, and prompt buyCollateral. Because the protocol keeps no liquidation spread, seized collateral that stays unsold carries price risk straight to reserves; the fuzz handler sells all of it at once, and in production the 4.2% buyer discount is the incentive to clear it promptly.
withdrawReserves is not floor-guarded. While positions are open, reserves are inflated by markup recorded as debt but not yet earned, and the governor can withdraw against it. The governor is trusted at MVP and becomes a multisig behind a timelock in production. A reserve floor tied to unearned markup is a candidate hardening.
The base rate must be non-zero at deployment. A zero base rate lets a position open at positionRate == 0. It is mechanically harmless but violates a protocol invariant, so it belongs on the deployment checklist.
Not covered
No formal verification, and no third-party audit. No economic or oracle-failure simulation beyond the bounded-tick model in the fuzz handler. Keeper behaviour is exercised in the fork suite but has had no adversarial liveness analysis.
Privileged roles
| Role | Can | Cannot |
|---|---|---|
| Governor | Set curve shape and collateral factors, set ibraFactor (≤ 1e18) and lpShareBps (7000–9900 bps), set the Closer address, withdraw reserves, pause | Alter the terms of an open position. Rate, contract price and ibra' factor are locked per position at origination |
| RayyanCloser | Move collateral to the Uniswap route, to the borrower, and the settlement agency fee to the operator | Touch base-token (LP) funds. The operator carve-out in withdrawInternal is collateral-only |
| Keeper | Call protocolClose on matured positions, absorb on breaches, move POL inside the band | Custody funds. Keeper downtime delays settlement and liquidation, which is a liveness concern rather than a safety one |
Governance changes to lpShareBps settle accrual at the old split before taking effect, and apply only to subsequent accrual.
Risk parameters
| Parameter | Launch value |
|---|---|
| WETH collateral / liquidation factor | 85% / 87.5% |
| WBTC collateral / liquidation factor | 80% / 83% |
| Liquidation credit (liquidation factor) | 95.8% of collateral value, against the amount owed after ibra'; the 4.2% difference is the buyer's discount and the protocol keeps none of it (store-front factor 1.0) |
| Borrow cap (utilization at borrow) | 80% |
| Settlement period | 360 days |
| Settlement agency fee | 0.25% of the amount settled, minimum 1 USDC, to the operator |
| LP share of markup | 85%, governance-bounded 70–99%, forward-only |
| ibra' factor | 100% rebate, locked per position |
The live Sepolia deployment of 2026-08-06 still runs the earlier liquidation and settlement terms (a 93% liquidation factor with a 0.6 store-front factor, against the full contract price, and a 1.5% settlement fee on collateral value) until the next deployment.