Skip to content

Add Echidna fuzz testing harness + runner #306

Open
0xCardiE wants to merge 34 commits intomasterfrom
feat/echidna_fuzz
Open

Add Echidna fuzz testing harness + runner #306
0xCardiE wants to merge 34 commits intomasterfrom
feat/echidna_fuzz

Conversation

@0xCardiE
Copy link
Copy Markdown
Contributor

@0xCardiE 0xCardiE commented Feb 28, 2026

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_happyCommit in the redistribution harnesses constructs valid overlays, sets staking mock data, and calls commit() through actors
  • Properties verify commits land in storage correctly (echidna_tracked_commit_matches_storage, echidna_commit_overlays_unique)

Reveal (covered, though hit rate is still probabilistic in the general harnesses)

  • act_happyReveal replays tracked pre-images from the same round
  • Properties verify reveal storage matches (echidna_tracked_reveal_matches_storage, echidna_revealed_commit_indices_valid, echidna_reveal_entries_imply_matching_commit)

Winner selection (covered via exposed internal)

  • act_winnerSelection in the base harness calls exposedWinnerSelection() which runs the real winnerSelection() internal function - this is the core claim logic:
    • getCurrentTruth() truth determination
    • winner random selection weighted by stake density
    • freezing non-revealers and disagreeing revealers
    • OracleContract.adjustPrice() call
    • currentClaimRound bookkeeping
  • Properties: echidna_winnerSelection_only_once_per_round, echidna_last_winnerSelection_freezes_nonrevealed

Pot withdrawal (covered via claim stub)

  • claimStub() in the claim-stub harness runs real winnerSelection() and then calls PostageStamp.withdraw(winner) directly
  • Properties: echidna_claim_withdraws_pot_to_winner_when_successful, echidna_claim_triggers_oracle_adjustPrice, echidna_claim_only_once_per_round

Real claim() proof path (now covered with fixture-based fuzzing)

  • EchidnaRedistributionRealClaimHarness exercises the real Redistribution.claim() verifier path with fixed valid CAC and SOC proof bundles
  • The harness prepares a real commit -> reveal -> claim flow, then runs the actual proof-verifying claim() function
  • Instead of trying to randomly discover valid cryptographic proofs, it starts from known-good fixtures and fuzzes targeted mutations around them
  • Mutations cover selected inclusion-proof fields, postage index fields, and SOC fields while keeping the rest of the scenario realistic
  • Properties:
    • echidna_unmutated_fixture_claim_succeeds
    • echidna_mutated_fixture_claim_does_not_succeed
    • echidna_successful_real_claim_effects_hold

Why the real claim() path needs a fixture-based approach

Naive random fuzzing is very unlikely to hit a valid proof-verifying claim() because the inputs must satisfy multiple correlated cryptographic relationships at once:

  • correct BMT Merkle inclusion proofs
  • valid postage signatures for the batch owner
  • valid SOC signatures and address derivation
  • matching proximity relationships against the round anchor
  • correct ordering and reserve-estimation conditions

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:

  • Echidna covers: the state machine (phase transitions, round bookkeeping, once-per-round constraints), economics (pot distribution, freezing, oracle updates), access control, and now the real claim() verifier path via fixed CAC/SOC fixtures plus targeted proof mutations
  • Chai unit tests still cover best: targeted, readable proof-verification scenarios with hand-crafted valid/invalid proofs and exact expected revert reasons

Supporting runner/config changes

Also added:

  • echidna/echidna-real-claim.yaml
  • ECHIDNA_CONFIG support in scripts/echidna.sh

This 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:

ECHIDNA_CONTRACT=EchidnaRedistributionRealClaimHarness \
ECHIDNA_CONFIG=echidna/echidna-real-claim.yaml \
yarn echidna

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.
@0xCardiE 0xCardiE self-assigned this Feb 28, 2026
0xCardiE added 11 commits March 2, 2026 21:17
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.
Copy link
Copy Markdown
Member

@nugaon nugaon left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread README.md Outdated

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
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I cannot imagine the decimals could change anyhow

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Comment thread README.md Outdated
Comment thread scripts/echidna.sh Outdated
echo "==> echidna: running contract $c" >&2

# Avoid stale Crytic compile artifacts causing old properties/tests to run.
rm -rf crytic-export
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is wrong, the docker writes the files with root, and my user permission cannot remove it.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 …')

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't completely understand why crytic-export generated in the first place if you automatically remove it in each iteration?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +103 to +105
uint64 minUp = oracle.minimumPriceUpscaled();
uint64 expected = uint64(p) << 10;
if (expected < minUp) expected = minUp;
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why do you override expected like this? seemingly, the bit overflow is surpressed like this, but it can hide other problems too.

Comment thread src/echidna/EchidnaPriceOracleHarness.sol Outdated
Comment thread src/echidna/EchidnaPriceOracleHarness.sol Outdated
Comment thread src/echidna/EchidnaPriceOracleHarness.sol
Comment thread src/echidna/EchidnaPriceOracleHarness.sol
Comment thread src/echidna/EchidnaSystemHarness.sol Outdated
Comment thread src/echidna/EchidnaRedistributionHarness.sol
@0xCardiE 0xCardiE force-pushed the feat/echidna_fuzz branch from 9694920 to 351eb8c Compare April 7, 2026 15:05
@0xCardiE
Copy link
Copy Markdown
Contributor Author

0xCardiE commented Apr 7, 2026

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.

yes you are right. I remove one part into another PR that should be separate
https://github.com/ethersphere/storage-incentives/pull/308/changes

0xCardiE added a commit that referenced this pull request Apr 7, 2026
- 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
0xCardiE added 3 commits April 7, 2026 18:10
- 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
0xCardiE added a commit that referenced this pull request Apr 7, 2026
- 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
@0xCardiE 0xCardiE force-pushed the feat/echidna_fuzz branch from 4dabb17 to e0bbd29 Compare April 7, 2026 16:10
After a new commit round, commit() clears currentCommits but currentReveals
is only reset when the reveal phase for that round starts. The property
echidna_reveal_entries_imply_matching_commit incorrectly failed when a new
unrevealed commit coexisted with prior-round reveals (Echidna counterexample:
happyCommit → reveal → wait → happyCommit).
@0xCardiE 0xCardiE force-pushed the feat/echidna_fuzz branch from f7f55c2 to ae49977 Compare April 7, 2026 16:20
0xCardiE added 4 commits April 7, 2026 18:31
Stale currentReveals persist until the first reveal() of the new round updates
currentRevealRound and deletes the array. That window includes reveal phase
(currentPhaseCommit false), not only commit phase.
@0xCardiE
Copy link
Copy Markdown
Contributor Author

0xCardiE commented Apr 9, 2026

Made serious cleanups per suggestions above, keeping fuzz tests that makes most sense to be in Echidna.

Copy link
Copy Markdown
Member

@nugaon nugaon left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

according the redistribution tests coverage in the corpus, the reveal and the claim function logic is not even touched by the echidna tests.
I commented some of the lines where the workflow breaks.
The testcode does not bypass the block number related checks in these functions and the runs have really low chance to hit any of the phases of the redistribution game.
maybe maxBlockDelay could be changed in the Echidna conf file to a lower number.

Comment thread .gitignore Outdated
Comment thread scripts/echidna.sh Outdated
# and without Hardhat artifacts CryticCompile will try (and fail) to run `npx hardhat compile`.
yarn -s hardhat compile --force >/dev/null

CONTRACTS_DEFAULT=(
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

it could be dynamic instead of hardcoded.

Comment thread echidna/README.md Outdated
@@ -0,0 +1,280 @@
---
name: Security Findings Exploitability
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

it is not connected to the PR.

Comment thread src/echidna/EchidnaRedistributionClaimHarness.sol
Comment on lines +455 to +456
bool okCall = actors[idx].callWinnerSelection();
if (!okCall) return;
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

it always returns at this point

Comment thread src/echidna/EchidnaRedistributionHarness.sol
@0xCardiE
Copy link
Copy Markdown
Contributor Author

according the redistribution tests coverage in the corpus, the reveal and the claim function logic is not even touched by the echidna tests. I commented some of the lines where the workflow breaks. The testcode does not bypass the block number related checks in these functions and the runs have really low chance to hit any of the phases of the redistribution game. maybe maxBlockDelay could be changed in the Echidna conf file to a lower number.

great point, this setting was wrong completely. thank you

maxBlockDelay 1000 made it nearly impossible for Echidna to land
commit, reveal, and claim within the same 152-block round. Reducing
to 38 (one phase width) lets the fuzzer naturally walk through all
three phases. Also adds act_tick() no-ops so Echidna can advance
block.number without running guard-clause logic.
- Increase seqLen 100→200 for better commit→reveal→claim coverage
- Auto-discover Echidna*Harness.sol in runner script instead of hardcoded list
- Remove phantom .gitignore entries (echidna/coverage/, echidna/out/)
- Update README next-steps to mention auto-discovery naming convention
- Remove unrelated .cursor/plans file from branch
- 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.
@0xCardiE
Copy link
Copy Markdown
Contributor Author

Fixed previous discrepancies and comments and also added some fuzzing to claim function and separate config and corpus for it.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants