Skip to content
Developers

Tests

The Foundry stack, and the five layers of testing — unit, invariant, fork, deployment and mutation — that a protocol with no upgrade path requires.

A protocol that can be patched treats testing as a way to find bugs before users do. A protocol that cannot be patched treats it as the only place a bug can be found at all. That difference shapes everything below.

Stack

ToolUse
Foundry (forge, cast, anvil)Test runner, fuzzing, invariants, fork tests, deployment scripts
Solidity 0.8.26, pinnedNo floating pragma anywhere
OpenZeppelin 5.xERC-20, ERC20Permit, ReentrancyGuard, SafeERC20. Not Ownable, not AccessControl, not TimelockController, no proxy
SoladyFixedPointMathLib where it is worth the gas
Slither, AderynContinuous integration gate: zero unjustified High or Medium
forge fmt, forge doc, solhintFormatting, generated documentation, linting
Halmos or CertoraSymbolic or formal work on the solvency invariants of Branch and on the v3/v4 depth readers, budget permitting
Medusa, EchidnaAdditional fuzzing alongside Foundry's own
Gambit or vertigo-rsMutation testing
PythonOff-chain economic simulation on historical gap data

Every contract uses strict checks-effects-interactions with a reentrancy guard on any function touching a token, typed custom errors rather than revert strings, an event on every state transition, and immutable or constant for every risk parameter (Rule R-3.0.1).

1. Unit tests

Target: 100% branch coverage on Branch, LiquidationEngine, RedemptionRouter, OracleAdapter, LiquidityOracle, RateFloor, PSM, StabilityPool, sfyUSD, CollateralSale and Closer (Rule R-16.2).

The mandatory cases are enumerated in the specification rather than left to judgement. Grouped by area:

Accounting. Repaying exactly getDebt() burns exactly getDebt() and leaves zero — the single most common CDP bug. Dust positions. A position pushed below the minimum by a redemption, then repaid, then brought back above it.

Oracle. Price zero, negative, stale, paused, outside the relative window, with a regressing roundId, with a null aggregator, with changed decimals. A 10-for-1 multiplier change with the price not yet updated, which must give CIRCUIT. A special dividend with no pause, which must give CIRCUIT and then clear automatically.

Composite. Each source group readable, unreadable, and disagreeing beyond tolerance. Two groups too correlated to count as independent. A single group trying to weigh more than half, which must be capped. Every combination that leaves fewer than two independent groups, which must give FROZEN.

Token upgrade. The 7-day freeze; a successful self-test clearing it; a failed self-test extending it three times and then shutting the branch down at 28 days.

Interest clock. A degraded episode of 23 hours, where interest accrues in full; one of 25 hours, where one hour is not charged; and the resumption.

Liquidation. At 99%, at 105%, at the threshold minus epsilon. With an empty pool. Partial then total. A flag at 89 seconds and at 1 801 seconds, both reverting. An empty hourly bucket. A DEGRADED liquidation attempted before the confirmation delay, reverting, and after the flag has expired, reverting. A DEGRADED liquidation on an unconfirmed fall, reverting. The quiet edge.

Redemption. Skipping positions below the liquidation threshold, positions younger than the cooldown, and reduced positions.

Depth oracle. v2, v3 and v4 against mocked pools. A slot already filled in the current hour, which must not be rewritten. An immediate downward ratchet. An upward move refused before 24 hours and then capped at 20% per day. Decay at 10% per day after 48 hours. An unknown pool giving zero.

Rate floor. A reference at 3.6%; at 8%, which clamps to 6%; reverting; with supplied assets below 10 M; with only 14 samples — each giving the fixed floors. open below the floor reverting. An existing position below the floor where repay works, borrow reverts, and setRate to the floor works.

Caps. Every tier date. badDebtCumul > 0 locking tiers 3 and 4 permanently. TCR below 200% at a mint, where tier 3 is retained and nothing locks. The ρ term with two, three and five active branches. The per-address cap before and after day 90. The pool cap and the per-address pool cap at day 90.

Activation. Each of the eight criteria failing in isolation, then success, then a second activate() reverting, then activate() after Sunset reverting.

PSM. repayWithUSDG with intake frozen, reverting, while fyUSD repay still works. Fees minted and routed, with reserve == minted[PSM] after every operation. swapIn at the cap. latchFeeSwitch.

Backstop. Five points skimmed below target, zero above. redistribute at 71 hours reverting and at 73 hours succeeding. sellCollateral outside a live session reverting.

Closer. A freeze of 72 hours plus one second, reverting. The 30-day cumulative bound reached, reverting. A fourth liquidation freeze, reverting. Every function after the expiry and after renounce(), reverting. freeze(ALL, MINT) blocking swapIn but not swapOut. And, critically: a Closer freeze not preventing any operation on the liveness list.

Sunset. By shutdownAll, by two shut branches, and by an aggregate TCR below 130% held for an hour. One-way. The clock frozen at day 90.

Settlement. Before urgentFrom + 30 days, reverting. With a sufficient pool; with an empty pool and a payer; with neither, producing bad debt.

Wrapper and sale. NAV including discounted inventory. In-kind delivery above the 20% threshold. redeemInKind. Implicit harvest. The discount ramp from 0 to 3% over six hours and its plateau. A contribution mid-lot not resetting the ramp. buy outside a live session reverting. The bucket.

Router and incentives. A deposit at the PSM cap reverting. A withdrawal with an insufficient reserve returning fyUSD. Checkpoints, out-of-range positions earning nothing, purge at 179 days reverting and at 181 succeeding.

Constructors. Every deployment order of the CREATE2 addresses, with no deadlock, and a revert on each violated constructor relation. And: every contract's ABI checked for write functions protected by anything outside the whitelist of modifiers.

2. Invariant tests

Bounded handlers, at least 1 000 runs by 100 000 calls in a nightly job. The full list, with each property in words and pseudo-code, is on Invariants.

Twenty-four properties covering the supply identity, collateral accounting, minimum debt, position safety, solvency under a moving price, pool solvency, list ordering, monotone interest, liveness of every risk-reducing operation, price independence from caller state, liquidation discipline, throughput buckets, freeze expiry, the constancy of every constant, the absence of residual roles, the Closer's expiry, the depth ratchet, the bad-debt capacity lock, one-way transitions, wrapper NAV, and the routing split.

3. Fork tests

A full deployment against the real chain — mainnet 4663 and testnet 46630 — with real Stock Tokens, real Chainlink feeds, real Uniswap pools and the real reference lending market (Rule R-16.4).

What is verified against reality rather than against a mock: decimals; uiMultiplier; where oraclePaused actually lives; whether balanceOf returns a raw or a scaled balance; the exact semantics of "price per token"; the EIP-1967 implementation slot; tickBitmap and StateView reads within the gas budget; borrowRateView on the reference market; and the readability and independence of each source group.

Scenario tests on the fork: dropping source groups until the branch reads DEGRADED and checking its rules — the haircut at the computed confidence, minting and redemption still available at their degraded terms, a liquidation with and without a confirmed fall; the return to LIVE24 and the quiet edge that follows it; a corporate action with oraclePaused, uiMultiplier and newUIMultiplier mocked; the donation attack, in which tokens sent directly to a branch have no effect and a third-party wrapper has no influence; a dormant branch activating after a 90-day warp with pokes; and the Closer expiring after a 365-day warp.

4. Deployment tests

The zero-setter test. The Forge script deploys, activates SPY and QQQ, seeds the Probe contracts, and then asserts, contract by contract, the ten checks listed on Contract addresses — no owner or role anywhere, no forbidden function name in any ABI, no risk constant in storage, the exact minter set, twenty distinct branches on twenty distinct tokens, the Closer's Safe and expiry, every cross-address consistent, every constructor relation verified by reading, the deployed bytecode equal to the compiled bytecode of the frozen specification, and no source group weighing more than half a branch's composite. The report is published (Rule R-16.5.1).

The team-disappears test. After deployment, no transaction is ever sent from the Safe or the deployer. Time is warped from day 1 to day 400 in one-hour steps, with a handler driving random users through borrowing, repayment, liquidation, redemption, PSM operations, pokes and activations. The assertions: every capacity tier opens on its date; GLD activates when its metrics turn green; the Closer expires; a severed feed produces a shutdown at seven days and settlement at day thirty-seven; and every invariant holds throughout (Rule R-16.5.2).

5. Mutation and simulation

Mutation testing on Branch, LiquidationEngine, OracleAdapter, LiquidityOracle and Closer, with a required score of at least 80%. A mutation score is the honest measure of whether the unit tests assert anything or merely execute code.

Economic simulation in Python over historical price gaps for SPY, QQQ, GLD and SLV, used to validate the single threshold, the recognition band, the haircut ceiling, the bonuses and the capacity tiers. The simulator ships as part of the pre-launch phase.

The constants rule

Guarantee

Every parameter marked as estimated is measured before the specification is frozen, and freezing the specification is freezing the constants. There is no post-deployment calibration pass, because there is no function that could apply one (Rule R-16.5).

This is why the fifteen on-chain hypotheses are blocking rather than advisory, and why several of them can cancel the project rather than adjust a number. See On-chain hypotheses.

Continuous integration

On every pull request: forge fmt --check, solhint, forge build, the full unit suite with a coverage gate, Slither and Aderyn with zero unjustified High or Medium. Nightly: the full invariant suite at the run counts above, plus Medusa. Before each audit round: mutation testing, the fork suite, and both deployment tests.

What testing cannot do

It does not find a specification error, and in a protocol with no upgrade path a specification error is permanent. That is what the audit sequence, the public contest and the fifteen blocking hypotheses are for — and it is why the caps start at 100 000 USD per branch rather than at a number that would matter.

Last reviewed: 2026-09-07 · Spec v0.4