Add Echidna fuzz testing harness + runner #306
Conversation
Add a minimal Echidna setup (harness + config + runner) to fuzz StakeRegistry/TestToken invariants and provide a yarn script for local execution.
Update the stake coverage property to use nodeEffectiveStake and ignore Echidna's crytic-export output directory.
Add properties for successful manageStake(add>0) post-state (potential/balance deltas and commitment recomputation) and add non-interference checks so unrelated actors' stake entries never change during actions.
Add Echidna properties for freeze/slash/migrate post-state correctness, reset actor tracking on stake deletions, and make the runner compile on-host to avoid container npx/hardhat dependency.
Expose winnerSelection in the Redistribution harness to assert phase partitioning, reveal↔commit linkage, single winner selection per round, and freezing of non-revealers. Strengthen PostageStamp fuzzing with wider non-interference checks and a pot monotonicity invariant, and document the new properties.
Introduce a fuzz-only RedistributionClaimStub that bypasses proof verification and executes claim→withdraw end-to-end. Add pot mock + invariants for single-claim-per-round, winner payout, oracle adjustPrice call, and freezing non-revealers; wire the harness into the runner and docs.
Add system-level properties for stake accounting (stake token balance covers sum of potential stake), pot accounting (pot never exceeds stamp token balance), and price floors (oracle/stamp never below minimum).
Run Prettier (with Solidity plugin) to satisfy CI formatting checks.
There was a problem hiding this comment.
Really nice initiative with really a lot of changes.
It would have been better in several different PRs.
I checked the basic setup and the PriceOrcale harness mostly, but I peeked into other harness logics as well.
I stumbled upon an issue with running the test, see below.
|
|
||
| Echidna repeatedly calls the harness “action” functions (like `act_manageStake`) with random inputs, building **stateful sequences**. After (and during) those sequences it checks `echidna_*` property functions such as: | ||
|
|
||
| - **Token properties**: total supply stays constant; decimals stay 16 |
There was a problem hiding this comment.
True, was aimed for robustness and if in some cases code changes that we check every minor detail.
Logic wise how its now this would never happen.
There was a problem hiding this comment.
these kind of tests should be chai manual tests. shouldn't echidna check only state-model correctness?
IMO this does not need detailed testing because it doesn't replace targeted tests.
There was a problem hiding this comment.
Yes you are right
The right mental model would be
Chai tests = targeted, readable, scenario-driven tests
Echidna = randomized exploration of both global invariants and per-call correctness across arbitrary state histories
| echo "==> echidna: running contract $c" >&2 | ||
|
|
||
| # Avoid stale Crytic compile artifacts causing old properties/tests to run. | ||
| rm -rf crytic-export |
There was a problem hiding this comment.
This is wrong, the docker writes the files with root, and my user permission cannot remove it.
There was a problem hiding this comment.
Current scripts/echidna.sh no longer removes anything on the host.
It runs rm -rf crytic-export inside the same docker run as Echidna (sh -c 'rm -rf crytic-export && echidna-test …')
There was a problem hiding this comment.
I don't completely understand why crytic-export generated in the first place if you automatically remove it in each iteration?
There was a problem hiding this comment.
It's generated as a side effect of echidna-test. Every invocation of Echidna triggers CryticCompile, which writes to crytic-export/. We can't prevent that; we can only clean it before the next iteration so it doesn't contaminate the next contract's run.
| uint64 minUp = oracle.minimumPriceUpscaled(); | ||
| uint64 expected = uint64(p) << 10; | ||
| if (expected < minUp) expected = minUp; |
There was a problem hiding this comment.
why do you override expected like this? seemingly, the bit overflow is surpressed like this, but it can hide other problems too.
9694920 to
351eb8c
Compare
yes you are right. I remove one part into another PR that should be separate |
- Run rm + echidna in one Docker invocation so host users need not delete root-owned crytic-export - Point root README fuzz blurb at echidna/README.md (avoid stale harness list) - PriceOracle harness: arm setPrice postconditions only after ok+returned; read expected upscaled from oracle; expand _clearPending; merge paused/revert state checks with OR - System harness: replace tautological min(pot,bal)<=bal with pot<=token balance check - Redistribution harness: clear winnerSelection pending at start of every action Made-with: Cursor
- Run rm + echidna in one Docker invocation so host users need not delete root-owned crytic-export - Point root README fuzz blurb at echidna/README.md (avoid stale harness list) - PriceOracle harness: arm setPrice postconditions only after ok+returned; read expected upscaled from oracle; expand _clearPending; merge paused/revert state checks with OR - System harness: replace tautological min(pot,bal)<=bal with pot<=token balance check - Redistribution harness: clear winnerSelection pending at start of every action
…ng scratch - Claim harness: same stale-pending pattern as redistribution winnerSelection; non-claim actions now drop pendingClaim - Postage harness: _clearPending zeros all pending snapshot fields (parity with PriceOracle harness _clearPending)
Solidity public array getters revert on out-of-bounds access (mapped to currentCommits declaration in Redistribution.sol). Echidna traces those as failures even when wrapped in staticcall. - Add RedistributionExposed.sol with currentCommitsLength/currentRevealsLength - Bound redistribution + system harness scans using real lengths (cap 25) - Simplify _scanRevealsLen to use length instead of probing past end
- Run rm + echidna in one Docker invocation so host users need not delete root-owned crytic-export - Point root README fuzz blurb at echidna/README.md (avoid stale harness list) - PriceOracle harness: arm setPrice postconditions only after ok+returned; read expected upscaled from oracle; expand _clearPending; merge paused/revert state checks with OR - System harness: replace tautological min(pot,bal)<=bal with pot<=token balance check - Redistribution harness: clear winnerSelection pending at start of every action
4dabb17 to
e0bbd29
Compare
- Remove unused _revealFull() helper from EchidnaRedistributionHarness - Remove dead seedBatch/Batch struct/mapping from redistribution stamp mock - Remove unread withdrawCalls from EchidnaPostageStampPotMock - Remove unread lastFreezeTime and lastRedundancy from shared mocks
… harness Add a shouldRevertWithdraw toggle to the pot mock so Echidna can explore the path where claimStub succeeds but PostageStamp.withdraw() fails via the .call() error-swallowing pattern. New property asserts pot and actor balances are preserved when withdraw reverts, while the round is still consumed (documenting the known H-1 behavior).
Add an Echidna harness that exercises the real redistribution claim verifier with fixed CAC/SOC proof fixtures and targeted mutations. Isolate its corpus/config and document the workflow so it can run independently from the shared suite.
Apply formatter output to the new real-claim Echidna harness and its README updates so the fixture-based claim fuzzing changes match the repository style.
|
Fixed previous discrepancies and comments and also added some fuzzing to claim function and separate config and corpus for it. |
|
I checked the new EchidnaRedistributionRealClaimHarness.sol but it has just partial coverage seemingly, e.g. act_prepareFixtureReveal does not go further after the redist.currentPhaseReveal() check. |
Use echidna/corpus/by-contract/<Harness>/ so each fuzz target keeps its own sequences and coverage HTML. Bump default testLimit/seqLen and set workers: 4. Document paths and ECHIDNA_TEST_LIMIT/ECHIDNA_SEQ_LEN/ECHIDNA_WORKERS overrides. Build optional CLI flags as a string so set -u does not fail when no overrides are set.
Real-claim harness: progress flags, tx counter, strict property after warmup; README and script document defaults and overrides.
Remove the fixture-based real claim harness so proof verification stays in Hardhat; trim deterministic library-level checks from the base harness. scripts/echidna.sh now passes --hardhat-ignore-compile and clears stale artifacts/build-info so CryticCompile works without Node inside Docker.
nugaon
left a comment
There was a problem hiding this comment.
I still don't get good coverage on claim at EchidnaPostageStampHarness.
the tests should be simplified, there are two different specific redistribution tests but seemingly none of them get to the claim phase (mostly stops at winnerselection).
as we discussed this PR is too heavy, rereading 3.5k lines all the time is not the best method that we could do... maybe that's why I have the feeling about this approach overcomplicates the codebase.
The fixes on the contracts seem good, we could merge those in a different fix PR.
About line numbers, I dont see it that way. There are tests, they should be verbose, not minimized. Dont see it add any complexity as its separate part of codebase not affecting actual contracts. They are made in a way that we cover all functions in contract, with properties that make sense to be checked. At best we can remove some properties that you might see as not needed. Otherwise think this is fine, it aims to run all the functions in contracts and exposes them to fuzzing best it can. |
nugaon
left a comment
There was a problem hiding this comment.
As we discussed, the fuzzing logic with the coverage will be revised later, but overall the current state of tests and configuration seems fine. Configurations, fixes, test setups seem to be in place and correct.
Raw LCOV totals include echidna_* property lines that fuzz txs never hit, making harness coverage look worse than exploration really is. Print actions-only metrics after each harness run.
The virtual _nextSeedValue override was only used by the removed fixture-based real-claim harness; inline private updateRandomness is enough.
Property checks run via eth_call and are not reflected in LCOV, so reporting a properties block percentage was misleading.
Move oracle clamp/cast fixes to #311; keep harness expectations aligned with master PriceOracle until that PR lands.
Move minimumInitialBalancePerChunk overflow fix to #313.
Compute expected balance from on-chain outpayment state before copyBatch so an extra CI-mined block cannot fail the test. Initialize copyBatch fixture contracts in its own beforeEach.



Introduce Echidna-based, stateful fuzz testing for this repo.
Add a Docker-based runner and documentation, plus a multi-actor harness that fuzzes all smart contracts with role modeling, cross-actor accounting invariants, non-interference checks, and post-condition properties for all contracts.
What is fuzz testing?
Fuzz testing ("fuzzing") is an automated testing technique that repeatedly calls contract functions with many randomized inputs and stateful sequences of calls. Instead of writing a single expected-output test per scenario, you write properties/invariants that must always hold, and the fuzzer tries hard to find a sequence that breaks them.
Why use it for smart contracts?
Finds edge cases humans miss: weird input combinations, unusual call ordering, boundary values, and unexpected state transitions.
Stateful bugs: catches issues that only appear after many steps (e.g. stake -> freeze -> slash -> migrate -> restake).
Property-driven: you encode "this should never happen" rules (access control, accounting conservation, deletion semantics), and the tool searches for counterexamples.
Echidna coverage of the commit -> reveal -> claim lifecycle
What we test
Commit (well covered)
act_happyCommitin the redistribution harnesses constructs valid overlays, sets staking mock data, and callscommit()through actorsechidna_tracked_commit_matches_storage,echidna_commit_overlays_unique)Reveal (covered, though hit rate is still probabilistic in the general harnesses)
act_happyRevealreplays tracked pre-images from the same roundechidna_tracked_reveal_matches_storage,echidna_revealed_commit_indices_valid,echidna_reveal_entries_imply_matching_commit)Winner selection (covered via exposed internal)
act_winnerSelectionin the base harness callsexposedWinnerSelection()which runs the realwinnerSelection()internal function - this is the core claim logic:getCurrentTruth()truth determinationOracleContract.adjustPrice()callcurrentClaimRoundbookkeepingechidna_winnerSelection_only_once_per_round,echidna_last_winnerSelection_freezes_nonrevealedPot withdrawal (covered via claim stub)
claimStub()in the claim-stub harness runs realwinnerSelection()and then callsPostageStamp.withdraw(winner)directlyechidna_claim_withdraws_pot_to_winner_when_successful,echidna_claim_triggers_oracle_adjustPrice,echidna_claim_only_once_per_roundReal
claim()proof path (now covered with fixture-based fuzzing)EchidnaRedistributionRealClaimHarnessexercises the realRedistribution.claim()verifier path with fixed valid CAC and SOC proof bundlescommit -> reveal -> claimflow, then runs the actual proof-verifyingclaim()functionechidna_unmutated_fixture_claim_succeedsechidna_mutated_fixture_claim_does_not_succeedechidna_successful_real_claim_effects_holdWhy the real
claim()path needs a fixture-based approachNaive random fuzzing is very unlikely to hit a valid proof-verifying
claim()because the inputs must satisfy multiple correlated cryptographic relationships at once:These witnesses need to be constructed, not discovered by chance. The fixture-based harness solves this by seeding Echidna with valid proof bundles and then fuzzing mutations around them while still executing the real on-chain verifier logic.
Is the current coverage sufficient?
The split is now more complete and more intentional:
claim()verifier path via fixed CAC/SOC fixtures plus targeted proof mutationsSupporting runner/config changes
Also added:
echidna/echidna-real-claim.yamlECHIDNA_CONFIGsupport inscripts/echidna.shThis lets the real-claim harness use its own isolated corpus and run independently from the shared suite when needed.
Validation
Validated both with the dedicated run: