# Fuzzing harnesses Property-based fuzzing of the deployable contracts with Echidna. The harness sources live in `contracts/fuzz/`, outside the Hardhat sources directory, so `npm test` does not compile them and they are never part of a deployment. Corpora are regenerated by every run and are gitignored. ## Run Echidna has no Windows build. From the `contracts/` directory, with Docker running: ```sh docker run --rm -v "$PWD:/work" -w /work ghcr.io/crytic/echidna/echidna:latest bash -c \ "solc-select install 0.8.24 && solc-select use 0.8.24 && \ echidna fuzz/DistributorFuzz.sol --contract DistributorFuzz --config fuzz/echidna-distributor.yaml" ``` On Windows Git Bash prefix the command with `MSYS_NO_PATHCONV=1` and give the mount as `C:/.../contracts:/work`. The config compiles with the same solc, optimizer setting and `cancun` target as `hardhat.config.js`, with the OpenZeppelin import mapped to the project's `node_modules`. ## DistributorFuzz - PayoutDistributor liability accounting Covers AUDIT.md invariants 7 to 11 and the claim rules stated in the contract's header. The harness owns the distributor and opens cycles over a fixed four-leaf Merkle tree built with the same leaf encoding and sorted-pair hashing as OpenZeppelin's `MerkleProof`: | leaf | account | behaviour | |---|---|---| | 0 | `alice` | accepts ETH, records it | | 1 | `bob` (or `alice` again when the fuzzer picks a duplicate) | accepts ETH | | 2 | `rejecting` | reverts on receive | | 3 | `reentering` | re-enters `claim` with its own proof from inside its payout | The fuzzer chooses, per call: the four leaf amounts (0 to 5 ETH each), the claim window (1 to 365 days), a funding shortfall (the tree's leaves then sum to more than the funded total, a malformed tree), duplicate leaves, which leaf to claim, wrong claim amounts, another cycle's proof replayed, sweeps, skims, force-sent ETH through `selfdestruct`, plain transfers, and time and block advances up to 30 days between calls. Ghost totals record every wei in and out. Properties, all checked after every call: | property | invariant | |---|---| | `echidna_balance_conserved` | balance == funded + force-sent - paid - swept - skimmed (7, 9) | | `echidna_liabilities_match` | `liabilities` == sum of (total - claimed) over unswept cycles (7) | | `echidna_balance_covers_liabilities` | balance >= `liabilities` at all times | | `echidna_no_overdraw` | no cycle pays more than it was funded, even from a malformed tree; `claimed` equals the ghost (8) | | `echidna_single_claim` | one claim per account per cycle; reentry never pays twice | | `echidna_sweep_exact` | a sweep returns exactly total - claimed, once (10) | | `echidna_treasury_only_sweeps_and_skims` | only sweeps and skims reach the treasury; all cycles swept means zero liabilities (11) | | `echidna_direct_payment_refused` | a plain transfer is refused; `openCycle` is the only funding path | | `echidna_payouts_reach_accounts` | every paid wei landed with the named account | Change of 2026-09-15: the claim operations used to look the account up by leaf index, so in duplicate mode leaf 1 (built for alice) was presented as bob, always failed, and the competing-claim scenario the table above advertises never ran; `echidna_single_claim` passed vacuously for it. The recipients are now stored with each cycle and the claims use them. Found by the additional in-house AI review of the evidence (2026-09-15). ### Result, 2026-09-15 Echidna image `ghcr.io/crytic/echidna/echidna` at digest `sha256:80f90c3a727986fc31380a509a87fe0a14cdbda13f4ead102b2d7ffaff285261` (Echidna 2.3.3), solc 0.8.24, contracts at the addendum commit, fixed harness. Log: `docs/audits/fuzz-2026-09-15/echidna-distributor.log`. | run | calls | properties | outcome | |---|---|---|---| | campaign, seed 4070208826713903324 | 200,291 | 9 | all passing | ### Result, 2026-09-11 (previous harness) Echidna 2.3.3, solc 0.8.24, contracts at commit `a44d93f` (tag `audit-2026-09-12-supplement`, byte-identical to the code baseline). | run | calls | properties | outcome | |---|---|---|---| | campaign, seed 3035278805320057958 | 200,144 | 9 | all passing | Coverage from the corpus: every state-changing statement in `PayoutDistributor.sol` executed at least once, including the overdraw guard, the rejected-transfer revert on claim, the reentrancy refusal, sweep after the deadline, skim of force-sent ETH, and the direct-payment revert. The only unexecuted lines are the `outstanding` view and the `renounceOwnership` revert, both covered by the unit tests. The harness branches that would record a successful wrong-amount claim or a cross-cycle proof replay were never reached, which is the expected outcome: no such claim ever succeeded. This is a bounded result over a four-leaf tree with the fuzzer as owner. It does not exercise a real Safe, trees of production size, or the off-chain builder that merges lines per address; those are covered by the integration tests and the Sepolia rehearsals in `docs/EVIDENCE.md`. ## RouterFuzz - RoyaltyRouter reserve arithmetic and deferred legs Covers AUDIT.md invariants 12 to 17 and the deferred-leg bookkeeping. The router is deployed with the production target ($65,000 in feed units) and the Sepolia seed ($43,700), so the threshold is crossed within a few payments and the fuzzer spends time on both sides of it. Destinations are two plain sinks (reserve, treasury) and an ops receiver the fuzzer can switch to refuse ETH, which is how deferred legs, `pushOwed` and `withdrawOwed` get exercised. The feed is the project's `MockAggregator` in every failure mode the router handles. Run from `contracts/` with the same container as above: ```sh echidna fuzz/RouterFuzz.sol --contract RouterFuzz --config fuzz/echidna-router.yaml ``` The fuzzer chooses, per call: royalties paid as ETH or as WETH (0 to 5 ETH), `unwrapWeth`, the price ($100 to $10,000), the feed mode (healthy, stale, reverting, future-dated, zero answer, negative answer), whether ops accepts ETH, `release`, `pushOwed` to any destination, `withdrawOwed` by ops, and up to three days between calls. Before each `release` the harness predicts which rule applies by mirroring the router's own price acceptance, then checks the per-release deltas of `totalToReserve`, `totalToTreasury` and `totalToOps` against that rule. | property | invariant | |---|---| | `echidna_conserved` | balance == paid in - allocated + owed; received + owed == allocated (14) | | `echidna_owed_sums` | the three `owed` entries sum to `totalOwed` | | `echidna_no_ops_before_threshold` | `totalToOps` stays 0 until the latch (12) | | `echidna_accrual_monotonic` | `reserveUsdAccrued` never decreases (13) | | `echidna_release_rules` | post-threshold releases are exactly 50/30/20 with the remainder to the reserve (15); outage releases go 100% to the reserve and add to `unvaluedWei`; pre-threshold releases go 100% to the reserve; the crossing release lands `reserveUsdAccrued` exactly on the target when no backlog was pending and splits at most the excess (16); `pending()` is 0 after every release | | `echidna_threshold_latches` | once met, never unmet (17) | | `echidna_owed_paid_exactly` | `pushOwed` and `withdrawOwed` move exactly the owed amount | | `echidna_backlog_cleared_after_threshold` | `unvaluedWei` is 0 whenever the threshold is met | | `echidna_valid_calls_succeed` | a `release` with ETH pending never reverts, nor does a push or a pull of an owed amount to a destination that accepts ETH, ops included while it accepts (added 2026-09-15) | Change of 2026-09-15: the harness swallowed every revert of `release` and `pushOwed`, so a valid release that reverted would have been invisible, and the threshold-crossing branch only bounded the treasury and ops deltas. It now flags an unexpected revert as a property failure and recomputes the crossing release independently from the published rule (value the backlog, fill the reserve, split the rest 50/30/20 with the remainder to the reserve) and requires all three deltas to match exactly. Found by the additional in-house AI review of the evidence (2026-09-15). ### Result, 2026-09-15 Same image, solc and commit as above, fixed harness. Log: `docs/audits/fuzz-2026-09-15/echidna-router.log`. | run | calls | properties | outcome | |---|---|---|---| | campaign, seed 6528613900499222773 | 200,286 | 9 | all passing (rerun after the ops push and pull success checks were added; the first r1 run, seed 2039011567472805080, 200,209 calls, also passed) | The `SEND_GAS` bound on pushes (2026-09-15) is exercised implicitly: the ops receiver is a plain contract and is paid within the bound; a gas-burning destination is covered by the unit tests (`test/pashov/07-recipient-gas.test.js`). ### Result, 2026-09-11 (previous harness) Echidna 2.3.3, solc 0.8.24, contracts at commit `a44d93f`. | run | calls | properties | outcome | |---|---|---|---| | campaign, seed 8443974535240454660 | 200,258 | 8 | all passing | Coverage from the corpus: every statement of `_preThreshold`, `_split`, `_accrue`, `_send`, `_payOwed`, `release`, `unwrapWeth`, `pushOwed` and `withdrawOwed` executed, including the backlog valuation after an outage, the case where the backlog alone completes the reserve, the exact-fill latch, the straddling fill with its rounding guard, and a deferred ops leg later paid by push and by pull. Not executed: `sweepToken` (the harness has no other ERC-20) and the constructor's seed-at-or-above-target branch, both covered by unit tests. This is a bounded result with a mock feed and mock destinations. It does not exercise the real Chainlink aggregator, real Safes, or WETH9 itself; the router's fit inside WETH9's 2300-gas refund is covered by the unit tests.