From 48f10c7bcf2364dc38264c021b6523aea1992c04 Mon Sep 17 00:00:00 2001 From: Cardinal Date: Fri, 27 Feb 2026 19:08:33 +0100 Subject: [PATCH 01/50] feat: add echidna fuzz harness for staking Add a minimal Echidna setup (harness + config + runner) to fuzz StakeRegistry/TestToken invariants and provide a yarn script for local execution. --- .gitignore | 7 +- README.md | 23 ++++ echidna/echidna.yaml | 16 +++ package.json | 1 + scripts/echidna.sh | 22 ++++ src/echidna/EchidnaStakeRegistryHarness.sol | 119 ++++++++++++++++++++ 6 files changed, 187 insertions(+), 1 deletion(-) create mode 100644 echidna/echidna.yaml create mode 100755 scripts/echidna.sh create mode 100644 src/echidna/EchidnaStakeRegistryHarness.sol diff --git a/.gitignore b/.gitignore index f0d92eca..4a029e8e 100644 --- a/.gitignore +++ b/.gitignore @@ -38,4 +38,9 @@ contractsInfo.json gas-report.txt # Tenderly -tenderly.log \ No newline at end of file +tenderly.log + +# Echidna fuzzing +echidna/corpus/ +echidna/coverage/ +echidna/out/ \ No newline at end of file diff --git a/README.md b/README.md index adcda8ac..ce86e9ba 100644 --- a/README.md +++ b/README.md @@ -101,6 +101,29 @@ To get started with this project, follow these steps: 2. Run `yarn install` at the root of the repo to install all dependencies. 3. Add a `.env` file in your root directory, where you'll store your sensitive information for deployment. An example file [`.env.example`](./.env.example) is provided for reference. +## Fuzz testing (Echidna) + +This repo includes a **small Echidna harness** that fuzzes `TestToken` + `StakeRegistry` with a few basic invariants. + +- **Harness contract**: `src/echidna/EchidnaStakeRegistryHarness.sol` +- **Echidna config**: `echidna/echidna.yaml` + +### Run (Docker) + +1. Install a Docker runtime (e.g. Docker Desktop). +2. Run: + +```bash +yarn echidna +``` + +### What this is doing + +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 +- **Stake properties**: committed stake never decreases after a successful update; commitment stays consistent with potential stake (given a constant oracle price) + ## Run ### [Tests](./test) diff --git a/echidna/echidna.yaml b/echidna/echidna.yaml new file mode 100644 index 00000000..05044e74 --- /dev/null +++ b/echidna/echidna.yaml @@ -0,0 +1,16 @@ +testMode: property + +# More steps per sequence helps stateful protocols. +seqLen: 50 + +# Start small; can be increased once it’s stable in CI. +testLimit: 25000 + +# Persist interesting inputs between runs. +corpusDir: echidna/corpus + +# Useful while iterating on invariants. +coverage: true + +# Keep output readable in CI. +format: text diff --git a/package.json b/package.json index bcaf1539..3efd28d6 100644 --- a/package.json +++ b/package.json @@ -61,6 +61,7 @@ "test:coverage": "hardhat coverage", "dev": "hardhat node --reset --watch --export contractsInfo.json", "compile": "hardhat compile", + "echidna": "bash scripts/echidna.sh", "local:deploy": "hardhat --network localhost deploy", "local:run": "cross-env HARDHAT_NETWORK=localhost ts-node --files", "local:export": "hardhat --network localhost export", diff --git a/scripts/echidna.sh b/scripts/echidna.sh new file mode 100755 index 00000000..56e94d8c --- /dev/null +++ b/scripts/echidna.sh @@ -0,0 +1,22 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" + +if ! command -v docker >/dev/null 2>&1; then + echo "docker not found. Install Docker Desktop (or another docker runtime) to run Echidna." >&2 + exit 127 +fi + +cd "$ROOT_DIR" + +IMAGE="ghcr.io/crytic/echidna:latest" +CONTRACT="EchidnaStakeRegistryHarness" + +docker run --rm \ + -v "$ROOT_DIR":/src \ + -w /src \ + "$IMAGE" \ + echidna-test . \ + --contract "$CONTRACT" \ + --config echidna/echidna.yaml diff --git a/src/echidna/EchidnaStakeRegistryHarness.sol b/src/echidna/EchidnaStakeRegistryHarness.sol new file mode 100644 index 00000000..02189fca --- /dev/null +++ b/src/echidna/EchidnaStakeRegistryHarness.sol @@ -0,0 +1,119 @@ +// SPDX-License-Identifier: BSD-3-Clause +pragma solidity ^0.8.19; + +import "../TestToken.sol"; +import "../Staking.sol"; + +contract ConstantPriceOracle is IPriceOracle { + uint32 internal immutable _price; + + constructor(uint32 price_) { + _price = price_; + } + + function currentPrice() external view returns (uint32) { + return _price; + } +} + +/// @notice Echidna harness for basic invariants across TestToken + StakeRegistry. +/// @dev Echidna calls public/external functions on this contract. We keep "actions" +/// non-reverting (by bounding inputs and using low-level calls) so Echidna can build +/// longer sequences. +contract EchidnaStakeRegistryHarness { + TestToken internal immutable token; + StakeRegistry internal immutable registry; + ConstantPriceOracle internal immutable oracle; + + uint256 internal immutable initialSupply; + + // Tracks the committedStake after the last successful stake update. + uint256 internal lastCommittedStake; + + constructor() { + // Keep values modest so arithmetic in invariants stays safe. + initialSupply = 1_000_000_000_000_000_000_000_000; // 1e24 + + token = new TestToken("TestToken", "TT", initialSupply); + oracle = new ConstantPriceOracle(1); + registry = new StakeRegistry(address(token), 10, address(oracle)); + + // Allow the registry to pull our tokens during manageStake(). + token.approve(address(registry), type(uint256).max); + } + + // ----------------------------- + // Actions (state transitions) + // ----------------------------- + + function act_tokenTransfer(address to, uint256 amount) external { + if (to == address(0)) return; + + uint256 bal = token.balanceOf(address(this)); + if (bal == 0) return; + + uint256 a = amount % (bal + 1); + if (a == 0) return; + + token.transfer(to, a); + } + + function act_manageStake(bytes32 setNonce, uint256 addAmount, uint8 height) external { + // Keep height small to avoid huge powers of two. + uint8 h = uint8(height % 16); + + uint256 available = token.balanceOf(address(this)); + if (available == 0) return; + + uint256 a = addAmount % (available + 1); + + // If this is the first stake update, enforce the minimum stake rule + // (or skip the call when we can't satisfy it). + uint256 lastUpdated = registry.lastUpdatedBlockNumberOfAddress(address(this)); + if (lastUpdated == 0 && a > 0) { + uint256 minStake = 100000000000000000 * (2 ** h); + if (a < minStake) { + a = minStake; + if (a > available) return; + } + } + + (bool ok, ) = address(registry).call(abi.encodeWithSelector(registry.manageStake.selector, setNonce, a, h)); + if (!ok) return; + + (, uint256 committedStake, , , ) = registry.stakes(address(this)); + lastCommittedStake = committedStake; + } + + function act_withdrawSurplus() external { + // withdrawFromStake() can be a no-op, but we keep this action non-reverting. + (bool ok, ) = address(registry).call(abi.encodeWithSelector(registry.withdrawFromStake.selector)); + ok; // silence unused var warning + } + + // ----------------------------- + // Properties (checked by Echidna) + // ----------------------------- + + function echidna_token_supply_constant() external view returns (bool) { + return token.totalSupply() == initialSupply; + } + + function echidna_token_decimals_16() external view returns (bool) { + return token.decimals() == 16; + } + + function echidna_stake_committed_never_decreases() external view returns (bool) { + (, uint256 committedStake, , , ) = registry.stakes(address(this)); + return committedStake >= lastCommittedStake; + } + + function echidna_stake_commitment_implies_potential_cover() external view returns (bool) { + (, uint256 committedStake, uint256 potentialStake, , uint8 h) = registry.stakes(address(this)); + + // With a constant oracle price of 1, committedStake is floor(potentialStake / 2**h), + // so committedStake * 2**h must never exceed potentialStake. + uint256 factor = (1 << h); + return committedStake * factor <= potentialStake; + } +} From 0788f9569798d1f226aa8fdd6afed6943174d7e6 Mon Sep 17 00:00:00 2001 From: Cardinal Date: Fri, 27 Feb 2026 19:16:20 +0100 Subject: [PATCH 02/50] fix image --- scripts/echidna.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/echidna.sh b/scripts/echidna.sh index 56e94d8c..0dc3ae6f 100755 --- a/scripts/echidna.sh +++ b/scripts/echidna.sh @@ -10,7 +10,7 @@ fi cd "$ROOT_DIR" -IMAGE="ghcr.io/crytic/echidna:latest" +IMAGE="${ECHIDNA_IMAGE:-ghcr.io/crytic/echidna/echidna:latest}" CONTRACT="EchidnaStakeRegistryHarness" docker run --rm \ From 9911aca7672b6e275cbbe4578a5dd62ab8d70421 Mon Sep 17 00:00:00 2001 From: Cardinal Date: Fri, 27 Feb 2026 19:19:33 +0100 Subject: [PATCH 03/50] fix: correct echidna stake invariant Update the stake coverage property to use nodeEffectiveStake and ignore Echidna's crytic-export output directory. --- .gitignore | 3 ++- src/echidna/EchidnaStakeRegistryHarness.sol | 9 +++------ 2 files changed, 5 insertions(+), 7 deletions(-) diff --git a/.gitignore b/.gitignore index 4a029e8e..e7339e56 100644 --- a/.gitignore +++ b/.gitignore @@ -43,4 +43,5 @@ tenderly.log # Echidna fuzzing echidna/corpus/ echidna/coverage/ -echidna/out/ \ No newline at end of file +echidna/out/ +crytic-export/ \ No newline at end of file diff --git a/src/echidna/EchidnaStakeRegistryHarness.sol b/src/echidna/EchidnaStakeRegistryHarness.sol index 02189fca..68f2f7ac 100644 --- a/src/echidna/EchidnaStakeRegistryHarness.sol +++ b/src/echidna/EchidnaStakeRegistryHarness.sol @@ -109,11 +109,8 @@ contract EchidnaStakeRegistryHarness { } function echidna_stake_commitment_implies_potential_cover() external view returns (bool) { - (, uint256 committedStake, uint256 potentialStake, , uint8 h) = registry.stakes(address(this)); - - // With a constant oracle price of 1, committedStake is floor(potentialStake / 2**h), - // so committedStake * 2**h must never exceed potentialStake. - uint256 factor = (1 << h); - return committedStake * factor <= potentialStake; + (, , uint256 potentialStake, , ) = registry.stakes(address(this)); + uint256 effective = registry.nodeEffectiveStake(address(this)); + return effective <= potentialStake; } } From f2a6b8883ab420baf26fbf05246815349827743a Mon Sep 17 00:00:00 2001 From: Cardinal Date: Fri, 27 Feb 2026 22:12:44 +0100 Subject: [PATCH 04/50] add explanation --- echidna/README.md | 103 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 103 insertions(+) create mode 100644 echidna/README.md diff --git a/echidna/README.md b/echidna/README.md new file mode 100644 index 00000000..248c8211 --- /dev/null +++ b/echidna/README.md @@ -0,0 +1,103 @@ +# Echidna fuzzing in this repo + +This directory contains a **minimal, stateful fuzz-testing setup** using [Echidna](https://github.com/crytic/echidna). + +Echidna works by: + +- Deploying a “harness” contract. +- Calling its public/external **action functions** with many randomized inputs, building **sequences** of calls. +- After (and during) those sequences, checking that `echidna_*` **property functions** always return `true`. + +If a property returns `false`, Echidna prints a **reproducer** (a short sequence of calls/inputs that triggers the failure). + +## What we are testing right now + +### Harness + +- **Harness contract**: `src/echidna/EchidnaStakeRegistryHarness.sol` + +It deploys: + +- `TestToken` (a mintable ERC20 preset used as BZZ stand-in) +- `StakeRegistry` (from `src/Staking.sol`) +- a small constant-price oracle used by `StakeRegistry` + +The harness gives `StakeRegistry` an infinite token allowance and then exposes a few actions Echidna can call. + +### Actions (what Echidna mutates) + +These functions are intentionally written to be **mostly non-reverting**, so Echidna can explore longer state sequences: + +- `act_manageStake(setNonce, addAmount, height)` + - Calls `StakeRegistry.manageStake(...)` with bounded inputs. + - Ensures the first stake can satisfy the minimum-stake requirement (otherwise skips). +- `act_withdrawSurplus()` + - Calls `StakeRegistry.withdrawFromStake()`. +- `act_tokenTransfer(to, amount)` + - Moves some tokens out of the harness to vary balances and edge cases. + +### Properties (what must always hold) + +The harness defines `echidna_*` properties that Echidna checks continuously: + +- **Token invariants** + - `echidna_token_supply_constant`: total supply stays equal to the initial supply minted to the harness. + - `echidna_token_decimals_16`: decimals stays `16` (this repo’s BZZ/sBZZ convention). +- **Stake invariants** + - `echidna_stake_committed_never_decreases`: after a *successful* `manageStake`, the recorded `committedStake` for the harness address never decreases. + - `echidna_stake_commitment_implies_potential_cover`: `nodeEffectiveStake(address(this)) <= potentialStake`. + +These are “sanity properties”: they’re meant to detect obvious bugs and unintended state corruption early. + +## What we expect (and what can go wrong) + +### When a property fails + +A failure means one of two things: + +- **Real bug**: there is a reachable sequence of calls that violates an intended invariant. +- **Bad/too-strong property**: the property is not actually guaranteed by the contract’s design. + +Example of the second case (we hit this during bring-up): + +- It is possible to change `height` with `_addAmount == 0` in `StakeRegistry.manageStake()`. +- In that case `committedStake` is **not recomputed**, so a property like + \( committedStake \cdot 2^{height} \le potentialStake \) + is **not guaranteed** and will correctly fail. + +### Common sources of “false positives” + +- **Role-gated functions**: if an invariant assumes some privileged function cannot be called, make sure the harness never grants itself those roles (or explicitly models them). +- **Reverts shortening sequences**: if actions revert too often, Echidna explores fewer interesting states. Prefer bounding inputs and using low-level calls (as the current harness does). +- **Time/block effects**: some contracts depend on `block.number`. Echidna can advance time with `--delay`/`--wait`, but invariants should be designed with that in mind. + +## How to run + +From repo root: + +```bash +yarn echidna +``` + +This uses Docker and the image `ghcr.io/crytic/echidna/echidna:latest`. + +### Output files + +Echidna may write artifacts such as: + +- `echidna/corpus/` (saved interesting inputs) +- `echidna/coverage/` +- `crytic-export/` (Crytic export artifacts) + +These are ignored by git via `.gitignore`. + +## How to extend this + +Typical next steps: + +- Add another harness under `src/echidna/` for `PostageStamp` or `Redistribution`. +- Keep actions non-reverting and model only the roles/privileges you want to include. +- Start with a few **obviously true** invariants, then iterate: + - If Echidna finds a counterexample, decide whether that is a **bug** or a **property mismatch**. + - Tighten properties only when you’re confident the protocol/design guarantees them. + From 50375ab9b1bc2f19d8145b45331a6d4f0db66b9a Mon Sep 17 00:00:00 2001 From: Cardinal Date: Fri, 27 Feb 2026 22:35:25 +0100 Subject: [PATCH 05/50] introduce more complex properties --- echidna/README.md | 12 +-- echidna/echidna.yaml | 2 +- src/echidna/EchidnaStakeRegistryHarness.sol | 90 ++++++++++++++++++--- 3 files changed, 87 insertions(+), 17 deletions(-) diff --git a/echidna/README.md b/echidna/README.md index 248c8211..5ea85421 100644 --- a/echidna/README.md +++ b/echidna/README.md @@ -40,12 +40,14 @@ These functions are intentionally written to be **mostly non-reverting**, so Ech The harness defines `echidna_*` properties that Echidna checks continuously: -- **Token invariants** - - `echidna_token_supply_constant`: total supply stays equal to the initial supply minted to the harness. - - `echidna_token_decimals_16`: decimals stays `16` (this repo’s BZZ/sBZZ convention). -- **Stake invariants** +- **Stake/accounting invariants** - `echidna_stake_committed_never_decreases`: after a *successful* `manageStake`, the recorded `committedStake` for the harness address never decreases. - - `echidna_stake_commitment_implies_potential_cover`: `nodeEffectiveStake(address(this)) <= potentialStake`. + - `echidna_registry_token_is_expected`: `StakeRegistry.bzzToken()` stays equal to the `TestToken` address. + - `echidna_registry_balance_covers_potential`: the ERC20 balance held by `StakeRegistry` is always at least the recorded `potentialStake` for the harness address. + - `echidna_withdrawable_matches_effective_math`: `withdrawableStake()` matches the same effective-stake math as the contract uses. + - `echidna_nodeEffective_matches_freeze_rule`: `nodeEffectiveStake()` is `0` while frozen (same-block update or explicitly frozen), otherwise equals the expected effective stake. + - `echidna_stake_empty_state_is_zeroed`: if a stake entry is deleted/empty, all fields are zeroed. + - `echidna_overlay_matches_nonce_and_network`: the stored overlay matches the expected `keccak256(owner, reverse(networkId), nonce)` for the last successful stake update. These are “sanity properties”: they’re meant to detect obvious bugs and unintended state corruption early. diff --git a/echidna/echidna.yaml b/echidna/echidna.yaml index 05044e74..df9dab20 100644 --- a/echidna/echidna.yaml +++ b/echidna/echidna.yaml @@ -4,7 +4,7 @@ testMode: property seqLen: 50 # Start small; can be increased once it’s stable in CI. -testLimit: 25000 +testLimit: 125000 # Persist interesting inputs between runs. corpusDir: echidna/corpus diff --git a/src/echidna/EchidnaStakeRegistryHarness.sol b/src/echidna/EchidnaStakeRegistryHarness.sol index 68f2f7ac..e24abadc 100644 --- a/src/echidna/EchidnaStakeRegistryHarness.sol +++ b/src/echidna/EchidnaStakeRegistryHarness.sol @@ -30,13 +30,20 @@ contract EchidnaStakeRegistryHarness { // Tracks the committedStake after the last successful stake update. uint256 internal lastCommittedStake; + // Tracks the inputs that should determine the current overlay. + bytes32 internal lastSetNonce; + uint64 internal trackedNetworkId; + uint64 internal networkIdAtLastStake; + constructor() { // Keep values modest so arithmetic in invariants stays safe. initialSupply = 1_000_000_000_000_000_000_000_000; // 1e24 token = new TestToken("TestToken", "TT", initialSupply); oracle = new ConstantPriceOracle(1); - registry = new StakeRegistry(address(token), 10, address(oracle)); + trackedNetworkId = 10; + networkIdAtLastStake = trackedNetworkId; + registry = new StakeRegistry(address(token), trackedNetworkId, address(oracle)); // Allow the registry to pull our tokens during manageStake(). token.approve(address(registry), type(uint256).max); @@ -48,6 +55,8 @@ contract EchidnaStakeRegistryHarness { function act_tokenTransfer(address to, uint256 amount) external { if (to == address(0)) return; + if (to == address(registry)) return; + if (to == address(token)) return; uint256 bal = token.balanceOf(address(this)); if (bal == 0) return; @@ -83,6 +92,8 @@ contract EchidnaStakeRegistryHarness { (, uint256 committedStake, , , ) = registry.stakes(address(this)); lastCommittedStake = committedStake; + lastSetNonce = setNonce; + networkIdAtLastStake = trackedNetworkId; } function act_withdrawSurplus() external { @@ -91,26 +102,83 @@ contract EchidnaStakeRegistryHarness { ok; // silence unused var warning } - // ----------------------------- - // Properties (checked by Echidna) - // ----------------------------- + function act_pause() external { + (bool ok, ) = address(registry).call(abi.encodeWithSelector(registry.pause.selector)); + ok; + } - function echidna_token_supply_constant() external view returns (bool) { - return token.totalSupply() == initialSupply; + function act_unpause() external { + (bool ok, ) = address(registry).call(abi.encodeWithSelector(registry.unPause.selector)); + ok; } - function echidna_token_decimals_16() external view returns (bool) { - return token.decimals() == 16; + function act_changeNetworkId(uint64 newNetworkId) external { + (bool ok, ) = address(registry).call(abi.encodeWithSelector(registry.changeNetworkId.selector, newNetworkId)); + if (!ok) return; + trackedNetworkId = newNetworkId; } + // ----------------------------- + // Properties (checked by Echidna) + // ----------------------------- + function echidna_stake_committed_never_decreases() external view returns (bool) { (, uint256 committedStake, , , ) = registry.stakes(address(this)); return committedStake >= lastCommittedStake; } - function echidna_stake_commitment_implies_potential_cover() external view returns (bool) { + function echidna_registry_token_is_expected() external view returns (bool) { + return registry.bzzToken() == address(token); + } + + function echidna_registry_balance_covers_potential() external view returns (bool) { (, , uint256 potentialStake, , ) = registry.stakes(address(this)); - uint256 effective = registry.nodeEffectiveStake(address(this)); - return effective <= potentialStake; + return token.balanceOf(address(registry)) >= potentialStake; + } + + function echidna_withdrawable_matches_effective_math() external view returns (bool) { + (, uint256 committedStake, uint256 potentialStake, , uint8 h) = registry.stakes(address(this)); + + uint256 effective = _min(potentialStake, committedStake * (1 << h) * uint256(oracle.currentPrice())); + uint256 expectedWithdrawable = potentialStake - effective; + + return registry.withdrawableStake() == expectedWithdrawable; + } + + function echidna_nodeEffective_matches_freeze_rule() external view returns (bool) { + (, uint256 committedStake, uint256 potentialStake, , uint8 h) = registry.stakes(address(this)); + uint256 lastUpdated = registry.lastUpdatedBlockNumberOfAddress(address(this)); + uint256 fromView = registry.nodeEffectiveStake(address(this)); + + if (lastUpdated >= block.number) { + return fromView == 0; + } + + uint256 expected = _min(potentialStake, committedStake * (1 << h) * uint256(oracle.currentPrice())); + return fromView == expected; + } + + function echidna_stake_empty_state_is_zeroed() external view returns (bool) { + (bytes32 overlay, uint256 committedStake, uint256 potentialStake, uint256 lastUpdated, uint8 h) = registry + .stakes(address(this)); + if (lastUpdated != 0) return true; + return overlay == bytes32(0) && committedStake == 0 && potentialStake == 0 && h == 0; + } + + function echidna_overlay_matches_nonce_and_network() external view returns (bool) { + (bytes32 overlay, , , uint256 lastUpdated, ) = registry.stakes(address(this)); + if (lastUpdated == 0) return true; + return overlay == keccak256(abi.encodePacked(address(this), _reverse(networkIdAtLastStake), lastSetNonce)); + } + + function _min(uint256 a, uint256 b) internal pure returns (uint256) { + return a < b ? a : b; + } + + function _reverse(uint64 input) internal pure returns (uint64 v) { + v = input; + v = ((v & 0xFF00FF00FF00FF00) >> 8) | ((v & 0x00FF00FF00FF00FF) << 8); + v = ((v & 0xFFFF0000FFFF0000) >> 16) | ((v & 0x0000FFFF0000FFFF) << 16); + v = (v >> 32) | (v << 32); } } From b14c60a2e538762dccdc8b692a727cf52a1f67a0 Mon Sep 17 00:00:00 2001 From: Cardinal Date: Fri, 27 Feb 2026 22:51:35 +0100 Subject: [PATCH 06/50] add more complex tests --- echidna/README.md | 41 ++- echidna/echidna.yaml | 2 +- src/echidna/EchidnaStakeRegistryHarness.sol | 380 +++++++++++++++----- 3 files changed, 324 insertions(+), 99 deletions(-) diff --git a/echidna/README.md b/echidna/README.md index 5ea85421..25921fda 100644 --- a/echidna/README.md +++ b/echidna/README.md @@ -22,32 +22,41 @@ It deploys: - `StakeRegistry` (from `src/Staking.sol`) - a small constant-price oracle used by `StakeRegistry` -The harness gives `StakeRegistry` an infinite token allowance and then exposes a few actions Echidna can call. +It also deploys several **actor contracts** (`EchidnaStakeActor`) which behave like independent users (each has its own address and token balance), plus a dedicated actor that receives the `REDISTRIBUTOR_ROLE` so we can fuzz freeze/slash flows. ### Actions (what Echidna mutates) These functions are intentionally written to be **mostly non-reverting**, so Echidna can explore longer state sequences: -- `act_manageStake(setNonce, addAmount, height)` - - Calls `StakeRegistry.manageStake(...)` with bounded inputs. - - Ensures the first stake can satisfy the minimum-stake requirement (otherwise skips). -- `act_withdrawSurplus()` - - Calls `StakeRegistry.withdrawFromStake()`. -- `act_tokenTransfer(to, amount)` - - Moves some tokens out of the harness to vary balances and edge cases. +- **Per-actor stake actions** + - `act_actor_manageStake(actorId, setNonce, addAmount, height)` + - `act_actor_withdrawSurplus(actorId)` + - `act_actor_migrateStake(actorId)` (only succeeds when paused) +- **Admin actions (executed by the harness admin)** + - `act_admin_pause()`, `act_admin_unpause()` + - `act_admin_changeNetworkId(newNetworkId)` +- **Redistributor actions (executed by the redistributor actor)** + - `act_redistributor_freeze(targetActorId, time)` + - `act_redistributor_slash(targetActorId, amount)` +- **Negative tests (unauthorized attempts)** + - `act_actor_tryPause(...)`, `act_actor_tryUnpause(...)`, `act_actor_tryChangeNetworkId(...)` + - `act_actor_tryFreeze(...)`, `act_actor_trySlash(...)` +- **Funding** + - `act_fundActor(actorId, amount)` transfers tokens from the harness to an actor so fuzzing doesn’t get “stuck” when actors run out of balance. ### Properties (what must always hold) The harness defines `echidna_*` properties that Echidna checks continuously: -- **Stake/accounting invariants** - - `echidna_stake_committed_never_decreases`: after a *successful* `manageStake`, the recorded `committedStake` for the harness address never decreases. - - `echidna_registry_token_is_expected`: `StakeRegistry.bzzToken()` stays equal to the `TestToken` address. - - `echidna_registry_balance_covers_potential`: the ERC20 balance held by `StakeRegistry` is always at least the recorded `potentialStake` for the harness address. - - `echidna_withdrawable_matches_effective_math`: `withdrawableStake()` matches the same effective-stake math as the contract uses. - - `echidna_nodeEffective_matches_freeze_rule`: `nodeEffectiveStake()` is `0` while frozen (same-block update or explicitly frozen), otherwise equals the expected effective stake. - - `echidna_stake_empty_state_is_zeroed`: if a stake entry is deleted/empty, all fields are zeroed. - - `echidna_overlay_matches_nonce_and_network`: the stored overlay matches the expected `keccak256(owner, reverse(networkId), nonce)` for the last successful stake update. +- **Authorization / “must never happen”** + - `echidna_never_performed_forbidden_calls`: asserts that unauthorized actors never successfully paused/unpaused/changed network id, never successfully froze/slashed, and that we didn’t observe other action-level invariant violations. +- **Cross-actor accounting** + - `echidna_registry_balance_covers_sum_potential`: registry token balance covers the sum of all actors’ `potentialStake`. +- **Per-actor stake invariants** + - `echidna_stake_committed_never_decreases_per_actor`: committed stake never decreases for an actor while it has an active stake entry. + - `echidna_nodeEffective_matches_freeze_rule_per_actor`: effective stake is `0` while frozen, otherwise matches expected effective stake math. + - `echidna_empty_state_is_zeroed_for_all`: if a stake entry is deleted/empty, all fields are zeroed. + - `echidna_overlay_matches_last_manageStake_for_all`: overlay matches `keccak256(owner, reverse(networkIdAtLastStake), lastNonce)` per actor. These are “sanity properties”: they’re meant to detect obvious bugs and unintended state corruption early. diff --git a/echidna/echidna.yaml b/echidna/echidna.yaml index df9dab20..1af619b5 100644 --- a/echidna/echidna.yaml +++ b/echidna/echidna.yaml @@ -1,7 +1,7 @@ testMode: property # More steps per sequence helps stateful protocols. -seqLen: 50 +seqLen: 100 # Start small; can be increased once it’s stable in CI. testLimit: 125000 diff --git a/src/echidna/EchidnaStakeRegistryHarness.sol b/src/echidna/EchidnaStakeRegistryHarness.sol index e24abadc..6f7671ff 100644 --- a/src/echidna/EchidnaStakeRegistryHarness.sol +++ b/src/echidna/EchidnaStakeRegistryHarness.sol @@ -16,10 +16,51 @@ contract ConstantPriceOracle is IPriceOracle { } } -/// @notice Echidna harness for basic invariants across TestToken + StakeRegistry. -/// @dev Echidna calls public/external functions on this contract. We keep "actions" -/// non-reverting (by bounding inputs and using low-level calls) so Echidna can build -/// longer sequences. +contract EchidnaStakeActor { + TestToken internal immutable token; + StakeRegistry internal immutable registry; + + constructor(TestToken token_, StakeRegistry registry_) { + token = token_; + registry = registry_; + token.approve(address(registry), type(uint256).max); + } + + function manageStake(bytes32 setNonce, uint256 addAmount, uint8 height) external returns (bool ok) { + (ok, ) = address(registry).call(abi.encodeWithSelector(registry.manageStake.selector, setNonce, addAmount, height)); + } + + function withdrawFromStake() external returns (bool ok) { + (ok, ) = address(registry).call(abi.encodeWithSelector(registry.withdrawFromStake.selector)); + } + + function migrateStake() external returns (bool ok) { + (ok, ) = address(registry).call(abi.encodeWithSelector(registry.migrateStake.selector)); + } + + function tryPause() external returns (bool ok) { + (ok, ) = address(registry).call(abi.encodeWithSelector(registry.pause.selector)); + } + + function tryUnpause() external returns (bool ok) { + (ok, ) = address(registry).call(abi.encodeWithSelector(registry.unPause.selector)); + } + + function tryChangeNetworkId(uint64 newNetworkId) external returns (bool ok) { + (ok, ) = address(registry).call(abi.encodeWithSelector(registry.changeNetworkId.selector, newNetworkId)); + } + + function tryFreezeDeposit(address owner, uint256 time) external returns (bool ok) { + (ok, ) = address(registry).call(abi.encodeWithSelector(registry.freezeDeposit.selector, owner, time)); + } + + function trySlashDeposit(address owner, uint256 amount) external returns (bool ok) { + (ok, ) = address(registry).call(abi.encodeWithSelector(registry.slashDeposit.selector, owner, amount)); + } +} + +/// @notice Echidna harness for stateful, multi-actor fuzzing of StakeRegistry. +/// @dev Echidna calls public/external functions on this contract. contract EchidnaStakeRegistryHarness { TestToken internal immutable token; StakeRegistry internal immutable registry; @@ -27,148 +68,319 @@ contract EchidnaStakeRegistryHarness { uint256 internal immutable initialSupply; - // Tracks the committedStake after the last successful stake update. - uint256 internal lastCommittedStake; + uint256 internal constant MIN_STAKE = 100000000000000000; // 1e17 (matches StakeRegistry) + uint32 internal constant ORACLE_PRICE = 1; + + uint256 internal constant ACTOR_COUNT = 3; + EchidnaStakeActor[3] internal actors; + EchidnaStakeActor internal redistributor; - // Tracks the inputs that should determine the current overlay. - bytes32 internal lastSetNonce; uint64 internal trackedNetworkId; - uint64 internal networkIdAtLastStake; + + // Tracking per-actor last successful state. + uint256[3] internal lastCommittedStakeByActor; + bytes32[3] internal lastSetNonceByActor; + uint64[3] internal networkIdAtLastStakeByActor; + + // “Must never happen” flags (set by actions, checked by properties). + bool internal unauthorizedAdminCallSucceeded; + bool internal unauthorizedFreezeSlashSucceeded; + bool internal pausedManageStakeSucceeded; + bool internal frozenManageStakeSucceeded; + bool internal actionInvariantViolated; constructor() { // Keep values modest so arithmetic in invariants stays safe. initialSupply = 1_000_000_000_000_000_000_000_000; // 1e24 token = new TestToken("TestToken", "TT", initialSupply); - oracle = new ConstantPriceOracle(1); + oracle = new ConstantPriceOracle(ORACLE_PRICE); trackedNetworkId = 10; - networkIdAtLastStake = trackedNetworkId; registry = new StakeRegistry(address(token), trackedNetworkId, address(oracle)); - // Allow the registry to pull our tokens during manageStake(). - token.approve(address(registry), type(uint256).max); + // Create actors and fund them. + for (uint256 i = 0; i < ACTOR_COUNT; i++) { + actors[i] = new EchidnaStakeActor(token, registry); + token.transfer(address(actors[i]), initialSupply / 20); // 5% each + networkIdAtLastStakeByActor[i] = trackedNetworkId; + } + + // A dedicated redistributor actor (role granted by admin = this harness). + redistributor = new EchidnaStakeActor(token, registry); + registry.grantRole(registry.REDISTRIBUTOR_ROLE(), address(redistributor)); } // ----------------------------- // Actions (state transitions) // ----------------------------- - function act_tokenTransfer(address to, uint256 amount) external { - if (to == address(0)) return; - if (to == address(registry)) return; - if (to == address(token)) return; - + function act_fundActor(uint8 actorId, uint256 amount) external { + EchidnaStakeActor a = _actor(actorId); uint256 bal = token.balanceOf(address(this)); if (bal == 0) return; - - uint256 a = amount % (bal + 1); - if (a == 0) return; - - token.transfer(to, a); + uint256 x = amount % (bal + 1); + if (x == 0) return; + token.transfer(address(a), x); } - function act_manageStake(bytes32 setNonce, uint256 addAmount, uint8 height) external { + function act_actor_manageStake(uint8 actorId, bytes32 setNonce, uint256 addAmount, uint8 height) external { + EchidnaStakeActor actor = _actor(actorId); // Keep height small to avoid huge powers of two. uint8 h = uint8(height % 16); - uint256 available = token.balanceOf(address(this)); + uint256 available = token.balanceOf(address(actor)); if (available == 0) return; - uint256 a = addAmount % (available + 1); + uint256 add = addAmount % (available + 1); // If this is the first stake update, enforce the minimum stake rule // (or skip the call when we can't satisfy it). - uint256 lastUpdated = registry.lastUpdatedBlockNumberOfAddress(address(this)); - if (lastUpdated == 0 && a > 0) { - uint256 minStake = 100000000000000000 * (2 ** h); - if (a < minStake) { - a = minStake; - if (a > available) return; + uint256 lastUpdated = registry.lastUpdatedBlockNumberOfAddress(address(actor)); + if (lastUpdated == 0 && add > 0) { + uint256 minStake = MIN_STAKE * (1 << h); + if (add < minStake) { + add = minStake; + if (add > available) return; } } - (bool ok, ) = address(registry).call(abi.encodeWithSelector(registry.manageStake.selector, setNonce, a, h)); + // If paused, manageStake must not succeed. + if (registry.paused()) { + bool okPaused = actor.manageStake(setNonce, add, h); + if (okPaused) pausedManageStakeSucceeded = true; + return; + } + + // If frozen (including same-block update), manageStake must not succeed. + if (lastUpdated != 0 && lastUpdated >= block.number) { + bool okFrozen = actor.manageStake(setNonce, add, h); + if (okFrozen) frozenManageStakeSucceeded = true; + return; + } + + bool ok = actor.manageStake(setNonce, add, h); + if (!ok) return; + + (, uint256 committedStake, , , ) = registry.stakes(address(actor)); + uint256 idx = uint256(actorId) % ACTOR_COUNT; + if (committedStake < lastCommittedStakeByActor[idx]) actionInvariantViolated = true; + lastCommittedStakeByActor[idx] = committedStake; + lastSetNonceByActor[idx] = setNonce; + networkIdAtLastStakeByActor[idx] = trackedNetworkId; + } + + function act_actor_withdrawSurplus(uint8 actorId) external { + EchidnaStakeActor a = _actor(actorId); + (bytes32 ov, uint256 committed, uint256 potential, uint256 lastUpdated, uint8 h) = registry.stakes(address(a)); + uint256 beforeBal = token.balanceOf(address(a)); + + bool ok = a.withdrawFromStake(); + if (!ok) return; + + (bytes32 ov2, , uint256 potentialAfter, , ) = registry.stakes(address(a)); + uint256 afterBal = token.balanceOf(address(a)); + + // No changes to overlay expected from withdraw. + if (ov2 != ov) actionInvariantViolated = true; + + // Expected surplus based on contract math. + uint256 effective = _min(potential, committed * (1 << h) * uint256(ORACLE_PRICE)); + uint256 surplus = potential - effective; + + if (surplus == 0) { + if (potentialAfter != potential) actionInvariantViolated = true; + if (afterBal != beforeBal) actionInvariantViolated = true; + return; + } + + if (potentialAfter + surplus != potential) actionInvariantViolated = true; + if (afterBal != beforeBal + surplus) actionInvariantViolated = true; + + // lastUpdated should not be modified by withdrawFromStake() in this contract. + if (registry.lastUpdatedBlockNumberOfAddress(address(a)) != lastUpdated) actionInvariantViolated = true; + } + + function act_actor_migrateStake(uint8 actorId) external { + EchidnaStakeActor a = _actor(actorId); + + uint256 beforeBal = token.balanceOf(address(a)); + (, , uint256 potential, uint256 lastUpdated, ) = registry.stakes(address(a)); + + bool ok = a.migrateStake(); if (!ok) return; - (, uint256 committedStake, , , ) = registry.stakes(address(this)); - lastCommittedStake = committedStake; - lastSetNonce = setNonce; - networkIdAtLastStake = trackedNetworkId; + // migrateStake only succeeds when paused; if it succeeded, stake must be deleted. + (bytes32 ov2, uint256 c2, uint256 p2, uint256 u2, uint8 h2) = registry.stakes(address(a)); + if (lastUpdated != 0) { + if (ov2 != bytes32(0) || c2 != 0 || p2 != 0 || u2 != 0 || h2 != 0) actionInvariantViolated = true; + if (token.balanceOf(address(a)) != beforeBal + potential) actionInvariantViolated = true; + // Keep tracking in sync so "committed never decreases" doesn't trip on deletion. + uint256 idx = uint256(actorId) % ACTOR_COUNT; + lastCommittedStakeByActor[idx] = 0; + lastSetNonceByActor[idx] = bytes32(0); + networkIdAtLastStakeByActor[idx] = trackedNetworkId; + } else { + // If no stake existed, migrate is a no-op. + if (token.balanceOf(address(a)) != beforeBal) actionInvariantViolated = true; + } + } + + function act_admin_pause() external { + registry.pause(); + } + + function act_admin_unpause() external { + registry.unPause(); + } + + function act_admin_changeNetworkId(uint64 newNetworkId) external { + registry.changeNetworkId(newNetworkId); + trackedNetworkId = newNetworkId; } - function act_withdrawSurplus() external { - // withdrawFromStake() can be a no-op, but we keep this action non-reverting. - (bool ok, ) = address(registry).call(abi.encodeWithSelector(registry.withdrawFromStake.selector)); - ok; // silence unused var warning + function act_actor_tryPause(uint8 actorId) external { + bool ok = _actor(actorId).tryPause(); + if (ok) unauthorizedAdminCallSucceeded = true; } - function act_pause() external { - (bool ok, ) = address(registry).call(abi.encodeWithSelector(registry.pause.selector)); - ok; + function act_actor_tryUnpause(uint8 actorId) external { + bool ok = _actor(actorId).tryUnpause(); + if (ok) unauthorizedAdminCallSucceeded = true; } - function act_unpause() external { - (bool ok, ) = address(registry).call(abi.encodeWithSelector(registry.unPause.selector)); - ok; + function act_actor_tryChangeNetworkId(uint8 actorId, uint64 newNetworkId) external { + bool ok = _actor(actorId).tryChangeNetworkId(newNetworkId); + if (ok) unauthorizedAdminCallSucceeded = true; } - function act_changeNetworkId(uint64 newNetworkId) external { - (bool ok, ) = address(registry).call(abi.encodeWithSelector(registry.changeNetworkId.selector, newNetworkId)); + function act_redistributor_freeze(uint8 targetActorId, uint32 time) external { + EchidnaStakeActor t = _actor(targetActorId); + uint256 before = registry.lastUpdatedBlockNumberOfAddress(address(t)); + bool ok = redistributor.tryFreezeDeposit(address(t), uint256(time)); if (!ok) return; - trackedNetworkId = newNetworkId; + + // Only affects existing stakes. + if (before != 0) { + uint256 afterU = registry.lastUpdatedBlockNumberOfAddress(address(t)); + if (afterU != block.number + uint256(time)) actionInvariantViolated = true; + } + } + + function act_redistributor_slash(uint8 targetActorId, uint256 amount) external { + EchidnaStakeActor t = _actor(targetActorId); + (, uint256 cBefore, uint256 pBefore, uint256 uBefore, uint8 hBefore) = registry.stakes(address(t)); + bool ok = redistributor.trySlashDeposit(address(t), amount); + if (!ok) return; + + (bytes32 ovAfter, uint256 cAfter, uint256 pAfter, uint256 uAfter, uint8 hAfter) = registry.stakes(address(t)); + + if (uBefore == 0) { + // No stake: should remain unchanged. + if (cAfter != cBefore || pAfter != pBefore || uAfter != uBefore || hAfter != hBefore) actionInvariantViolated = true; + return; + } + + if (pBefore > amount) { + if (pAfter != pBefore - amount) actionInvariantViolated = true; + if (uAfter != block.number) actionInvariantViolated = true; + // overlay/committed/height must remain unchanged on partial slash. + if (ovAfter != registry.overlayOfAddress(address(t))) actionInvariantViolated = true; + if (cAfter != cBefore || hAfter != hBefore) actionInvariantViolated = true; + } else { + // Stake deleted. + if (ovAfter != bytes32(0) || cAfter != 0 || pAfter != 0 || uAfter != 0 || hAfter != 0) actionInvariantViolated = true; + uint256 idx = uint256(targetActorId) % ACTOR_COUNT; + lastCommittedStakeByActor[idx] = 0; + lastSetNonceByActor[idx] = bytes32(0); + networkIdAtLastStakeByActor[idx] = trackedNetworkId; + } + } + + function act_actor_tryFreeze(uint8 actorId, uint8 targetActorId, uint32 time) external { + bool ok = _actor(actorId).tryFreezeDeposit(address(_actor(targetActorId)), uint256(time)); + if (ok) unauthorizedFreezeSlashSucceeded = true; + } + + function act_actor_trySlash(uint8 actorId, uint8 targetActorId, uint256 amount) external { + bool ok = _actor(actorId).trySlashDeposit(address(_actor(targetActorId)), amount); + if (ok) unauthorizedFreezeSlashSucceeded = true; } // ----------------------------- // Properties (checked by Echidna) // ----------------------------- - function echidna_stake_committed_never_decreases() external view returns (bool) { - (, uint256 committedStake, , , ) = registry.stakes(address(this)); - return committedStake >= lastCommittedStake; + function echidna_never_performed_forbidden_calls() external view returns (bool) { + return + !unauthorizedAdminCallSucceeded && + !unauthorizedFreezeSlashSucceeded && + !pausedManageStakeSucceeded && + !frozenManageStakeSucceeded && + !actionInvariantViolated; } function echidna_registry_token_is_expected() external view returns (bool) { return registry.bzzToken() == address(token); } - function echidna_registry_balance_covers_potential() external view returns (bool) { - (, , uint256 potentialStake, , ) = registry.stakes(address(this)); - return token.balanceOf(address(registry)) >= potentialStake; + function echidna_registry_balance_covers_sum_potential() external view returns (bool) { + uint256 sumPotential; + for (uint256 i = 0; i < ACTOR_COUNT; i++) { + (, , uint256 potentialStake, , ) = registry.stakes(address(actors[i])); + sumPotential += potentialStake; + } + return token.balanceOf(address(registry)) >= sumPotential; } - function echidna_withdrawable_matches_effective_math() external view returns (bool) { - (, uint256 committedStake, uint256 potentialStake, , uint8 h) = registry.stakes(address(this)); - - uint256 effective = _min(potentialStake, committedStake * (1 << h) * uint256(oracle.currentPrice())); - uint256 expectedWithdrawable = potentialStake - effective; - - return registry.withdrawableStake() == expectedWithdrawable; + function echidna_stake_committed_never_decreases_per_actor() external view returns (bool) { + for (uint256 i = 0; i < ACTOR_COUNT; i++) { + (, uint256 committedStake, , uint256 lastUpdated, ) = registry.stakes(address(actors[i])); + if (lastUpdated == 0) continue; + if (committedStake < lastCommittedStakeByActor[i]) return false; + } + return true; } - function echidna_nodeEffective_matches_freeze_rule() external view returns (bool) { - (, uint256 committedStake, uint256 potentialStake, , uint8 h) = registry.stakes(address(this)); - uint256 lastUpdated = registry.lastUpdatedBlockNumberOfAddress(address(this)); - uint256 fromView = registry.nodeEffectiveStake(address(this)); - - if (lastUpdated >= block.number) { - return fromView == 0; + function echidna_nodeEffective_matches_freeze_rule_per_actor() external view returns (bool) { + for (uint256 i = 0; i < ACTOR_COUNT; i++) { + (, uint256 committedStake, uint256 potentialStake, uint256 lastUpdated, uint8 h) = registry.stakes( + address(actors[i]) + ); + uint256 fromView = registry.nodeEffectiveStake(address(actors[i])); + if (lastUpdated == 0) { + if (fromView != 0) return false; + continue; + } + if (lastUpdated >= block.number) { + if (fromView != 0) return false; + continue; + } + uint256 expected = _min(potentialStake, committedStake * (1 << h) * uint256(ORACLE_PRICE)); + if (fromView != expected) return false; } - - uint256 expected = _min(potentialStake, committedStake * (1 << h) * uint256(oracle.currentPrice())); - return fromView == expected; + return true; } - function echidna_stake_empty_state_is_zeroed() external view returns (bool) { - (bytes32 overlay, uint256 committedStake, uint256 potentialStake, uint256 lastUpdated, uint8 h) = registry - .stakes(address(this)); - if (lastUpdated != 0) return true; - return overlay == bytes32(0) && committedStake == 0 && potentialStake == 0 && h == 0; + function echidna_empty_state_is_zeroed_for_all() external view returns (bool) { + for (uint256 i = 0; i < ACTOR_COUNT; i++) { + (bytes32 overlay, uint256 committedStake, uint256 potentialStake, uint256 lastUpdated, uint8 h) = registry + .stakes(address(actors[i])); + if (lastUpdated != 0) continue; + if (overlay != bytes32(0) || committedStake != 0 || potentialStake != 0 || h != 0) return false; + } + return true; } - function echidna_overlay_matches_nonce_and_network() external view returns (bool) { - (bytes32 overlay, , , uint256 lastUpdated, ) = registry.stakes(address(this)); - if (lastUpdated == 0) return true; - return overlay == keccak256(abi.encodePacked(address(this), _reverse(networkIdAtLastStake), lastSetNonce)); + function echidna_overlay_matches_last_manageStake_for_all() external view returns (bool) { + for (uint256 i = 0; i < ACTOR_COUNT; i++) { + (bytes32 overlay, , , uint256 lastUpdated, ) = registry.stakes(address(actors[i])); + if (lastUpdated == 0) continue; + bytes32 expected = keccak256( + abi.encodePacked(address(actors[i]), _reverse(networkIdAtLastStakeByActor[i]), lastSetNonceByActor[i]) + ); + if (overlay != expected) return false; + } + return true; } function _min(uint256 a, uint256 b) internal pure returns (uint256) { @@ -181,4 +393,8 @@ contract EchidnaStakeRegistryHarness { v = ((v & 0xFFFF0000FFFF0000) >> 16) | ((v & 0x0000FFFF0000FFFF) << 16); v = (v >> 32) | (v << 32); } + + function _actor(uint8 actorId) internal view returns (EchidnaStakeActor) { + return actors[uint256(actorId) % ACTOR_COUNT]; + } } From fd524e0b03f6183f3bebe7785d240e906987eca1 Mon Sep 17 00:00:00 2001 From: Cardinal Date: Fri, 27 Feb 2026 23:09:14 +0100 Subject: [PATCH 07/50] feat: add manageStake postconditions and non-interference checks 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. --- echidna/README.md | 3 + src/echidna/EchidnaStakeRegistryHarness.sol | 214 ++++++++++++++++++-- 2 files changed, 197 insertions(+), 20 deletions(-) diff --git a/echidna/README.md b/echidna/README.md index 25921fda..62f59a3a 100644 --- a/echidna/README.md +++ b/echidna/README.md @@ -57,6 +57,9 @@ The harness defines `echidna_*` properties that Echidna checks continuously: - `echidna_nodeEffective_matches_freeze_rule_per_actor`: effective stake is `0` while frozen, otherwise matches expected effective stake math. - `echidna_empty_state_is_zeroed_for_all`: if a stake entry is deleted/empty, all fields are zeroed. - `echidna_overlay_matches_last_manageStake_for_all`: overlay matches `keccak256(owner, reverse(networkIdAtLastStake), lastNonce)` per actor. +- **Post-conditions for successful `manageStake(add > 0)`** + - `echidna_last_manageStake_add_updates_potential_and_registry_balance`: on the immediate post-state after a successful `manageStake` with `addAmount > 0`, both the actor’s `potentialStake` and the registry’s ERC20 balance must increase by exactly `addAmount`. + - `echidna_last_manageStake_add_recomputes_committedStake`: on that same immediate post-state, `committedStake` must equal `floor(potential / (price * 2**height))`. These are “sanity properties”: they’re meant to detect obvious bugs and unintended state corruption early. diff --git a/src/echidna/EchidnaStakeRegistryHarness.sol b/src/echidna/EchidnaStakeRegistryHarness.sol index 6f7671ff..b87a8880 100644 --- a/src/echidna/EchidnaStakeRegistryHarness.sol +++ b/src/echidna/EchidnaStakeRegistryHarness.sol @@ -89,6 +89,16 @@ contract EchidnaStakeRegistryHarness { bool internal frozenManageStakeSucceeded; bool internal actionInvariantViolated; + // Post-condition checks for the last *successful* manageStake(add > 0). + // We keep these checks "pending" only until the next action, so properties + // validate the immediate post-state without being invalidated by later actions. + bool internal pendingManageStakeAddCheck; + uint256 internal pendingActorIdx; + uint256 internal pendingAddAmount; + uint8 internal pendingHeight; + uint256 internal pendingPotentialBefore; + uint256 internal pendingRegistryBalanceBefore; + constructor() { // Keep values modest so arithmetic in invariants stays safe. initialSupply = 1_000_000_000_000_000_000_000_000; // 1e24 @@ -115,16 +125,28 @@ contract EchidnaStakeRegistryHarness { // ----------------------------- function act_fundActor(uint8 actorId, uint256 amount) external { + _clearPendingManageStakeAddCheck(); + bytes32 d0 = _stakeDigest(address(actors[0])); + bytes32 d1 = _stakeDigest(address(actors[1])); + bytes32 d2 = _stakeDigest(address(actors[2])); + EchidnaStakeActor a = _actor(actorId); uint256 bal = token.balanceOf(address(this)); if (bal == 0) return; uint256 x = amount % (bal + 1); if (x == 0) return; token.transfer(address(a), x); + + if (_stakeDigest(address(actors[0])) != d0) actionInvariantViolated = true; + if (_stakeDigest(address(actors[1])) != d1) actionInvariantViolated = true; + if (_stakeDigest(address(actors[2])) != d2) actionInvariantViolated = true; } function act_actor_manageStake(uint8 actorId, bytes32 setNonce, uint256 addAmount, uint8 height) external { - EchidnaStakeActor actor = _actor(actorId); + _clearPendingManageStakeAddCheck(); + + uint256 idx = uint256(actorId) % ACTOR_COUNT; + EchidnaStakeActor actor = actors[idx]; // Keep height small to avoid huge powers of two. uint8 h = uint8(height % 16); @@ -158,25 +180,48 @@ contract EchidnaStakeRegistryHarness { return; } + // Snapshot other actors so we can detect unintended writes. + (bytes32 otherDigestA, bytes32 otherDigestB) = _otherDigests(idx); + + // Prepare pending post-conditions only for add > 0. + if (add > 0) { + pendingActorIdx = idx; + pendingAddAmount = add; + pendingHeight = h; + (, , pendingPotentialBefore, , ) = registry.stakes(address(actor)); + pendingRegistryBalanceBefore = token.balanceOf(address(registry)); + } + bool ok = actor.manageStake(setNonce, add, h); if (!ok) return; + _checkOtherDigestsUnchanged(idx, otherDigestA, otherDigestB); + (, uint256 committedStake, , , ) = registry.stakes(address(actor)); - uint256 idx = uint256(actorId) % ACTOR_COUNT; if (committedStake < lastCommittedStakeByActor[idx]) actionInvariantViolated = true; lastCommittedStakeByActor[idx] = committedStake; lastSetNonceByActor[idx] = setNonce; networkIdAtLastStakeByActor[idx] = trackedNetworkId; + + // Arm post-condition properties for the immediate post-state. + if (add > 0) pendingManageStakeAddCheck = true; } function act_actor_withdrawSurplus(uint8 actorId) external { - EchidnaStakeActor a = _actor(actorId); - (bytes32 ov, uint256 committed, uint256 potential, uint256 lastUpdated, uint8 h) = registry.stakes(address(a)); + _clearPendingManageStakeAddCheck(); + + uint256 idx = uint256(actorId) % ACTOR_COUNT; + EchidnaStakeActor a = actors[idx]; + (bytes32 otherDigestA, bytes32 otherDigestB) = _otherDigests(idx); + + (bytes32 ov, uint256 committed, uint256 potential, , uint8 h) = registry.stakes(address(a)); uint256 beforeBal = token.balanceOf(address(a)); bool ok = a.withdrawFromStake(); if (!ok) return; + _checkOtherDigestsUnchanged(idx, otherDigestA, otherDigestB); + (bytes32 ov2, , uint256 potentialAfter, , ) = registry.stakes(address(a)); uint256 afterBal = token.balanceOf(address(a)); @@ -195,13 +240,14 @@ contract EchidnaStakeRegistryHarness { if (potentialAfter + surplus != potential) actionInvariantViolated = true; if (afterBal != beforeBal + surplus) actionInvariantViolated = true; - - // lastUpdated should not be modified by withdrawFromStake() in this contract. - if (registry.lastUpdatedBlockNumberOfAddress(address(a)) != lastUpdated) actionInvariantViolated = true; } function act_actor_migrateStake(uint8 actorId) external { - EchidnaStakeActor a = _actor(actorId); + _clearPendingManageStakeAddCheck(); + + uint256 idx = uint256(actorId) % ACTOR_COUNT; + EchidnaStakeActor a = actors[idx]; + (bytes32 otherDigestA, bytes32 otherDigestB) = _otherDigests(idx); uint256 beforeBal = token.balanceOf(address(a)); (, , uint256 potential, uint256 lastUpdated, ) = registry.stakes(address(a)); @@ -209,13 +255,14 @@ contract EchidnaStakeRegistryHarness { bool ok = a.migrateStake(); if (!ok) return; + _checkOtherDigestsUnchanged(idx, otherDigestA, otherDigestB); + // migrateStake only succeeds when paused; if it succeeded, stake must be deleted. (bytes32 ov2, uint256 c2, uint256 p2, uint256 u2, uint8 h2) = registry.stakes(address(a)); if (lastUpdated != 0) { if (ov2 != bytes32(0) || c2 != 0 || p2 != 0 || u2 != 0 || h2 != 0) actionInvariantViolated = true; if (token.balanceOf(address(a)) != beforeBal + potential) actionInvariantViolated = true; // Keep tracking in sync so "committed never decreases" doesn't trip on deletion. - uint256 idx = uint256(actorId) % ACTOR_COUNT; lastCommittedStakeByActor[idx] = 0; lastSetNonceByActor[idx] = bytes32(0); networkIdAtLastStakeByActor[idx] = trackedNetworkId; @@ -226,39 +273,88 @@ contract EchidnaStakeRegistryHarness { } function act_admin_pause() external { + _clearPendingManageStakeAddCheck(); + bytes32 d0 = _stakeDigest(address(actors[0])); + bytes32 d1 = _stakeDigest(address(actors[1])); + bytes32 d2 = _stakeDigest(address(actors[2])); registry.pause(); + if (_stakeDigest(address(actors[0])) != d0) actionInvariantViolated = true; + if (_stakeDigest(address(actors[1])) != d1) actionInvariantViolated = true; + if (_stakeDigest(address(actors[2])) != d2) actionInvariantViolated = true; } function act_admin_unpause() external { + _clearPendingManageStakeAddCheck(); + bytes32 d0 = _stakeDigest(address(actors[0])); + bytes32 d1 = _stakeDigest(address(actors[1])); + bytes32 d2 = _stakeDigest(address(actors[2])); registry.unPause(); + if (_stakeDigest(address(actors[0])) != d0) actionInvariantViolated = true; + if (_stakeDigest(address(actors[1])) != d1) actionInvariantViolated = true; + if (_stakeDigest(address(actors[2])) != d2) actionInvariantViolated = true; } function act_admin_changeNetworkId(uint64 newNetworkId) external { + _clearPendingManageStakeAddCheck(); + bytes32 d0 = _stakeDigest(address(actors[0])); + bytes32 d1 = _stakeDigest(address(actors[1])); + bytes32 d2 = _stakeDigest(address(actors[2])); registry.changeNetworkId(newNetworkId); trackedNetworkId = newNetworkId; + if (_stakeDigest(address(actors[0])) != d0) actionInvariantViolated = true; + if (_stakeDigest(address(actors[1])) != d1) actionInvariantViolated = true; + if (_stakeDigest(address(actors[2])) != d2) actionInvariantViolated = true; } function act_actor_tryPause(uint8 actorId) external { + _clearPendingManageStakeAddCheck(); + bytes32 d0 = _stakeDigest(address(actors[0])); + bytes32 d1 = _stakeDigest(address(actors[1])); + bytes32 d2 = _stakeDigest(address(actors[2])); bool ok = _actor(actorId).tryPause(); if (ok) unauthorizedAdminCallSucceeded = true; + if (_stakeDigest(address(actors[0])) != d0) actionInvariantViolated = true; + if (_stakeDigest(address(actors[1])) != d1) actionInvariantViolated = true; + if (_stakeDigest(address(actors[2])) != d2) actionInvariantViolated = true; } function act_actor_tryUnpause(uint8 actorId) external { + _clearPendingManageStakeAddCheck(); + bytes32 d0 = _stakeDigest(address(actors[0])); + bytes32 d1 = _stakeDigest(address(actors[1])); + bytes32 d2 = _stakeDigest(address(actors[2])); bool ok = _actor(actorId).tryUnpause(); if (ok) unauthorizedAdminCallSucceeded = true; + if (_stakeDigest(address(actors[0])) != d0) actionInvariantViolated = true; + if (_stakeDigest(address(actors[1])) != d1) actionInvariantViolated = true; + if (_stakeDigest(address(actors[2])) != d2) actionInvariantViolated = true; } function act_actor_tryChangeNetworkId(uint8 actorId, uint64 newNetworkId) external { + _clearPendingManageStakeAddCheck(); + bytes32 d0 = _stakeDigest(address(actors[0])); + bytes32 d1 = _stakeDigest(address(actors[1])); + bytes32 d2 = _stakeDigest(address(actors[2])); bool ok = _actor(actorId).tryChangeNetworkId(newNetworkId); if (ok) unauthorizedAdminCallSucceeded = true; + if (_stakeDigest(address(actors[0])) != d0) actionInvariantViolated = true; + if (_stakeDigest(address(actors[1])) != d1) actionInvariantViolated = true; + if (_stakeDigest(address(actors[2])) != d2) actionInvariantViolated = true; } function act_redistributor_freeze(uint8 targetActorId, uint32 time) external { - EchidnaStakeActor t = _actor(targetActorId); + _clearPendingManageStakeAddCheck(); + + uint256 idx = uint256(targetActorId) % ACTOR_COUNT; + EchidnaStakeActor t = actors[idx]; + (bytes32 otherDigestA, bytes32 otherDigestB) = _otherDigests(idx); + uint256 before = registry.lastUpdatedBlockNumberOfAddress(address(t)); bool ok = redistributor.tryFreezeDeposit(address(t), uint256(time)); if (!ok) return; + _checkOtherDigestsUnchanged(idx, otherDigestA, otherDigestB); + // Only affects existing stakes. if (before != 0) { uint256 afterU = registry.lastUpdatedBlockNumberOfAddress(address(t)); @@ -267,29 +363,31 @@ contract EchidnaStakeRegistryHarness { } function act_redistributor_slash(uint8 targetActorId, uint256 amount) external { - EchidnaStakeActor t = _actor(targetActorId); - (, uint256 cBefore, uint256 pBefore, uint256 uBefore, uint8 hBefore) = registry.stakes(address(t)); + _clearPendingManageStakeAddCheck(); + + uint256 idx = uint256(targetActorId) % ACTOR_COUNT; + EchidnaStakeActor t = actors[idx]; + (bytes32 otherDigestA, bytes32 otherDigestB) = _otherDigests(idx); + + (, , uint256 pBefore, uint256 uBefore, ) = registry.stakes(address(t)); bool ok = redistributor.trySlashDeposit(address(t), amount); if (!ok) return; - (bytes32 ovAfter, uint256 cAfter, uint256 pAfter, uint256 uAfter, uint8 hAfter) = registry.stakes(address(t)); + _checkOtherDigestsUnchanged(idx, otherDigestA, otherDigestB); if (uBefore == 0) { - // No stake: should remain unchanged. - if (cAfter != cBefore || pAfter != pBefore || uAfter != uBefore || hAfter != hBefore) actionInvariantViolated = true; + // No stake: should remain empty. + if (registry.lastUpdatedBlockNumberOfAddress(address(t)) != 0) actionInvariantViolated = true; return; } if (pBefore > amount) { + (, , uint256 pAfter, uint256 uAfter, ) = registry.stakes(address(t)); if (pAfter != pBefore - amount) actionInvariantViolated = true; if (uAfter != block.number) actionInvariantViolated = true; - // overlay/committed/height must remain unchanged on partial slash. - if (ovAfter != registry.overlayOfAddress(address(t))) actionInvariantViolated = true; - if (cAfter != cBefore || hAfter != hBefore) actionInvariantViolated = true; } else { // Stake deleted. - if (ovAfter != bytes32(0) || cAfter != 0 || pAfter != 0 || uAfter != 0 || hAfter != 0) actionInvariantViolated = true; - uint256 idx = uint256(targetActorId) % ACTOR_COUNT; + if (registry.lastUpdatedBlockNumberOfAddress(address(t)) != 0) actionInvariantViolated = true; lastCommittedStakeByActor[idx] = 0; lastSetNonceByActor[idx] = bytes32(0); networkIdAtLastStakeByActor[idx] = trackedNetworkId; @@ -297,13 +395,27 @@ contract EchidnaStakeRegistryHarness { } function act_actor_tryFreeze(uint8 actorId, uint8 targetActorId, uint32 time) external { + _clearPendingManageStakeAddCheck(); + bytes32 d0 = _stakeDigest(address(actors[0])); + bytes32 d1 = _stakeDigest(address(actors[1])); + bytes32 d2 = _stakeDigest(address(actors[2])); bool ok = _actor(actorId).tryFreezeDeposit(address(_actor(targetActorId)), uint256(time)); if (ok) unauthorizedFreezeSlashSucceeded = true; + if (_stakeDigest(address(actors[0])) != d0) actionInvariantViolated = true; + if (_stakeDigest(address(actors[1])) != d1) actionInvariantViolated = true; + if (_stakeDigest(address(actors[2])) != d2) actionInvariantViolated = true; } function act_actor_trySlash(uint8 actorId, uint8 targetActorId, uint256 amount) external { + _clearPendingManageStakeAddCheck(); + bytes32 d0 = _stakeDigest(address(actors[0])); + bytes32 d1 = _stakeDigest(address(actors[1])); + bytes32 d2 = _stakeDigest(address(actors[2])); bool ok = _actor(actorId).trySlashDeposit(address(_actor(targetActorId)), amount); if (ok) unauthorizedFreezeSlashSucceeded = true; + if (_stakeDigest(address(actors[0])) != d0) actionInvariantViolated = true; + if (_stakeDigest(address(actors[1])) != d1) actionInvariantViolated = true; + if (_stakeDigest(address(actors[2])) != d2) actionInvariantViolated = true; } // ----------------------------- @@ -332,6 +444,29 @@ contract EchidnaStakeRegistryHarness { return token.balanceOf(address(registry)) >= sumPotential; } + /// @notice After a successful manageStake(add > 0), potential and registry balance + /// must both increase by exactly `add`. + function echidna_last_manageStake_add_updates_potential_and_registry_balance() external view returns (bool) { + if (!pendingManageStakeAddCheck) return true; + address a = address(actors[pendingActorIdx]); + (, , uint256 potentialAfter, , ) = registry.stakes(a); + if (potentialAfter != pendingPotentialBefore + pendingAddAmount) return false; + if (token.balanceOf(address(registry)) != pendingRegistryBalanceBefore + pendingAddAmount) return false; + return true; + } + + /// @notice After a successful manageStake(add > 0), committedStake must be + /// recomputed to floor(potential / (price * 2**height)). + function echidna_last_manageStake_add_recomputes_committedStake() external view returns (bool) { + if (!pendingManageStakeAddCheck) return true; + address a = address(actors[pendingActorIdx]); + (, uint256 committedAfter, uint256 potentialAfter, , uint8 hAfter) = registry.stakes(a); + if (hAfter != pendingHeight) return false; + uint256 denom = uint256(ORACLE_PRICE) * (1 << pendingHeight); + uint256 expectedCommitted = potentialAfter / denom; + return committedAfter == expectedCommitted; + } + function echidna_stake_committed_never_decreases_per_actor() external view returns (bool) { for (uint256 i = 0; i < ACTOR_COUNT; i++) { (, uint256 committedStake, , uint256 lastUpdated, ) = registry.stakes(address(actors[i])); @@ -397,4 +532,43 @@ contract EchidnaStakeRegistryHarness { function _actor(uint8 actorId) internal view returns (EchidnaStakeActor) { return actors[uint256(actorId) % ACTOR_COUNT]; } + + function _stakeDigest(address who) internal view returns (bytes32) { + (bytes32 overlay, uint256 committedStake, uint256 potentialStake, uint256 lastUpdated, uint8 h) = registry.stakes( + who + ); + return keccak256(abi.encodePacked(overlay, committedStake, potentialStake, lastUpdated, h)); + } + + function _otherDigests(uint256 idx) internal view returns (bytes32 dA, bytes32 dB) { + if (idx == 0) { + dA = _stakeDigest(address(actors[1])); + dB = _stakeDigest(address(actors[2])); + } else if (idx == 1) { + dA = _stakeDigest(address(actors[0])); + dB = _stakeDigest(address(actors[2])); + } else { + dA = _stakeDigest(address(actors[0])); + dB = _stakeDigest(address(actors[1])); + } + } + + function _checkOtherDigestsUnchanged(uint256 idx, bytes32 dA, bytes32 dB) internal { + if (idx == 0) { + if (_stakeDigest(address(actors[1])) != dA) actionInvariantViolated = true; + if (_stakeDigest(address(actors[2])) != dB) actionInvariantViolated = true; + } else if (idx == 1) { + if (_stakeDigest(address(actors[0])) != dA) actionInvariantViolated = true; + if (_stakeDigest(address(actors[2])) != dB) actionInvariantViolated = true; + } else { + if (_stakeDigest(address(actors[0])) != dA) actionInvariantViolated = true; + if (_stakeDigest(address(actors[1])) != dB) actionInvariantViolated = true; + } + } + + function _clearPendingManageStakeAddCheck() internal { + if (pendingManageStakeAddCheck) { + pendingManageStakeAddCheck = false; + } + } } From 92520d8e32897f0d00935269b65d89ac8a4b0066 Mon Sep 17 00:00:00 2001 From: Cardinal Date: Fri, 27 Feb 2026 23:31:55 +0100 Subject: [PATCH 08/50] feat: add freeze/slash/migrate postconditions 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. --- echidna/README.md | 5 + scripts/echidna.sh | 7 + src/echidna/EchidnaStakeRegistryHarness.sol | 186 ++++++++++++++------ 3 files changed, 148 insertions(+), 50 deletions(-) diff --git a/echidna/README.md b/echidna/README.md index 62f59a3a..5362d689 100644 --- a/echidna/README.md +++ b/echidna/README.md @@ -60,6 +60,11 @@ The harness defines `echidna_*` properties that Echidna checks continuously: - **Post-conditions for successful `manageStake(add > 0)`** - `echidna_last_manageStake_add_updates_potential_and_registry_balance`: on the immediate post-state after a successful `manageStake` with `addAmount > 0`, both the actor’s `potentialStake` and the registry’s ERC20 balance must increase by exactly `addAmount`. - `echidna_last_manageStake_add_recomputes_committedStake`: on that same immediate post-state, `committedStake` must equal `floor(potential / (price * 2**height))`. +- **Pause/migrate and penalty post-conditions** + - `echidna_migrate_never_succeeds_while_unpaused`: `migrateStake()` must never succeed unless the registry is paused. + - `echidna_last_migrate_refunds_and_deletes_when_stake_exists`: on the immediate post-state after a successful `migrateStake()`, the stake is deleted and the actor is refunded exactly their previous `potentialStake` (or it is a no-op if no stake existed). + - `echidna_last_freeze_only_updates_lastUpdated`: on the immediate post-state after a successful redistributor `freezeDeposit`, only `lastUpdatedBlockNumber` is modified (other stake fields remain unchanged). + - `echidna_last_slash_updates_expected_fields`: on the immediate post-state after a successful redistributor `slashDeposit`, partial slashes only decrease `potentialStake` and set `lastUpdatedBlockNumber`, and full slashes delete the stake. These are “sanity properties”: they’re meant to detect obvious bugs and unintended state corruption early. diff --git a/scripts/echidna.sh b/scripts/echidna.sh index 0dc3ae6f..44ea0841 100755 --- a/scripts/echidna.sh +++ b/scripts/echidna.sh @@ -13,6 +13,13 @@ cd "$ROOT_DIR" IMAGE="${ECHIDNA_IMAGE:-ghcr.io/crytic/echidna/echidna:latest}" CONTRACT="EchidnaStakeRegistryHarness" +# Avoid stale Crytic compile artifacts causing old properties/tests to run. +rm -rf crytic-export + +# Compile on the host. The Echidna container image doesn't ship with Node/npx, +# and without Hardhat artifacts CryticCompile will try (and fail) to run `npx hardhat compile`. +yarn -s hardhat compile --force >/dev/null + docker run --rm \ -v "$ROOT_DIR":/src \ -w /src \ diff --git a/src/echidna/EchidnaStakeRegistryHarness.sol b/src/echidna/EchidnaStakeRegistryHarness.sol index b87a8880..82778d20 100644 --- a/src/echidna/EchidnaStakeRegistryHarness.sol +++ b/src/echidna/EchidnaStakeRegistryHarness.sol @@ -99,6 +99,35 @@ contract EchidnaStakeRegistryHarness { uint256 internal pendingPotentialBefore; uint256 internal pendingRegistryBalanceBefore; + // Post-condition checks for the last freeze/slash/migrate call (pending until next action). + bool internal pendingFreezeCheck; + uint256 internal pendingFreezeIdx; + bool internal pendingFreezeHadStake; + bytes32 internal pendingFreezeOverlay; + uint256 internal pendingFreezeCommitted; + uint256 internal pendingFreezePotential; + uint8 internal pendingFreezeHeight; + uint256 internal pendingFreezeExpectedLastUpdated; + + bool internal pendingSlashCheck; + uint256 internal pendingSlashIdx; + bool internal pendingSlashHadStake; + bytes32 internal pendingSlashOverlay; + uint256 internal pendingSlashCommitted; + uint256 internal pendingSlashPotential; + uint8 internal pendingSlashHeight; + uint256 internal pendingSlashLastUpdated; + uint256 internal pendingSlashAmount; + uint256 internal pendingSlashExpectedBlockNumber; + + bool internal pendingMigrateCheck; + uint256 internal pendingMigrateIdx; + bool internal pendingMigrateHadStake; + uint256 internal pendingMigratePotentialBefore; + uint256 internal pendingMigrateActorBalanceBefore; + uint256 internal pendingMigrateLastUpdatedBefore; + bool internal migrateSucceededWhileUnpaused; + constructor() { // Keep values modest so arithmetic in invariants stays safe. initialSupply = 1_000_000_000_000_000_000_000_000; // 1e24 @@ -125,7 +154,7 @@ contract EchidnaStakeRegistryHarness { // ----------------------------- function act_fundActor(uint8 actorId, uint256 amount) external { - _clearPendingManageStakeAddCheck(); + _clearPendingChecks(); bytes32 d0 = _stakeDigest(address(actors[0])); bytes32 d1 = _stakeDigest(address(actors[1])); bytes32 d2 = _stakeDigest(address(actors[2])); @@ -143,7 +172,7 @@ contract EchidnaStakeRegistryHarness { } function act_actor_manageStake(uint8 actorId, bytes32 setNonce, uint256 addAmount, uint8 height) external { - _clearPendingManageStakeAddCheck(); + _clearPendingChecks(); uint256 idx = uint256(actorId) % ACTOR_COUNT; EchidnaStakeActor actor = actors[idx]; @@ -208,7 +237,7 @@ contract EchidnaStakeRegistryHarness { } function act_actor_withdrawSurplus(uint8 actorId) external { - _clearPendingManageStakeAddCheck(); + _clearPendingChecks(); uint256 idx = uint256(actorId) % ACTOR_COUNT; EchidnaStakeActor a = actors[idx]; @@ -243,37 +272,35 @@ contract EchidnaStakeRegistryHarness { } function act_actor_migrateStake(uint8 actorId) external { - _clearPendingManageStakeAddCheck(); + _clearPendingChecks(); uint256 idx = uint256(actorId) % ACTOR_COUNT; EchidnaStakeActor a = actors[idx]; (bytes32 otherDigestA, bytes32 otherDigestB) = _otherDigests(idx); - uint256 beforeBal = token.balanceOf(address(a)); - (, , uint256 potential, uint256 lastUpdated, ) = registry.stakes(address(a)); + pendingMigrateIdx = idx; + pendingMigrateActorBalanceBefore = token.balanceOf(address(a)); + (, , pendingMigratePotentialBefore, pendingMigrateLastUpdatedBefore, ) = registry.stakes(address(a)); + pendingMigrateHadStake = pendingMigrateLastUpdatedBefore != 0; bool ok = a.migrateStake(); if (!ok) return; + if (!registry.paused()) migrateSucceededWhileUnpaused = true; _checkOtherDigestsUnchanged(idx, otherDigestA, otherDigestB); + pendingMigrateCheck = true; - // migrateStake only succeeds when paused; if it succeeded, stake must be deleted. - (bytes32 ov2, uint256 c2, uint256 p2, uint256 u2, uint8 h2) = registry.stakes(address(a)); - if (lastUpdated != 0) { - if (ov2 != bytes32(0) || c2 != 0 || p2 != 0 || u2 != 0 || h2 != 0) actionInvariantViolated = true; - if (token.balanceOf(address(a)) != beforeBal + potential) actionInvariantViolated = true; - // Keep tracking in sync so "committed never decreases" doesn't trip on deletion. + // If a stake existed, migrateStake refunds and deletes it. Reset per-actor tracking + // so future re-stakes don't incorrectly look like "commitment decreased". + if (pendingMigrateHadStake) { lastCommittedStakeByActor[idx] = 0; lastSetNonceByActor[idx] = bytes32(0); networkIdAtLastStakeByActor[idx] = trackedNetworkId; - } else { - // If no stake existed, migrate is a no-op. - if (token.balanceOf(address(a)) != beforeBal) actionInvariantViolated = true; } } function act_admin_pause() external { - _clearPendingManageStakeAddCheck(); + _clearPendingChecks(); bytes32 d0 = _stakeDigest(address(actors[0])); bytes32 d1 = _stakeDigest(address(actors[1])); bytes32 d2 = _stakeDigest(address(actors[2])); @@ -284,7 +311,7 @@ contract EchidnaStakeRegistryHarness { } function act_admin_unpause() external { - _clearPendingManageStakeAddCheck(); + _clearPendingChecks(); bytes32 d0 = _stakeDigest(address(actors[0])); bytes32 d1 = _stakeDigest(address(actors[1])); bytes32 d2 = _stakeDigest(address(actors[2])); @@ -295,7 +322,7 @@ contract EchidnaStakeRegistryHarness { } function act_admin_changeNetworkId(uint64 newNetworkId) external { - _clearPendingManageStakeAddCheck(); + _clearPendingChecks(); bytes32 d0 = _stakeDigest(address(actors[0])); bytes32 d1 = _stakeDigest(address(actors[1])); bytes32 d2 = _stakeDigest(address(actors[2])); @@ -307,7 +334,7 @@ contract EchidnaStakeRegistryHarness { } function act_actor_tryPause(uint8 actorId) external { - _clearPendingManageStakeAddCheck(); + _clearPendingChecks(); bytes32 d0 = _stakeDigest(address(actors[0])); bytes32 d1 = _stakeDigest(address(actors[1])); bytes32 d2 = _stakeDigest(address(actors[2])); @@ -319,7 +346,7 @@ contract EchidnaStakeRegistryHarness { } function act_actor_tryUnpause(uint8 actorId) external { - _clearPendingManageStakeAddCheck(); + _clearPendingChecks(); bytes32 d0 = _stakeDigest(address(actors[0])); bytes32 d1 = _stakeDigest(address(actors[1])); bytes32 d2 = _stakeDigest(address(actors[2])); @@ -331,7 +358,7 @@ contract EchidnaStakeRegistryHarness { } function act_actor_tryChangeNetworkId(uint8 actorId, uint64 newNetworkId) external { - _clearPendingManageStakeAddCheck(); + _clearPendingChecks(); bytes32 d0 = _stakeDigest(address(actors[0])); bytes32 d1 = _stakeDigest(address(actors[1])); bytes32 d2 = _stakeDigest(address(actors[2])); @@ -343,51 +370,48 @@ contract EchidnaStakeRegistryHarness { } function act_redistributor_freeze(uint8 targetActorId, uint32 time) external { - _clearPendingManageStakeAddCheck(); + _clearPendingChecks(); uint256 idx = uint256(targetActorId) % ACTOR_COUNT; EchidnaStakeActor t = actors[idx]; (bytes32 otherDigestA, bytes32 otherDigestB) = _otherDigests(idx); - uint256 before = registry.lastUpdatedBlockNumberOfAddress(address(t)); + (pendingFreezeOverlay, pendingFreezeCommitted, pendingFreezePotential, pendingFreezeExpectedLastUpdated, pendingFreezeHeight) = registry + .stakes(address(t)); + pendingFreezeIdx = idx; + pendingFreezeHadStake = pendingFreezeExpectedLastUpdated != 0; + pendingFreezeExpectedLastUpdated = pendingFreezeHadStake ? block.number + uint256(time) : 0; + bool ok = redistributor.tryFreezeDeposit(address(t), uint256(time)); if (!ok) return; _checkOtherDigestsUnchanged(idx, otherDigestA, otherDigestB); - - // Only affects existing stakes. - if (before != 0) { - uint256 afterU = registry.lastUpdatedBlockNumberOfAddress(address(t)); - if (afterU != block.number + uint256(time)) actionInvariantViolated = true; - } + pendingFreezeCheck = true; } function act_redistributor_slash(uint8 targetActorId, uint256 amount) external { - _clearPendingManageStakeAddCheck(); + _clearPendingChecks(); uint256 idx = uint256(targetActorId) % ACTOR_COUNT; EchidnaStakeActor t = actors[idx]; (bytes32 otherDigestA, bytes32 otherDigestB) = _otherDigests(idx); - (, , uint256 pBefore, uint256 uBefore, ) = registry.stakes(address(t)); + (pendingSlashOverlay, pendingSlashCommitted, pendingSlashPotential, pendingSlashLastUpdated, pendingSlashHeight) = registry + .stakes(address(t)); + pendingSlashIdx = idx; + pendingSlashHadStake = pendingSlashLastUpdated != 0; + pendingSlashAmount = amount; + pendingSlashExpectedBlockNumber = block.number; + bool ok = redistributor.trySlashDeposit(address(t), amount); if (!ok) return; _checkOtherDigestsUnchanged(idx, otherDigestA, otherDigestB); + pendingSlashCheck = true; - if (uBefore == 0) { - // No stake: should remain empty. - if (registry.lastUpdatedBlockNumberOfAddress(address(t)) != 0) actionInvariantViolated = true; - return; - } - - if (pBefore > amount) { - (, , uint256 pAfter, uint256 uAfter, ) = registry.stakes(address(t)); - if (pAfter != pBefore - amount) actionInvariantViolated = true; - if (uAfter != block.number) actionInvariantViolated = true; - } else { - // Stake deleted. - if (registry.lastUpdatedBlockNumberOfAddress(address(t)) != 0) actionInvariantViolated = true; + // If the slash deleted the stake (amount >= potential), reset per-actor tracking + // so future re-stakes don't incorrectly look like "commitment decreased". + if (pendingSlashHadStake && pendingSlashPotential <= pendingSlashAmount) { lastCommittedStakeByActor[idx] = 0; lastSetNonceByActor[idx] = bytes32(0); networkIdAtLastStakeByActor[idx] = trackedNetworkId; @@ -395,7 +419,7 @@ contract EchidnaStakeRegistryHarness { } function act_actor_tryFreeze(uint8 actorId, uint8 targetActorId, uint32 time) external { - _clearPendingManageStakeAddCheck(); + _clearPendingChecks(); bytes32 d0 = _stakeDigest(address(actors[0])); bytes32 d1 = _stakeDigest(address(actors[1])); bytes32 d2 = _stakeDigest(address(actors[2])); @@ -407,7 +431,7 @@ contract EchidnaStakeRegistryHarness { } function act_actor_trySlash(uint8 actorId, uint8 targetActorId, uint256 amount) external { - _clearPendingManageStakeAddCheck(); + _clearPendingChecks(); bytes32 d0 = _stakeDigest(address(actors[0])); bytes32 d1 = _stakeDigest(address(actors[1])); bytes32 d2 = _stakeDigest(address(actors[2])); @@ -467,6 +491,67 @@ contract EchidnaStakeRegistryHarness { return committedAfter == expectedCommitted; } + function echidna_migrate_never_succeeds_while_unpaused() external view returns (bool) { + return !migrateSucceededWhileUnpaused; + } + + function echidna_last_migrate_refunds_and_deletes_when_stake_exists() external view returns (bool) { + if (!pendingMigrateCheck) return true; + address a = address(actors[pendingMigrateIdx]); + + // migrateStake has whenPaused; if the call succeeded, we must be paused. + if (!registry.paused()) return false; + + if (!pendingMigrateHadStake) { + // No stake existed; migrate is a no-op. + if (token.balanceOf(a) != pendingMigrateActorBalanceBefore) return false; + return registry.lastUpdatedBlockNumberOfAddress(a) == 0; + } + + // Stake existed; it must be deleted and balance refunded. + if (token.balanceOf(a) != pendingMigrateActorBalanceBefore + pendingMigratePotentialBefore) return false; + return registry.lastUpdatedBlockNumberOfAddress(a) == 0; + } + + function echidna_last_freeze_only_updates_lastUpdated() external view returns (bool) { + if (!pendingFreezeCheck) return true; + address a = address(actors[pendingFreezeIdx]); + (bytes32 ov, uint256 c, uint256 p, uint256 u, uint8 h) = registry.stakes(a); + + if (!pendingFreezeHadStake) { + return ov == bytes32(0) && c == 0 && p == 0 && u == 0 && h == 0; + } + + if (ov != pendingFreezeOverlay) return false; + if (c != pendingFreezeCommitted) return false; + if (p != pendingFreezePotential) return false; + if (h != pendingFreezeHeight) return false; + return u == pendingFreezeExpectedLastUpdated; + } + + function echidna_last_slash_updates_expected_fields() external view returns (bool) { + if (!pendingSlashCheck) return true; + address a = address(actors[pendingSlashIdx]); + (bytes32 ov, uint256 c, uint256 p, uint256 u, uint8 h) = registry.stakes(a); + + if (!pendingSlashHadStake) { + // No stake existed; slash does nothing. + return ov == bytes32(0) && c == 0 && p == 0 && u == 0 && h == 0; + } + + if (pendingSlashPotential > pendingSlashAmount) { + // Partial slash: only potential decreases, lastUpdated set to block.number at slash time. + if (ov != pendingSlashOverlay) return false; + if (c != pendingSlashCommitted) return false; + if (h != pendingSlashHeight) return false; + if (p != pendingSlashPotential - pendingSlashAmount) return false; + return u == pendingSlashExpectedBlockNumber; + } + + // Full slash: stake deleted. + return ov == bytes32(0) && c == 0 && p == 0 && u == 0 && h == 0; + } + function echidna_stake_committed_never_decreases_per_actor() external view returns (bool) { for (uint256 i = 0; i < ACTOR_COUNT; i++) { (, uint256 committedStake, , uint256 lastUpdated, ) = registry.stakes(address(actors[i])); @@ -566,9 +651,10 @@ contract EchidnaStakeRegistryHarness { } } - function _clearPendingManageStakeAddCheck() internal { - if (pendingManageStakeAddCheck) { - pendingManageStakeAddCheck = false; - } + function _clearPendingChecks() internal { + pendingManageStakeAddCheck = false; + pendingFreezeCheck = false; + pendingSlashCheck = false; + pendingMigrateCheck = false; } } From 74e0c33ad991a6b2c430b7f8970b3df5f88a3f6d Mon Sep 17 00:00:00 2001 From: Cardinal Date: Mon, 2 Mar 2026 21:17:46 +0100 Subject: [PATCH 09/50] implement oracle contract, make default run all --- echidna/README.md | 22 +- scripts/echidna.sh | 37 ++- src/echidna/EchidnaPriceOracleHarness.sol | 281 ++++++++++++++++++++++ 3 files changed, 327 insertions(+), 13 deletions(-) create mode 100644 src/echidna/EchidnaPriceOracleHarness.sol diff --git a/echidna/README.md b/echidna/README.md index 5362d689..54b414b3 100644 --- a/echidna/README.md +++ b/echidna/README.md @@ -14,9 +14,12 @@ If a property returns `false`, Echidna prints a **reproducer** (a short sequence ### Harness -- **Harness contract**: `src/echidna/EchidnaStakeRegistryHarness.sol` +This repo currently contains multiple harnesses: -It deploys: +- **Staking harness**: `src/echidna/EchidnaStakeRegistryHarness.sol` +- **Oracle harness**: `src/echidna/EchidnaPriceOracleHarness.sol` + +The staking harness deploys: - `TestToken` (a mintable ERC20 preset used as BZZ stand-in) - `StakeRegistry` (from `src/Staking.sol`) @@ -24,6 +27,12 @@ It deploys: It also deploys several **actor contracts** (`EchidnaStakeActor`) which behave like independent users (each has its own address and token balance), plus a dedicated actor that receives the `REDISTRIBUTOR_ROLE` so we can fuzz freeze/slash flows. +The oracle harness deploys: + +- `PriceOracle` (from `src/PriceOracle.sol`) +- a `PostageStamp` mock that can succeed or revert on `setPrice(uint256)` +- an updater actor (has `PRICE_UPDATER_ROLE`) and a random actor (no roles) to fuzz access control + ### Actions (what Echidna mutates) These functions are intentionally written to be **mostly non-reverting**, so Echidna can explore longer state sequences: @@ -98,6 +107,15 @@ From repo root: yarn echidna ``` +By default, this runs **all** Echidna harness contracts in `src/echidna/`. + +To run only a specific harness contract: + +```bash +ECHIDNA_CONTRACT=EchidnaStakeRegistryHarness yarn echidna +ECHIDNA_CONTRACT=EchidnaPriceOracleHarness yarn echidna +``` + This uses Docker and the image `ghcr.io/crytic/echidna/echidna:latest`. ### Output files diff --git a/scripts/echidna.sh b/scripts/echidna.sh index 44ea0841..da1ed3d5 100755 --- a/scripts/echidna.sh +++ b/scripts/echidna.sh @@ -11,19 +11,34 @@ fi cd "$ROOT_DIR" IMAGE="${ECHIDNA_IMAGE:-ghcr.io/crytic/echidna/echidna:latest}" -CONTRACT="EchidnaStakeRegistryHarness" - -# Avoid stale Crytic compile artifacts causing old properties/tests to run. -rm -rf crytic-export +CONTRACT="${ECHIDNA_CONTRACT:-}" # Compile on the host. The Echidna container image doesn't ship with Node/npx, # and without Hardhat artifacts CryticCompile will try (and fail) to run `npx hardhat compile`. yarn -s hardhat compile --force >/dev/null -docker run --rm \ - -v "$ROOT_DIR":/src \ - -w /src \ - "$IMAGE" \ - echidna-test . \ - --contract "$CONTRACT" \ - --config echidna/echidna.yaml +CONTRACTS_DEFAULT=( + "EchidnaStakeRegistryHarness" + "EchidnaPriceOracleHarness" +) + +if [[ -n "$CONTRACT" ]]; then + CONTRACTS_TO_RUN=("$CONTRACT") +else + CONTRACTS_TO_RUN=("${CONTRACTS_DEFAULT[@]}") +fi + +for c in "${CONTRACTS_TO_RUN[@]}"; do + echo "==> echidna: running contract $c" >&2 + + # Avoid stale Crytic compile artifacts causing old properties/tests to run. + rm -rf crytic-export + + docker run --rm \ + -v "$ROOT_DIR":/src \ + -w /src \ + "$IMAGE" \ + echidna-test . \ + --contract "$c" \ + --config echidna/echidna.yaml +done diff --git a/src/echidna/EchidnaPriceOracleHarness.sol b/src/echidna/EchidnaPriceOracleHarness.sol new file mode 100644 index 00000000..364a3070 --- /dev/null +++ b/src/echidna/EchidnaPriceOracleHarness.sol @@ -0,0 +1,281 @@ +// SPDX-License-Identifier: BSD-3-Clause +pragma solidity ^0.8.19; + +import "../PriceOracle.sol"; + +contract EchidnaPostageStampMock { + uint256 public lastPrice; + uint256 public setPriceCalls; + bool public shouldRevert; + + function setShouldRevert(bool v) external { + shouldRevert = v; + } + + function setPrice(uint256 price) external { + if (shouldRevert) revert("mock revert"); + lastPrice = price; + setPriceCalls += 1; + } +} + +contract EchidnaOracleActor { + PriceOracle internal immutable oracle; + + constructor(PriceOracle oracle_) { + oracle = oracle_; + } + + function callSetPrice(uint32 p) external returns (bool ok, bool returned) { + bytes memory data; + (ok, data) = address(oracle).call(abi.encodeWithSelector(oracle.setPrice.selector, p)); + returned = ok && data.length >= 32 ? abi.decode(data, (bool)) : false; + } + + function callAdjustPrice(uint16 r) external returns (bool ok, bool returned) { + bytes memory data; + (ok, data) = address(oracle).call(abi.encodeWithSelector(oracle.adjustPrice.selector, r)); + returned = ok && data.length >= 32 ? abi.decode(data, (bool)) : false; + } + + function callPause() external returns (bool ok) { + (ok, ) = address(oracle).call(abi.encodeWithSelector(oracle.pause.selector)); + } + + function callUnpause() external returns (bool ok) { + (ok, ) = address(oracle).call(abi.encodeWithSelector(oracle.unPause.selector)); + } +} + +/// @notice Echidna harness for comprehensive fuzzing of PriceOracle. +contract EchidnaPriceOracleHarness { + PriceOracle internal immutable oracle; + EchidnaPostageStampMock internal immutable stamp; + + EchidnaOracleActor internal immutable updater; + EchidnaOracleActor internal immutable rando; + + // “Must never happen” flags. + bool internal unauthorizedAdminCallSucceeded; + bool internal unauthorizedAdjustSucceeded; + bool internal pausedAdjustChangedState; + + // Pending post-conditions (cleared on each action). + bool internal pendingSetPrice; + uint64 internal pendingExpectedUpScaled; + uint256 internal pendingStampCallsBefore; + bool internal pendingStampShouldCall; + + bool internal pendingAdjust; + bool internal pendingAdjustPaused; + uint64 internal pendingPriceBefore; + uint64 internal pendingLastAdjustedBefore; + uint64 internal pendingExpectedLastAdjustedAfter; + uint64 internal pendingExpectedUpScaledAfter; + uint256 internal pendingAdjustStampCallsBefore; + bool internal pendingAdjustStampShouldCall; + + constructor() { + stamp = new EchidnaPostageStampMock(); + oracle = new PriceOracle(address(stamp)); + + updater = new EchidnaOracleActor(oracle); + rando = new EchidnaOracleActor(oracle); + + oracle.grantRole(oracle.PRICE_UPDATER_ROLE(), address(updater)); + } + + // ----------------------------- + // Actions + // ----------------------------- + + function act_setStampRevertMode(bool v) external { + _clearPending(); + stamp.setShouldRevert(v); + } + + function act_admin_setPrice(uint32 p) external { + _clearPending(); + + pendingStampCallsBefore = stamp.setPriceCalls(); + + uint64 minUp = oracle.minimumPriceUpscaled(); + uint64 expected = uint64(p) << 10; + if (expected < minUp) expected = minUp; + + pendingExpectedUpScaled = expected; + pendingStampShouldCall = !stamp.shouldRevert(); + pendingSetPrice = true; + + (bool ok, bool returned) = _callSetPriceAsAdmin(p); + ok; + returned; + } + + function act_admin_pause() external { + _clearPending(); + oracle.pause(); + } + + function act_admin_unpause() external { + _clearPending(); + oracle.unPause(); + } + + function act_updater_adjustPrice(uint16 redundancy) external { + _clearPending(); + + pendingAdjustPaused = oracle.isPaused(); + pendingPriceBefore = oracle.currentPriceUpScaled(); + pendingLastAdjustedBefore = oracle.lastAdjustedRound(); + pendingAdjustStampCallsBefore = stamp.setPriceCalls(); + + // Precompute expected post-state when the call is supposed to reach the main branch. + if (!pendingAdjustPaused) { + uint64 currentRound = oracle.currentRound(); + + // Only compute expected if the call would not revert on the round check and redundancy != 0. + if (redundancy != 0 && currentRound > pendingLastAdjustedBefore) { + pendingExpectedLastAdjustedAfter = currentRound; + pendingExpectedUpScaledAfter = _expectedAdjustedPrice(pendingPriceBefore, redundancy, currentRound, pendingLastAdjustedBefore); + pendingAdjustStampShouldCall = !stamp.shouldRevert(); + pendingAdjust = true; + } + } + + (bool ok, bool returned) = updater.callAdjustPrice(redundancy); + ok; + returned; + + // If paused, adjustPrice must not change state. + if (pendingAdjustPaused) { + if (oracle.currentPriceUpScaled() != pendingPriceBefore) pausedAdjustChangedState = true; + if (oracle.lastAdjustedRound() != pendingLastAdjustedBefore) pausedAdjustChangedState = true; + if (stamp.setPriceCalls() != pendingAdjustStampCallsBefore) pausedAdjustChangedState = true; + } + } + + function act_rando_tryAdjustPrice(uint16 redundancy) external { + _clearPending(); + (bool ok, bool returned) = rando.callAdjustPrice(redundancy); + // When not paused, adjustPrice is role-gated and should not succeed for a rando. + if (!oracle.isPaused() && ok && returned) unauthorizedAdjustSucceeded = true; + } + + function act_rando_trySetPrice(uint32 p) external { + _clearPending(); + (bool ok, bool returned) = rando.callSetPrice(p); + if (ok && returned) unauthorizedAdminCallSucceeded = true; + } + + function act_rando_tryPause() external { + _clearPending(); + bool ok = rando.callPause(); + if (ok) unauthorizedAdminCallSucceeded = true; + } + + function act_rando_tryUnpause() external { + _clearPending(); + bool ok = rando.callUnpause(); + if (ok) unauthorizedAdminCallSucceeded = true; + } + + // ----------------------------- + // Properties + // ----------------------------- + + function echidna_never_performed_forbidden_calls() external view returns (bool) { + return !unauthorizedAdminCallSucceeded && !unauthorizedAdjustSucceeded && !pausedAdjustChangedState; + } + + function echidna_price_never_below_minimum() external view returns (bool) { + return oracle.currentPriceUpScaled() >= oracle.minimumPriceUpscaled(); + } + + function echidna_currentPrice_matches_upscaled() external view returns (bool) { + return oracle.currentPrice() == uint32(oracle.currentPriceUpScaled() >> 10); + } + + function echidna_lastAdjustedRound_not_in_future() external view returns (bool) { + return oracle.lastAdjustedRound() <= oracle.currentRound(); + } + + function echidna_setPrice_updates_expected_state_and_calls_stamp() external view returns (bool) { + if (!pendingSetPrice) return true; + if (oracle.currentPriceUpScaled() != pendingExpectedUpScaled) return false; + if (oracle.currentPrice() != uint32(pendingExpectedUpScaled >> 10)) return false; + + uint256 callsAfter = stamp.setPriceCalls(); + if (pendingStampShouldCall) { + if (callsAfter != pendingStampCallsBefore + 1) return false; + if (stamp.lastPrice() != uint256(oracle.currentPrice())) return false; + } else { + if (callsAfter != pendingStampCallsBefore) return false; + } + return true; + } + + function echidna_adjustPrice_postconditions_hold_when_applicable() external view returns (bool) { + if (!pendingAdjust) return true; + + if (oracle.lastAdjustedRound() != pendingExpectedLastAdjustedAfter) return false; + if (oracle.currentPriceUpScaled() != pendingExpectedUpScaledAfter) return false; + if (oracle.currentPrice() != uint32(pendingExpectedUpScaledAfter >> 10)) return false; + + uint256 callsAfter = stamp.setPriceCalls(); + if (pendingAdjustStampShouldCall) { + if (callsAfter != pendingAdjustStampCallsBefore + 1) return false; + if (stamp.lastPrice() != uint256(oracle.currentPrice())) return false; + } else { + if (callsAfter != pendingAdjustStampCallsBefore) return false; + } + return true; + } + + // ----------------------------- + // Helpers + // ----------------------------- + + function _callSetPriceAsAdmin(uint32 p) internal returns (bool ok, bool returned) { + // This harness is the admin because it deployed PriceOracle. + bytes memory data; + (ok, data) = address(oracle).call(abi.encodeWithSelector(oracle.setPrice.selector, p)); + returned = ok && data.length >= 32 ? abi.decode(data, (bool)) : false; + } + + function _expectedAdjustedPrice( + uint64 priceUpScaledBefore, + uint16 redundancy, + uint64 currentRound, + uint64 lastAdjusted + ) internal view returns (uint64) { + uint16 used = redundancy; + uint16 maxRed = uint16(4 + 4); + if (used > maxRed) used = maxRed; + + uint256 price = uint256(priceUpScaledBefore); + uint256 base = uint256(oracle.priceBase()); + + uint256 rate = uint256(oracle.changeRate(uint256(used))); + price = (rate * price) / base; + + uint64 skipped = currentRound - lastAdjusted - 1; + if (skipped > 0) { + uint256 rateMax = uint256(oracle.changeRate(0)); + for (uint64 i = 0; i < skipped; i++) { + price = (rateMax * price) / base; + } + } + + uint256 minUp = uint256(oracle.minimumPriceUpscaled()); + if (price < minUp) price = minUp; + require(price <= type(uint64).max, "expected overflow"); + return uint64(price); + } + + function _clearPending() internal { + pendingSetPrice = false; + pendingAdjust = false; + } +} + From c79e7a39cfc0d3a56c9e954e05af495df6a9451f Mon Sep 17 00:00:00 2001 From: Cardinal Date: Tue, 3 Mar 2026 11:27:12 +0100 Subject: [PATCH 10/50] fix price oracle problesm --- echidna/echidna.yaml | 11 ++- src/PriceOracle.sol | 3 +- src/echidna/EchidnaPriceOracleHarness.sol | 81 +++++++++++++++++------ 3 files changed, 73 insertions(+), 22 deletions(-) diff --git a/echidna/echidna.yaml b/echidna/echidna.yaml index 1af619b5..acd0db94 100644 --- a/echidna/echidna.yaml +++ b/echidna/echidna.yaml @@ -4,7 +4,16 @@ testMode: property seqLen: 100 # Start small; can be increased once it’s stable in CI. -testLimit: 125000 +testLimit: 25000 + +# Shrinking a counterexample can be *much* slower than fuzzing. +# Keep this modest; increase locally if you want a smaller reproducer. +shrinkLimit: 1000 + +# Bound random time/block jumps between transactions. +# This keeps PriceOracle.adjustPrice() (skippedRounds loop) from becoming extremely slow. +maxTimeDelay: 0 +maxBlockDelay: 1000 # Persist interesting inputs between runs. corpusDir: echidna/corpus diff --git a/src/PriceOracle.sol b/src/PriceOracle.sol index 5675f4f9..e7c78509 100644 --- a/src/PriceOracle.sol +++ b/src/PriceOracle.sol @@ -78,7 +78,8 @@ contract PriceOracle is AccessControl { revert CallerNotAdmin(); } - uint64 _currentPriceUpScaled = _price << 10; + // Cast before shifting to avoid uint32 overflow/truncation. + uint64 _currentPriceUpScaled = uint64(_price) << 10; uint64 _minimumPriceUpscaled = minimumPriceUpscaled; // Enforce minimum price diff --git a/src/echidna/EchidnaPriceOracleHarness.sol b/src/echidna/EchidnaPriceOracleHarness.sol index 364a3070..2a00d1c8 100644 --- a/src/echidna/EchidnaPriceOracleHarness.sol +++ b/src/echidna/EchidnaPriceOracleHarness.sol @@ -74,6 +74,7 @@ contract EchidnaPriceOracleHarness { uint64 internal pendingExpectedUpScaledAfter; uint256 internal pendingAdjustStampCallsBefore; bool internal pendingAdjustStampShouldCall; + bool internal adjustWouldOverflowButSucceeded; constructor() { stamp = new EchidnaPostageStampMock(); @@ -130,29 +131,61 @@ contract EchidnaPriceOracleHarness { pendingLastAdjustedBefore = oracle.lastAdjustedRound(); pendingAdjustStampCallsBefore = stamp.setPriceCalls(); - // Precompute expected post-state when the call is supposed to reach the main branch. - if (!pendingAdjustPaused) { - uint64 currentRound = oracle.currentRound(); + (bool ok, bool returned) = updater.callAdjustPrice(redundancy); - // Only compute expected if the call would not revert on the round check and redundancy != 0. - if (redundancy != 0 && currentRound > pendingLastAdjustedBefore) { - pendingExpectedLastAdjustedAfter = currentRound; - pendingExpectedUpScaledAfter = _expectedAdjustedPrice(pendingPriceBefore, redundancy, currentRound, pendingLastAdjustedBefore); - pendingAdjustStampShouldCall = !stamp.shouldRevert(); - pendingAdjust = true; - } + if (pendingAdjustPaused) { + // If paused, adjustPrice must not change state. + if (oracle.currentPriceUpScaled() != pendingPriceBefore) pausedAdjustChangedState = true; + if (oracle.lastAdjustedRound() != pendingLastAdjustedBefore) pausedAdjustChangedState = true; + if (stamp.setPriceCalls() != pendingAdjustStampCallsBefore) pausedAdjustChangedState = true; + ok; + returned; + return; } - (bool ok, bool returned) = updater.callAdjustPrice(redundancy); - ok; - returned; + // Not paused. Determine whether the call is expected to revert on basic guards. + uint64 currentRound = oracle.currentRound(); + bool wouldRevertEarly = (redundancy == 0) || (currentRound <= pendingLastAdjustedBefore); - // If paused, adjustPrice must not change state. - if (pendingAdjustPaused) { + if (wouldRevertEarly) { + // If it unexpectedly succeeded, that's a bug in either model or contract. + if (ok) adjustWouldOverflowButSucceeded = true; + // A revert should not change state. if (oracle.currentPriceUpScaled() != pendingPriceBefore) pausedAdjustChangedState = true; if (oracle.lastAdjustedRound() != pendingLastAdjustedBefore) pausedAdjustChangedState = true; if (stamp.setPriceCalls() != pendingAdjustStampCallsBefore) pausedAdjustChangedState = true; + returned; + return; + } + + // Compute expected post-state. If arithmetic would overflow in the contract, we expect a revert. + (bool canCompute, uint64 expected) = _tryExpectedAdjustedPrice( + pendingPriceBefore, + redundancy, + currentRound, + pendingLastAdjustedBefore + ); + + if (!canCompute) { + if (ok && returned) adjustWouldOverflowButSucceeded = true; + returned; + return; + } + + // If we can compute a valid expected result, the call should not revert. + if (!ok) { + // Don't arm pendingAdjust (it would fail postconditions); just flag the mismatch. + adjustWouldOverflowButSucceeded = true; + returned; + return; } + + pendingExpectedLastAdjustedAfter = currentRound; + pendingExpectedUpScaledAfter = expected; + pendingAdjustStampShouldCall = !stamp.shouldRevert(); + pendingAdjust = true; + + returned; } function act_rando_tryAdjustPrice(uint16 redundancy) external { @@ -185,7 +218,11 @@ contract EchidnaPriceOracleHarness { // ----------------------------- function echidna_never_performed_forbidden_calls() external view returns (bool) { - return !unauthorizedAdminCallSucceeded && !unauthorizedAdjustSucceeded && !pausedAdjustChangedState; + return + !unauthorizedAdminCallSucceeded && + !unauthorizedAdjustSucceeded && + !pausedAdjustChangedState && + !adjustWouldOverflowButSucceeded; } function echidna_price_never_below_minimum() external view returns (bool) { @@ -243,12 +280,12 @@ contract EchidnaPriceOracleHarness { returned = ok && data.length >= 32 ? abi.decode(data, (bool)) : false; } - function _expectedAdjustedPrice( + function _tryExpectedAdjustedPrice( uint64 priceUpScaledBefore, uint16 redundancy, uint64 currentRound, uint64 lastAdjusted - ) internal view returns (uint64) { + ) internal view returns (bool ok, uint64 expected) { uint16 used = redundancy; uint16 maxRed = uint16(4 + 4); if (used > maxRed) used = maxRed; @@ -257,20 +294,24 @@ contract EchidnaPriceOracleHarness { uint256 base = uint256(oracle.priceBase()); uint256 rate = uint256(oracle.changeRate(uint256(used))); + // In the real contract, this multiplication happens in uint64 space: + // uint32 * uint64 -> uint64, and would revert on overflow. + if (rate * price > type(uint64).max) return (false, 0); price = (rate * price) / base; uint64 skipped = currentRound - lastAdjusted - 1; if (skipped > 0) { uint256 rateMax = uint256(oracle.changeRate(0)); for (uint64 i = 0; i < skipped; i++) { + if (rateMax * price > type(uint64).max) return (false, 0); price = (rateMax * price) / base; } } uint256 minUp = uint256(oracle.minimumPriceUpscaled()); if (price < minUp) price = minUp; - require(price <= type(uint64).max, "expected overflow"); - return uint64(price); + if (price > type(uint64).max) return (false, 0); + return (true, uint64(price)); } function _clearPending() internal { From 60b1824d0929cfa5258d53c232fc6d7e222542b3 Mon Sep 17 00:00:00 2001 From: Cardinal Date: Tue, 3 Mar 2026 11:45:36 +0100 Subject: [PATCH 11/50] introduce postagestamp fuzz --- echidna/README.md | 1 + scripts/echidna.sh | 1 + src/PostageStamp.sol | 3 +- src/echidna/EchidnaPostageStampHarness.sol | 390 +++++++++++++++++++++ 4 files changed, 394 insertions(+), 1 deletion(-) create mode 100644 src/echidna/EchidnaPostageStampHarness.sol diff --git a/echidna/README.md b/echidna/README.md index 54b414b3..59cadc78 100644 --- a/echidna/README.md +++ b/echidna/README.md @@ -18,6 +18,7 @@ This repo currently contains multiple harnesses: - **Staking harness**: `src/echidna/EchidnaStakeRegistryHarness.sol` - **Oracle harness**: `src/echidna/EchidnaPriceOracleHarness.sol` +- **PostageStamp harness**: `src/echidna/EchidnaPostageStampHarness.sol` The staking harness deploys: diff --git a/scripts/echidna.sh b/scripts/echidna.sh index da1ed3d5..d8327c44 100755 --- a/scripts/echidna.sh +++ b/scripts/echidna.sh @@ -20,6 +20,7 @@ yarn -s hardhat compile --force >/dev/null CONTRACTS_DEFAULT=( "EchidnaStakeRegistryHarness" "EchidnaPriceOracleHarness" + "EchidnaPostageStampHarness" ) if [[ -n "$CONTRACT" ]]; then diff --git a/src/PostageStamp.sol b/src/PostageStamp.sol index 00cdaa3f..23e4114e 100644 --- a/src/PostageStamp.sol +++ b/src/PostageStamp.sol @@ -566,7 +566,8 @@ contract PostageStamp is AccessControl, Pausable { } function minimumInitialBalancePerChunk() public view returns (uint256) { - return minimumValidityBlocks * lastPrice; + // Cast to uint256 before multiplying to avoid uint64 overflow. + return uint256(minimumValidityBlocks) * uint256(lastPrice); } /** diff --git a/src/echidna/EchidnaPostageStampHarness.sol b/src/echidna/EchidnaPostageStampHarness.sol new file mode 100644 index 00000000..7ad2185b --- /dev/null +++ b/src/echidna/EchidnaPostageStampHarness.sol @@ -0,0 +1,390 @@ +// SPDX-License-Identifier: BSD-3-Clause +pragma solidity ^0.8.19; + +import "../TestToken.sol"; +import "../PostageStamp.sol"; + +contract EchidnaPostageActor { + TestToken internal immutable token; + PostageStamp internal immutable stamp; + + constructor(TestToken token_, PostageStamp stamp_) { + token = token_; + stamp = stamp_; + token.approve(address(stamp), type(uint256).max); + } + + function createBatchMutable( + uint256 initialBalancePerChunk, + uint8 depth, + uint8 bucketDepth, + bytes32 nonce + ) external returns (bool ok, bytes32 batchId) { + bytes memory data; + (ok, data) = address(stamp).call( + abi.encodeWithSelector( + stamp.createBatch.selector, + address(this), + initialBalancePerChunk, + depth, + bucketDepth, + nonce, + false + ) + ); + if (ok && data.length >= 32) batchId = abi.decode(data, (bytes32)); + } + + function createBatchImmutable( + uint256 initialBalancePerChunk, + uint8 depth, + uint8 bucketDepth, + bytes32 nonce + ) external returns (bool ok, bytes32 batchId) { + bytes memory data; + (ok, data) = address(stamp).call( + abi.encodeWithSelector( + stamp.createBatch.selector, + address(this), + initialBalancePerChunk, + depth, + bucketDepth, + nonce, + true + ) + ); + if (ok && data.length >= 32) batchId = abi.decode(data, (bytes32)); + } + + function topUp(bytes32 batchId, uint256 topupAmountPerChunk) external returns (bool ok) { + (ok, ) = address(stamp).call(abi.encodeWithSelector(stamp.topUp.selector, batchId, topupAmountPerChunk)); + } + + function increaseDepth(bytes32 batchId, uint8 newDepth) external returns (bool ok) { + (ok, ) = address(stamp).call(abi.encodeWithSelector(stamp.increaseDepth.selector, batchId, newDepth)); + } + + function trySetPrice(uint256 price) external returns (bool ok) { + (ok, ) = address(stamp).call(abi.encodeWithSelector(stamp.setPrice.selector, price)); + } + + function tryWithdraw(address beneficiary) external returns (bool ok) { + (ok, ) = address(stamp).call(abi.encodeWithSelector(stamp.withdraw.selector, beneficiary)); + } + + function tryPause() external returns (bool ok) { + (ok, ) = address(stamp).call(abi.encodeWithSelector(stamp.pause.selector)); + } + + function tryUnpause() external returns (bool ok) { + (ok, ) = address(stamp).call(abi.encodeWithSelector(stamp.unPause.selector)); + } +} + +/// @notice Echidna harness for PostageStamp state machine and invariants. +contract EchidnaPostageStampHarness { + TestToken internal immutable token; + PostageStamp internal immutable stamp; + + uint256 internal constant ACTOR_COUNT = 3; + EchidnaPostageActor[3] internal actors; + EchidnaPostageActor internal oracleActor; + EchidnaPostageActor internal redistributorActor; + EchidnaPostageActor internal pauserActor; + + // Ring buffer of observed batchIds. + uint256 internal constant MAX_TRACKED = 16; + bytes32[MAX_TRACKED] internal tracked; + uint256 internal trackedCount; + + // Forbidden-call flags. + bool internal unauthorizedPriceSetSucceeded; + bool internal unauthorizedWithdrawSucceeded; + bool internal unauthorizedPauseSucceeded; + + // Pending postconditions (cleared on each action). + bool internal pendingCreate; + bytes32 internal pendingBatchId; + uint256 internal pendingCreateTotalAmount; + uint256 internal pendingStampTokenBalanceBefore; + uint256 internal pendingCreateNormalisedExpected; + uint8 internal pendingCreateDepth; + uint8 internal pendingCreateBucketDepth; + bool internal pendingCreateImmutable; + + bool internal pendingTopUp; + bytes32 internal pendingTopUpBatchId; + uint256 internal pendingTopUpTokenBefore; + uint256 internal pendingTopUpNormalisedBefore; + uint256 internal pendingTopUpTotalAmount; + uint256 internal pendingTopUpPerChunk; + + bool internal pendingIncreaseDepth; + bytes32 internal pendingIncBatchId; + uint8 internal pendingIncOldDepth; + uint8 internal pendingIncNewDepth; + uint256 internal pendingIncValidChunkBefore; + uint256 internal pendingIncTokenBefore; + + bool internal pendingExpireAll; + + // Temporary inputs to reduce stack pressure in helpers. + bytes32 internal tmpNonce; + bool internal tmpImmutable; + + constructor() { + token = new TestToken("TestToken", "TT", 1_000_000_000_000_000_000_000_000); // 1e24 + stamp = new PostageStamp(address(token), 2); + + for (uint256 i = 0; i < ACTOR_COUNT; i++) { + actors[i] = new EchidnaPostageActor(token, stamp); + token.transfer(address(actors[i]), 1_000_000_000_000_000_000_000_00); // 1e23 / 10 + } + + oracleActor = new EchidnaPostageActor(token, stamp); + redistributorActor = new EchidnaPostageActor(token, stamp); + pauserActor = new EchidnaPostageActor(token, stamp); + + stamp.grantRole(stamp.PRICE_ORACLE_ROLE(), address(oracleActor)); + stamp.grantRole(stamp.REDISTRIBUTOR_ROLE(), address(redistributorActor)); + stamp.grantRole(stamp.PAUSER_ROLE(), address(pauserActor)); + } + + // ----------------------------- + // Actions + // ----------------------------- + + function act_fundActor(uint8 actorId, uint256 amount) external { + _clearPending(); + uint256 bal = token.balanceOf(address(this)); + if (bal == 0) return; + uint256 x = amount % (bal + 1); + if (x == 0) return; + token.transfer(address(_actor(actorId)), x); + } + + function act_createBatch(uint8 actorId, uint256 initialPerChunk, uint8 depthRaw, bytes32 nonce, bool immutableFlag) + external + { + _clearPending(); + tmpNonce = nonce; + tmpImmutable = immutableFlag; + _createBatchInternal(actorId, initialPerChunk, depthRaw); + } + + function act_topUp(uint8 actorId, uint8 batchIndex, uint256 topupPerChunk) external { + _clearPending(); + EchidnaPostageActor a = _actor(actorId); + + if (stamp.paused()) return; + + bytes32 batchId = _batch(batchIndex); + if (batchId == bytes32(0)) return; + + // Only the creator can topUp (msg.sender irrelevant); topUp isn't owner gated but will revert if batch doesn't exist/expired. + uint8 depth = stamp.batchDepth(batchId); + if (depth == 0) return; + + uint256 maxPerChunk = token.balanceOf(address(a)) / (1 << depth); + if (maxPerChunk == 0) return; + uint256 perChunk = topupPerChunk % (maxPerChunk + 1); + if (perChunk == 0) return; + + uint256 tokenBefore = token.balanceOf(address(stamp)); + uint256 normBefore = stamp.batchNormalisedBalance(batchId); + uint256 totalAmount = perChunk * (1 << depth); + + bool ok = a.topUp(batchId, perChunk); + if (!ok) return; + + pendingTopUp = true; + pendingTopUpBatchId = batchId; + pendingTopUpTokenBefore = tokenBefore; + pendingTopUpNormalisedBefore = normBefore; + pendingTopUpTotalAmount = totalAmount; + pendingTopUpPerChunk = perChunk; + } + + function act_increaseDepth(uint8 actorId, uint8 batchIndex, uint8 newDepthRaw) external { + _clearPending(); + EchidnaPostageActor a = _actor(actorId); + + if (stamp.paused()) return; + + bytes32 batchId = _batch(batchIndex); + if (batchId == bytes32(0)) return; + + // increaseDepth is owner-gated; we only attempt if the batch owner matches this actor. + if (stamp.batchOwner(batchId) != address(a)) return; + + // Normalize state to avoid this call also expiring unrelated batches (it calls expireLimited internally). + stamp.expireLimited(type(uint256).max); + + uint8 oldDepth = stamp.batchDepth(batchId); + if (oldDepth == 0) return; + + uint8 minBucket = stamp.minimumBucketDepth(); + uint8 newDepth = uint8(minBucket + 1 + (newDepthRaw % 12)); + if (newDepth <= oldDepth) return; + + uint256 validBefore = stamp.validChunkCount(); + uint256 tokenBefore = token.balanceOf(address(stamp)); + + bool ok = a.increaseDepth(batchId, newDepth); + if (!ok) return; + + pendingIncreaseDepth = true; + pendingIncBatchId = batchId; + pendingIncOldDepth = oldDepth; + pendingIncNewDepth = newDepth; + pendingIncValidChunkBefore = validBefore; + pendingIncTokenBefore = tokenBefore; + } + + function act_oracle_setPrice(uint256 price) external { + _clearPending(); + oracleActor.trySetPrice(price); + } + + function act_expireAll() external { + _clearPending(); + stamp.expireLimited(type(uint256).max); + pendingExpireAll = true; + } + + function act_rando_trySetPrice(uint8 actorId, uint256 price) external { + _clearPending(); + bool ok = _actor(actorId).trySetPrice(price); + if (ok) unauthorizedPriceSetSucceeded = true; + } + + function act_rando_tryWithdraw(uint8 actorId, address beneficiary) external { + _clearPending(); + if (beneficiary == address(0)) beneficiary = address(0xBEEF); + bool ok = _actor(actorId).tryWithdraw(beneficiary); + if (ok) unauthorizedWithdrawSucceeded = true; + } + + function act_rando_tryPause(uint8 actorId) external { + _clearPending(); + bool ok = _actor(actorId).tryPause(); + if (ok) unauthorizedPauseSucceeded = true; + } + + // ----------------------------- + // Properties + // ----------------------------- + + function echidna_never_performed_forbidden_calls() external view returns (bool) { + return !unauthorizedPriceSetSucceeded && !unauthorizedWithdrawSucceeded && !unauthorizedPauseSucceeded; + } + + function echidna_minimumInitialBalancePerChunk_matches_formula() external view returns (bool) { + return stamp.minimumInitialBalancePerChunk() == uint256(stamp.minimumValidityBlocks()) * uint256(stamp.lastPrice()); + } + + function echidna_lastExpiryBalance_never_exceeds_currentTotalOutPayment() external view returns (bool) { + return stamp.lastExpiryBalance() <= stamp.currentTotalOutPayment(); + } + + function echidna_createBatch_postconditions_hold() external view returns (bool) { + if (!pendingCreate) return true; + + if (token.balanceOf(address(stamp)) != pendingStampTokenBalanceBefore + pendingCreateTotalAmount) return false; + + if (stamp.batchOwner(pendingBatchId) == address(0)) return false; + if (stamp.batchDepth(pendingBatchId) != pendingCreateDepth) return false; + if (stamp.batchBucketDepth(pendingBatchId) != pendingCreateBucketDepth) return false; + if (stamp.batchImmutableFlag(pendingBatchId) != pendingCreateImmutable) return false; + + // Normalised balance is computed as currentTotalOutPayment + perChunk at creation time. + if (stamp.batchNormalisedBalance(pendingBatchId) != pendingCreateNormalisedExpected) return false; + return true; + } + + function echidna_topUp_postconditions_hold() external view returns (bool) { + if (!pendingTopUp) return true; + if (token.balanceOf(address(stamp)) != pendingTopUpTokenBefore + pendingTopUpTotalAmount) return false; + return stamp.batchNormalisedBalance(pendingTopUpBatchId) == pendingTopUpNormalisedBefore + pendingTopUpPerChunk; + } + + function echidna_increaseDepth_updates_validChunkCount_and_keeps_balance() external view returns (bool) { + if (!pendingIncreaseDepth) return true; + if (token.balanceOf(address(stamp)) != pendingIncTokenBefore) return false; + + uint256 expectedDelta = (1 << pendingIncNewDepth) - (1 << pendingIncOldDepth); + if (stamp.validChunkCount() != pendingIncValidChunkBefore + expectedDelta) return false; + + if (stamp.batchDepth(pendingIncBatchId) != pendingIncNewDepth) return false; + return true; + } + + function echidna_expireAll_clears_expired_batches() external view returns (bool) { + if (!pendingExpireAll) return true; + return !stamp.expiredBatchesExist(); + } + + // ----------------------------- + // Helpers + // ----------------------------- + + function _actor(uint8 actorId) internal view returns (EchidnaPostageActor) { + return actors[uint256(actorId) % ACTOR_COUNT]; + } + + function _batch(uint8 batchIndex) internal view returns (bytes32) { + if (trackedCount == 0) return bytes32(0); + return tracked[uint256(batchIndex) % MAX_TRACKED]; + } + + function _clearPending() internal { + pendingCreate = false; + pendingTopUp = false; + pendingIncreaseDepth = false; + pendingExpireAll = false; + } + + function _createBatchInternal(uint8 actorId, uint256 initialPerChunk, uint8 depthRaw) internal { + if (stamp.paused()) return; + + EchidnaPostageActor a = _actor(actorId); + + // Bound depth and bucketDepth to safe values. + uint8 depth = uint8(2 + (depthRaw % 12)); // [2..13] + uint8 minBucket = stamp.minimumBucketDepth(); + if (minBucket >= depth) return; + uint8 bucketDepth = uint8(minBucket + (uint8(uint256(tmpNonce) % uint256(depth - minBucket)))); + if (bucketDepth >= depth) return; + + // Bound initialPerChunk and ensure actor can pay. + uint256 available = token.balanceOf(address(a)); + uint256 denom = (1 << depth); + uint256 maxPerChunk = available / denom; + if (maxPerChunk == 0) return; + uint256 perChunk = (initialPerChunk % maxPerChunk) + 1; // non-zero + + // Store pre-call snapshots directly into pending fields (to reduce stack pressure). + pendingStampTokenBalanceBefore = token.balanceOf(address(stamp)); + pendingCreateTotalAmount = perChunk * denom; + pendingCreateNormalisedExpected = stamp.currentTotalOutPayment() + perChunk; + pendingCreateDepth = depth; + pendingCreateBucketDepth = bucketDepth; + pendingCreateImmutable = tmpImmutable; + + bool ok; + bytes32 batchId; + if (tmpImmutable) { + (ok, batchId) = a.createBatchImmutable(perChunk, depth, bucketDepth, tmpNonce); + } else { + (ok, batchId) = a.createBatchMutable(perChunk, depth, bucketDepth, tmpNonce); + } + if (!ok || batchId == bytes32(0)) return; + + tracked[trackedCount % MAX_TRACKED] = batchId; + trackedCount++; + + pendingBatchId = batchId; + pendingCreate = true; + } +} + From 59b1d4c88f3d563a6bb4fd44ff2ed55f32f154bd Mon Sep 17 00:00:00 2001 From: Cardinal Date: Tue, 3 Mar 2026 14:17:50 +0100 Subject: [PATCH 12/50] finalize stamp fuzzing --- src/echidna/EchidnaPostageStampHarness.sol | 149 ++++++++++++++++++++- 1 file changed, 145 insertions(+), 4 deletions(-) diff --git a/src/echidna/EchidnaPostageStampHarness.sol b/src/echidna/EchidnaPostageStampHarness.sol index 7ad2185b..7c22b69b 100644 --- a/src/echidna/EchidnaPostageStampHarness.sol +++ b/src/echidna/EchidnaPostageStampHarness.sol @@ -101,6 +101,8 @@ contract EchidnaPostageStampHarness { bool internal unauthorizedPriceSetSucceeded; bool internal unauthorizedWithdrawSucceeded; bool internal unauthorizedPauseSucceeded; + bool internal pausedMutationSucceeded; + bool internal nonInterferenceViolated; // Pending postconditions (cleared on each action). bool internal pendingCreate; @@ -125,12 +127,29 @@ contract EchidnaPostageStampHarness { uint8 internal pendingIncNewDepth; uint256 internal pendingIncValidChunkBefore; uint256 internal pendingIncTokenBefore; + uint8 internal pendingIncBucketDepth; + uint256 internal pendingIncExpectedNormalised; bool internal pendingExpireAll; + bool internal pendingSetPrice; + uint256 internal pendingSetPriceTotalOutPaymentBefore; + uint64 internal pendingSetPriceLastUpdatedExpected; + uint64 internal pendingSetPriceLastPriceExpected; + + bool internal pendingWithdraw; + address internal pendingWithdrawBeneficiary; + uint256 internal pendingWithdrawBeneficiaryBalBefore; + uint256 internal pendingWithdrawExpectedAmount; + uint256 internal pendingWithdrawStampBalBefore; + // Temporary inputs to reduce stack pressure in helpers. bytes32 internal tmpNonce; bool internal tmpImmutable; + bytes32 internal tmpBatchA; + bytes32 internal tmpBatchB; + bytes32 internal tmpDigestA; + bytes32 internal tmpDigestB; constructor() { token = new TestToken("TestToken", "TT", 1_000_000_000_000_000_000_000_000); // 1e24 @@ -167,6 +186,8 @@ contract EchidnaPostageStampHarness { external { _clearPending(); + // Normalize expiry so createBatch's internal expireLimited() doesn't unexpectedly mutate other batches. + stamp.expireLimited(type(uint256).max); tmpNonce = nonce; tmpImmutable = immutableFlag; _createBatchInternal(actorId, initialPerChunk, depthRaw); @@ -176,10 +197,15 @@ contract EchidnaPostageStampHarness { _clearPending(); EchidnaPostageActor a = _actor(actorId); - if (stamp.paused()) return; + if (stamp.paused()) { + bool okPaused = a.topUp(_batch(batchIndex), 1); + if (okPaused) pausedMutationSucceeded = true; + return; + } bytes32 batchId = _batch(batchIndex); if (batchId == bytes32(0)) return; + _armNonInterference(batchIndex, batchId); // Only the creator can topUp (msg.sender irrelevant); topUp isn't owner gated but will revert if batch doesn't exist/expired. uint8 depth = stamp.batchDepth(batchId); @@ -196,6 +222,7 @@ contract EchidnaPostageStampHarness { bool ok = a.topUp(batchId, perChunk); if (!ok) return; + _checkNonInterference(batchId); pendingTopUp = true; pendingTopUpBatchId = batchId; @@ -209,7 +236,11 @@ contract EchidnaPostageStampHarness { _clearPending(); EchidnaPostageActor a = _actor(actorId); - if (stamp.paused()) return; + if (stamp.paused()) { + bool okPaused = a.increaseDepth(_batch(batchIndex), 3); + if (okPaused) pausedMutationSucceeded = true; + return; + } bytes32 batchId = _batch(batchIndex); if (batchId == bytes32(0)) return; @@ -229,9 +260,18 @@ contract EchidnaPostageStampHarness { uint256 validBefore = stamp.validChunkCount(); uint256 tokenBefore = token.balanceOf(address(stamp)); + uint8 bucketDepthBefore = stamp.batchBucketDepth(batchId); + + uint256 ctopBefore = stamp.currentTotalOutPayment(); + uint256 remainingBefore = stamp.remainingBalance(batchId); + uint8 depthChange = newDepth - oldDepth; + uint256 expectedNormalisedAfter = ctopBefore + (remainingBefore / (1 << depthChange)); + + _armNonInterference(batchIndex, batchId); bool ok = a.increaseDepth(batchId, newDepth); if (!ok) return; + _checkNonInterference(batchId); pendingIncreaseDepth = true; pendingIncBatchId = batchId; @@ -239,11 +279,21 @@ contract EchidnaPostageStampHarness { pendingIncNewDepth = newDepth; pendingIncValidChunkBefore = validBefore; pendingIncTokenBefore = tokenBefore; + pendingIncBucketDepth = bucketDepthBefore; + pendingIncExpectedNormalised = expectedNormalisedAfter; } function act_oracle_setPrice(uint256 price) external { _clearPending(); - oracleActor.trySetPrice(price); + bool ok = oracleActor.trySetPrice(price); + if (!ok) return; + + pendingSetPrice = true; + pendingSetPriceLastUpdatedExpected = uint64(block.number); + pendingSetPriceLastPriceExpected = uint64(price); + // Capture the exact base total payout immediately after setting the price. + // At this point `lastUpdatedBlock == block.number`, so `currentTotalOutPayment()` equals `totalOutPayment`. + pendingSetPriceTotalOutPaymentBefore = stamp.currentTotalOutPayment(); } function act_expireAll() external { @@ -252,6 +302,35 @@ contract EchidnaPostageStampHarness { pendingExpireAll = true; } + function act_redistributor_withdraw(uint8 beneficiaryActorId) external { + _clearPending(); + address beneficiary = address(_actor(beneficiaryActorId)); + if (beneficiary == address(0)) beneficiary = address(0xBEEF); + + uint256 amount = stamp.totalPot(); + uint256 balBefore = token.balanceOf(beneficiary); + uint256 stampBalBefore = token.balanceOf(address(stamp)); + + bool ok = redistributorActor.tryWithdraw(beneficiary); + if (!ok) return; + + pendingWithdraw = true; + pendingWithdrawBeneficiary = beneficiary; + pendingWithdrawBeneficiaryBalBefore = balBefore; + pendingWithdrawStampBalBefore = stampBalBefore; + pendingWithdrawExpectedAmount = amount; + } + + function act_pauser_pause() external { + _clearPending(); + pauserActor.tryPause(); + } + + function act_pauser_unpause() external { + _clearPending(); + pauserActor.tryUnpause(); + } + function act_rando_trySetPrice(uint8 actorId, uint256 price) external { _clearPending(); bool ok = _actor(actorId).trySetPrice(price); @@ -271,12 +350,23 @@ contract EchidnaPostageStampHarness { if (ok) unauthorizedPauseSucceeded = true; } + function act_rando_tryUnpause(uint8 actorId) external { + _clearPending(); + bool ok = _actor(actorId).tryUnpause(); + if (ok) unauthorizedPauseSucceeded = true; + } + // ----------------------------- // Properties // ----------------------------- function echidna_never_performed_forbidden_calls() external view returns (bool) { - return !unauthorizedPriceSetSucceeded && !unauthorizedWithdrawSucceeded && !unauthorizedPauseSucceeded; + return + !unauthorizedPriceSetSucceeded && + !unauthorizedWithdrawSucceeded && + !unauthorizedPauseSucceeded && + !pausedMutationSucceeded && + !nonInterferenceViolated; } function echidna_minimumInitialBalancePerChunk_matches_formula() external view returns (bool) { @@ -316,6 +406,8 @@ contract EchidnaPostageStampHarness { if (stamp.validChunkCount() != pendingIncValidChunkBefore + expectedDelta) return false; if (stamp.batchDepth(pendingIncBatchId) != pendingIncNewDepth) return false; + if (stamp.batchBucketDepth(pendingIncBatchId) != pendingIncBucketDepth) return false; + if (stamp.batchNormalisedBalance(pendingIncBatchId) != pendingIncExpectedNormalised) return false; return true; } @@ -324,6 +416,23 @@ contract EchidnaPostageStampHarness { return !stamp.expiredBatchesExist(); } + function echidna_setPrice_postconditions_hold() external view returns (bool) { + if (!pendingSetPrice) return true; + if (stamp.lastUpdatedBlock() != pendingSetPriceLastUpdatedExpected) return false; + if (stamp.lastPrice() != pendingSetPriceLastPriceExpected) return false; + uint256 blocksSince = block.number - uint256(pendingSetPriceLastUpdatedExpected); + uint256 expected = pendingSetPriceTotalOutPaymentBefore + uint256(pendingSetPriceLastPriceExpected) * blocksSince; + return stamp.currentTotalOutPayment() == expected; + } + + function echidna_withdraw_postconditions_hold() external view returns (bool) { + if (!pendingWithdraw) return true; + if (stamp.pot() != 0) return false; + if (token.balanceOf(pendingWithdrawBeneficiary) != pendingWithdrawBeneficiaryBalBefore + pendingWithdrawExpectedAmount) + return false; + return token.balanceOf(address(stamp)) == pendingWithdrawStampBalBefore - pendingWithdrawExpectedAmount; + } + // ----------------------------- // Helpers // ----------------------------- @@ -342,6 +451,38 @@ contract EchidnaPostageStampHarness { pendingTopUp = false; pendingIncreaseDepth = false; pendingExpireAll = false; + pendingSetPrice = false; + pendingWithdraw = false; + } + + function _batchDigest(bytes32 batchId) internal view returns (bytes32) { + address owner = stamp.batchOwner(batchId); + if (owner == address(0)) return bytes32(0); + return keccak256( + abi.encodePacked( + owner, + stamp.batchDepth(batchId), + stamp.batchBucketDepth(batchId), + stamp.batchImmutableFlag(batchId), + stamp.batchNormalisedBalance(batchId), + stamp.batchLastUpdatedBlockNumber(batchId) + ) + ); + } + + function _armNonInterference(uint8 batchIndex, bytes32 target) internal { + tmpBatchA = _batch(uint8(batchIndex + 1)); + tmpBatchB = _batch(uint8(batchIndex + 2)); + if (tmpBatchA == target) tmpBatchA = bytes32(0); + if (tmpBatchB == target) tmpBatchB = bytes32(0); + tmpDigestA = tmpBatchA == bytes32(0) ? bytes32(0) : _batchDigest(tmpBatchA); + tmpDigestB = tmpBatchB == bytes32(0) ? bytes32(0) : _batchDigest(tmpBatchB); + } + + function _checkNonInterference(bytes32 target) internal { + target; + if (tmpBatchA != bytes32(0) && _batchDigest(tmpBatchA) != tmpDigestA) nonInterferenceViolated = true; + if (tmpBatchB != bytes32(0) && _batchDigest(tmpBatchB) != tmpDigestB) nonInterferenceViolated = true; } function _createBatchInternal(uint8 actorId, uint256 initialPerChunk, uint8 depthRaw) internal { From b946e353caf87f1a740c505ff27ea9dfaa611404 Mon Sep 17 00:00:00 2001 From: Cardinal Date: Tue, 3 Mar 2026 15:04:17 +0100 Subject: [PATCH 13/50] basic redis fuzzing added --- echidna/README.md | 128 +++++-- scripts/echidna.sh | 1 + src/echidna/EchidnaRedistributionHarness.sol | 373 +++++++++++++++++++ 3 files changed, 463 insertions(+), 39 deletions(-) create mode 100644 src/echidna/EchidnaRedistributionHarness.sol diff --git a/echidna/README.md b/echidna/README.md index 59cadc78..018766bd 100644 --- a/echidna/README.md +++ b/echidna/README.md @@ -19,8 +19,11 @@ This repo currently contains multiple harnesses: - **Staking harness**: `src/echidna/EchidnaStakeRegistryHarness.sol` - **Oracle harness**: `src/echidna/EchidnaPriceOracleHarness.sol` - **PostageStamp harness**: `src/echidna/EchidnaPostageStampHarness.sol` +- **Redistribution harness**: `src/echidna/EchidnaRedistributionHarness.sol` -The staking harness deploys: +### What each harness deploys + +The **staking harness** deploys: - `TestToken` (a mintable ERC20 preset used as BZZ stand-in) - `StakeRegistry` (from `src/Staking.sol`) @@ -28,53 +31,98 @@ The staking harness deploys: It also deploys several **actor contracts** (`EchidnaStakeActor`) which behave like independent users (each has its own address and token balance), plus a dedicated actor that receives the `REDISTRIBUTOR_ROLE` so we can fuzz freeze/slash flows. -The oracle harness deploys: +The **oracle harness** deploys: - `PriceOracle` (from `src/PriceOracle.sol`) - a `PostageStamp` mock that can succeed or revert on `setPrice(uint256)` - an updater actor (has `PRICE_UPDATER_ROLE`) and a random actor (no roles) to fuzz access control +The **postage stamp harness** deploys: + +- `TestToken` (ERC20 used as BZZ stand-in) +- `PostageStamp` (from `src/PostageStamp.sol`) +- actor contracts with roles: + - a price oracle actor (has `PRICE_ORACLE_ROLE`) + - a redistributor actor (has `REDISTRIBUTOR_ROLE`) + - a pauser actor (has `PAUSER_ROLE`) + +The **redistribution harness** (base) deploys: + +- `Redistribution` (from `src/Redistribution.sol`) +- mocks for its dependencies: + - `IStakeRegistry` (overlay/height/effective stake + `freezeDeposit` tracking) + - `IPostageStamp` (tracks `withdraw()` calls; provides minimal `batches()`/`validChunkCount()` access) + - `IPriceOracle` (tracks `adjustPrice()` calls) +- a small set of actor contracts (independent `msg.sender`s) to fuzz access control and commit/reveal/claim entrypoints + ### Actions (what Echidna mutates) -These functions are intentionally written to be **mostly non-reverting**, so Echidna can explore longer state sequences: - -- **Per-actor stake actions** - - `act_actor_manageStake(actorId, setNonce, addAmount, height)` - - `act_actor_withdrawSurplus(actorId)` - - `act_actor_migrateStake(actorId)` (only succeeds when paused) -- **Admin actions (executed by the harness admin)** - - `act_admin_pause()`, `act_admin_unpause()` - - `act_admin_changeNetworkId(newNetworkId)` -- **Redistributor actions (executed by the redistributor actor)** - - `act_redistributor_freeze(targetActorId, time)` - - `act_redistributor_slash(targetActorId, amount)` -- **Negative tests (unauthorized attempts)** - - `act_actor_tryPause(...)`, `act_actor_tryUnpause(...)`, `act_actor_tryChangeNetworkId(...)` - - `act_actor_tryFreeze(...)`, `act_actor_trySlash(...)` -- **Funding** - - `act_fundActor(actorId, amount)` transfers tokens from the harness to an actor so fuzzing doesn’t get “stuck” when actors run out of balance. +Harness action functions are intentionally written to be **mostly non-reverting**, so Echidna can explore longer state sequences. + +Key actions per harness: + +- **Staking harness** + - Stake actions: `act_actor_manageStake`, `act_actor_withdrawSurplus`, `act_actor_migrateStake` + - Admin actions: `act_admin_pause`, `act_admin_unpause`, `act_admin_changeNetworkId` + - Redistributor actions: `act_redistributor_freeze`, `act_redistributor_slash` + - Negative tests: `act_actor_try*` (unauthorized attempts) + - Funding: `act_fundActor` + +- **Oracle harness** + - Admin actions: `act_admin_setPrice`, `act_admin_pause`, `act_admin_unpause` + - Updater actions: `act_updater_adjustPrice` + - Negative tests: `act_rando_try*` + - PostageStamp mock behavior: `act_setStampRevertMode` + +- **PostageStamp harness** + - Batch actions: `act_createBatch`, `act_topUp`, `act_increaseDepth`, `act_expireAll` + - Price update: `act_oracle_setPrice` + - Pot withdrawal: `act_redistributor_withdraw` + - Pause/unpause: `act_pauser_pause`, `act_pauser_unpause` + - Negative tests: `act_rando_try*` + - Funding: `act_fundActor` + +- **Redistribution harness (base)** + - Stake configuration: `act_setActorStake` + - Game entrypoints: `act_commit`, `act_reveal`, `act_claim` (often reverts early; still useful to shake out panics/state bugs) + - Admin actions: `act_admin_pause`, `act_admin_unpause`, `act_admin_setSampleMaxValue`, `act_admin_setFreezingParams` + - Negative tests: `act_rando_try*` (unauthorized attempts) ### Properties (what must always hold) -The harness defines `echidna_*` properties that Echidna checks continuously: - -- **Authorization / “must never happen”** - - `echidna_never_performed_forbidden_calls`: asserts that unauthorized actors never successfully paused/unpaused/changed network id, never successfully froze/slashed, and that we didn’t observe other action-level invariant violations. -- **Cross-actor accounting** - - `echidna_registry_balance_covers_sum_potential`: registry token balance covers the sum of all actors’ `potentialStake`. -- **Per-actor stake invariants** - - `echidna_stake_committed_never_decreases_per_actor`: committed stake never decreases for an actor while it has an active stake entry. - - `echidna_nodeEffective_matches_freeze_rule_per_actor`: effective stake is `0` while frozen, otherwise matches expected effective stake math. - - `echidna_empty_state_is_zeroed_for_all`: if a stake entry is deleted/empty, all fields are zeroed. - - `echidna_overlay_matches_last_manageStake_for_all`: overlay matches `keccak256(owner, reverse(networkIdAtLastStake), lastNonce)` per actor. -- **Post-conditions for successful `manageStake(add > 0)`** - - `echidna_last_manageStake_add_updates_potential_and_registry_balance`: on the immediate post-state after a successful `manageStake` with `addAmount > 0`, both the actor’s `potentialStake` and the registry’s ERC20 balance must increase by exactly `addAmount`. - - `echidna_last_manageStake_add_recomputes_committedStake`: on that same immediate post-state, `committedStake` must equal `floor(potential / (price * 2**height))`. -- **Pause/migrate and penalty post-conditions** - - `echidna_migrate_never_succeeds_while_unpaused`: `migrateStake()` must never succeed unless the registry is paused. - - `echidna_last_migrate_refunds_and_deletes_when_stake_exists`: on the immediate post-state after a successful `migrateStake()`, the stake is deleted and the actor is refunded exactly their previous `potentialStake` (or it is a no-op if no stake existed). - - `echidna_last_freeze_only_updates_lastUpdated`: on the immediate post-state after a successful redistributor `freezeDeposit`, only `lastUpdatedBlockNumber` is modified (other stake fields remain unchanged). - - `echidna_last_slash_updates_expected_fields`: on the immediate post-state after a successful redistributor `slashDeposit`, partial slashes only decrease `potentialStake` and set `lastUpdatedBlockNumber`, and full slashes delete the stake. +Each harness defines `echidna_*` properties that Echidna checks continuously. + +Common patterns used across harnesses: + +- **Authorization (“must never happen”)**: calls that should be role-gated must never succeed for unauthorized actors. +- **Post-conditions**: for successful state transitions, the immediate post-state must match expected math and accounting. + +High-signal properties per harness: + +- **Staking harness** + - Access control + “must never happen” flags (`echidna_never_performed_forbidden_calls`) + - Registry accounting (ERC20 balance covers sum of potential stake) + - Per-actor invariants (commitment monotonicity, effective stake/freeze semantics, overlay derivation) + - Post-conditions for `manageStake(add>0)`, `freezeDeposit`, `slashDeposit`, `migrateStake` + +- **Oracle harness** + - Access control (admin-only + updater-only) and “paused means no changes” + - Price invariants: price never below minimum; downscaled vs upscaled consistency; lastAdjustedRound not in the future + - Post-conditions for `setPrice` and `adjustPrice` (including skipped-round math), with overflow-aware modeling + +- **PostageStamp harness** + - Access control (oracle-only price updates, redistributor-only withdraw, pauser-only pause/unpause) + - Pause-mode negative tests (batch mutations must not succeed while paused) + - Batch post-conditions (`createBatch`, `topUp`, `increaseDepth`) and expiry sanity (`expireAll`) + - Pot/withdraw post-conditions (beneficiary receives exactly the withdrawn amount; `pot` resets) + - Non-interference checks for unrelated tracked batches during targeted operations + +- **Redistribution harness (base)** + - Access control “must never happen” flag (`echidna_never_performed_forbidden_calls`) + - Round bookkeeping sanity (`currentCommitRound/currentRevealRound` never in the future) + - Commit/reveal internal consistency: + - committed overlays remain unique + - if a commit is marked as revealed, its `revealIndex` points to a reveal with the same overlay/owner These are “sanity properties”: they’re meant to detect obvious bugs and unintended state corruption early. @@ -115,6 +163,8 @@ To run only a specific harness contract: ```bash ECHIDNA_CONTRACT=EchidnaStakeRegistryHarness yarn echidna ECHIDNA_CONTRACT=EchidnaPriceOracleHarness yarn echidna +ECHIDNA_CONTRACT=EchidnaPostageStampHarness yarn echidna +ECHIDNA_CONTRACT=EchidnaRedistributionHarness yarn echidna ``` This uses Docker and the image `ghcr.io/crytic/echidna/echidna:latest`. @@ -133,7 +183,7 @@ These are ignored by git via `.gitignore`. Typical next steps: -- Add another harness under `src/echidna/` for `PostageStamp` or `Redistribution`. +- Add another harness under `src/echidna/` for other protocol contracts. - Keep actions non-reverting and model only the roles/privileges you want to include. - Start with a few **obviously true** invariants, then iterate: - If Echidna finds a counterexample, decide whether that is a **bug** or a **property mismatch**. diff --git a/scripts/echidna.sh b/scripts/echidna.sh index d8327c44..6f47b93b 100755 --- a/scripts/echidna.sh +++ b/scripts/echidna.sh @@ -21,6 +21,7 @@ CONTRACTS_DEFAULT=( "EchidnaStakeRegistryHarness" "EchidnaPriceOracleHarness" "EchidnaPostageStampHarness" + "EchidnaRedistributionHarness" ) if [[ -n "$CONTRACT" ]]; then diff --git a/src/echidna/EchidnaRedistributionHarness.sol b/src/echidna/EchidnaRedistributionHarness.sol new file mode 100644 index 00000000..0b3298e4 --- /dev/null +++ b/src/echidna/EchidnaRedistributionHarness.sol @@ -0,0 +1,373 @@ +// SPDX-License-Identifier: BSD-3-Clause +pragma solidity ^0.8.19; + +import "../Redistribution.sol"; +import "../interface/IPostageStamp.sol"; + +contract EchidnaStakeRegistryMock is IStakeRegistry { + struct Node { + bytes32 overlay; + uint8 height; + uint256 effectiveStake; + uint256 lastUpdated; + bool exists; + } + + mapping(address => Node) internal nodes; + + function setNode(address owner, bytes32 overlay, uint8 height, uint256 effectiveStake, uint256 lastUpdated) external { + nodes[owner] = Node({ + overlay: overlay, + height: height, + effectiveStake: effectiveStake, + lastUpdated: lastUpdated, + exists: true + }); + } + + function freezeDeposit(address _owner, uint256 _time) external { + if (!nodes[_owner].exists) return; + nodes[_owner].lastUpdated = block.number + _time; + } + + function lastUpdatedBlockNumberOfAddress(address _owner) external view returns (uint256) { + return nodes[_owner].lastUpdated; + } + + function overlayOfAddress(address _owner) external view returns (bytes32) { + return nodes[_owner].overlay; + } + + function heightOfAddress(address _owner) external view returns (uint8) { + return nodes[_owner].height; + } + + function nodeEffectiveStake(address _owner) external view returns (uint256) { + return nodes[_owner].effectiveStake; + } +} + +contract EchidnaPriceOracleMock is IPriceOracle { + uint256 public calls; + uint16 public lastRedundancy; + + function adjustPrice(uint16 redundancy) external returns (bool) { + calls += 1; + lastRedundancy = redundancy; + return true; + } +} + +contract EchidnaPostageStampMock is IPostageStamp { + uint256 public withdrawCalls; + address public lastBeneficiary; + uint256 public validChunkCountValue; + + // Minimal batch data for claim's stampFunction() access pattern. + mapping(bytes32 => Batch) internal _batches; + + struct Batch { + address owner; + uint8 depth; + uint8 bucketDepth; + bool immutableFlag; + uint256 normalisedBalance; + uint256 lastUpdatedBlockNumber; + } + + function setValidChunkCount(uint256 v) external { + validChunkCountValue = v; + } + + function seedBatch(bytes32 id, address owner, uint8 depth, uint8 bucketDepth) external { + _batches[id] = Batch({ + owner: owner, + depth: depth, + bucketDepth: bucketDepth, + immutableFlag: false, + normalisedBalance: 1, + lastUpdatedBlockNumber: block.number + }); + } + + function withdraw(address beneficiary) external { + withdrawCalls += 1; + lastBeneficiary = beneficiary; + } + + function setPrice(uint256) external {} + + function validChunkCount() external view returns (uint256) { + return validChunkCountValue; + } + + function batchOwner(bytes32 _batchId) external view returns (address) { + return _batches[_batchId].owner; + } + + function batchDepth(bytes32 _batchId) external view returns (uint8) { + return _batches[_batchId].depth; + } + + function batchBucketDepth(bytes32 _batchId) external view returns (uint8) { + return _batches[_batchId].bucketDepth; + } + + function remainingBalance(bytes32) external pure returns (uint256) { + return 1; + } + + function minimumInitialBalancePerChunk() external pure returns (uint256) { + return 1; + } + + function batches( + bytes32 id + ) + external + view + returns (address owner, uint8 depth, uint8 bucketDepth, bool immutableFlag, uint256 normalisedBalance, uint256 lastUpdatedBlockNumber) + { + Batch memory b = _batches[id]; + return (b.owner, b.depth, b.bucketDepth, b.immutableFlag, b.normalisedBalance, b.lastUpdatedBlockNumber); + } +} + +contract EchidnaRedistributionActor { + Redistribution internal immutable redist; + + constructor(Redistribution r) { + redist = r; + } + + function callCommit(bytes32 obfuscatedHash, uint64 roundNumber) external returns (bool ok) { + (ok, ) = address(redist).call(abi.encodeWithSelector(redist.commit.selector, obfuscatedHash, roundNumber)); + } + + function callReveal(uint8 depth, bytes32 hash, bytes32 nonce) external returns (bool ok) { + (ok, ) = address(redist).call(abi.encodeWithSelector(redist.reveal.selector, depth, hash, nonce)); + } + + function callClaim() external returns (bool ok) { + // Create minimal calldata that avoids immediate out-of-bounds panics. + Redistribution.ChunkInclusionProof memory p; + p.proofSegments = new bytes32[](1); + p.proofSegments2 = new bytes32[](0); + p.proofSegments3 = new bytes32[](0); + p.socProof = new Redistribution.SOCProof[](0); + + (ok, ) = address(redist).call(abi.encodeWithSelector(redist.claim.selector, p, p, p)); + } + + function tryPause() external returns (bool ok) { + (ok, ) = address(redist).call(abi.encodeWithSelector(redist.pause.selector)); + } + + function tryUnpause() external returns (bool ok) { + (ok, ) = address(redist).call(abi.encodeWithSelector(redist.unPause.selector)); + } + + function trySetSampleMaxValue(uint256 v) external returns (bool ok) { + (ok, ) = address(redist).call(abi.encodeWithSelector(redist.setSampleMaxValue.selector, v)); + } + + function trySetFreezingParams(uint8 a, uint8 b, uint8 c) external returns (bool ok) { + (ok, ) = address(redist).call(abi.encodeWithSelector(redist.setFreezingParams.selector, a, b, c)); + } +} + +/// @notice Base Echidna harness for Redistribution. +/// @dev Focuses on wiring dependencies, access control, and basic internal consistency. +contract EchidnaRedistributionHarness { + EchidnaStakeRegistryMock internal immutable stakeMock; + EchidnaPostageStampMock internal immutable stampMock; + EchidnaPriceOracleMock internal immutable oracleMock; + Redistribution internal immutable redist; + + uint256 internal constant ACTOR_COUNT = 3; + EchidnaRedistributionActor[3] internal actors; + + // Forbidden-call flags. + bool internal unauthorizedAdminCallSucceeded; + + constructor() { + stakeMock = new EchidnaStakeRegistryMock(); + stampMock = new EchidnaPostageStampMock(); + oracleMock = new EchidnaPriceOracleMock(); + + redist = new Redistribution(address(stakeMock), address(stampMock), address(oracleMock)); + + for (uint256 i = 0; i < ACTOR_COUNT; i++) { + actors[i] = new EchidnaRedistributionActor(redist); + // Seed a stake that will eventually satisfy commit constraints once block.number is large enough. + stakeMock.setNode(address(actors[i]), bytes32(uint256(i + 1)), 0, 1e18, 1); + } + } + + // ----------------------------- + // Actions + // ----------------------------- + + function act_setActorStake(uint8 actorId, bytes32 overlay, uint8 height, uint256 effectiveStake, uint256 lastUpdated) + external + { + EchidnaRedistributionActor a = actors[uint256(actorId) % ACTOR_COUNT]; + // Bound height so 2**depthResponsibility doesn't explode too hard during reveal. + uint8 h = uint8(height % 16); + // Avoid lastUpdated=0 unless we want NotStaked; keep at least 1. + uint256 u = lastUpdated == 0 ? 1 : lastUpdated; + stakeMock.setNode(address(a), overlay, h, effectiveStake, u); + } + + function act_commit(uint8 actorId, bytes32 obfuscatedHash, int8 roundDelta) external { + EchidnaRedistributionActor a = actors[uint256(actorId) % ACTOR_COUNT]; + uint64 cr = redist.currentRound(); + uint64 rn = cr; + if (roundDelta < 0 && uint64(uint8(-roundDelta)) < cr) rn = cr - uint64(uint8(-roundDelta)); + if (roundDelta > 0) rn = cr + uint64(uint8(roundDelta)); + a.callCommit(obfuscatedHash, rn); + } + + function act_reveal(uint8 actorId, uint8 depth, bytes32 hash, bytes32 nonce) external { + EchidnaRedistributionActor a = actors[uint256(actorId) % ACTOR_COUNT]; + a.callReveal(uint8(depth % 32), hash, nonce); + } + + function act_claim(uint8 actorId) external { + EchidnaRedistributionActor a = actors[uint256(actorId) % ACTOR_COUNT]; + a.callClaim(); + } + + function act_admin_pause() external { + redist.pause(); + } + + function act_admin_unpause() external { + redist.unPause(); + } + + function act_admin_setSampleMaxValue(uint256 v) external { + redist.setSampleMaxValue(v); + } + + function act_admin_setFreezingParams(uint8 a, uint8 b, uint8 c) external { + redist.setFreezingParams(a, b, c); + } + + function act_rando_tryPause(uint8 actorId) external { + bool ok = actors[uint256(actorId) % ACTOR_COUNT].tryPause(); + if (ok) unauthorizedAdminCallSucceeded = true; + } + + function act_rando_tryUnpause(uint8 actorId) external { + bool ok = actors[uint256(actorId) % ACTOR_COUNT].tryUnpause(); + if (ok) unauthorizedAdminCallSucceeded = true; + } + + function act_rando_trySetSampleMaxValue(uint8 actorId, uint256 v) external { + bool ok = actors[uint256(actorId) % ACTOR_COUNT].trySetSampleMaxValue(v); + if (ok) unauthorizedAdminCallSucceeded = true; + } + + function act_rando_trySetFreezingParams(uint8 actorId, uint8 a, uint8 b, uint8 c) external { + bool ok = actors[uint256(actorId) % ACTOR_COUNT].trySetFreezingParams(a, b, c); + if (ok) unauthorizedAdminCallSucceeded = true; + } + + // ----------------------------- + // Properties + // ----------------------------- + + function echidna_never_performed_forbidden_calls() external view returns (bool) { + return !unauthorizedAdminCallSucceeded; + } + + function echidna_round_counters_not_in_future() external view returns (bool) { + uint64 cr = redist.currentRound(); + return redist.currentCommitRound() <= cr && redist.currentRevealRound() <= cr; + } + + function echidna_commit_overlays_unique() external view returns (bool) { + (uint256 n, bytes32[25] memory overlays, , , ) = _scanCommits(); + for (uint256 i = 0; i < n; i++) { + bytes32 oi = overlays[i]; + for (uint256 j = i + 1; j < n; j++) { + bytes32 oj = overlays[j]; + if (oi != bytes32(0) && oi == oj) return false; + } + } + return true; + } + + function echidna_revealed_commit_indices_valid() external view returns (bool) { + (uint256 cN, , uint256[25] memory revealIndex, bool[25] memory revealed, address[25] memory owner) = _scanCommits(); + uint256 rN = _scanRevealsLen(); + for (uint256 i = 0; i < cN; i++) { + if (!revealed[i]) continue; + uint256 ri = revealIndex[i]; + if (ri >= rN) return false; + (bool ok, bytes32 rOverlay, address rOwner) = _revealOverlayOwner(ri); + if (!ok) return false; + // Compare against commit i overlay/owner. + (bool ok2, bytes32 cOverlay, address cOwner) = _commitOverlayOwner(i); + if (!ok2) return false; + if (rOverlay != cOverlay) return false; + if (rOwner != cOwner) return false; + if (cOwner != owner[i]) return false; + } + return true; + } + + // ----------------------------- + // View helpers (avoid needing array length getters) + // ----------------------------- + + function _scanCommits() + internal + view + returns (uint256 n, bytes32[25] memory overlays, uint256[25] memory revealIndex, bool[25] memory revealed, address[25] memory owner) + { + for (uint256 i = 0; i < 25; i++) { + (bool ok, bytes32 ov, address ow, bool rev, uint256 ri) = _commitFields(i); + if (!ok) break; + overlays[i] = ov; + owner[i] = ow; + revealed[i] = rev; + revealIndex[i] = ri; + n++; + } + } + + function _scanRevealsLen() internal view returns (uint256 n) { + for (uint256 i = 0; i < 25; i++) { + (bool ok, , ) = _revealOverlayOwner(i); + if (!ok) break; + n++; + } + } + + function _commitOverlayOwner(uint256 i) internal view returns (bool ok, bytes32 ov, address ow) { + (ok, ov, ow, , ) = _commitFields(i); + } + + function _commitFields( + uint256 i + ) internal view returns (bool ok, bytes32 ov, address ow, bool rev, uint256 ri) { + bytes memory data; + (ok, data) = address(redist).staticcall(abi.encodeWithSignature("currentCommits(uint256)", i)); + if (!ok) return (false, bytes32(0), address(0), false, 0); + // Commit struct getter returns: + // (bytes32 overlay, address owner, bool revealed, uint8 height, uint256 stake, bytes32 obfuscatedHash, uint256 revealIndex) + (ov, ow, rev, , , , ri) = abi.decode(data, (bytes32, address, bool, uint8, uint256, bytes32, uint256)); + } + + function _revealOverlayOwner(uint256 i) internal view returns (bool ok, bytes32 ov, address ow) { + bytes memory data; + (ok, data) = address(redist).staticcall(abi.encodeWithSignature("currentReveals(uint256)", i)); + if (!ok) return (false, bytes32(0), address(0)); + // Reveal struct getter returns: + // (bytes32 overlay, address owner, uint8 depth, uint256 stake, uint256 stakeDensity, bytes32 hash) + (ov, ow, , , , ) = abi.decode(data, (bytes32, address, uint8, uint256, uint256, bytes32)); + } +} + From c69ea42371c0a452f26041032463c9af8a8f35c2 Mon Sep 17 00:00:00 2001 From: Cardinal Date: Tue, 3 Mar 2026 20:47:27 +0100 Subject: [PATCH 14/50] add advanced fuzzing for stamps --- echidna/README.md | 9 + src/echidna/EchidnaRedistributionHarness.sol | 278 ++++++++++++++++++- 2 files changed, 286 insertions(+), 1 deletion(-) diff --git a/echidna/README.md b/echidna/README.md index 018766bd..08a464fe 100644 --- a/echidna/README.md +++ b/echidna/README.md @@ -55,6 +55,9 @@ The **redistribution harness** (base) deploys: - `IPriceOracle` (tracks `adjustPrice()` calls) - a small set of actor contracts (independent `msg.sender`s) to fuzz access control and commit/reveal/claim entrypoints +It also includes “happy-path” actions (`act_happyCommit`, `act_happyReveal`) that try to **increase the rate of successful** +`commit → reveal` sequences by pre-conditioning the mocked stake/overlay inputs (so we can assert stronger post-conditions). + ### Actions (what Echidna mutates) Harness action functions are intentionally written to be **mostly non-reverting**, so Echidna can explore longer state sequences. @@ -85,8 +88,10 @@ Key actions per harness: - **Redistribution harness (base)** - Stake configuration: `act_setActorStake` - Game entrypoints: `act_commit`, `act_reveal`, `act_claim` (often reverts early; still useful to shake out panics/state bugs) + - Happy-path flow: `act_happyCommit`, `act_happyReveal` - Admin actions: `act_admin_pause`, `act_admin_unpause`, `act_admin_setSampleMaxValue`, `act_admin_setFreezingParams` - Negative tests: `act_rando_try*` (unauthorized attempts) + - Pause gating checks: `act_tryCommitWhilePaused`, `act_tryRevealWhilePaused` ### Properties (what must always hold) @@ -119,10 +124,14 @@ High-signal properties per harness: - **Redistribution harness (base)** - Access control “must never happen” flag (`echidna_never_performed_forbidden_calls`) + - Pause gating: `echidna_never_succeeded_while_paused` - Round bookkeeping sanity (`currentCommitRound/currentRevealRound` never in the future) - Commit/reveal internal consistency: - committed overlays remain unique - if a commit is marked as revealed, its `revealIndex` points to a reveal with the same overlay/owner + - Happy-path post-conditions (only asserted for the currently active commit round): + - `echidna_tracked_commit_matches_storage` + - `echidna_tracked_reveal_matches_storage` These are “sanity properties”: they’re meant to detect obvious bugs and unintended state corruption early. diff --git a/src/echidna/EchidnaRedistributionHarness.sol b/src/echidna/EchidnaRedistributionHarness.sol index 0b3298e4..e5371ae4 100644 --- a/src/echidna/EchidnaRedistributionHarness.sol +++ b/src/echidna/EchidnaRedistributionHarness.sol @@ -189,6 +189,39 @@ contract EchidnaRedistributionHarness { // Forbidden-call flags. bool internal unauthorizedAdminCallSucceeded; + bool internal commitSucceededWhilePaused; + bool internal revealSucceededWhilePaused; + + // Tracked "happy-path" state per actor (used to assert strong postconditions when we succeed). + bool[3] internal trackedHasCommit; + bool[3] internal trackedHasReveal; + uint64[3] internal trackedRound; + bytes32[3] internal trackedOverlay; + uint8[3] internal trackedHeight; + uint8[3] internal trackedDepth; + uint256[3] internal trackedStake; + bytes32[3] internal trackedReserveHash; + bytes32[3] internal trackedNonce; + bytes32[3] internal trackedObfuscated; + + struct CommitView { + bytes32 overlay; + address owner; + bool revealed; + uint8 height; + uint256 stake; + bytes32 obfuscatedHash; + uint256 revealIndex; + } + + struct RevealView { + bytes32 overlay; + address owner; + uint8 depth; + uint256 stake; + uint256 stakeDensity; + bytes32 hash; + } constructor() { stakeMock = new EchidnaStakeRegistryMock(); @@ -216,7 +249,8 @@ contract EchidnaRedistributionHarness { uint8 h = uint8(height % 16); // Avoid lastUpdated=0 unless we want NotStaked; keep at least 1. uint256 u = lastUpdated == 0 ? 1 : lastUpdated; - stakeMock.setNode(address(a), overlay, h, effectiveStake, u); + uint256 stake = _boundStake(effectiveStake); + stakeMock.setNode(address(a), overlay, h, stake, u); } function act_commit(uint8 actorId, bytes32 obfuscatedHash, int8 roundDelta) external { @@ -274,6 +308,106 @@ contract EchidnaRedistributionHarness { if (ok) unauthorizedAdminCallSucceeded = true; } + // ----------------------------- + // Advanced actions (aim for successful commit/reveal) + // ----------------------------- + + function act_happyCommit(uint8 actorId, uint8 height, uint256 stakeAmount, bytes32 reserveHash, bytes32 nonce) external { + if (redist.paused()) return; + if (!redist.currentPhaseCommit()) return; + // Avoid the "phase last block" restriction in commit phase. + if (block.number % 152 == (152 / 4) - 1) return; + + uint256 idx = uint256(actorId) % ACTOR_COUNT; + EchidnaRedistributionActor a = actors[idx]; + + // Pick a unique overlay per actor that still has a high chance of being eligible. + // We set depthResponsibility = 0 (depth == height), which makes proximity always pass. + bytes32 anchor = redist.currentRoundAnchor(); + bytes32 overlay = keccak256(abi.encodePacked("overlay", idx, anchor)); + + uint8 h = uint8(height % 16); + uint8 d = h; + uint256 stake = _boundStake(stakeAmount); + uint256 lastUpdated = _backdateLastUpdated(); + + // Set node data so commit checks can pass. + stakeMock.setNode(address(a), overlay, h, stake, lastUpdated); + + // Avoid reverting on AlreadyCommitted for identical overlay. + if (_commitOverlayExists(overlay)) return; + + bytes32 obfuscated = redist.wrapCommit(overlay, d, reserveHash, nonce); + bool ok = a.callCommit(obfuscated, redist.currentRound()); + if (!ok) return; + + trackedHasCommit[idx] = true; + trackedHasReveal[idx] = false; + trackedRound[idx] = redist.currentRound(); + trackedOverlay[idx] = overlay; + trackedHeight[idx] = h; + trackedDepth[idx] = d; + trackedStake[idx] = stake; + trackedReserveHash[idx] = reserveHash; + trackedNonce[idx] = nonce; + trackedObfuscated[idx] = obfuscated; + } + + function act_happyReveal(uint8 actorId) external { + if (redist.paused()) return; + if (!redist.currentPhaseReveal()) return; + + uint256 idx = uint256(actorId) % ACTOR_COUNT; + if (!trackedHasCommit[idx] || trackedHasReveal[idx]) return; + + // Reveal must happen in the same round that received commits. + if (redist.currentRound() != trackedRound[idx]) return; + if (redist.currentCommitRound() != trackedRound[idx]) return; + + EchidnaRedistributionActor a = actors[idx]; + // Ensure the actor's overlay/height match the committed values. + stakeMock.setNode(address(a), trackedOverlay[idx], trackedHeight[idx], trackedStake[idx], _backdateLastUpdated()); + + bool ok = a.callReveal(trackedDepth[idx], trackedReserveHash[idx], trackedNonce[idx]); + if (!ok) return; + + trackedHasReveal[idx] = true; + } + + function act_tryCommitWhilePaused(uint8 actorId, bytes32 reserveHash, bytes32 nonce) external { + if (!redist.paused()) return; + if (!redist.currentPhaseCommit()) return; + if (block.number % 152 == (152 / 4) - 1) return; + + uint256 idx = uint256(actorId) % ACTOR_COUNT; + EchidnaRedistributionActor a = actors[idx]; + + bytes32 anchor = redist.currentRoundAnchor(); + bytes32 overlay = keccak256(abi.encodePacked("paused-overlay", idx, anchor)); + uint8 h = 0; + uint8 d = 0; + stakeMock.setNode(address(a), overlay, h, 1e18, _backdateLastUpdated()); + + bytes32 obfuscated = redist.wrapCommit(overlay, d, reserveHash, nonce); + bool ok = a.callCommit(obfuscated, redist.currentRound()); + if (ok) commitSucceededWhilePaused = true; + } + + function act_tryRevealWhilePaused(uint8 actorId) external { + if (!redist.paused()) return; + if (!redist.currentPhaseReveal()) return; + + uint256 idx = uint256(actorId) % ACTOR_COUNT; + if (!trackedHasCommit[idx]) return; + if (redist.currentRound() != trackedRound[idx]) return; + if (redist.currentCommitRound() != trackedRound[idx]) return; + + EchidnaRedistributionActor a = actors[idx]; + stakeMock.setNode(address(a), trackedOverlay[idx], trackedHeight[idx], trackedStake[idx], _backdateLastUpdated()); + bool ok = a.callReveal(trackedDepth[idx], trackedReserveHash[idx], trackedNonce[idx]); + if (ok) revealSucceededWhilePaused = true; + } + // ----------------------------- // Properties // ----------------------------- @@ -282,6 +416,10 @@ contract EchidnaRedistributionHarness { return !unauthorizedAdminCallSucceeded; } + function echidna_never_succeeded_while_paused() external view returns (bool) { + return !commitSucceededWhilePaused && !revealSucceededWhilePaused; + } + function echidna_round_counters_not_in_future() external view returns (bool) { uint64 cr = redist.currentRound(); return redist.currentCommitRound() <= cr && redist.currentRevealRound() <= cr; @@ -318,6 +456,47 @@ contract EchidnaRedistributionHarness { return true; } + function echidna_tracked_commit_matches_storage() external view returns (bool) { + uint64 liveCommitRound = redist.currentCommitRound(); + for (uint256 i = 0; i < ACTOR_COUNT; i++) { + if (!trackedHasCommit[i]) continue; + // `currentCommits` is deleted when a new commit round begins. + // Only assert strong postconditions for commits in the currently tracked commit round. + if (trackedRound[i] != liveCommitRound) continue; + + (bool ok, uint256 commitIdx) = _findCommit(trackedOverlay[i], trackedObfuscated[i]); + if (!ok) return false; + + ( + , + bytes32 ov, + address ow, + , + uint8 h, + uint256 stake, + bytes32 obf, + /* revealIndex */ + ) = _commitFull(commitIdx); + + if (ov != trackedOverlay[i]) return false; + if (obf != trackedObfuscated[i]) return false; + if (ow != address(actors[i])) return false; + if (h != trackedHeight[i]) return false; + if (stake != trackedStake[i]) return false; + } + return true; + } + + function echidna_tracked_reveal_matches_storage() external view returns (bool) { + uint64 liveCommitRound = redist.currentCommitRound(); + for (uint256 i = 0; i < ACTOR_COUNT; i++) { + if (!trackedHasReveal[i]) continue; + if (trackedRound[i] != liveCommitRound) continue; + if (!_checkTrackedReveal(i)) return false; + } + return true; + } + // ----------------------------- // View helpers (avoid needing array length getters) // ----------------------------- @@ -369,5 +548,102 @@ contract EchidnaRedistributionHarness { // (bytes32 overlay, address owner, uint8 depth, uint256 stake, uint256 stakeDensity, bytes32 hash) (ov, ow, , , , ) = abi.decode(data, (bytes32, address, uint8, uint256, uint256, bytes32)); } + + function _commitFull( + uint256 i + ) + internal + view + returns ( + bool ok, + bytes32 overlay, + address owner, + bool revealed, + uint8 height, + uint256 stake, + bytes32 obfuscatedHash, + uint256 revealIndex + ) + { + bytes memory data; + (ok, data) = address(redist).staticcall(abi.encodeWithSignature("currentCommits(uint256)", i)); + if (!ok) return (false, bytes32(0), address(0), false, 0, 0, bytes32(0), 0); + (overlay, owner, revealed, height, stake, obfuscatedHash, revealIndex) = + abi.decode(data, (bytes32, address, bool, uint8, uint256, bytes32, uint256)); + } + + function _revealFull( + uint256 i + ) internal view returns (bool ok, bytes32 overlay, address owner, uint8 depth, uint256 stake, uint256 stakeDensity, bytes32 hash) { + bytes memory data; + (ok, data) = address(redist).staticcall(abi.encodeWithSignature("currentReveals(uint256)", i)); + if (!ok) return (false, bytes32(0), address(0), 0, 0, 0, bytes32(0)); + (overlay, owner, depth, stake, stakeDensity, hash) = abi.decode(data, (bytes32, address, uint8, uint256, uint256, bytes32)); + } + + function _checkTrackedReveal(uint256 actorIdx) internal view returns (bool) { + (bool ok, uint256 commitIdx) = _findCommit(trackedOverlay[actorIdx], trackedObfuscated[actorIdx]); + if (!ok) return false; + + bytes memory cdata; + (ok, cdata) = address(redist).staticcall(abi.encodeWithSignature("currentCommits(uint256)", commitIdx)); + if (!ok) return false; + CommitView memory c = abi.decode(cdata, (CommitView)); + + if (c.owner != address(actors[actorIdx])) return false; + if (c.overlay != trackedOverlay[actorIdx]) return false; + if (c.obfuscatedHash != trackedObfuscated[actorIdx]) return false; + if (c.height != trackedHeight[actorIdx]) return false; + if (c.stake != trackedStake[actorIdx]) return false; + if (!c.revealed) return false; + + bytes memory rdata; + (ok, rdata) = address(redist).staticcall(abi.encodeWithSignature("currentReveals(uint256)", c.revealIndex)); + if (!ok) return false; + RevealView memory r = abi.decode(rdata, (RevealView)); + + if (r.overlay != trackedOverlay[actorIdx]) return false; + if (r.owner != address(actors[actorIdx])) return false; + if (r.depth != trackedDepth[actorIdx]) return false; + if (r.hash != trackedReserveHash[actorIdx]) return false; + if (r.stake != c.stake) return false; + + uint8 dr = trackedDepth[actorIdx] - c.height; + uint256 expectedDensity = c.stake * (uint256(1) << dr); + if (r.stakeDensity != expectedDensity) return false; + return true; + } + + function _findCommit(bytes32 overlay, bytes32 obfuscated) internal view returns (bool ok, uint256 idx) { + for (uint256 i = 0; i < 25; i++) { + (bool okI, bytes32 ov, , , , , bytes32 obf, ) = _commitFull(i); + if (!okI) break; + if (ov == overlay && obf == obfuscated) return (true, i); + } + return (false, 0); + } + + function _commitOverlayExists(bytes32 overlay) internal view returns (bool) { + for (uint256 i = 0; i < 25; i++) { + (bool ok, bytes32 ov, , , ) = _commitFields(i); + if (!ok) break; + if (ov == overlay) return true; + } + return false; + } + + function _backdateLastUpdated() internal view returns (uint256) { + uint256 twoRounds = 2 * 152; + if (block.number > twoRounds + 1) return block.number - twoRounds - 1; + return 1; + } + + function _boundStake(uint256 s) internal pure returns (uint256) { + // Keep stake densities well within uint256 range even if depthResponsibility grows a bit. + uint256 max = 1e24; + if (s == 0) return 1; + if (s > max) return (s % max) + 1; + return s; + } } From 241808d1e1c55d81aff53be4cfd8d8bf3bde3d8f Mon Sep 17 00:00:00 2001 From: Cardinal Date: Tue, 3 Mar 2026 22:07:48 +0100 Subject: [PATCH 15/50] add cross contract wiring for full fuzzing --- echidna/README.md | 27 +++ scripts/echidna.sh | 1 + src/echidna/EchidnaSystemHarness.sol | 345 +++++++++++++++++++++++++++ 3 files changed, 373 insertions(+) create mode 100644 src/echidna/EchidnaSystemHarness.sol diff --git a/echidna/README.md b/echidna/README.md index 08a464fe..f577371e 100644 --- a/echidna/README.md +++ b/echidna/README.md @@ -20,6 +20,7 @@ This repo currently contains multiple harnesses: - **Oracle harness**: `src/echidna/EchidnaPriceOracleHarness.sol` - **PostageStamp harness**: `src/echidna/EchidnaPostageStampHarness.sol` - **Redistribution harness**: `src/echidna/EchidnaRedistributionHarness.sol` +- **System/integration harness**: `src/echidna/EchidnaSystemHarness.sol` ### What each harness deploys @@ -58,6 +59,20 @@ The **redistribution harness** (base) deploys: It also includes “happy-path” actions (`act_happyCommit`, `act_happyReveal`) that try to **increase the rate of successful** `commit → reveal` sequences by pre-conditioning the mocked stake/overlay inputs (so we can assert stronger post-conditions). +The **system/integration harness** deploys: + +- `TestToken` +- `PostageStamp` +- `PriceOracle` (wired as the `PRICE_ORACLE_ROLE` on `PostageStamp`) +- `StakeRegistry` (wired to `PriceOracle.currentPrice()`) +- `Redistribution` (wired to `StakeRegistry`, `PostageStamp`, `PriceOracle`) + +and grants: + +- `StakeRegistry.REDISTRIBUTOR_ROLE` to `Redistribution` (so it can `freezeDeposit`) +- `PostageStamp.REDISTRIBUTOR_ROLE` to `Redistribution` (so it can `withdraw`) +- `PriceOracle.PRICE_UPDATER_ROLE` to one actor (to fuzz `adjustPrice`) + ### Actions (what Echidna mutates) Harness action functions are intentionally written to be **mostly non-reverting**, so Echidna can explore longer state sequences. @@ -93,6 +108,12 @@ Key actions per harness: - Negative tests: `act_rando_try*` (unauthorized attempts) - Pause gating checks: `act_tryCommitWhilePaused`, `act_tryRevealWhilePaused` +- **System/integration harness** + - Stake actions: `act_actor_manageStake`, `act_actor_withdrawSurplus` + - Postage actions: `act_actor_createBatch`, `act_actor_topUp`, `act_actor_increaseDepth`, `act_actor_expireAll` + - Oracle actions: `act_admin_setOraclePrice`, `act_updater_adjustOraclePrice`, `act_rando_tryAdjustOraclePrice` + - Redistribution flow: `act_redist_happyCommit`, `act_redist_happyReveal` + ### Properties (what must always hold) Each harness defines `echidna_*` properties that Echidna checks continuously. @@ -133,6 +154,11 @@ High-signal properties per harness: - `echidna_tracked_commit_matches_storage` - `echidna_tracked_reveal_matches_storage` +- **System/integration harness** + - Wiring invariants: correct addresses + roles across contracts + - Oracle↔stamp invariant: `PostageStamp.lastPrice` tracks `PriceOracle.currentPrice()` after updates + - Redistribution happy-path consistency: tracked commit/reveal values appear in `Redistribution` storage + These are “sanity properties”: they’re meant to detect obvious bugs and unintended state corruption early. ## What we expect (and what can go wrong) @@ -174,6 +200,7 @@ ECHIDNA_CONTRACT=EchidnaStakeRegistryHarness yarn echidna ECHIDNA_CONTRACT=EchidnaPriceOracleHarness yarn echidna ECHIDNA_CONTRACT=EchidnaPostageStampHarness yarn echidna ECHIDNA_CONTRACT=EchidnaRedistributionHarness yarn echidna +ECHIDNA_CONTRACT=EchidnaSystemHarness yarn echidna ``` This uses Docker and the image `ghcr.io/crytic/echidna/echidna:latest`. diff --git a/scripts/echidna.sh b/scripts/echidna.sh index 6f47b93b..29412d0a 100755 --- a/scripts/echidna.sh +++ b/scripts/echidna.sh @@ -22,6 +22,7 @@ CONTRACTS_DEFAULT=( "EchidnaPriceOracleHarness" "EchidnaPostageStampHarness" "EchidnaRedistributionHarness" + "EchidnaSystemHarness" ) if [[ -n "$CONTRACT" ]]; then diff --git a/src/echidna/EchidnaSystemHarness.sol b/src/echidna/EchidnaSystemHarness.sol new file mode 100644 index 00000000..5be47ae8 --- /dev/null +++ b/src/echidna/EchidnaSystemHarness.sol @@ -0,0 +1,345 @@ +// SPDX-License-Identifier: BSD-3-Clause +pragma solidity ^0.8.19; + +import "../TestToken.sol"; +import "../PostageStamp.sol"; +import "../PriceOracle.sol"; +import "../Redistribution.sol" as RedistMod; +import "../Staking.sol" as StakingMod; + +contract EchidnaSystemActor { + TestToken internal immutable token; + StakingMod.StakeRegistry internal immutable stake; + PostageStamp internal immutable stamp; + PriceOracle internal immutable oracle; + RedistMod.Redistribution internal immutable redist; + + constructor(TestToken t, StakingMod.StakeRegistry s, PostageStamp p, PriceOracle o, RedistMod.Redistribution r) { + token = t; + stake = s; + stamp = p; + oracle = o; + redist = r; + + token.approve(address(stake), type(uint256).max); + token.approve(address(stamp), type(uint256).max); + } + + function callManageStake(bytes32 setNonce, uint256 addAmount, uint8 height) external returns (bool ok) { + (ok, ) = address(stake).call(abi.encodeWithSelector(stake.manageStake.selector, setNonce, addAmount, height)); + } + + function callWithdrawFromStake() external returns (bool ok) { + (ok, ) = address(stake).call(abi.encodeWithSelector(stake.withdrawFromStake.selector)); + } + + function callCreateBatch( + address owner, + uint256 initialBalancePerChunk, + uint8 depth, + uint8 bucketDepth, + bytes32 nonce, + bool immutableFlag + ) external returns (bool ok) { + (ok, ) = address(stamp).call( + abi.encodeWithSelector(stamp.createBatch.selector, owner, initialBalancePerChunk, depth, bucketDepth, nonce, immutableFlag) + ); + } + + function callTopUp(bytes32 batchId, uint256 topupAmountPerChunk) external returns (bool ok) { + (ok, ) = address(stamp).call(abi.encodeWithSignature("topUp(bytes32,uint256)", batchId, topupAmountPerChunk)); + } + + function callIncreaseDepth(bytes32 batchId, uint8 newDepth) external returns (bool ok) { + (ok, ) = address(stamp).call(abi.encodeWithSignature("increaseDepth(bytes32,uint8)", batchId, newDepth)); + } + + function callExpireAll() external returns (bool ok) { + (ok, ) = address(stamp).call(abi.encodeWithSelector(stamp.expireLimited.selector, type(uint256).max)); + } + + function callCommit(bytes32 obfuscatedHash, uint64 roundNumber) external returns (bool ok) { + (ok, ) = address(redist).call(abi.encodeWithSelector(redist.commit.selector, obfuscatedHash, roundNumber)); + } + + function callReveal(uint8 depth, bytes32 hash, bytes32 nonce) external returns (bool ok) { + (ok, ) = address(redist).call(abi.encodeWithSelector(redist.reveal.selector, depth, hash, nonce)); + } + + function callAdjustPrice(uint16 redundancy) external returns (bool ok) { + (ok, ) = address(oracle).call(abi.encodeWithSelector(oracle.adjustPrice.selector, redundancy)); + } +} + +/// @notice Integration fuzz harness: StakeRegistry + PostageStamp + PriceOracle + Redistribution wired together. +contract EchidnaSystemHarness { + uint256 internal constant ACTOR_COUNT = 3; + + TestToken internal immutable token; + PostageStamp internal immutable stamp; + PriceOracle internal immutable oracle; + StakingMod.StakeRegistry internal immutable stake; + RedistMod.Redistribution internal immutable redist; + + EchidnaSystemActor[3] internal actors; + uint256 internal constant BOOTSTRAP_HEIGHT = 16; + bytes32[3] internal bootstrapNonce; + + // Integration negative-test flags. + bool internal unauthorizedOracleAdjustSucceeded; + + // Tracked commit/reveal preimages for the integrated happy-path flow. + bool[3] internal trackedHasCommit; + bool[3] internal trackedHasReveal; + uint64[3] internal trackedRound; + bytes32[3] internal trackedObfuscated; + bytes32[3] internal trackedHash; + bytes32[3] internal trackedRevealNonce; + uint8[3] internal trackedDepth; + + constructor() { + token = new TestToken("TestToken", "TST", 0); + + // Deploy postage first; oracle depends on it. + stamp = new PostageStamp(address(token), 16); + oracle = new PriceOracle(address(stamp)); + + // Wire roles: the oracle must be able to call PostageStamp.setPrice. + stamp.grantRole(stamp.PRICE_ORACLE_ROLE(), address(oracle)); + + // Deploy stake registry (uses oracle.currentPrice()). + stake = new StakingMod.StakeRegistry(address(token), 1, address(oracle)); + + // Deploy redistribution (uses stake/stamp/oracle). + redist = new RedistMod.Redistribution(address(stake), address(stamp), address(oracle)); + + // Wire roles: redistribution must be able to freeze stake and withdraw the stamp pot. + stake.grantRole(stake.REDISTRIBUTOR_ROLE(), address(redist)); + stamp.grantRole(stamp.REDISTRIBUTOR_ROLE(), address(redist)); + + // Create actor contracts and pre-fund + pre-stake them. + for (uint256 i = 0; i < ACTOR_COUNT; i++) { + actors[i] = new EchidnaSystemActor(token, stake, stamp, oracle, redist); + bootstrapNonce[i] = keccak256(abi.encodePacked("bootstrap", i)); + // Mint enough for both staking and postage operations. + token.mint(address(actors[i]), 1e28); + // Bootstrap a stake early so that after 2 rounds pass, commit() can succeed. + actors[i].callManageStake(bootstrapNonce[i], 1e24, uint8(BOOTSTRAP_HEIGHT)); + } + + // Create a dedicated price updater (actor[0]) for adjustPrice. + oracle.grantRole(oracle.PRICE_UPDATER_ROLE(), address(actors[0])); + } + + // ----------------------------- + // Integration actions + // ----------------------------- + + function act_actor_manageStake(uint8 actorId, bytes32 setNonce, uint256 addAmount, uint8 height) external { + EchidnaSystemActor a = actors[uint256(actorId) % ACTOR_COUNT]; + uint8 h = uint8(height % 32); + uint256 amt = _boundStakeAdd(addAmount); + a.callManageStake(setNonce, amt, h); + } + + function act_actor_withdrawSurplus(uint8 actorId) external { + EchidnaSystemActor a = actors[uint256(actorId) % ACTOR_COUNT]; + a.callWithdrawFromStake(); + } + + function act_actor_createBatch(uint8 actorId, uint256 initialBalancePerChunk, uint8 depth, uint8 bucketDepth, bytes32 nonce, bool imm) + external + { + EchidnaSystemActor a = actors[uint256(actorId) % ACTOR_COUNT]; + uint8 d = uint8((depth % 12) + 1); // avoid huge shifts, avoid 0 + uint8 b = uint8(bucketDepth % d); + if (b < 16) b = 16; + if (b >= d) b = uint8(d - 1); + + uint256 min = stamp.minimumInitialBalancePerChunk(); + uint256 init = initialBalancePerChunk % (min + 1e6); + if (init < min) init = min; + if (init == 0) init = 1; + + a.callCreateBatch(address(a), init, d, b, nonce, imm); + } + + function act_actor_topUp(uint8 actorId, bytes32 batchId, uint256 topupAmountPerChunk) external { + EchidnaSystemActor a = actors[uint256(actorId) % ACTOR_COUNT]; + uint256 amt = topupAmountPerChunk % 1e9; + if (amt == 0) amt = 1; + a.callTopUp(batchId, amt); + } + + function act_actor_increaseDepth(uint8 actorId, bytes32 batchId, uint8 newDepth) external { + EchidnaSystemActor a = actors[uint256(actorId) % ACTOR_COUNT]; + uint8 d = uint8((newDepth % 12) + 1); + a.callIncreaseDepth(batchId, d); + } + + function act_actor_expireAll(uint8 actorId) external { + EchidnaSystemActor a = actors[uint256(actorId) % ACTOR_COUNT]; + a.callExpireAll(); + } + + function act_admin_setOraclePrice(uint32 p) external { + // This should update both oracle state and PostageStamp.lastPrice (via setPrice()). + oracle.setPrice(p); + } + + function act_updater_adjustOraclePrice(uint16 redundancy) external { + // Only actor[0] has PRICE_UPDATER_ROLE. + actors[0].callAdjustPrice(uint16((redundancy % 8) + 1)); + } + + function act_rando_tryAdjustOraclePrice(uint8 actorId, uint16 redundancy) external { + EchidnaSystemActor a = actors[uint256(actorId) % ACTOR_COUNT]; + bool ok = a.callAdjustPrice(uint16((redundancy % 8) + 1)); + if (ok && address(a) != address(actors[0])) unauthorizedOracleAdjustSucceeded = true; + } + + function act_redist_happyCommit(uint8 actorId, bytes32 hash, bytes32 revealNonce) external { + if (redist.paused()) return; + if (!redist.currentPhaseCommit()) return; + // Avoid the commit-phase last-block restriction. + if (block.number % 152 == (152 / 4) - 1) return; + + uint256 idx = uint256(actorId) % ACTOR_COUNT; + EchidnaSystemActor a = actors[idx]; + + // Must have staked at least 2 rounds prior. + uint256 lastUpdated = stake.lastUpdatedBlockNumberOfAddress(address(a)); + if (lastUpdated == 0) return; + if (lastUpdated >= block.number - 2 * 152) return; + + // Use the actor's current staking height as the reveal depth (depthResponsibility = 0 => proximity always passes). + uint8 height = stake.heightOfAddress(address(a)); + uint8 depth = height; + + bytes32 overlay = stake.overlayOfAddress(address(a)); + bytes32 obfuscated = redist.wrapCommit(overlay, depth, hash, revealNonce); + + bool ok = a.callCommit(obfuscated, redist.currentRound()); + if (!ok) return; + + trackedHasCommit[idx] = true; + trackedHasReveal[idx] = false; + trackedRound[idx] = redist.currentRound(); + trackedObfuscated[idx] = obfuscated; + trackedHash[idx] = hash; + trackedRevealNonce[idx] = revealNonce; + trackedDepth[idx] = depth; + } + + function act_redist_happyReveal(uint8 actorId) external { + if (redist.paused()) return; + if (!redist.currentPhaseReveal()) return; + + uint256 idx = uint256(actorId) % ACTOR_COUNT; + if (!trackedHasCommit[idx] || trackedHasReveal[idx]) return; + + // Must reveal in the same round that holds commits. + if (redist.currentRound() != trackedRound[idx]) return; + if (redist.currentCommitRound() != trackedRound[idx]) return; + + EchidnaSystemActor a = actors[idx]; + bool ok = a.callReveal(trackedDepth[idx], trackedHash[idx], trackedRevealNonce[idx]); + if (!ok) return; + trackedHasReveal[idx] = true; + } + + // ----------------------------- + // Integration properties + // ----------------------------- + + function echidna_system_is_wired_correctly() external view returns (bool) { + if (address(stake.OracleContract()) != address(oracle)) return false; + if (address(redist.Stakes()) != address(stake)) return false; + if (address(redist.PostageContract()) != address(stamp)) return false; + if (address(redist.OracleContract()) != address(oracle)) return false; + return true; + } + + function echidna_roles_wired_correctly() external view returns (bool) { + if (!stamp.hasRole(stamp.PRICE_ORACLE_ROLE(), address(oracle))) return false; + if (!stake.hasRole(stake.REDISTRIBUTOR_ROLE(), address(redist))) return false; + if (!stamp.hasRole(stamp.REDISTRIBUTOR_ROLE(), address(redist))) return false; + if (!oracle.hasRole(oracle.PRICE_UPDATER_ROLE(), address(actors[0]))) return false; + return true; + } + + function echidna_unauthorized_oracle_adjust_never_succeeds() external view returns (bool) { + return !unauthorizedOracleAdjustSucceeded; + } + + function echidna_oracle_price_matches_stamp_lastPrice_when_updated() external view returns (bool) { + // `PostageStamp.lastPrice` updates on every successful oracle `setPrice`/`adjustPrice` call. + // We don't assert it *always* equals oracle.currentPrice(), because it can lag if the stamp call failed. + // But in this integrated harness, oracle holds PRICE_ORACLE_ROLE and the stamp call should succeed. + uint64 lp = stamp.lastPrice(); + if (lp == 0) return true; // not updated yet + return uint32(lp) == oracle.currentPrice(); + } + + function echidna_tracked_redist_commit_reveal_consistent() external view returns (bool) { + uint64 liveCommitRound = redist.currentCommitRound(); + for (uint256 i = 0; i < ACTOR_COUNT; i++) { + if (!trackedHasCommit[i]) continue; + if (trackedRound[i] != liveCommitRound) continue; + + // Verify the commit exists in storage (scan bounded prefix). + if (!_commitExists(trackedObfuscated[i], address(actors[i]))) return false; + } + + for (uint256 i = 0; i < ACTOR_COUNT; i++) { + if (!trackedHasReveal[i]) continue; + if (trackedRound[i] != liveCommitRound) continue; + + if (!_revealMatchesCommit(address(actors[i]), trackedDepth[i], trackedHash[i])) return false; + } + return true; + } + + // ----------------------------- + // Internal helpers + // ----------------------------- + + function _boundStakeAdd(uint256 a) internal pure returns (uint256) { + if (a == 0) return 1e18; + uint256 max = 1e25; + if (a > max) return (a % max) + 1; + return a; + } + + function _commitExists(bytes32 obfuscated, address owner) internal view returns (bool) { + // currentCommits is deleted each new commit round; bounded scan to avoid unbounded loops. + for (uint256 i = 0; i < 25; i++) { + (bool ok, bytes memory data) = address(redist).staticcall(abi.encodeWithSignature("currentCommits(uint256)", i)); + if (!ok) break; + (bytes32 ov, address ow, bool rev, uint8 h, uint256 st, bytes32 obf, uint256 ri) = + abi.decode(data, (bytes32, address, bool, uint8, uint256, bytes32, uint256)); + ov; + rev; + h; + st; + ri; + if (ow == owner && obf == obfuscated) return true; + } + return false; + } + + function _revealMatchesCommit(address owner, uint8 depth, bytes32 hash) internal view returns (bool) { + for (uint256 i = 0; i < 25; i++) { + (bool ok, bytes memory data) = address(redist).staticcall(abi.encodeWithSignature("currentReveals(uint256)", i)); + if (!ok) break; + (bytes32 ov, address ow, uint8 d, uint256 st, uint256 sd, bytes32 h) = + abi.decode(data, (bytes32, address, uint8, uint256, uint256, bytes32)); + ov; + st; + sd; + if (ow == owner && d == depth && h == hash) return true; + } + return false; + } +} + From 0bf2d016c1fab51014060725acec4d31d826c275 Mon Sep 17 00:00:00 2001 From: Cardinal Date: Tue, 3 Mar 2026 22:46:47 +0100 Subject: [PATCH 16/50] add Redistribution winnerSelection state-machine fuzzing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- echidna/README.md | 9 +- src/echidna/EchidnaPostageStampHarness.sol | 86 +++++++++++-- src/echidna/EchidnaRedistributionHarness.sol | 126 ++++++++++++++++++- 3 files changed, 208 insertions(+), 13 deletions(-) diff --git a/echidna/README.md b/echidna/README.md index f577371e..9beaf17b 100644 --- a/echidna/README.md +++ b/echidna/README.md @@ -104,6 +104,7 @@ Key actions per harness: - Stake configuration: `act_setActorStake` - Game entrypoints: `act_commit`, `act_reveal`, `act_claim` (often reverts early; still useful to shake out panics/state bugs) - Happy-path flow: `act_happyCommit`, `act_happyReveal` + - Winner selection (fuzz-only exposure): `act_winnerSelection` - Admin actions: `act_admin_pause`, `act_admin_unpause`, `act_admin_setSampleMaxValue`, `act_admin_setFreezingParams` - Negative tests: `act_rando_try*` (unauthorized attempts) - Pause gating checks: `act_tryCommitWhilePaused`, `act_tryRevealWhilePaused` @@ -141,15 +142,21 @@ High-signal properties per harness: - Pause-mode negative tests (batch mutations must not succeed while paused) - Batch post-conditions (`createBatch`, `topUp`, `increaseDepth`) and expiry sanity (`expireAll`) - Pot/withdraw post-conditions (beneficiary receives exactly the withdrawn amount; `pot` resets) - - Non-interference checks for unrelated tracked batches during targeted operations + - Non-interference checks for unrelated tracked batches during targeted operations (now checks multiple other batches) + - Pot monotonicity: pot must never decrease except by a successful withdraw-to-zero (`echidna_pot_never_decreases_except_withdraw`) - **Redistribution harness (base)** - Access control “must never happen” flag (`echidna_never_performed_forbidden_calls`) - Pause gating: `echidna_never_succeeded_while_paused` + - Phase sanity: exactly one of commit/reveal/claim is active (`echidna_phase_partitions_round`) - Round bookkeeping sanity (`currentCommitRound/currentRevealRound` never in the future) - Commit/reveal internal consistency: - committed overlays remain unique - if a commit is marked as revealed, its `revealIndex` points to a reveal with the same overlay/owner + - every reveal entry must correspond to a revealed commit (`echidna_reveal_entries_imply_matching_commit`) + - Claim-phase state machine (using a fuzz-only exposed `winnerSelection()`): + - winner selection cannot succeed twice in the same round (`echidna_winnerSelection_only_once_per_round`) + - successful winner selection freezes all non-revealers (`echidna_last_winnerSelection_freezes_nonrevealed`) - Happy-path post-conditions (only asserted for the currently active commit round): - `echidna_tracked_commit_matches_storage` - `echidna_tracked_reveal_matches_storage` diff --git a/src/echidna/EchidnaPostageStampHarness.sol b/src/echidna/EchidnaPostageStampHarness.sol index 7c22b69b..1fa65a5c 100644 --- a/src/echidna/EchidnaPostageStampHarness.sol +++ b/src/echidna/EchidnaPostageStampHarness.sol @@ -103,6 +103,7 @@ contract EchidnaPostageStampHarness { bool internal unauthorizedPauseSucceeded; bool internal pausedMutationSucceeded; bool internal nonInterferenceViolated; + bool internal potDecreasedUnexpectedly; // Pending postconditions (cleared on each action). bool internal pendingCreate; @@ -148,8 +149,16 @@ contract EchidnaPostageStampHarness { bool internal tmpImmutable; bytes32 internal tmpBatchA; bytes32 internal tmpBatchB; + bytes32 internal tmpBatchC; + bytes32 internal tmpBatchD; + bytes32 internal tmpBatchE; bytes32 internal tmpDigestA; bytes32 internal tmpDigestB; + bytes32 internal tmpDigestC; + bytes32 internal tmpDigestD; + bytes32 internal tmpDigestE; + + uint256 internal lastPotObserved; constructor() { token = new TestToken("TestToken", "TT", 1_000_000_000_000_000_000_000_000); // 1e24 @@ -167,6 +176,8 @@ contract EchidnaPostageStampHarness { stamp.grantRole(stamp.PRICE_ORACLE_ROLE(), address(oracleActor)); stamp.grantRole(stamp.REDISTRIBUTOR_ROLE(), address(redistributorActor)); stamp.grantRole(stamp.PAUSER_ROLE(), address(pauserActor)); + + lastPotObserved = stamp.pot(); } // ----------------------------- @@ -186,11 +197,13 @@ contract EchidnaPostageStampHarness { external { _clearPending(); + uint256 potBefore = stamp.pot(); // Normalize expiry so createBatch's internal expireLimited() doesn't unexpectedly mutate other batches. stamp.expireLimited(type(uint256).max); tmpNonce = nonce; tmpImmutable = immutableFlag; _createBatchInternal(actorId, initialPerChunk, depthRaw); + _observePot(potBefore, false); } function act_topUp(uint8 actorId, uint8 batchIndex, uint256 topupPerChunk) external { @@ -234,29 +247,43 @@ contract EchidnaPostageStampHarness { function act_increaseDepth(uint8 actorId, uint8 batchIndex, uint8 newDepthRaw) external { _clearPending(); + uint256 potBefore = stamp.pot(); EchidnaPostageActor a = _actor(actorId); if (stamp.paused()) { bool okPaused = a.increaseDepth(_batch(batchIndex), 3); if (okPaused) pausedMutationSucceeded = true; + _observePot(potBefore, false); return; } bytes32 batchId = _batch(batchIndex); - if (batchId == bytes32(0)) return; + if (batchId == bytes32(0)) { + _observePot(potBefore, false); + return; + } // increaseDepth is owner-gated; we only attempt if the batch owner matches this actor. - if (stamp.batchOwner(batchId) != address(a)) return; + if (stamp.batchOwner(batchId) != address(a)) { + _observePot(potBefore, false); + return; + } // Normalize state to avoid this call also expiring unrelated batches (it calls expireLimited internally). stamp.expireLimited(type(uint256).max); uint8 oldDepth = stamp.batchDepth(batchId); - if (oldDepth == 0) return; + if (oldDepth == 0) { + _observePot(potBefore, false); + return; + } uint8 minBucket = stamp.minimumBucketDepth(); uint8 newDepth = uint8(minBucket + 1 + (newDepthRaw % 12)); - if (newDepth <= oldDepth) return; + if (newDepth <= oldDepth) { + _observePot(potBefore, false); + return; + } uint256 validBefore = stamp.validChunkCount(); uint256 tokenBefore = token.balanceOf(address(stamp)); @@ -270,7 +297,10 @@ contract EchidnaPostageStampHarness { _armNonInterference(batchIndex, batchId); bool ok = a.increaseDepth(batchId, newDepth); - if (!ok) return; + if (!ok) { + _observePot(potBefore, false); + return; + } _checkNonInterference(batchId); pendingIncreaseDepth = true; @@ -281,12 +311,17 @@ contract EchidnaPostageStampHarness { pendingIncTokenBefore = tokenBefore; pendingIncBucketDepth = bucketDepthBefore; pendingIncExpectedNormalised = expectedNormalisedAfter; + _observePot(potBefore, false); } function act_oracle_setPrice(uint256 price) external { _clearPending(); + uint256 potBefore = stamp.pot(); bool ok = oracleActor.trySetPrice(price); - if (!ok) return; + if (!ok) { + _observePot(potBefore, false); + return; + } pendingSetPrice = true; pendingSetPriceLastUpdatedExpected = uint64(block.number); @@ -294,16 +329,20 @@ contract EchidnaPostageStampHarness { // Capture the exact base total payout immediately after setting the price. // At this point `lastUpdatedBlock == block.number`, so `currentTotalOutPayment()` equals `totalOutPayment`. pendingSetPriceTotalOutPaymentBefore = stamp.currentTotalOutPayment(); + _observePot(potBefore, false); } function act_expireAll() external { _clearPending(); + uint256 potBefore = stamp.pot(); stamp.expireLimited(type(uint256).max); pendingExpireAll = true; + _observePot(potBefore, false); } function act_redistributor_withdraw(uint8 beneficiaryActorId) external { _clearPending(); + uint256 potBefore = stamp.pot(); address beneficiary = address(_actor(beneficiaryActorId)); if (beneficiary == address(0)) beneficiary = address(0xBEEF); @@ -312,13 +351,17 @@ contract EchidnaPostageStampHarness { uint256 stampBalBefore = token.balanceOf(address(stamp)); bool ok = redistributorActor.tryWithdraw(beneficiary); - if (!ok) return; + if (!ok) { + _observePot(potBefore, false); + return; + } pendingWithdraw = true; pendingWithdrawBeneficiary = beneficiary; pendingWithdrawBeneficiaryBalBefore = balBefore; pendingWithdrawStampBalBefore = stampBalBefore; pendingWithdrawExpectedAmount = amount; + _observePot(potBefore, true); } function act_pauser_pause() external { @@ -366,7 +409,12 @@ contract EchidnaPostageStampHarness { !unauthorizedWithdrawSucceeded && !unauthorizedPauseSucceeded && !pausedMutationSucceeded && - !nonInterferenceViolated; + !nonInterferenceViolated && + !potDecreasedUnexpectedly; + } + + function echidna_pot_never_decreases_except_withdraw() external view returns (bool) { + return !potDecreasedUnexpectedly; } function echidna_minimumInitialBalancePerChunk_matches_formula() external view returns (bool) { @@ -473,16 +521,38 @@ contract EchidnaPostageStampHarness { function _armNonInterference(uint8 batchIndex, bytes32 target) internal { tmpBatchA = _batch(uint8(batchIndex + 1)); tmpBatchB = _batch(uint8(batchIndex + 2)); + tmpBatchC = _batch(uint8(batchIndex + 3)); + tmpBatchD = _batch(uint8(batchIndex + 4)); + tmpBatchE = _batch(uint8(batchIndex + 5)); if (tmpBatchA == target) tmpBatchA = bytes32(0); if (tmpBatchB == target) tmpBatchB = bytes32(0); + if (tmpBatchC == target) tmpBatchC = bytes32(0); + if (tmpBatchD == target) tmpBatchD = bytes32(0); + if (tmpBatchE == target) tmpBatchE = bytes32(0); tmpDigestA = tmpBatchA == bytes32(0) ? bytes32(0) : _batchDigest(tmpBatchA); tmpDigestB = tmpBatchB == bytes32(0) ? bytes32(0) : _batchDigest(tmpBatchB); + tmpDigestC = tmpBatchC == bytes32(0) ? bytes32(0) : _batchDigest(tmpBatchC); + tmpDigestD = tmpBatchD == bytes32(0) ? bytes32(0) : _batchDigest(tmpBatchD); + tmpDigestE = tmpBatchE == bytes32(0) ? bytes32(0) : _batchDigest(tmpBatchE); } function _checkNonInterference(bytes32 target) internal { target; if (tmpBatchA != bytes32(0) && _batchDigest(tmpBatchA) != tmpDigestA) nonInterferenceViolated = true; if (tmpBatchB != bytes32(0) && _batchDigest(tmpBatchB) != tmpDigestB) nonInterferenceViolated = true; + if (tmpBatchC != bytes32(0) && _batchDigest(tmpBatchC) != tmpDigestC) nonInterferenceViolated = true; + if (tmpBatchD != bytes32(0) && _batchDigest(tmpBatchD) != tmpDigestD) nonInterferenceViolated = true; + if (tmpBatchE != bytes32(0) && _batchDigest(tmpBatchE) != tmpDigestE) nonInterferenceViolated = true; + } + + function _observePot(uint256 potBefore, bool withdrew) internal { + potBefore; + uint256 prev = lastPotObserved; + uint256 nowPot = stamp.pot(); + if (nowPot < prev) { + if (!(withdrew && nowPot == 0 && prev > 0)) potDecreasedUnexpectedly = true; + } + lastPotObserved = nowPot; } function _createBatchInternal(uint8 actorId, uint256 initialPerChunk, uint8 depthRaw) internal { diff --git a/src/echidna/EchidnaRedistributionHarness.sol b/src/echidna/EchidnaRedistributionHarness.sol index e5371ae4..0b4480d3 100644 --- a/src/echidna/EchidnaRedistributionHarness.sol +++ b/src/echidna/EchidnaRedistributionHarness.sol @@ -14,6 +14,8 @@ contract EchidnaStakeRegistryMock is IStakeRegistry { } mapping(address => Node) internal nodes; + mapping(address => uint256) public freezeCount; + mapping(address => uint256) public lastFreezeTime; function setNode(address owner, bytes32 overlay, uint8 height, uint256 effectiveStake, uint256 lastUpdated) external { nodes[owner] = Node({ @@ -27,6 +29,8 @@ contract EchidnaStakeRegistryMock is IStakeRegistry { function freezeDeposit(address _owner, uint256 _time) external { if (!nodes[_owner].exists) return; + freezeCount[_owner] += 1; + lastFreezeTime[_owner] = _time; nodes[_owner].lastUpdated = block.number + _time; } @@ -133,10 +137,18 @@ contract EchidnaPostageStampMock is IPostageStamp { } } +contract RedistributionExposed is Redistribution { + constructor(address staking, address postageContract, address oracleContract) Redistribution(staking, postageContract, oracleContract) {} + + function exposedWinnerSelection() external { + winnerSelection(); + } +} + contract EchidnaRedistributionActor { - Redistribution internal immutable redist; + RedistributionExposed internal immutable redist; - constructor(Redistribution r) { + constructor(RedistributionExposed r) { redist = r; } @@ -159,6 +171,10 @@ contract EchidnaRedistributionActor { (ok, ) = address(redist).call(abi.encodeWithSelector(redist.claim.selector, p, p, p)); } + function callWinnerSelection() external returns (bool ok) { + (ok, ) = address(redist).call(abi.encodeWithSelector(redist.exposedWinnerSelection.selector)); + } + function tryPause() external returns (bool ok) { (ok, ) = address(redist).call(abi.encodeWithSelector(redist.pause.selector)); } @@ -182,7 +198,7 @@ contract EchidnaRedistributionHarness { EchidnaStakeRegistryMock internal immutable stakeMock; EchidnaPostageStampMock internal immutable stampMock; EchidnaPriceOracleMock internal immutable oracleMock; - Redistribution internal immutable redist; + RedistributionExposed internal immutable redist; uint256 internal constant ACTOR_COUNT = 3; EchidnaRedistributionActor[3] internal actors; @@ -191,6 +207,17 @@ contract EchidnaRedistributionHarness { bool internal unauthorizedAdminCallSucceeded; bool internal commitSucceededWhilePaused; bool internal revealSucceededWhilePaused; + bool internal winnerSelectionSucceededTwiceSameRound; + + // Pending winnerSelection postconditions. + bool internal pendingWinnerSelection; + uint64 internal pendingWinnerSelectionRound; + uint8 internal pendingWinnerSelectionLen; + address[25] internal pendingWSOwners; + bool[25] internal pendingWSRevealed; + uint256[25] internal pendingWSFreezeCountBefore; + + uint64 internal lastWinnerSelectionRound; // Tracked "happy-path" state per actor (used to assert strong postconditions when we succeed). bool[3] internal trackedHasCommit; @@ -228,7 +255,7 @@ contract EchidnaRedistributionHarness { stampMock = new EchidnaPostageStampMock(); oracleMock = new EchidnaPriceOracleMock(); - redist = new Redistribution(address(stakeMock), address(stampMock), address(oracleMock)); + redist = new RedistributionExposed(address(stakeMock), address(stampMock), address(oracleMock)); for (uint256 i = 0; i < ACTOR_COUNT; i++) { actors[i] = new EchidnaRedistributionActor(redist); @@ -408,6 +435,40 @@ contract EchidnaRedistributionHarness { if (ok) revealSucceededWhilePaused = true; } + function act_winnerSelection(uint8 actorId) external { + uint256 idx = uint256(actorId) % ACTOR_COUNT; + // Snapshot current commits (bounded) and freeze counts before selection. + pendingWinnerSelection = false; + pendingWinnerSelectionLen = 0; + pendingWinnerSelectionRound = redist.currentRound(); + + for (uint256 i = 0; i < 25; i++) { + (bool ok, bytes memory data) = address(redist).staticcall(abi.encodeWithSignature("currentCommits(uint256)", i)); + if (!ok) break; + (bytes32 ov, address ow, bool rev, uint8 h, uint256 st, bytes32 obf, uint256 ri) = + abi.decode(data, (bytes32, address, bool, uint8, uint256, bytes32, uint256)); + ov; + h; + st; + obf; + ri; + pendingWSOwners[i] = ow; + pendingWSRevealed[i] = rev; + pendingWSFreezeCountBefore[i] = stakeMock.freezeCount(ow); + pendingWinnerSelectionLen++; + } + + bool okCall = actors[idx].callWinnerSelection(); + if (!okCall) return; + + // "Only once per round" should hold: a second success in the same round is forbidden. + if (lastWinnerSelectionRound == pendingWinnerSelectionRound) winnerSelectionSucceededTwiceSameRound = true; + lastWinnerSelectionRound = pendingWinnerSelectionRound; + + // Arm postconditions: non-revealers must have been frozen. + pendingWinnerSelection = true; + } + // ----------------------------- // Properties // ----------------------------- @@ -420,6 +481,54 @@ contract EchidnaRedistributionHarness { return !commitSucceededWhilePaused && !revealSucceededWhilePaused; } + function echidna_phase_partitions_round() external view returns (bool) { + bool c = redist.currentPhaseCommit(); + bool r = redist.currentPhaseReveal(); + bool cl = redist.currentPhaseClaim(); + // Exactly one phase must be true for any block. + return (c && !r && !cl) || (!c && r && !cl) || (!c && !r && cl); + } + + function echidna_reveal_entries_imply_matching_commit() external view returns (bool) { + // For each reveal entry, there must exist a commit marked revealed with matching overlay/owner and revealIndex pointing here. + for (uint256 i = 0; i < 25; i++) { + (bool okR, bytes32 rOverlay, address rOwner) = _revealOverlayOwner(i); + if (!okR) break; + + bool found = false; + for (uint256 j = 0; j < 25; j++) { + (bool okC, bytes32 cOverlay, address cOwner, bool cRevealed, uint256 cRevealIndex) = _commitRevealLink(j); + if (!okC) break; + if (cRevealed && cRevealIndex == i && cOverlay == rOverlay && cOwner == rOwner) { + found = true; + break; + } + } + if (!found) return false; + } + return true; + } + + function echidna_winnerSelection_only_once_per_round() external view returns (bool) { + return !winnerSelectionSucceededTwiceSameRound; + } + + function echidna_last_winnerSelection_freezes_nonrevealed() external view returns (bool) { + if (!pendingWinnerSelection) return true; + // If we moved to another claim round, the commit set and expectations are stale; ignore. + if (redist.currentClaimRound() != pendingWinnerSelectionRound) return true; + + for (uint256 i = 0; i < pendingWinnerSelectionLen; i++) { + if (pendingWSRevealed[i]) continue; + address ow = pendingWSOwners[i]; + if (ow == address(0)) continue; + if (stakeMock.freezeCount(ow) <= pendingWSFreezeCountBefore[i]) return false; + // Freeze should move lastUpdated into the future in the mock. + if (stakeMock.lastUpdatedBlockNumberOfAddress(ow) <= block.number) return false; + } + return true; + } + function echidna_round_counters_not_in_future() external view returns (bool) { uint64 cr = redist.currentRound(); return redist.currentCommitRound() <= cr && redist.currentRevealRound() <= cr; @@ -529,6 +638,15 @@ contract EchidnaRedistributionHarness { (ok, ov, ow, , ) = _commitFields(i); } + function _commitRevealLink( + uint256 i + ) internal view returns (bool ok, bytes32 overlay, address owner, bool revealed, uint256 revealIndex) { + bytes memory data; + (ok, data) = address(redist).staticcall(abi.encodeWithSignature("currentCommits(uint256)", i)); + if (!ok) return (false, bytes32(0), address(0), false, 0); + (overlay, owner, revealed, , , , revealIndex) = abi.decode(data, (bytes32, address, bool, uint8, uint256, bytes32, uint256)); + } + function _commitFields( uint256 i ) internal view returns (bool ok, bytes32 ov, address ow, bool rev, uint256 ri) { From eb51a97701bf02180b798aa7c5d174c0df56a624 Mon Sep 17 00:00:00 2001 From: Cardinal Date: Tue, 3 Mar 2026 23:35:33 +0100 Subject: [PATCH 17/50] add Redistribution claim-stub fuzz harness MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- echidna/README.md | 20 + scripts/echidna.sh | 1 + .../EchidnaRedistributionClaimHarness.sol | 350 ++++++++++++++++++ 3 files changed, 371 insertions(+) create mode 100644 src/echidna/EchidnaRedistributionClaimHarness.sol diff --git a/echidna/README.md b/echidna/README.md index 9beaf17b..44b0bf64 100644 --- a/echidna/README.md +++ b/echidna/README.md @@ -20,6 +20,7 @@ This repo currently contains multiple harnesses: - **Oracle harness**: `src/echidna/EchidnaPriceOracleHarness.sol` - **PostageStamp harness**: `src/echidna/EchidnaPostageStampHarness.sol` - **Redistribution harness**: `src/echidna/EchidnaRedistributionHarness.sol` +- **Redistribution claim-stub harness**: `src/echidna/EchidnaRedistributionClaimHarness.sol` - **System/integration harness**: `src/echidna/EchidnaSystemHarness.sol` ### What each harness deploys @@ -59,6 +60,14 @@ The **redistribution harness** (base) deploys: It also includes “happy-path” actions (`act_happyCommit`, `act_happyReveal`) that try to **increase the rate of successful** `commit → reveal` sequences by pre-conditioning the mocked stake/overlay inputs (so we can assert stronger post-conditions). +The **redistribution claim-stub harness** deploys: + +- a fuzz-only `RedistributionClaimStub` that runs the real `winnerSelection()` but exposes `claimStub()` which **bypasses** + inclusion/SOC/stamp proof verification and directly calls `withdraw(winner)` on a small pot mock. + +This is meant to fuzz the **claim-phase state machine + pot withdrawal effects** end-to-end, without paying the cost of generating +valid Merkle/SOC/postage proofs. + The **system/integration harness** deploys: - `TestToken` @@ -109,6 +118,10 @@ Key actions per harness: - Negative tests: `act_rando_try*` (unauthorized attempts) - Pause gating checks: `act_tryCommitWhilePaused`, `act_tryRevealWhilePaused` +- **Redistribution claim-stub harness** + - Happy-path flow: `act_happyCommit`, `act_happyReveal`, `act_claimStub` + - Pot seeding: `act_seedPot` + - **System/integration harness** - Stake actions: `act_actor_manageStake`, `act_actor_withdrawSurplus` - Postage actions: `act_actor_createBatch`, `act_actor_topUp`, `act_actor_increaseDepth`, `act_actor_expireAll` @@ -161,6 +174,12 @@ High-signal properties per harness: - `echidna_tracked_commit_matches_storage` - `echidna_tracked_reveal_matches_storage` +- **Redistribution claim-stub harness** + - claim can only succeed once per round (`echidna_claim_only_once_per_round`) + - successful claim withdraws the entire pot to the selected winner (`echidna_claim_withdraws_pot_to_winner_when_successful`) + - claim triggers an oracle `adjustPrice` call (`echidna_claim_triggers_oracle_adjustPrice`) + - non-revealers are frozen during claim processing (`echidna_nonrevealers_frozen_after_claim_selection`) + - **System/integration harness** - Wiring invariants: correct addresses + roles across contracts - Oracle↔stamp invariant: `PostageStamp.lastPrice` tracks `PriceOracle.currentPrice()` after updates @@ -207,6 +226,7 @@ ECHIDNA_CONTRACT=EchidnaStakeRegistryHarness yarn echidna ECHIDNA_CONTRACT=EchidnaPriceOracleHarness yarn echidna ECHIDNA_CONTRACT=EchidnaPostageStampHarness yarn echidna ECHIDNA_CONTRACT=EchidnaRedistributionHarness yarn echidna +ECHIDNA_CONTRACT=EchidnaRedistributionClaimHarness yarn echidna ECHIDNA_CONTRACT=EchidnaSystemHarness yarn echidna ``` diff --git a/scripts/echidna.sh b/scripts/echidna.sh index 29412d0a..d70e8702 100755 --- a/scripts/echidna.sh +++ b/scripts/echidna.sh @@ -22,6 +22,7 @@ CONTRACTS_DEFAULT=( "EchidnaPriceOracleHarness" "EchidnaPostageStampHarness" "EchidnaRedistributionHarness" + "EchidnaRedistributionClaimHarness" "EchidnaSystemHarness" ) diff --git a/src/echidna/EchidnaRedistributionClaimHarness.sol b/src/echidna/EchidnaRedistributionClaimHarness.sol new file mode 100644 index 00000000..afeac7db --- /dev/null +++ b/src/echidna/EchidnaRedistributionClaimHarness.sol @@ -0,0 +1,350 @@ +// SPDX-License-Identifier: BSD-3-Clause +pragma solidity ^0.8.19; + +import "../Redistribution.sol"; +import "../TestToken.sol"; +import "../interface/IPostageStamp.sol"; + +contract EchidnaStakeRegistryMock2 is IStakeRegistry { + struct Node { + bytes32 overlay; + uint8 height; + uint256 effectiveStake; + uint256 lastUpdated; + bool exists; + } + + mapping(address => Node) internal nodes; + mapping(address => uint256) public freezeCount; + + function setNode(address owner, bytes32 overlay, uint8 height, uint256 effectiveStake, uint256 lastUpdated) external { + nodes[owner] = Node({ + overlay: overlay, + height: height, + effectiveStake: effectiveStake, + lastUpdated: lastUpdated, + exists: true + }); + } + + function freezeDeposit(address _owner, uint256 _time) external { + if (!nodes[_owner].exists) return; + freezeCount[_owner] += 1; + nodes[_owner].lastUpdated = block.number + _time; + } + + function lastUpdatedBlockNumberOfAddress(address _owner) external view returns (uint256) { + return nodes[_owner].lastUpdated; + } + + function overlayOfAddress(address _owner) external view returns (bytes32) { + return nodes[_owner].overlay; + } + + function heightOfAddress(address _owner) external view returns (uint8) { + return nodes[_owner].height; + } + + function nodeEffectiveStake(address _owner) external view returns (uint256) { + return nodes[_owner].effectiveStake; + } +} + +contract EchidnaPriceOracleMock2 is IPriceOracle { + uint256 public calls; + uint16 public lastRedundancy; + + function adjustPrice(uint16 redundancy) external returns (bool) { + calls += 1; + lastRedundancy = redundancy; + return true; + } +} + +contract EchidnaPostageStampPotMock is IPostageStamp { + TestToken internal immutable token; + + uint256 public pot; + uint256 public withdrawCalls; + address public lastBeneficiary; + uint256 public lastAmount; + uint256 public validChunkCountValue; + + constructor(TestToken t) { + token = t; + } + + function seedPot(uint256 amount) external { + // Mint to this mock and treat it as withdrawable pot. + token.mint(address(this), amount); + pot += amount; + } + + function setValidChunkCount(uint256 v) external { + validChunkCountValue = v; + } + + function withdraw(address beneficiary) external { + uint256 bal = token.balanceOf(address(this)); + uint256 amt = pot < bal ? pot : bal; + withdrawCalls += 1; + lastBeneficiary = beneficiary; + lastAmount = amt; + pot = 0; + if (amt > 0) { + token.transfer(beneficiary, amt); + } + } + + // Unused in this claim-stub harness but required by the interface. + function setPrice(uint256) external {} + function validChunkCount() external view returns (uint256) { return validChunkCountValue; } + function batchOwner(bytes32) external pure returns (address) { return address(0); } + function batchDepth(bytes32) external pure returns (uint8) { return 0; } + function batchBucketDepth(bytes32) external pure returns (uint8) { return 0; } + function remainingBalance(bytes32) external pure returns (uint256) { return 0; } + function minimumInitialBalancePerChunk() external pure returns (uint256) { return 0; } + function batches(bytes32) + external + pure + returns (address owner, uint8 depth, uint8 bucketDepth, bool immutableFlag, uint256 normalisedBalance, uint256 lastUpdatedBlockNumber) + { + return (address(0), 0, 0, false, 0, 0); + } +} + +contract RedistributionClaimStub is Redistribution { + constructor(address staking, address postageContract, address oracleContract) Redistribution(staking, postageContract, oracleContract) {} + + /// @notice Fuzz-only claim: run real winnerSelection(), then withdraw pot to winner. + /// @dev Bypasses inclusion/SOC/stamp proof verification entirely. + function claimStub() external whenNotPaused { + winnerSelection(); + Reveal memory winnerSelected = winner; + + (bool success, ) = address(PostageContract).call(abi.encodeWithSignature("withdraw(address)", winnerSelected.owner)); + if (!success) { + emit WithdrawFailed(winnerSelected.owner); + } + + emit WinnerSelected(winnerSelected); + emit ChunkCount(PostageContract.validChunkCount()); + } +} + +contract EchidnaRedistributionClaimActor { + RedistributionClaimStub internal immutable redist; + + constructor(RedistributionClaimStub r) { + redist = r; + } + + function callCommit(bytes32 obfuscatedHash, uint64 roundNumber) external returns (bool ok) { + (ok, ) = address(redist).call(abi.encodeWithSelector(redist.commit.selector, obfuscatedHash, roundNumber)); + } + + function callReveal(uint8 depth, bytes32 hash, bytes32 nonce) external returns (bool ok) { + (ok, ) = address(redist).call(abi.encodeWithSelector(redist.reveal.selector, depth, hash, nonce)); + } + + function callClaimStub() external returns (bool ok) { + (ok, ) = address(redist).call(abi.encodeWithSelector(redist.claimStub.selector)); + } +} + +/// @notice Harness to fuzz commit→reveal→claim-withdraw end-to-end (without proof verification). +contract EchidnaRedistributionClaimHarness { + uint256 internal constant ACTOR_COUNT = 3; + uint256 internal constant ROUND_LENGTH = 152; + + TestToken internal immutable token; + EchidnaStakeRegistryMock2 internal immutable stakeMock; + EchidnaPostageStampPotMock internal immutable stampMock; + EchidnaPriceOracleMock2 internal immutable oracleMock; + RedistributionClaimStub internal immutable redist; + + EchidnaRedistributionClaimActor[3] internal actors; + + // Track a "happy-path" preimage so reveal/claim can actually succeed. + bool[3] internal trackedHasCommit; + bool[3] internal trackedHasReveal; + uint64[3] internal trackedRound; + bytes32[3] internal trackedObfuscated; + bytes32[3] internal trackedHash; + bytes32[3] internal trackedNonce; + uint8[3] internal trackedDepth; + + // Pending claim postconditions. + bool internal pendingClaim; + uint64 internal pendingClaimRound; + uint256 internal pendingPotBefore; + uint256 internal pendingOracleCallsBefore; + uint256[3] internal pendingActorBalBefore; + + // Flags. + bool internal claimSucceededTwiceSameRound; + uint64 internal lastClaimRound; + + constructor() { + token = new TestToken("TestToken", "TT", 0); + stakeMock = new EchidnaStakeRegistryMock2(); + stampMock = new EchidnaPostageStampPotMock(token); + oracleMock = new EchidnaPriceOracleMock2(); + redist = new RedistributionClaimStub(address(stakeMock), address(stampMock), address(oracleMock)); + + for (uint256 i = 0; i < ACTOR_COUNT; i++) { + actors[i] = new EchidnaRedistributionClaimActor(redist); + // Seed eligible stake; lastUpdated=1 ensures it will become "2 rounds old" later. + stakeMock.setNode(address(actors[i]), bytes32(uint256(i + 1)), 0, 1e18, 1); + } + } + + // ----------------------------- + // Actions + // ----------------------------- + + function act_seedPot(uint256 amount) external { + uint256 x = amount % 1e24; + if (x == 0) x = 1e18; + stampMock.seedPot(x); + } + + function act_setActorNode(uint8 actorId, bytes32 overlay, uint8 height, uint256 effectiveStake, uint256 lastUpdated) external { + uint256 idx = uint256(actorId) % ACTOR_COUNT; + uint8 h = uint8(height % 16); + uint256 stake = effectiveStake == 0 ? 1e18 : (effectiveStake % 1e24) + 1; + uint256 u = lastUpdated == 0 ? 1 : lastUpdated; + stakeMock.setNode(address(actors[idx]), overlay, h, stake, u); + } + + function act_happyCommit(uint8 actorId, bytes32 hash, bytes32 nonce) external { + if (!redist.currentPhaseCommit()) return; + if (block.number % ROUND_LENGTH == (ROUND_LENGTH / 4) - 1) return; + + uint256 idx = uint256(actorId) % ACTOR_COUNT; + EchidnaRedistributionClaimActor a = actors[idx]; + + // Make proximity always pass by setting depth == height (depthResponsibility=0). + bytes32 overlay = keccak256(abi.encodePacked("overlay", idx, redist.currentRoundAnchor())); + uint8 height = 0; + uint8 depth = 0; + + // Ensure staking is old enough. + stakeMock.setNode(address(a), overlay, height, 1e18, _backdateLastUpdated()); + + bytes32 obf = redist.wrapCommit(overlay, depth, hash, nonce); + bool ok = a.callCommit(obf, redist.currentRound()); + if (!ok) return; + + trackedHasCommit[idx] = true; + trackedHasReveal[idx] = false; + trackedRound[idx] = redist.currentRound(); + trackedObfuscated[idx] = obf; + trackedHash[idx] = hash; + trackedNonce[idx] = nonce; + trackedDepth[idx] = depth; + } + + function act_happyReveal(uint8 actorId) external { + if (!redist.currentPhaseReveal()) return; + + uint256 idx = uint256(actorId) % ACTOR_COUNT; + if (!trackedHasCommit[idx] || trackedHasReveal[idx]) return; + if (redist.currentRound() != trackedRound[idx]) return; + if (redist.currentCommitRound() != trackedRound[idx]) return; + + bool ok = actors[idx].callReveal(trackedDepth[idx], trackedHash[idx], trackedNonce[idx]); + if (!ok) return; + trackedHasReveal[idx] = true; + } + + function act_claimStub(uint8 actorId) external { + uint256 idx = uint256(actorId) % ACTOR_COUNT; + + pendingClaim = false; + pendingClaimRound = redist.currentRound(); + pendingOracleCallsBefore = oracleMock.calls(); + + // Snapshot pot + actor balances before claim. + pendingPotBefore = stampMock.pot(); + for (uint256 i = 0; i < ACTOR_COUNT; i++) { + pendingActorBalBefore[i] = token.balanceOf(address(actors[i])); + } + + bool ok = actors[idx].callClaimStub(); + if (!ok) return; + + if (lastClaimRound == pendingClaimRound) claimSucceededTwiceSameRound = true; + lastClaimRound = pendingClaimRound; + + pendingClaim = true; + } + + // ----------------------------- + // Properties + // ----------------------------- + + function echidna_claim_only_once_per_round() external view returns (bool) { + return !claimSucceededTwiceSameRound; + } + + function echidna_claim_withdraws_pot_to_winner_when_successful() external view returns (bool) { + if (!pendingClaim) return true; + if (redist.currentClaimRound() != pendingClaimRound) return true; // stale + + // Pot must be zeroed by our mock withdraw on success. + if (stampMock.pot() != 0) return false; + + // Beneficiary must match the winner selected by the round logic. + (, address winnerOwner, , , , ) = redist.winner(); + if (stampMock.lastBeneficiary() != winnerOwner) return false; + + // The amount transferred must match the pot snapshot (our mock mints on seedPot). + if (stampMock.lastAmount() != pendingPotBefore) return false; + + // Exactly one actor's balance should increase by lastAmount, matching the beneficiary. + uint256 increased = 0; + for (uint256 i = 0; i < ACTOR_COUNT; i++) { + uint256 afterBal = token.balanceOf(address(actors[i])); + if (afterBal != pendingActorBalBefore[i]) { + if (afterBal != pendingActorBalBefore[i] + stampMock.lastAmount()) return false; + if (address(actors[i]) != stampMock.lastBeneficiary()) return false; + increased += 1; + } + } + // If potBefore was 0, no balances should change. + if (pendingPotBefore == 0) return increased == 0; + return increased == 1; + } + + function echidna_claim_triggers_oracle_adjustPrice() external view returns (bool) { + if (!pendingClaim) return true; + if (oracleMock.calls() <= pendingOracleCallsBefore) return false; + return true; + } + + function echidna_nonrevealers_frozen_after_claim_selection() external view returns (bool) { + if (!pendingClaim) return true; + if (redist.currentClaimRound() != pendingClaimRound) return true; + + // Any actor that committed but did not reveal in that round should have been frozen at least once. + for (uint256 i = 0; i < ACTOR_COUNT; i++) { + if (!trackedHasCommit[i]) continue; + if (trackedRound[i] != pendingClaimRound) continue; + if (trackedHasReveal[i]) continue; + if (stakeMock.freezeCount(address(actors[i])) == 0) return false; + } + return true; + } + + // ----------------------------- + // Helpers + // ----------------------------- + + function _backdateLastUpdated() internal view returns (uint256) { + uint256 twoRounds = 2 * ROUND_LENGTH; + if (block.number > twoRounds + 1) return block.number - twoRounds - 1; + return 1; + } +} + From 73b21f36a0fc27d2e7c59e2b3fbc94e5af8c15be Mon Sep 17 00:00:00 2001 From: Cardinal Date: Wed, 4 Mar 2026 15:00:29 +0100 Subject: [PATCH 18/50] add economic invariants to system fuzz harness 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). --- src/echidna/EchidnaSystemHarness.sol | 31 ++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/src/echidna/EchidnaSystemHarness.sol b/src/echidna/EchidnaSystemHarness.sol index 5be47ae8..219f57ee 100644 --- a/src/echidna/EchidnaSystemHarness.sol +++ b/src/echidna/EchidnaSystemHarness.sol @@ -281,6 +281,37 @@ contract EchidnaSystemHarness { return uint32(lp) == oracle.currentPrice(); } + function echidna_oracle_price_never_below_minimum() external view returns (bool) { + // Both representations should respect the minimum. + if (oracle.currentPriceUpScaled() < oracle.minimumPriceUpscaled()) return false; + if (oracle.currentPrice() < oracle.minimumPrice()) return false; + return true; + } + + function echidna_stamp_lastPrice_never_below_oracle_minimum_when_set() external view returns (bool) { + uint64 lp = stamp.lastPrice(); + if (lp == 0) return true; + return lp >= uint64(oracle.minimumPrice()); + } + + function echidna_stake_balance_covers_sum_potential() external view returns (bool) { + uint256 sumPotential = 0; + for (uint256 i = 0; i < ACTOR_COUNT; i++) { + (, , uint256 potentialStake, , ) = stake.stakes(address(actors[i])); + sumPotential += potentialStake; + } + return token.balanceOf(address(stake)) >= sumPotential; + } + + function echidna_stamp_totalPot_never_exceeds_token_balance() external view returns (bool) { + // `totalPot()` is `min(pot, tokenBalance)` but is not `view` (it calls `expireLimited`), + // so we assert the semantic relationship in a view-safe way. + uint256 bal = token.balanceOf(address(stamp)); + uint256 p = stamp.pot(); + uint256 totalPotView = p < bal ? p : bal; + return totalPotView <= bal; + } + function echidna_tracked_redist_commit_reveal_consistent() external view returns (bool) { uint64 liveCommitRound = redist.currentCommitRound(); for (uint256 i = 0; i < ACTOR_COUNT; i++) { From ca61760645ce15aeb047d039071cff3b5586255b Mon Sep 17 00:00:00 2001 From: Cardinal Date: Thu, 5 Mar 2026 15:12:43 +0100 Subject: [PATCH 19/50] chore: format echidna harnesses Run Prettier (with Solidity plugin) to satisfy CI formatting checks. --- src/echidna/EchidnaPostageStampHarness.sol | 46 +++--- src/echidna/EchidnaPriceOracleHarness.sol | 1 - .../EchidnaRedistributionClaimHarness.sol | 64 +++++++-- src/echidna/EchidnaRedistributionHarness.sol | 135 +++++++++++++----- src/echidna/EchidnaStakeRegistryHarness.sol | 27 ++-- src/echidna/EchidnaSystemHarness.sol | 42 ++++-- 6 files changed, 230 insertions(+), 85 deletions(-) diff --git a/src/echidna/EchidnaPostageStampHarness.sol b/src/echidna/EchidnaPostageStampHarness.sol index 1fa65a5c..4dd585d1 100644 --- a/src/echidna/EchidnaPostageStampHarness.sol +++ b/src/echidna/EchidnaPostageStampHarness.sol @@ -193,9 +193,13 @@ contract EchidnaPostageStampHarness { token.transfer(address(_actor(actorId)), x); } - function act_createBatch(uint8 actorId, uint256 initialPerChunk, uint8 depthRaw, bytes32 nonce, bool immutableFlag) - external - { + function act_createBatch( + uint8 actorId, + uint256 initialPerChunk, + uint8 depthRaw, + bytes32 nonce, + bool immutableFlag + ) external { _clearPending(); uint256 potBefore = stamp.pot(); // Normalize expiry so createBatch's internal expireLimited() doesn't unexpectedly mutate other batches. @@ -418,7 +422,9 @@ contract EchidnaPostageStampHarness { } function echidna_minimumInitialBalancePerChunk_matches_formula() external view returns (bool) { - return stamp.minimumInitialBalancePerChunk() == uint256(stamp.minimumValidityBlocks()) * uint256(stamp.lastPrice()); + return + stamp.minimumInitialBalancePerChunk() == + uint256(stamp.minimumValidityBlocks()) * uint256(stamp.lastPrice()); } function echidna_lastExpiryBalance_never_exceeds_currentTotalOutPayment() external view returns (bool) { @@ -469,15 +475,19 @@ contract EchidnaPostageStampHarness { if (stamp.lastUpdatedBlock() != pendingSetPriceLastUpdatedExpected) return false; if (stamp.lastPrice() != pendingSetPriceLastPriceExpected) return false; uint256 blocksSince = block.number - uint256(pendingSetPriceLastUpdatedExpected); - uint256 expected = pendingSetPriceTotalOutPaymentBefore + uint256(pendingSetPriceLastPriceExpected) * blocksSince; + uint256 expected = pendingSetPriceTotalOutPaymentBefore + + uint256(pendingSetPriceLastPriceExpected) * + blocksSince; return stamp.currentTotalOutPayment() == expected; } function echidna_withdraw_postconditions_hold() external view returns (bool) { if (!pendingWithdraw) return true; if (stamp.pot() != 0) return false; - if (token.balanceOf(pendingWithdrawBeneficiary) != pendingWithdrawBeneficiaryBalBefore + pendingWithdrawExpectedAmount) - return false; + if ( + token.balanceOf(pendingWithdrawBeneficiary) != + pendingWithdrawBeneficiaryBalBefore + pendingWithdrawExpectedAmount + ) return false; return token.balanceOf(address(stamp)) == pendingWithdrawStampBalBefore - pendingWithdrawExpectedAmount; } @@ -506,16 +516,17 @@ contract EchidnaPostageStampHarness { function _batchDigest(bytes32 batchId) internal view returns (bytes32) { address owner = stamp.batchOwner(batchId); if (owner == address(0)) return bytes32(0); - return keccak256( - abi.encodePacked( - owner, - stamp.batchDepth(batchId), - stamp.batchBucketDepth(batchId), - stamp.batchImmutableFlag(batchId), - stamp.batchNormalisedBalance(batchId), - stamp.batchLastUpdatedBlockNumber(batchId) - ) - ); + return + keccak256( + abi.encodePacked( + owner, + stamp.batchDepth(batchId), + stamp.batchBucketDepth(batchId), + stamp.batchImmutableFlag(batchId), + stamp.batchNormalisedBalance(batchId), + stamp.batchLastUpdatedBlockNumber(batchId) + ) + ); } function _armNonInterference(uint8 batchIndex, bytes32 target) internal { @@ -598,4 +609,3 @@ contract EchidnaPostageStampHarness { pendingCreate = true; } } - diff --git a/src/echidna/EchidnaPriceOracleHarness.sol b/src/echidna/EchidnaPriceOracleHarness.sol index 2a00d1c8..23e4f213 100644 --- a/src/echidna/EchidnaPriceOracleHarness.sol +++ b/src/echidna/EchidnaPriceOracleHarness.sol @@ -319,4 +319,3 @@ contract EchidnaPriceOracleHarness { pendingAdjust = false; } } - diff --git a/src/echidna/EchidnaRedistributionClaimHarness.sol b/src/echidna/EchidnaRedistributionClaimHarness.sol index afeac7db..ee69edc8 100644 --- a/src/echidna/EchidnaRedistributionClaimHarness.sol +++ b/src/echidna/EchidnaRedistributionClaimHarness.sol @@ -17,7 +17,13 @@ contract EchidnaStakeRegistryMock2 is IStakeRegistry { mapping(address => Node) internal nodes; mapping(address => uint256) public freezeCount; - function setNode(address owner, bytes32 overlay, uint8 height, uint256 effectiveStake, uint256 lastUpdated) external { + function setNode( + address owner, + bytes32 overlay, + uint8 height, + uint256 effectiveStake, + uint256 lastUpdated + ) external { nodes[owner] = Node({ overlay: overlay, height: height, @@ -98,23 +104,48 @@ contract EchidnaPostageStampPotMock is IPostageStamp { // Unused in this claim-stub harness but required by the interface. function setPrice(uint256) external {} - function validChunkCount() external view returns (uint256) { return validChunkCountValue; } - function batchOwner(bytes32) external pure returns (address) { return address(0); } - function batchDepth(bytes32) external pure returns (uint8) { return 0; } - function batchBucketDepth(bytes32) external pure returns (uint8) { return 0; } - function remainingBalance(bytes32) external pure returns (uint256) { return 0; } - function minimumInitialBalancePerChunk() external pure returns (uint256) { return 0; } - function batches(bytes32) + function validChunkCount() external view returns (uint256) { + return validChunkCountValue; + } + function batchOwner(bytes32) external pure returns (address) { + return address(0); + } + function batchDepth(bytes32) external pure returns (uint8) { + return 0; + } + function batchBucketDepth(bytes32) external pure returns (uint8) { + return 0; + } + function remainingBalance(bytes32) external pure returns (uint256) { + return 0; + } + function minimumInitialBalancePerChunk() external pure returns (uint256) { + return 0; + } + function batches( + bytes32 + ) external pure - returns (address owner, uint8 depth, uint8 bucketDepth, bool immutableFlag, uint256 normalisedBalance, uint256 lastUpdatedBlockNumber) + returns ( + address owner, + uint8 depth, + uint8 bucketDepth, + bool immutableFlag, + uint256 normalisedBalance, + uint256 lastUpdatedBlockNumber + ) { return (address(0), 0, 0, false, 0, 0); } } contract RedistributionClaimStub is Redistribution { - constructor(address staking, address postageContract, address oracleContract) Redistribution(staking, postageContract, oracleContract) {} + constructor( + address staking, + address postageContract, + address oracleContract + ) Redistribution(staking, postageContract, oracleContract) {} /// @notice Fuzz-only claim: run real winnerSelection(), then withdraw pot to winner. /// @dev Bypasses inclusion/SOC/stamp proof verification entirely. @@ -122,7 +153,9 @@ contract RedistributionClaimStub is Redistribution { winnerSelection(); Reveal memory winnerSelected = winner; - (bool success, ) = address(PostageContract).call(abi.encodeWithSignature("withdraw(address)", winnerSelected.owner)); + (bool success, ) = address(PostageContract).call( + abi.encodeWithSignature("withdraw(address)", winnerSelected.owner) + ); if (!success) { emit WithdrawFailed(winnerSelected.owner); } @@ -209,7 +242,13 @@ contract EchidnaRedistributionClaimHarness { stampMock.seedPot(x); } - function act_setActorNode(uint8 actorId, bytes32 overlay, uint8 height, uint256 effectiveStake, uint256 lastUpdated) external { + function act_setActorNode( + uint8 actorId, + bytes32 overlay, + uint8 height, + uint256 effectiveStake, + uint256 lastUpdated + ) external { uint256 idx = uint256(actorId) % ACTOR_COUNT; uint8 h = uint8(height % 16); uint256 stake = effectiveStake == 0 ? 1e18 : (effectiveStake % 1e24) + 1; @@ -347,4 +386,3 @@ contract EchidnaRedistributionClaimHarness { return 1; } } - diff --git a/src/echidna/EchidnaRedistributionHarness.sol b/src/echidna/EchidnaRedistributionHarness.sol index 0b4480d3..4d6f9b67 100644 --- a/src/echidna/EchidnaRedistributionHarness.sol +++ b/src/echidna/EchidnaRedistributionHarness.sol @@ -17,7 +17,13 @@ contract EchidnaStakeRegistryMock is IStakeRegistry { mapping(address => uint256) public freezeCount; mapping(address => uint256) public lastFreezeTime; - function setNode(address owner, bytes32 overlay, uint8 height, uint256 effectiveStake, uint256 lastUpdated) external { + function setNode( + address owner, + bytes32 overlay, + uint8 height, + uint256 effectiveStake, + uint256 lastUpdated + ) external { nodes[owner] = Node({ overlay: overlay, height: height, @@ -130,7 +136,14 @@ contract EchidnaPostageStampMock is IPostageStamp { ) external view - returns (address owner, uint8 depth, uint8 bucketDepth, bool immutableFlag, uint256 normalisedBalance, uint256 lastUpdatedBlockNumber) + returns ( + address owner, + uint8 depth, + uint8 bucketDepth, + bool immutableFlag, + uint256 normalisedBalance, + uint256 lastUpdatedBlockNumber + ) { Batch memory b = _batches[id]; return (b.owner, b.depth, b.bucketDepth, b.immutableFlag, b.normalisedBalance, b.lastUpdatedBlockNumber); @@ -138,7 +151,11 @@ contract EchidnaPostageStampMock is IPostageStamp { } contract RedistributionExposed is Redistribution { - constructor(address staking, address postageContract, address oracleContract) Redistribution(staking, postageContract, oracleContract) {} + constructor( + address staking, + address postageContract, + address oracleContract + ) Redistribution(staking, postageContract, oracleContract) {} function exposedWinnerSelection() external { winnerSelection(); @@ -268,9 +285,13 @@ contract EchidnaRedistributionHarness { // Actions // ----------------------------- - function act_setActorStake(uint8 actorId, bytes32 overlay, uint8 height, uint256 effectiveStake, uint256 lastUpdated) - external - { + function act_setActorStake( + uint8 actorId, + bytes32 overlay, + uint8 height, + uint256 effectiveStake, + uint256 lastUpdated + ) external { EchidnaRedistributionActor a = actors[uint256(actorId) % ACTOR_COUNT]; // Bound height so 2**depthResponsibility doesn't explode too hard during reveal. uint8 h = uint8(height % 16); @@ -339,7 +360,13 @@ contract EchidnaRedistributionHarness { // Advanced actions (aim for successful commit/reveal) // ----------------------------- - function act_happyCommit(uint8 actorId, uint8 height, uint256 stakeAmount, bytes32 reserveHash, bytes32 nonce) external { + function act_happyCommit( + uint8 actorId, + uint8 height, + uint256 stakeAmount, + bytes32 reserveHash, + bytes32 nonce + ) external { if (redist.paused()) return; if (!redist.currentPhaseCommit()) return; // Avoid the "phase last block" restriction in commit phase. @@ -393,7 +420,13 @@ contract EchidnaRedistributionHarness { EchidnaRedistributionActor a = actors[idx]; // Ensure the actor's overlay/height match the committed values. - stakeMock.setNode(address(a), trackedOverlay[idx], trackedHeight[idx], trackedStake[idx], _backdateLastUpdated()); + stakeMock.setNode( + address(a), + trackedOverlay[idx], + trackedHeight[idx], + trackedStake[idx], + _backdateLastUpdated() + ); bool ok = a.callReveal(trackedDepth[idx], trackedReserveHash[idx], trackedNonce[idx]); if (!ok) return; @@ -430,7 +463,13 @@ contract EchidnaRedistributionHarness { if (redist.currentCommitRound() != trackedRound[idx]) return; EchidnaRedistributionActor a = actors[idx]; - stakeMock.setNode(address(a), trackedOverlay[idx], trackedHeight[idx], trackedStake[idx], _backdateLastUpdated()); + stakeMock.setNode( + address(a), + trackedOverlay[idx], + trackedHeight[idx], + trackedStake[idx], + _backdateLastUpdated() + ); bool ok = a.callReveal(trackedDepth[idx], trackedReserveHash[idx], trackedNonce[idx]); if (ok) revealSucceededWhilePaused = true; } @@ -443,10 +482,14 @@ contract EchidnaRedistributionHarness { pendingWinnerSelectionRound = redist.currentRound(); for (uint256 i = 0; i < 25; i++) { - (bool ok, bytes memory data) = address(redist).staticcall(abi.encodeWithSignature("currentCommits(uint256)", i)); + (bool ok, bytes memory data) = address(redist).staticcall( + abi.encodeWithSignature("currentCommits(uint256)", i) + ); if (!ok) break; - (bytes32 ov, address ow, bool rev, uint8 h, uint256 st, bytes32 obf, uint256 ri) = - abi.decode(data, (bytes32, address, bool, uint8, uint256, bytes32, uint256)); + (bytes32 ov, address ow, bool rev, uint8 h, uint256 st, bytes32 obf, uint256 ri) = abi.decode( + data, + (bytes32, address, bool, uint8, uint256, bytes32, uint256) + ); ov; h; st; @@ -497,7 +540,9 @@ contract EchidnaRedistributionHarness { bool found = false; for (uint256 j = 0; j < 25; j++) { - (bool okC, bytes32 cOverlay, address cOwner, bool cRevealed, uint256 cRevealIndex) = _commitRevealLink(j); + (bool okC, bytes32 cOverlay, address cOwner, bool cRevealed, uint256 cRevealIndex) = _commitRevealLink( + j + ); if (!okC) break; if (cRevealed && cRevealIndex == i && cOverlay == rOverlay && cOwner == rOwner) { found = true; @@ -547,7 +592,13 @@ contract EchidnaRedistributionHarness { } function echidna_revealed_commit_indices_valid() external view returns (bool) { - (uint256 cN, , uint256[25] memory revealIndex, bool[25] memory revealed, address[25] memory owner) = _scanCommits(); + ( + uint256 cN, + , + uint256[25] memory revealIndex, + bool[25] memory revealed, + address[25] memory owner + ) = _scanCommits(); uint256 rN = _scanRevealsLen(); for (uint256 i = 0; i < cN; i++) { if (!revealed[i]) continue; @@ -576,16 +627,9 @@ contract EchidnaRedistributionHarness { (bool ok, uint256 commitIdx) = _findCommit(trackedOverlay[i], trackedObfuscated[i]); if (!ok) return false; - ( - , - bytes32 ov, - address ow, - , - uint8 h, - uint256 stake, - bytes32 obf, - /* revealIndex */ - ) = _commitFull(commitIdx); + (, bytes32 ov, address ow, , uint8 h, uint256 stake, bytes32 obf /* revealIndex */, ) = _commitFull( + commitIdx + ); if (ov != trackedOverlay[i]) return false; if (obf != trackedObfuscated[i]) return false; @@ -613,7 +657,13 @@ contract EchidnaRedistributionHarness { function _scanCommits() internal view - returns (uint256 n, bytes32[25] memory overlays, uint256[25] memory revealIndex, bool[25] memory revealed, address[25] memory owner) + returns ( + uint256 n, + bytes32[25] memory overlays, + uint256[25] memory revealIndex, + bool[25] memory revealed, + address[25] memory owner + ) { for (uint256 i = 0; i < 25; i++) { (bool ok, bytes32 ov, address ow, bool rev, uint256 ri) = _commitFields(i); @@ -644,12 +694,13 @@ contract EchidnaRedistributionHarness { bytes memory data; (ok, data) = address(redist).staticcall(abi.encodeWithSignature("currentCommits(uint256)", i)); if (!ok) return (false, bytes32(0), address(0), false, 0); - (overlay, owner, revealed, , , , revealIndex) = abi.decode(data, (bytes32, address, bool, uint8, uint256, bytes32, uint256)); + (overlay, owner, revealed, , , , revealIndex) = abi.decode( + data, + (bytes32, address, bool, uint8, uint256, bytes32, uint256) + ); } - function _commitFields( - uint256 i - ) internal view returns (bool ok, bytes32 ov, address ow, bool rev, uint256 ri) { + function _commitFields(uint256 i) internal view returns (bool ok, bytes32 ov, address ow, bool rev, uint256 ri) { bytes memory data; (ok, data) = address(redist).staticcall(abi.encodeWithSignature("currentCommits(uint256)", i)); if (!ok) return (false, bytes32(0), address(0), false, 0); @@ -686,17 +737,34 @@ contract EchidnaRedistributionHarness { bytes memory data; (ok, data) = address(redist).staticcall(abi.encodeWithSignature("currentCommits(uint256)", i)); if (!ok) return (false, bytes32(0), address(0), false, 0, 0, bytes32(0), 0); - (overlay, owner, revealed, height, stake, obfuscatedHash, revealIndex) = - abi.decode(data, (bytes32, address, bool, uint8, uint256, bytes32, uint256)); + (overlay, owner, revealed, height, stake, obfuscatedHash, revealIndex) = abi.decode( + data, + (bytes32, address, bool, uint8, uint256, bytes32, uint256) + ); } function _revealFull( uint256 i - ) internal view returns (bool ok, bytes32 overlay, address owner, uint8 depth, uint256 stake, uint256 stakeDensity, bytes32 hash) { + ) + internal + view + returns ( + bool ok, + bytes32 overlay, + address owner, + uint8 depth, + uint256 stake, + uint256 stakeDensity, + bytes32 hash + ) + { bytes memory data; (ok, data) = address(redist).staticcall(abi.encodeWithSignature("currentReveals(uint256)", i)); if (!ok) return (false, bytes32(0), address(0), 0, 0, 0, bytes32(0)); - (overlay, owner, depth, stake, stakeDensity, hash) = abi.decode(data, (bytes32, address, uint8, uint256, uint256, bytes32)); + (overlay, owner, depth, stake, stakeDensity, hash) = abi.decode( + data, + (bytes32, address, uint8, uint256, uint256, bytes32) + ); } function _checkTrackedReveal(uint256 actorIdx) internal view returns (bool) { @@ -764,4 +832,3 @@ contract EchidnaRedistributionHarness { return s; } } - diff --git a/src/echidna/EchidnaStakeRegistryHarness.sol b/src/echidna/EchidnaStakeRegistryHarness.sol index 82778d20..481bcfbe 100644 --- a/src/echidna/EchidnaStakeRegistryHarness.sol +++ b/src/echidna/EchidnaStakeRegistryHarness.sol @@ -27,7 +27,9 @@ contract EchidnaStakeActor { } function manageStake(bytes32 setNonce, uint256 addAmount, uint8 height) external returns (bool ok) { - (ok, ) = address(registry).call(abi.encodeWithSelector(registry.manageStake.selector, setNonce, addAmount, height)); + (ok, ) = address(registry).call( + abi.encodeWithSelector(registry.manageStake.selector, setNonce, addAmount, height) + ); } function withdrawFromStake() external returns (bool ok) { @@ -376,8 +378,13 @@ contract EchidnaStakeRegistryHarness { EchidnaStakeActor t = actors[idx]; (bytes32 otherDigestA, bytes32 otherDigestB) = _otherDigests(idx); - (pendingFreezeOverlay, pendingFreezeCommitted, pendingFreezePotential, pendingFreezeExpectedLastUpdated, pendingFreezeHeight) = registry - .stakes(address(t)); + ( + pendingFreezeOverlay, + pendingFreezeCommitted, + pendingFreezePotential, + pendingFreezeExpectedLastUpdated, + pendingFreezeHeight + ) = registry.stakes(address(t)); pendingFreezeIdx = idx; pendingFreezeHadStake = pendingFreezeExpectedLastUpdated != 0; pendingFreezeExpectedLastUpdated = pendingFreezeHadStake ? block.number + uint256(time) : 0; @@ -396,8 +403,13 @@ contract EchidnaStakeRegistryHarness { EchidnaStakeActor t = actors[idx]; (bytes32 otherDigestA, bytes32 otherDigestB) = _otherDigests(idx); - (pendingSlashOverlay, pendingSlashCommitted, pendingSlashPotential, pendingSlashLastUpdated, pendingSlashHeight) = registry - .stakes(address(t)); + ( + pendingSlashOverlay, + pendingSlashCommitted, + pendingSlashPotential, + pendingSlashLastUpdated, + pendingSlashHeight + ) = registry.stakes(address(t)); pendingSlashIdx = idx; pendingSlashHadStake = pendingSlashLastUpdated != 0; pendingSlashAmount = amount; @@ -619,9 +631,8 @@ contract EchidnaStakeRegistryHarness { } function _stakeDigest(address who) internal view returns (bytes32) { - (bytes32 overlay, uint256 committedStake, uint256 potentialStake, uint256 lastUpdated, uint8 h) = registry.stakes( - who - ); + (bytes32 overlay, uint256 committedStake, uint256 potentialStake, uint256 lastUpdated, uint8 h) = registry + .stakes(who); return keccak256(abi.encodePacked(overlay, committedStake, potentialStake, lastUpdated, h)); } diff --git a/src/echidna/EchidnaSystemHarness.sol b/src/echidna/EchidnaSystemHarness.sol index 219f57ee..5f537e80 100644 --- a/src/echidna/EchidnaSystemHarness.sol +++ b/src/echidna/EchidnaSystemHarness.sol @@ -42,7 +42,15 @@ contract EchidnaSystemActor { bool immutableFlag ) external returns (bool ok) { (ok, ) = address(stamp).call( - abi.encodeWithSelector(stamp.createBatch.selector, owner, initialBalancePerChunk, depth, bucketDepth, nonce, immutableFlag) + abi.encodeWithSelector( + stamp.createBatch.selector, + owner, + initialBalancePerChunk, + depth, + bucketDepth, + nonce, + immutableFlag + ) ); } @@ -147,9 +155,14 @@ contract EchidnaSystemHarness { a.callWithdrawFromStake(); } - function act_actor_createBatch(uint8 actorId, uint256 initialBalancePerChunk, uint8 depth, uint8 bucketDepth, bytes32 nonce, bool imm) - external - { + function act_actor_createBatch( + uint8 actorId, + uint256 initialBalancePerChunk, + uint8 depth, + uint8 bucketDepth, + bytes32 nonce, + bool imm + ) external { EchidnaSystemActor a = actors[uint256(actorId) % ACTOR_COUNT]; uint8 d = uint8((depth % 12) + 1); // avoid huge shifts, avoid 0 uint8 b = uint8(bucketDepth % d); @@ -345,10 +358,14 @@ contract EchidnaSystemHarness { function _commitExists(bytes32 obfuscated, address owner) internal view returns (bool) { // currentCommits is deleted each new commit round; bounded scan to avoid unbounded loops. for (uint256 i = 0; i < 25; i++) { - (bool ok, bytes memory data) = address(redist).staticcall(abi.encodeWithSignature("currentCommits(uint256)", i)); + (bool ok, bytes memory data) = address(redist).staticcall( + abi.encodeWithSignature("currentCommits(uint256)", i) + ); if (!ok) break; - (bytes32 ov, address ow, bool rev, uint8 h, uint256 st, bytes32 obf, uint256 ri) = - abi.decode(data, (bytes32, address, bool, uint8, uint256, bytes32, uint256)); + (bytes32 ov, address ow, bool rev, uint8 h, uint256 st, bytes32 obf, uint256 ri) = abi.decode( + data, + (bytes32, address, bool, uint8, uint256, bytes32, uint256) + ); ov; rev; h; @@ -361,10 +378,14 @@ contract EchidnaSystemHarness { function _revealMatchesCommit(address owner, uint8 depth, bytes32 hash) internal view returns (bool) { for (uint256 i = 0; i < 25; i++) { - (bool ok, bytes memory data) = address(redist).staticcall(abi.encodeWithSignature("currentReveals(uint256)", i)); + (bool ok, bytes memory data) = address(redist).staticcall( + abi.encodeWithSignature("currentReveals(uint256)", i) + ); if (!ok) break; - (bytes32 ov, address ow, uint8 d, uint256 st, uint256 sd, bytes32 h) = - abi.decode(data, (bytes32, address, uint8, uint256, uint256, bytes32)); + (bytes32 ov, address ow, uint8 d, uint256 st, uint256 sd, bytes32 h) = abi.decode( + data, + (bytes32, address, uint8, uint256, uint256, bytes32) + ); ov; st; sd; @@ -373,4 +394,3 @@ contract EchidnaSystemHarness { return false; } } - From 45cab194e87cd9e20e077a29c46cbdbdede445c2 Mon Sep 17 00:00:00 2001 From: Cardinal Date: Tue, 7 Apr 2026 17:20:48 +0200 Subject: [PATCH 20/50] fix(echidna): address PR #306 review (runner, README, harness hygiene) - 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 --- README.md | 17 +----- echidna/README.md | 1 + scripts/echidna.sh | 10 ++-- src/echidna/EchidnaPriceOracleHarness.sol | 59 +++++++++++--------- src/echidna/EchidnaRedistributionHarness.sol | 24 +++++++- src/echidna/EchidnaSystemHarness.sol | 11 ++-- 6 files changed, 64 insertions(+), 58 deletions(-) diff --git a/README.md b/README.md index ce86e9ba..4a600352 100644 --- a/README.md +++ b/README.md @@ -103,27 +103,12 @@ To get started with this project, follow these steps: ## Fuzz testing (Echidna) -This repo includes a **small Echidna harness** that fuzzes `TestToken` + `StakeRegistry` with a few basic invariants. - -- **Harness contract**: `src/echidna/EchidnaStakeRegistryHarness.sol` -- **Echidna config**: `echidna/echidna.yaml` - -### Run (Docker) - -1. Install a Docker runtime (e.g. Docker Desktop). -2. Run: +Harness layout, properties, and troubleshooting are documented in [echidna/README.md](./echidna/README.md). Run (Docker required): ```bash yarn echidna ``` -### What this is doing - -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 -- **Stake properties**: committed stake never decreases after a successful update; commitment stays consistent with potential stake (given a constant oracle price) - ## Run ### [Tests](./test) diff --git a/echidna/README.md b/echidna/README.md index 44b0bf64..112a1d02 100644 --- a/echidna/README.md +++ b/echidna/README.md @@ -183,6 +183,7 @@ High-signal properties per harness: - **System/integration harness** - Wiring invariants: correct addresses + roles across contracts - Oracle↔stamp invariant: `PostageStamp.lastPrice` tracks `PriceOracle.currentPrice()` after updates + - Stamp accounting: internal `pot` does not exceed the stamp contract’s BZZ balance (`echidna_stamp_internal_pot_not_above_contract_balance`) - Redistribution happy-path consistency: tracked commit/reveal values appear in `Redistribution` storage These are “sanity properties”: they’re meant to detect obvious bugs and unintended state corruption early. diff --git a/scripts/echidna.sh b/scripts/echidna.sh index d70e8702..2f1575c4 100755 --- a/scripts/echidna.sh +++ b/scripts/echidna.sh @@ -35,14 +35,12 @@ fi for c in "${CONTRACTS_TO_RUN[@]}"; do echo "==> echidna: running contract $c" >&2 - # Avoid stale Crytic compile artifacts causing old properties/tests to run. - rm -rf crytic-export - + # Drop stale Crytic output inside Docker (same uid as container root). A host + # `rm -rf crytic-export` often fails after Docker created the dir as root. docker run --rm \ + --entrypoint sh \ -v "$ROOT_DIR":/src \ -w /src \ "$IMAGE" \ - echidna-test . \ - --contract "$c" \ - --config echidna/echidna.yaml + -c "rm -rf crytic-export && echidna-test . --contract ${c} --config echidna/echidna.yaml" done diff --git a/src/echidna/EchidnaPriceOracleHarness.sol b/src/echidna/EchidnaPriceOracleHarness.sol index 23e4f213..e9c32d23 100644 --- a/src/echidna/EchidnaPriceOracleHarness.sol +++ b/src/echidna/EchidnaPriceOracleHarness.sol @@ -98,19 +98,17 @@ contract EchidnaPriceOracleHarness { function act_admin_setPrice(uint32 p) external { _clearPending(); - pendingStampCallsBefore = stamp.setPriceCalls(); + uint256 callsBefore = stamp.setPriceCalls(); + (bool ok, bool returned) = _callSetPriceAsAdmin(p); - uint64 minUp = oracle.minimumPriceUpscaled(); - uint64 expected = uint64(p) << 10; - if (expected < minUp) expected = minUp; + // Do not arm postconditions unless the call fully succeeded; otherwise + // `pendingSetPrice` would not match what the contract actually did (e.g. stamp callback failed). + if (!ok || !returned) return; - pendingExpectedUpScaled = expected; + pendingStampCallsBefore = callsBefore; + pendingExpectedUpScaled = oracle.currentPriceUpScaled(); pendingStampShouldCall = !stamp.shouldRevert(); pendingSetPrice = true; - - (bool ok, bool returned) = _callSetPriceAsAdmin(p); - ok; - returned; } function act_admin_pause() external { @@ -134,12 +132,13 @@ contract EchidnaPriceOracleHarness { (bool ok, bool returned) = updater.callAdjustPrice(redundancy); if (pendingAdjustPaused) { - // If paused, adjustPrice must not change state. - if (oracle.currentPriceUpScaled() != pendingPriceBefore) pausedAdjustChangedState = true; - if (oracle.lastAdjustedRound() != pendingLastAdjustedBefore) pausedAdjustChangedState = true; - if (stamp.setPriceCalls() != pendingAdjustStampCallsBefore) pausedAdjustChangedState = true; - ok; - returned; + if ( + oracle.currentPriceUpScaled() != pendingPriceBefore || + oracle.lastAdjustedRound() != pendingLastAdjustedBefore || + stamp.setPriceCalls() != pendingAdjustStampCallsBefore + ) { + pausedAdjustChangedState = true; + } return; } @@ -148,13 +147,14 @@ contract EchidnaPriceOracleHarness { bool wouldRevertEarly = (redundancy == 0) || (currentRound <= pendingLastAdjustedBefore); if (wouldRevertEarly) { - // If it unexpectedly succeeded, that's a bug in either model or contract. if (ok) adjustWouldOverflowButSucceeded = true; - // A revert should not change state. - if (oracle.currentPriceUpScaled() != pendingPriceBefore) pausedAdjustChangedState = true; - if (oracle.lastAdjustedRound() != pendingLastAdjustedBefore) pausedAdjustChangedState = true; - if (stamp.setPriceCalls() != pendingAdjustStampCallsBefore) pausedAdjustChangedState = true; - returned; + if ( + oracle.currentPriceUpScaled() != pendingPriceBefore || + oracle.lastAdjustedRound() != pendingLastAdjustedBefore || + stamp.setPriceCalls() != pendingAdjustStampCallsBefore + ) { + pausedAdjustChangedState = true; + } return; } @@ -168,15 +168,11 @@ contract EchidnaPriceOracleHarness { if (!canCompute) { if (ok && returned) adjustWouldOverflowButSucceeded = true; - returned; return; } - // If we can compute a valid expected result, the call should not revert. if (!ok) { - // Don't arm pendingAdjust (it would fail postconditions); just flag the mismatch. adjustWouldOverflowButSucceeded = true; - returned; return; } @@ -184,8 +180,6 @@ contract EchidnaPriceOracleHarness { pendingExpectedUpScaledAfter = expected; pendingAdjustStampShouldCall = !stamp.shouldRevert(); pendingAdjust = true; - - returned; } function act_rando_tryAdjustPrice(uint16 redundancy) external { @@ -317,5 +311,16 @@ contract EchidnaPriceOracleHarness { function _clearPending() internal { pendingSetPrice = false; pendingAdjust = false; + pendingExpectedUpScaled = 0; + pendingStampCallsBefore = 0; + pendingStampShouldCall = false; + pendingAdjustPaused = false; + pendingPriceBefore = 0; + pendingLastAdjustedBefore = 0; + pendingExpectedLastAdjustedAfter = 0; + pendingExpectedUpScaledAfter = 0; + pendingAdjustStampCallsBefore = 0; + pendingAdjustStampShouldCall = false; + adjustWouldOverflowButSucceeded = false; } } diff --git a/src/echidna/EchidnaRedistributionHarness.sol b/src/echidna/EchidnaRedistributionHarness.sol index 4d6f9b67..fd884b38 100644 --- a/src/echidna/EchidnaRedistributionHarness.sol +++ b/src/echidna/EchidnaRedistributionHarness.sol @@ -281,6 +281,11 @@ contract EchidnaRedistributionHarness { } } + function _clearWinnerSelectionPending() internal { + pendingWinnerSelection = false; + pendingWinnerSelectionLen = 0; + } + // ----------------------------- // Actions // ----------------------------- @@ -292,6 +297,7 @@ contract EchidnaRedistributionHarness { uint256 effectiveStake, uint256 lastUpdated ) external { + _clearWinnerSelectionPending(); EchidnaRedistributionActor a = actors[uint256(actorId) % ACTOR_COUNT]; // Bound height so 2**depthResponsibility doesn't explode too hard during reveal. uint8 h = uint8(height % 16); @@ -302,6 +308,7 @@ contract EchidnaRedistributionHarness { } function act_commit(uint8 actorId, bytes32 obfuscatedHash, int8 roundDelta) external { + _clearWinnerSelectionPending(); EchidnaRedistributionActor a = actors[uint256(actorId) % ACTOR_COUNT]; uint64 cr = redist.currentRound(); uint64 rn = cr; @@ -311,47 +318,57 @@ contract EchidnaRedistributionHarness { } function act_reveal(uint8 actorId, uint8 depth, bytes32 hash, bytes32 nonce) external { + _clearWinnerSelectionPending(); EchidnaRedistributionActor a = actors[uint256(actorId) % ACTOR_COUNT]; a.callReveal(uint8(depth % 32), hash, nonce); } function act_claim(uint8 actorId) external { + _clearWinnerSelectionPending(); EchidnaRedistributionActor a = actors[uint256(actorId) % ACTOR_COUNT]; a.callClaim(); } function act_admin_pause() external { + _clearWinnerSelectionPending(); redist.pause(); } function act_admin_unpause() external { + _clearWinnerSelectionPending(); redist.unPause(); } function act_admin_setSampleMaxValue(uint256 v) external { + _clearWinnerSelectionPending(); redist.setSampleMaxValue(v); } function act_admin_setFreezingParams(uint8 a, uint8 b, uint8 c) external { + _clearWinnerSelectionPending(); redist.setFreezingParams(a, b, c); } function act_rando_tryPause(uint8 actorId) external { + _clearWinnerSelectionPending(); bool ok = actors[uint256(actorId) % ACTOR_COUNT].tryPause(); if (ok) unauthorizedAdminCallSucceeded = true; } function act_rando_tryUnpause(uint8 actorId) external { + _clearWinnerSelectionPending(); bool ok = actors[uint256(actorId) % ACTOR_COUNT].tryUnpause(); if (ok) unauthorizedAdminCallSucceeded = true; } function act_rando_trySetSampleMaxValue(uint8 actorId, uint256 v) external { + _clearWinnerSelectionPending(); bool ok = actors[uint256(actorId) % ACTOR_COUNT].trySetSampleMaxValue(v); if (ok) unauthorizedAdminCallSucceeded = true; } function act_rando_trySetFreezingParams(uint8 actorId, uint8 a, uint8 b, uint8 c) external { + _clearWinnerSelectionPending(); bool ok = actors[uint256(actorId) % ACTOR_COUNT].trySetFreezingParams(a, b, c); if (ok) unauthorizedAdminCallSucceeded = true; } @@ -367,6 +384,7 @@ contract EchidnaRedistributionHarness { bytes32 reserveHash, bytes32 nonce ) external { + _clearWinnerSelectionPending(); if (redist.paused()) return; if (!redist.currentPhaseCommit()) return; // Avoid the "phase last block" restriction in commit phase. @@ -408,6 +426,7 @@ contract EchidnaRedistributionHarness { } function act_happyReveal(uint8 actorId) external { + _clearWinnerSelectionPending(); if (redist.paused()) return; if (!redist.currentPhaseReveal()) return; @@ -435,6 +454,7 @@ contract EchidnaRedistributionHarness { } function act_tryCommitWhilePaused(uint8 actorId, bytes32 reserveHash, bytes32 nonce) external { + _clearWinnerSelectionPending(); if (!redist.paused()) return; if (!redist.currentPhaseCommit()) return; if (block.number % 152 == (152 / 4) - 1) return; @@ -454,6 +474,7 @@ contract EchidnaRedistributionHarness { } function act_tryRevealWhilePaused(uint8 actorId) external { + _clearWinnerSelectionPending(); if (!redist.paused()) return; if (!redist.currentPhaseReveal()) return; @@ -475,10 +496,9 @@ contract EchidnaRedistributionHarness { } function act_winnerSelection(uint8 actorId) external { + _clearWinnerSelectionPending(); uint256 idx = uint256(actorId) % ACTOR_COUNT; // Snapshot current commits (bounded) and freeze counts before selection. - pendingWinnerSelection = false; - pendingWinnerSelectionLen = 0; pendingWinnerSelectionRound = redist.currentRound(); for (uint256 i = 0; i < 25; i++) { diff --git a/src/echidna/EchidnaSystemHarness.sol b/src/echidna/EchidnaSystemHarness.sol index 5f537e80..420e1e12 100644 --- a/src/echidna/EchidnaSystemHarness.sol +++ b/src/echidna/EchidnaSystemHarness.sol @@ -316,13 +316,10 @@ contract EchidnaSystemHarness { return token.balanceOf(address(stake)) >= sumPotential; } - function echidna_stamp_totalPot_never_exceeds_token_balance() external view returns (bool) { - // `totalPot()` is `min(pot, tokenBalance)` but is not `view` (it calls `expireLimited`), - // so we assert the semantic relationship in a view-safe way. - uint256 bal = token.balanceOf(address(stamp)); - uint256 p = stamp.pot(); - uint256 totalPotView = p < bal ? p : bal; - return totalPotView <= bal; + function echidna_stamp_internal_pot_not_above_contract_balance() external view returns (bool) { + // Raw `pot` tracks accrued liability; it must not exceed ERC20 balance held by the stamp contract. + // (`totalPot()` caps at balance but is non-view; this is the meaningful accounting check.) + return stamp.pot() <= token.balanceOf(address(stamp)); } function echidna_tracked_redist_commit_reveal_consistent() external view returns (bool) { From 16c9f5e967825fa315bd1bb8f567c28c328bc72d Mon Sep 17 00:00:00 2001 From: Cardinal Date: Tue, 7 Apr 2026 17:29:01 +0200 Subject: [PATCH 21/50] fix(echidna): clear claim pending across actions; reset postage pending 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) --- src/echidna/EchidnaPostageStampHarness.sol | 30 +++++++++++++++++++ .../EchidnaRedistributionClaimHarness.sol | 11 +++++-- 2 files changed, 39 insertions(+), 2 deletions(-) diff --git a/src/echidna/EchidnaPostageStampHarness.sol b/src/echidna/EchidnaPostageStampHarness.sol index 4dd585d1..09c95621 100644 --- a/src/echidna/EchidnaPostageStampHarness.sol +++ b/src/echidna/EchidnaPostageStampHarness.sol @@ -506,11 +506,41 @@ contract EchidnaPostageStampHarness { function _clearPending() internal { pendingCreate = false; + pendingBatchId = bytes32(0); + pendingCreateTotalAmount = 0; + pendingStampTokenBalanceBefore = 0; + pendingCreateNormalisedExpected = 0; + pendingCreateDepth = 0; + pendingCreateBucketDepth = 0; + pendingCreateImmutable = false; + pendingTopUp = false; + pendingTopUpBatchId = bytes32(0); + pendingTopUpTokenBefore = 0; + pendingTopUpNormalisedBefore = 0; + pendingTopUpTotalAmount = 0; + pendingTopUpPerChunk = 0; + pendingIncreaseDepth = false; + pendingIncBatchId = bytes32(0); + pendingIncOldDepth = 0; + pendingIncNewDepth = 0; + pendingIncValidChunkBefore = 0; + pendingIncTokenBefore = 0; + pendingIncBucketDepth = 0; + pendingIncExpectedNormalised = 0; + pendingExpireAll = false; pendingSetPrice = false; + pendingSetPriceTotalOutPaymentBefore = 0; + pendingSetPriceLastUpdatedExpected = 0; + pendingSetPriceLastPriceExpected = 0; + pendingWithdraw = false; + pendingWithdrawBeneficiary = address(0); + pendingWithdrawBeneficiaryBalBefore = 0; + pendingWithdrawExpectedAmount = 0; + pendingWithdrawStampBalBefore = 0; } function _batchDigest(bytes32 batchId) internal view returns (bytes32) { diff --git a/src/echidna/EchidnaRedistributionClaimHarness.sol b/src/echidna/EchidnaRedistributionClaimHarness.sol index ee69edc8..aa9582d6 100644 --- a/src/echidna/EchidnaRedistributionClaimHarness.sol +++ b/src/echidna/EchidnaRedistributionClaimHarness.sol @@ -232,11 +232,16 @@ contract EchidnaRedistributionClaimHarness { } } + function _clearClaimPending() internal { + pendingClaim = false; + } + // ----------------------------- // Actions // ----------------------------- function act_seedPot(uint256 amount) external { + _clearClaimPending(); uint256 x = amount % 1e24; if (x == 0) x = 1e18; stampMock.seedPot(x); @@ -249,6 +254,7 @@ contract EchidnaRedistributionClaimHarness { uint256 effectiveStake, uint256 lastUpdated ) external { + _clearClaimPending(); uint256 idx = uint256(actorId) % ACTOR_COUNT; uint8 h = uint8(height % 16); uint256 stake = effectiveStake == 0 ? 1e18 : (effectiveStake % 1e24) + 1; @@ -257,6 +263,7 @@ contract EchidnaRedistributionClaimHarness { } function act_happyCommit(uint8 actorId, bytes32 hash, bytes32 nonce) external { + _clearClaimPending(); if (!redist.currentPhaseCommit()) return; if (block.number % ROUND_LENGTH == (ROUND_LENGTH / 4) - 1) return; @@ -285,6 +292,7 @@ contract EchidnaRedistributionClaimHarness { } function act_happyReveal(uint8 actorId) external { + _clearClaimPending(); if (!redist.currentPhaseReveal()) return; uint256 idx = uint256(actorId) % ACTOR_COUNT; @@ -298,9 +306,8 @@ contract EchidnaRedistributionClaimHarness { } function act_claimStub(uint8 actorId) external { + _clearClaimPending(); uint256 idx = uint256(actorId) % ACTOR_COUNT; - - pendingClaim = false; pendingClaimRound = redist.currentRound(); pendingOracleCallsBefore = oracleMock.calls(); From 114f6b5d196b7d979e2f226f8a71b568d3713bc2 Mon Sep 17 00:00:00 2001 From: Cardinal Date: Tue, 7 Apr 2026 17:53:00 +0200 Subject: [PATCH 22/50] fix(echidna): avoid OOB currentCommits/currentReveals getter reverts 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 --- src/echidna/EchidnaRedistributionHarness.sol | 53 +++++++++++--------- src/echidna/EchidnaSystemHarness.sol | 16 ++++-- src/echidna/RedistributionExposed.sol | 24 +++++++++ 3 files changed, 63 insertions(+), 30 deletions(-) create mode 100644 src/echidna/RedistributionExposed.sol diff --git a/src/echidna/EchidnaRedistributionHarness.sol b/src/echidna/EchidnaRedistributionHarness.sol index fd884b38..ce9e2f74 100644 --- a/src/echidna/EchidnaRedistributionHarness.sol +++ b/src/echidna/EchidnaRedistributionHarness.sol @@ -3,6 +3,7 @@ pragma solidity ^0.8.19; import "../Redistribution.sol"; import "../interface/IPostageStamp.sol"; +import "./RedistributionExposed.sol"; contract EchidnaStakeRegistryMock is IStakeRegistry { struct Node { @@ -150,18 +151,6 @@ contract EchidnaPostageStampMock is IPostageStamp { } } -contract RedistributionExposed is Redistribution { - constructor( - address staking, - address postageContract, - address oracleContract - ) Redistribution(staking, postageContract, oracleContract) {} - - function exposedWinnerSelection() external { - winnerSelection(); - } -} - contract EchidnaRedistributionActor { RedistributionExposed internal immutable redist; @@ -218,6 +207,8 @@ contract EchidnaRedistributionHarness { RedistributionExposed internal immutable redist; uint256 internal constant ACTOR_COUNT = 3; + /// @dev Cap scans; must match pending winnerSelection snapshot arrays (size 25). + uint256 internal constant MAX_COMMIT_REVEAL_SCAN = 25; EchidnaRedistributionActor[3] internal actors; // Forbidden-call flags. @@ -286,6 +277,16 @@ contract EchidnaRedistributionHarness { pendingWinnerSelectionLen = 0; } + function _boundedCommitsLen() internal view returns (uint256) { + uint256 n = redist.currentCommitsLength(); + return n > MAX_COMMIT_REVEAL_SCAN ? MAX_COMMIT_REVEAL_SCAN : n; + } + + function _boundedRevealsLen() internal view returns (uint256) { + uint256 n = redist.currentRevealsLength(); + return n > MAX_COMMIT_REVEAL_SCAN ? MAX_COMMIT_REVEAL_SCAN : n; + } + // ----------------------------- // Actions // ----------------------------- @@ -501,7 +502,8 @@ contract EchidnaRedistributionHarness { // Snapshot current commits (bounded) and freeze counts before selection. pendingWinnerSelectionRound = redist.currentRound(); - for (uint256 i = 0; i < 25; i++) { + uint256 commitLim = _boundedCommitsLen(); + for (uint256 i = 0; i < commitLim; i++) { (bool ok, bytes memory data) = address(redist).staticcall( abi.encodeWithSignature("currentCommits(uint256)", i) ); @@ -554,16 +556,18 @@ contract EchidnaRedistributionHarness { function echidna_reveal_entries_imply_matching_commit() external view returns (bool) { // For each reveal entry, there must exist a commit marked revealed with matching overlay/owner and revealIndex pointing here. - for (uint256 i = 0; i < 25; i++) { + uint256 rLim = _boundedRevealsLen(); + uint256 cLim = _boundedCommitsLen(); + for (uint256 i = 0; i < rLim; i++) { (bool okR, bytes32 rOverlay, address rOwner) = _revealOverlayOwner(i); - if (!okR) break; + if (!okR) return false; bool found = false; - for (uint256 j = 0; j < 25; j++) { + for (uint256 j = 0; j < cLim; j++) { (bool okC, bytes32 cOverlay, address cOwner, bool cRevealed, uint256 cRevealIndex) = _commitRevealLink( j ); - if (!okC) break; + if (!okC) return false; if (cRevealed && cRevealIndex == i && cOverlay == rOverlay && cOwner == rOwner) { found = true; break; @@ -685,7 +689,8 @@ contract EchidnaRedistributionHarness { address[25] memory owner ) { - for (uint256 i = 0; i < 25; i++) { + uint256 lim = _boundedCommitsLen(); + for (uint256 i = 0; i < lim; i++) { (bool ok, bytes32 ov, address ow, bool rev, uint256 ri) = _commitFields(i); if (!ok) break; overlays[i] = ov; @@ -697,11 +702,7 @@ contract EchidnaRedistributionHarness { } function _scanRevealsLen() internal view returns (uint256 n) { - for (uint256 i = 0; i < 25; i++) { - (bool ok, , ) = _revealOverlayOwner(i); - if (!ok) break; - n++; - } + n = _boundedRevealsLen(); } function _commitOverlayOwner(uint256 i) internal view returns (bool ok, bytes32 ov, address ow) { @@ -821,7 +822,8 @@ contract EchidnaRedistributionHarness { } function _findCommit(bytes32 overlay, bytes32 obfuscated) internal view returns (bool ok, uint256 idx) { - for (uint256 i = 0; i < 25; i++) { + uint256 lim = _boundedCommitsLen(); + for (uint256 i = 0; i < lim; i++) { (bool okI, bytes32 ov, , , , , bytes32 obf, ) = _commitFull(i); if (!okI) break; if (ov == overlay && obf == obfuscated) return (true, i); @@ -830,7 +832,8 @@ contract EchidnaRedistributionHarness { } function _commitOverlayExists(bytes32 overlay) internal view returns (bool) { - for (uint256 i = 0; i < 25; i++) { + uint256 lim = _boundedCommitsLen(); + for (uint256 i = 0; i < lim; i++) { (bool ok, bytes32 ov, , , ) = _commitFields(i); if (!ok) break; if (ov == overlay) return true; diff --git a/src/echidna/EchidnaSystemHarness.sol b/src/echidna/EchidnaSystemHarness.sol index 420e1e12..43bf1635 100644 --- a/src/echidna/EchidnaSystemHarness.sol +++ b/src/echidna/EchidnaSystemHarness.sol @@ -6,6 +6,7 @@ import "../PostageStamp.sol"; import "../PriceOracle.sol"; import "../Redistribution.sol" as RedistMod; import "../Staking.sol" as StakingMod; +import "./RedistributionExposed.sol"; contract EchidnaSystemActor { TestToken internal immutable token; @@ -118,8 +119,10 @@ contract EchidnaSystemHarness { // Deploy stake registry (uses oracle.currentPrice()). stake = new StakingMod.StakeRegistry(address(token), 1, address(oracle)); - // Deploy redistribution (uses stake/stamp/oracle). - redist = new RedistMod.Redistribution(address(stake), address(stamp), address(oracle)); + // Deploy redistribution (uses stake/stamp/oracle). Exposed wrapper adds length helpers for harness scans. + redist = RedistMod.Redistribution( + address(new RedistributionExposed(address(stake), address(stamp), address(oracle))) + ); // Wire roles: redistribution must be able to freeze stake and withdraw the stamp pot. stake.grantRole(stake.REDISTRIBUTOR_ROLE(), address(redist)); @@ -353,8 +356,9 @@ contract EchidnaSystemHarness { } function _commitExists(bytes32 obfuscated, address owner) internal view returns (bool) { - // currentCommits is deleted each new commit round; bounded scan to avoid unbounded loops. - for (uint256 i = 0; i < 25; i++) { + uint256 lim = RedistributionExposed(address(redist)).currentCommitsLength(); + if (lim > 25) lim = 25; + for (uint256 i = 0; i < lim; i++) { (bool ok, bytes memory data) = address(redist).staticcall( abi.encodeWithSignature("currentCommits(uint256)", i) ); @@ -374,7 +378,9 @@ contract EchidnaSystemHarness { } function _revealMatchesCommit(address owner, uint8 depth, bytes32 hash) internal view returns (bool) { - for (uint256 i = 0; i < 25; i++) { + uint256 lim = RedistributionExposed(address(redist)).currentRevealsLength(); + if (lim > 25) lim = 25; + for (uint256 i = 0; i < lim; i++) { (bool ok, bytes memory data) = address(redist).staticcall( abi.encodeWithSignature("currentReveals(uint256)", i) ); diff --git a/src/echidna/RedistributionExposed.sol b/src/echidna/RedistributionExposed.sol new file mode 100644 index 00000000..16d106f6 --- /dev/null +++ b/src/echidna/RedistributionExposed.sol @@ -0,0 +1,24 @@ +// SPDX-License-Identifier: BSD-3-Clause +pragma solidity ^0.8.19; + +import "../Redistribution.sol"; + +/// @notice Test/fuzz wrapper: exposes `winnerSelection` and array lengths so harnesses need not call +/// the auto-generated `currentCommits(i)` / `currentReveals(i)` getters out of bounds (those revert). +contract RedistributionExposed is Redistribution { + constructor(address staking, address postageContract, address oracleContract) + Redistribution(staking, postageContract, oracleContract) + {} + + function exposedWinnerSelection() external { + winnerSelection(); + } + + function currentCommitsLength() external view returns (uint256) { + return currentCommits.length; + } + + function currentRevealsLength() external view returns (uint256) { + return currentReveals.length; + } +} From ae499775d08157c6490e19e270c9391bf0079e91 Mon Sep 17 00:00:00 2001 From: Cardinal Date: Tue, 7 Apr 2026 18:18:01 +0200 Subject: [PATCH 23/50] =?UTF-8?q?fix(echidna):=20skip=20reveal=E2=86=94com?= =?UTF-8?q?mit=20linkage=20property=20in=20stale=20reveal=20window?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- src/echidna/EchidnaRedistributionHarness.sol | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/echidna/EchidnaRedistributionHarness.sol b/src/echidna/EchidnaRedistributionHarness.sol index ce9e2f74..a095037e 100644 --- a/src/echidna/EchidnaRedistributionHarness.sol +++ b/src/echidna/EchidnaRedistributionHarness.sol @@ -556,6 +556,15 @@ contract EchidnaRedistributionHarness { function echidna_reveal_entries_imply_matching_commit() external view returns (bool) { // For each reveal entry, there must exist a commit marked revealed with matching overlay/owner and revealIndex pointing here. + // + // `commit()` deletes `currentCommits` when `currentCommitRound` advances but does not clear + // `currentReveals` until the reveal phase for that round begins (`reveal()` deletes when + // `currentRevealRound` catches up). During commit phase with `currentCommitRound != currentRevealRound`, + // stale reveal slots are expected and must not be checked against the fresh commit array. + if (redist.currentPhaseCommit() && redist.currentCommitRound() != redist.currentRevealRound()) { + return true; + } + uint256 rLim = _boundedRevealsLen(); uint256 cLim = _boundedCommitsLen(); for (uint256 i = 0; i < rLim; i++) { From ab475ea1766d68fc40fbc6dcd0a375503ce1f0d8 Mon Sep 17 00:00:00 2001 From: Cardinal Date: Tue, 7 Apr 2026 18:31:02 +0200 Subject: [PATCH 24/50] =?UTF-8?q?fix(echidna):=20widen=20reveal=E2=86=94co?= =?UTF-8?q?mmit=20property=20skip=20when=20reveal=20round=20lags?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/echidna/EchidnaRedistributionHarness.sol | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/echidna/EchidnaRedistributionHarness.sol b/src/echidna/EchidnaRedistributionHarness.sol index a095037e..e811d708 100644 --- a/src/echidna/EchidnaRedistributionHarness.sol +++ b/src/echidna/EchidnaRedistributionHarness.sol @@ -558,10 +558,11 @@ contract EchidnaRedistributionHarness { // For each reveal entry, there must exist a commit marked revealed with matching overlay/owner and revealIndex pointing here. // // `commit()` deletes `currentCommits` when `currentCommitRound` advances but does not clear - // `currentReveals` until the reveal phase for that round begins (`reveal()` deletes when - // `currentRevealRound` catches up). During commit phase with `currentCommitRound != currentRevealRound`, - // stale reveal slots are expected and must not be checked against the fresh commit array. - if (redist.currentPhaseCommit() && redist.currentCommitRound() != redist.currentRevealRound()) { + // `currentReveals` until the first `reveal()` of the new round (`reveal()` deletes when + // `currentRevealRound` catches up to `cr`). Until then, `currentCommitRound != currentRevealRound` + // even in **reveal phase** (currentPhaseCommit false): old `currentReveals` entries coexist with + // fresh unrevealed `currentCommits`. Skip linkage checks for that whole transitional window. + if (redist.currentCommitRound() != redist.currentRevealRound()) { return true; } From 04fde9d5a87613d1b214e86c0b139b50ad20407d Mon Sep 17 00:00:00 2001 From: Cardinal Date: Tue, 7 Apr 2026 18:59:25 +0200 Subject: [PATCH 25/50] cleanup of various dead or broken code --- src/echidna/EchidnaMocks.sol | 70 ++++++++++++++++ src/echidna/EchidnaPostageStampHarness.sol | 45 ++++------- .../EchidnaRedistributionClaimHarness.sol | 71 ++-------------- src/echidna/EchidnaRedistributionHarness.sol | 81 ++----------------- src/echidna/EchidnaSystemHarness.sol | 9 ++- 5 files changed, 102 insertions(+), 174 deletions(-) create mode 100644 src/echidna/EchidnaMocks.sol diff --git a/src/echidna/EchidnaMocks.sol b/src/echidna/EchidnaMocks.sol new file mode 100644 index 00000000..2a471887 --- /dev/null +++ b/src/echidna/EchidnaMocks.sol @@ -0,0 +1,70 @@ +// SPDX-License-Identifier: BSD-3-Clause +pragma solidity ^0.8.19; + +import "../Redistribution.sol"; + +/// @notice Shared stake registry mock for redistribution harnesses. +contract EchidnaStakeRegistryMock is IStakeRegistry { + struct Node { + bytes32 overlay; + uint8 height; + uint256 effectiveStake; + uint256 lastUpdated; + bool exists; + } + + mapping(address => Node) internal nodes; + mapping(address => uint256) public freezeCount; + mapping(address => uint256) public lastFreezeTime; + + function setNode( + address owner, + bytes32 overlay, + uint8 height, + uint256 effectiveStake, + uint256 lastUpdated + ) external { + nodes[owner] = Node({ + overlay: overlay, + height: height, + effectiveStake: effectiveStake, + lastUpdated: lastUpdated, + exists: true + }); + } + + function freezeDeposit(address _owner, uint256 _time) external { + if (!nodes[_owner].exists) return; + freezeCount[_owner] += 1; + lastFreezeTime[_owner] = _time; + nodes[_owner].lastUpdated = block.number + _time; + } + + function lastUpdatedBlockNumberOfAddress(address _owner) external view returns (uint256) { + return nodes[_owner].lastUpdated; + } + + function overlayOfAddress(address _owner) external view returns (bytes32) { + return nodes[_owner].overlay; + } + + function heightOfAddress(address _owner) external view returns (uint8) { + return nodes[_owner].height; + } + + function nodeEffectiveStake(address _owner) external view returns (uint256) { + return nodes[_owner].effectiveStake; + } +} + +/// @notice Shared price oracle mock for redistribution harnesses. +contract EchidnaPriceOracleMock is IPriceOracle { + uint256 public calls; + uint16 public lastRedundancy; + + function adjustPrice(uint16 redundancy) external returns (bool) { + calls += 1; + lastRedundancy = redundancy; + return true; + } +} diff --git a/src/echidna/EchidnaPostageStampHarness.sol b/src/echidna/EchidnaPostageStampHarness.sol index 09c95621..562d0614 100644 --- a/src/echidna/EchidnaPostageStampHarness.sol +++ b/src/echidna/EchidnaPostageStampHarness.sol @@ -201,13 +201,12 @@ contract EchidnaPostageStampHarness { bool immutableFlag ) external { _clearPending(); - uint256 potBefore = stamp.pot(); // Normalize expiry so createBatch's internal expireLimited() doesn't unexpectedly mutate other batches. stamp.expireLimited(type(uint256).max); tmpNonce = nonce; tmpImmutable = immutableFlag; _createBatchInternal(actorId, initialPerChunk, depthRaw); - _observePot(potBefore, false); + _observePot(false); } function act_topUp(uint8 actorId, uint8 batchIndex, uint256 topupPerChunk) external { @@ -239,7 +238,7 @@ contract EchidnaPostageStampHarness { bool ok = a.topUp(batchId, perChunk); if (!ok) return; - _checkNonInterference(batchId); + _checkNonInterference(); pendingTopUp = true; pendingTopUpBatchId = batchId; @@ -251,25 +250,24 @@ contract EchidnaPostageStampHarness { function act_increaseDepth(uint8 actorId, uint8 batchIndex, uint8 newDepthRaw) external { _clearPending(); - uint256 potBefore = stamp.pot(); EchidnaPostageActor a = _actor(actorId); if (stamp.paused()) { bool okPaused = a.increaseDepth(_batch(batchIndex), 3); if (okPaused) pausedMutationSucceeded = true; - _observePot(potBefore, false); + _observePot(false); return; } bytes32 batchId = _batch(batchIndex); if (batchId == bytes32(0)) { - _observePot(potBefore, false); + _observePot(false); return; } // increaseDepth is owner-gated; we only attempt if the batch owner matches this actor. if (stamp.batchOwner(batchId) != address(a)) { - _observePot(potBefore, false); + _observePot(false); return; } @@ -278,14 +276,14 @@ contract EchidnaPostageStampHarness { uint8 oldDepth = stamp.batchDepth(batchId); if (oldDepth == 0) { - _observePot(potBefore, false); + _observePot(false); return; } uint8 minBucket = stamp.minimumBucketDepth(); uint8 newDepth = uint8(minBucket + 1 + (newDepthRaw % 12)); if (newDepth <= oldDepth) { - _observePot(potBefore, false); + _observePot(false); return; } @@ -302,10 +300,10 @@ contract EchidnaPostageStampHarness { bool ok = a.increaseDepth(batchId, newDepth); if (!ok) { - _observePot(potBefore, false); + _observePot(false); return; } - _checkNonInterference(batchId); + _checkNonInterference(); pendingIncreaseDepth = true; pendingIncBatchId = batchId; @@ -315,15 +313,14 @@ contract EchidnaPostageStampHarness { pendingIncTokenBefore = tokenBefore; pendingIncBucketDepth = bucketDepthBefore; pendingIncExpectedNormalised = expectedNormalisedAfter; - _observePot(potBefore, false); + _observePot(false); } function act_oracle_setPrice(uint256 price) external { _clearPending(); - uint256 potBefore = stamp.pot(); bool ok = oracleActor.trySetPrice(price); if (!ok) { - _observePot(potBefore, false); + _observePot(false); return; } @@ -333,20 +330,18 @@ contract EchidnaPostageStampHarness { // Capture the exact base total payout immediately after setting the price. // At this point `lastUpdatedBlock == block.number`, so `currentTotalOutPayment()` equals `totalOutPayment`. pendingSetPriceTotalOutPaymentBefore = stamp.currentTotalOutPayment(); - _observePot(potBefore, false); + _observePot(false); } function act_expireAll() external { _clearPending(); - uint256 potBefore = stamp.pot(); stamp.expireLimited(type(uint256).max); pendingExpireAll = true; - _observePot(potBefore, false); + _observePot(false); } function act_redistributor_withdraw(uint8 beneficiaryActorId) external { _clearPending(); - uint256 potBefore = stamp.pot(); address beneficiary = address(_actor(beneficiaryActorId)); if (beneficiary == address(0)) beneficiary = address(0xBEEF); @@ -356,7 +351,7 @@ contract EchidnaPostageStampHarness { bool ok = redistributorActor.tryWithdraw(beneficiary); if (!ok) { - _observePot(potBefore, false); + _observePot(false); return; } @@ -365,7 +360,7 @@ contract EchidnaPostageStampHarness { pendingWithdrawBeneficiaryBalBefore = balBefore; pendingWithdrawStampBalBefore = stampBalBefore; pendingWithdrawExpectedAmount = amount; - _observePot(potBefore, true); + _observePot(true); } function act_pauser_pause() external { @@ -417,10 +412,6 @@ contract EchidnaPostageStampHarness { !potDecreasedUnexpectedly; } - function echidna_pot_never_decreases_except_withdraw() external view returns (bool) { - return !potDecreasedUnexpectedly; - } - function echidna_minimumInitialBalancePerChunk_matches_formula() external view returns (bool) { return stamp.minimumInitialBalancePerChunk() == @@ -577,8 +568,7 @@ contract EchidnaPostageStampHarness { tmpDigestE = tmpBatchE == bytes32(0) ? bytes32(0) : _batchDigest(tmpBatchE); } - function _checkNonInterference(bytes32 target) internal { - target; + function _checkNonInterference() internal { if (tmpBatchA != bytes32(0) && _batchDigest(tmpBatchA) != tmpDigestA) nonInterferenceViolated = true; if (tmpBatchB != bytes32(0) && _batchDigest(tmpBatchB) != tmpDigestB) nonInterferenceViolated = true; if (tmpBatchC != bytes32(0) && _batchDigest(tmpBatchC) != tmpDigestC) nonInterferenceViolated = true; @@ -586,8 +576,7 @@ contract EchidnaPostageStampHarness { if (tmpBatchE != bytes32(0) && _batchDigest(tmpBatchE) != tmpDigestE) nonInterferenceViolated = true; } - function _observePot(uint256 potBefore, bool withdrew) internal { - potBefore; + function _observePot(bool withdrew) internal { uint256 prev = lastPotObserved; uint256 nowPot = stamp.pot(); if (nowPot < prev) { diff --git a/src/echidna/EchidnaRedistributionClaimHarness.sol b/src/echidna/EchidnaRedistributionClaimHarness.sol index aa9582d6..8c12ce86 100644 --- a/src/echidna/EchidnaRedistributionClaimHarness.sol +++ b/src/echidna/EchidnaRedistributionClaimHarness.sol @@ -4,68 +4,7 @@ pragma solidity ^0.8.19; import "../Redistribution.sol"; import "../TestToken.sol"; import "../interface/IPostageStamp.sol"; - -contract EchidnaStakeRegistryMock2 is IStakeRegistry { - struct Node { - bytes32 overlay; - uint8 height; - uint256 effectiveStake; - uint256 lastUpdated; - bool exists; - } - - mapping(address => Node) internal nodes; - mapping(address => uint256) public freezeCount; - - function setNode( - address owner, - bytes32 overlay, - uint8 height, - uint256 effectiveStake, - uint256 lastUpdated - ) external { - nodes[owner] = Node({ - overlay: overlay, - height: height, - effectiveStake: effectiveStake, - lastUpdated: lastUpdated, - exists: true - }); - } - - function freezeDeposit(address _owner, uint256 _time) external { - if (!nodes[_owner].exists) return; - freezeCount[_owner] += 1; - nodes[_owner].lastUpdated = block.number + _time; - } - - function lastUpdatedBlockNumberOfAddress(address _owner) external view returns (uint256) { - return nodes[_owner].lastUpdated; - } - - function overlayOfAddress(address _owner) external view returns (bytes32) { - return nodes[_owner].overlay; - } - - function heightOfAddress(address _owner) external view returns (uint8) { - return nodes[_owner].height; - } - - function nodeEffectiveStake(address _owner) external view returns (uint256) { - return nodes[_owner].effectiveStake; - } -} - -contract EchidnaPriceOracleMock2 is IPriceOracle { - uint256 public calls; - uint16 public lastRedundancy; - - function adjustPrice(uint16 redundancy) external returns (bool) { - calls += 1; - lastRedundancy = redundancy; - return true; - } -} +import "./EchidnaMocks.sol"; contract EchidnaPostageStampPotMock is IPostageStamp { TestToken internal immutable token; @@ -191,9 +130,9 @@ contract EchidnaRedistributionClaimHarness { uint256 internal constant ROUND_LENGTH = 152; TestToken internal immutable token; - EchidnaStakeRegistryMock2 internal immutable stakeMock; + EchidnaStakeRegistryMock internal immutable stakeMock; EchidnaPostageStampPotMock internal immutable stampMock; - EchidnaPriceOracleMock2 internal immutable oracleMock; + EchidnaPriceOracleMock internal immutable oracleMock; RedistributionClaimStub internal immutable redist; EchidnaRedistributionClaimActor[3] internal actors; @@ -220,9 +159,9 @@ contract EchidnaRedistributionClaimHarness { constructor() { token = new TestToken("TestToken", "TT", 0); - stakeMock = new EchidnaStakeRegistryMock2(); + stakeMock = new EchidnaStakeRegistryMock(); stampMock = new EchidnaPostageStampPotMock(token); - oracleMock = new EchidnaPriceOracleMock2(); + oracleMock = new EchidnaPriceOracleMock(); redist = new RedistributionClaimStub(address(stakeMock), address(stampMock), address(oracleMock)); for (uint256 i = 0; i < ACTOR_COUNT; i++) { diff --git a/src/echidna/EchidnaRedistributionHarness.sol b/src/echidna/EchidnaRedistributionHarness.sol index e811d708..e405e799 100644 --- a/src/echidna/EchidnaRedistributionHarness.sol +++ b/src/echidna/EchidnaRedistributionHarness.sol @@ -4,70 +4,7 @@ pragma solidity ^0.8.19; import "../Redistribution.sol"; import "../interface/IPostageStamp.sol"; import "./RedistributionExposed.sol"; - -contract EchidnaStakeRegistryMock is IStakeRegistry { - struct Node { - bytes32 overlay; - uint8 height; - uint256 effectiveStake; - uint256 lastUpdated; - bool exists; - } - - mapping(address => Node) internal nodes; - mapping(address => uint256) public freezeCount; - mapping(address => uint256) public lastFreezeTime; - - function setNode( - address owner, - bytes32 overlay, - uint8 height, - uint256 effectiveStake, - uint256 lastUpdated - ) external { - nodes[owner] = Node({ - overlay: overlay, - height: height, - effectiveStake: effectiveStake, - lastUpdated: lastUpdated, - exists: true - }); - } - - function freezeDeposit(address _owner, uint256 _time) external { - if (!nodes[_owner].exists) return; - freezeCount[_owner] += 1; - lastFreezeTime[_owner] = _time; - nodes[_owner].lastUpdated = block.number + _time; - } - - function lastUpdatedBlockNumberOfAddress(address _owner) external view returns (uint256) { - return nodes[_owner].lastUpdated; - } - - function overlayOfAddress(address _owner) external view returns (bytes32) { - return nodes[_owner].overlay; - } - - function heightOfAddress(address _owner) external view returns (uint8) { - return nodes[_owner].height; - } - - function nodeEffectiveStake(address _owner) external view returns (uint256) { - return nodes[_owner].effectiveStake; - } -} - -contract EchidnaPriceOracleMock is IPriceOracle { - uint256 public calls; - uint16 public lastRedundancy; - - function adjustPrice(uint16 redundancy) external returns (bool) { - calls += 1; - lastRedundancy = redundancy; - return true; - } -} +import "./EchidnaMocks.sol"; contract EchidnaPostageStampMock is IPostageStamp { uint256 public withdrawCalls; @@ -508,18 +445,10 @@ contract EchidnaRedistributionHarness { abi.encodeWithSignature("currentCommits(uint256)", i) ); if (!ok) break; - (bytes32 ov, address ow, bool rev, uint8 h, uint256 st, bytes32 obf, uint256 ri) = abi.decode( - data, - (bytes32, address, bool, uint8, uint256, bytes32, uint256) - ); - ov; - h; - st; - obf; - ri; - pendingWSOwners[i] = ow; - pendingWSRevealed[i] = rev; - pendingWSFreezeCountBefore[i] = stakeMock.freezeCount(ow); + CommitView memory cv = abi.decode(data, (CommitView)); + pendingWSOwners[i] = cv.owner; + pendingWSRevealed[i] = cv.revealed; + pendingWSFreezeCountBefore[i] = stakeMock.freezeCount(cv.owner); pendingWinnerSelectionLen++; } diff --git a/src/echidna/EchidnaSystemHarness.sol b/src/echidna/EchidnaSystemHarness.sol index 43bf1635..03f75254 100644 --- a/src/echidna/EchidnaSystemHarness.sol +++ b/src/echidna/EchidnaSystemHarness.sol @@ -167,10 +167,11 @@ contract EchidnaSystemHarness { bool imm ) external { EchidnaSystemActor a = actors[uint256(actorId) % ACTOR_COUNT]; - uint8 d = uint8((depth % 12) + 1); // avoid huge shifts, avoid 0 - uint8 b = uint8(bucketDepth % d); - if (b < 16) b = 16; - if (b >= d) b = uint8(d - 1); + uint8 minBucket = stamp.minimumBucketDepth(); + // depth must exceed minimumBucketDepth; allow [minBucket+1 .. minBucket+10] + uint8 d = uint8(minBucket + 1 + (depth % 10)); + // bucketDepth must be in [minBucket, d-1] + uint8 b = uint8(minBucket + (bucketDepth % (d - minBucket))); uint256 min = stamp.minimumInitialBalancePerChunk(); uint256 init = initialBalancePerChunk % (min + 1e6); From 385519774ed1dc554a312cac0c502ac90902abfc Mon Sep 17 00:00:00 2001 From: Cardinal Date: Tue, 7 Apr 2026 19:11:57 +0200 Subject: [PATCH 26/50] fix lint --- src/echidna/RedistributionExposed.sol | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/echidna/RedistributionExposed.sol b/src/echidna/RedistributionExposed.sol index 16d106f6..e196c519 100644 --- a/src/echidna/RedistributionExposed.sol +++ b/src/echidna/RedistributionExposed.sol @@ -6,9 +6,11 @@ import "../Redistribution.sol"; /// @notice Test/fuzz wrapper: exposes `winnerSelection` and array lengths so harnesses need not call /// the auto-generated `currentCommits(i)` / `currentReveals(i)` getters out of bounds (those revert). contract RedistributionExposed is Redistribution { - constructor(address staking, address postageContract, address oracleContract) - Redistribution(staking, postageContract, oracleContract) - {} + constructor( + address staking, + address postageContract, + address oracleContract + ) Redistribution(staking, postageContract, oracleContract) {} function exposedWinnerSelection() external { winnerSelection(); From 067faf06e08d03d6ecea203a12537d6a602daa4f Mon Sep 17 00:00:00 2001 From: Cardinal Date: Thu, 9 Apr 2026 18:01:28 +0200 Subject: [PATCH 27/50] clean scenarios that should be reserved for chai and not echidna --- ...y_findings_exploitability_8fd9957b.plan.md | 280 ++++++++++++++++++ echidna/README.md | 3 +- src/echidna/EchidnaPostageStampHarness.sol | 6 - src/echidna/EchidnaPriceOracleHarness.sol | 4 - src/echidna/EchidnaStakeRegistryHarness.sol | 4 - src/echidna/EchidnaSystemHarness.sol | 16 - 6 files changed, 281 insertions(+), 32 deletions(-) create mode 100644 .cursor/plans/security_findings_exploitability_8fd9957b.plan.md diff --git a/.cursor/plans/security_findings_exploitability_8fd9957b.plan.md b/.cursor/plans/security_findings_exploitability_8fd9957b.plan.md new file mode 100644 index 00000000..6eddea69 --- /dev/null +++ b/.cursor/plans/security_findings_exploitability_8fd9957b.plan.md @@ -0,0 +1,280 @@ +--- +name: Security Findings Exploitability +overview: Detailed exploitability analysis of all 10 reported security findings against the current Staking, Redistribution, PostageStamp, and PriceOracle contracts. +todos: [] +isProject: false +--- + +# Exploitability Analysis of Reported Security Findings + +Below is a finding-by-finding assessment against the **current code on disk** (pre-fix). For each I state whether the vulnerability is real, whether it is actually exploitable by an external attacker, and whether the severity assigned is justified. + +--- + +## C-1 (CRITICAL) -- Overlay lost after `delete` in `slashDeposit` + +**Code:** [src/Staking.sol](src/Staking.sol) lines 225-237 + +```225:237:src/Staking.sol + function slashDeposit(address _owner, uint256 _amount) external { + if (!hasRole(REDISTRIBUTOR_ROLE, msg.sender)) revert OnlyRedistributor(); + + if (stakes[_owner].lastUpdatedBlockNumber != 0) { + if (stakes[_owner].potentialStake > _amount) { + stakes[_owner].potentialStake -= _amount; + stakes[_owner].lastUpdatedBlockNumber = block.number; + } else { + delete stakes[_owner]; + } + } + emit StakeSlashed(_owner, stakes[_owner].overlay, _amount); + } +``` + +**Bug:** After `delete stakes[_owner]` zeroes the struct, the `emit` on line 236 reads `stakes[_owner].overlay` which is now `bytes32(0)`. + +**Exploitable? NO.** + +- Impact is limited to an **incorrect event emission** (overlay logged as zero). No state corruption, no fund loss. +- `**slashDeposit` is dead code in the current system.** The Redistribution contract's `IStakeRegistry` interface does not include `slashDeposit`, and the only call site in Redistribution.sol is commented out (line 565). No contract currently holds `REDISTRIBUTOR_ROLE` and calls this function. +- Even if called, only off-chain event indexers would see the wrong overlay. No on-chain consequence. + +**Verdict: CRITICAL is a gross overstatement. LOW/Informational at most -- and only relevant once `slashDeposit` is wired into a live redistributor.** + +--- + +## H-1 (HIGH) -- `claim()` silently swallows PostageStamp withdrawal failure + +**Code:** [src/Redistribution.sol](src/Redistribution.sol) lines 485-491 + +```485:491:src/Redistribution.sol + (bool success, ) = address(PostageContract).call( + abi.encodeWithSignature("withdraw(address)", winnerSelected.owner) + ); + if (!success) { + emit WithdrawFailed(winnerSelected.owner); + } +``` + +**Bug:** If `PostageStamp.withdraw` reverts (paused, transfer failure, etc.), the round is still marked as claimed (`currentClaimRound = cr` at line 580). The winner gets no BZZ. + +**Exploitable? PARTIALLY -- but not by an external attacker.** + +- BZZ is NOT lost from the system. The `pot` in PostageStamp remains unchanged on revert, and it accumulates into the next round's pot. +- The failure requires PostageStamp to be in a broken state (paused, out of BZZ, etc.), which is an **admin-controlled condition**. +- No external attacker can trigger this without admin access to PostageStamp. +- The round's winner misses their reward, but the next round's winner gets a larger pot. + +**Verdict: Valid design concern. The fix (reverting on failure) is sensible. But HIGH overstates it since it requires PostageStamp malfunction and BZZ is not destroyed. MEDIUM is more appropriate.** + +--- + +## H-2 (HIGH) -- PriceOracle state desyncs from PostageStamp on failed `setPrice` + +**Code:** [src/PriceOracle.sol](src/PriceOracle.sol) `adjustPrice()` lines ~145-160 + +```solidity +currentPriceUpScaled = _currentPriceUpScaled; // state updated +lastAdjustedRound = currentRoundNumber; // state updated + +(bool success, ) = address(postageStamp).call( + abi.encodeWithSignature("setPrice(uint256)", uint256(currentPrice())) +); +if (!success) { + emit StampPriceUpdateFailed(currentPrice()); + return false; // but PostageStamp still has old price +} +``` + +**Bug:** Oracle's `currentPriceUpScaled` and `lastAdjustedRound` are committed before the PostageStamp call. On failure, oracle has new price, PostageStamp has old price, and `lastAdjustedRound` prevents retry in the same round. + +**Exploitable? NO -- not by an external attacker.** + +- Requires PostageStamp's `setPrice` to revert (wrong role setup, paused, etc.) -- all admin-controlled. +- Admin can fix the desync by calling `PriceOracle.setPrice()` directly. +- There is no way for a regular user to trigger the PostageStamp call failure. + +**Verdict: Valid defensive-coding improvement. But HIGH overstates it since it requires misconfiguration and is admin-recoverable. MEDIUM/LOW.** + +--- + +## H-3 (HIGH) -- CEI violation in `PostageStamp.withdraw()` + +**Code:** [src/PostageStamp.sol](src/PostageStamp.sol) lines 515-527 + +```515:527:src/PostageStamp.sol + function withdraw(address beneficiary) external { + if (!hasRole(REDISTRIBUTOR_ROLE, msg.sender)) { + revert OnlyRedistributor(); + } + + uint256 totalAmount = totalPot(); + if (!ERC20(bzzToken).transfer(beneficiary, totalAmount)) { + revert TransferFailed(); + } + + emit PotWithdrawn(beneficiary, totalAmount); + pot = 0; // <-- state updated AFTER external transfer + } +``` + +**Bug:** `pot = 0` happens after the ERC20 transfer. Classic Checks-Effects-Interactions violation. + +**Exploitable? NO.** + +1. `withdraw` can **only** be called by `REDISTRIBUTOR_ROLE` (the Redistribution contract). +2. BZZ is a standard ERC20 **without transfer hooks** (no ERC777, no callbacks). +3. Even if BZZ had hooks, the `beneficiary` (round winner) does NOT hold `REDISTRIBUTOR_ROLE`, so they cannot reenter `withdraw()`. +4. The Redistribution contract calls via a single low-level `.call()` and does not reenter. + +**Verdict: The CEI violation exists in the code, and the fix is good defensive practice. But it is NOT exploitable given the known token and access control. HIGH is overstated. LOW/Informational.** + +--- + +## H-4 (HIGH) -- `setPrice()` overflow for uint64 range + +**Code:** [src/PriceOracle.sol](src/PriceOracle.sol) lines 82-99 + +```solidity +function setPrice(uint32 _price) external returns (bool) { + ... + uint64 _currentPriceUpScaled = _price << 10; // shift on uint32, high bits lost + ... +} +``` + +**Bug:** `_price << 10` is computed in `uint32` arithmetic. For `_price >= 2^22 (4,194,304)`, the high bits are silently truncated (Solidity 0.8.x does NOT check overflow on shifts). + +**Exploitable? NO.** + +- `setPrice` is **admin-only** (`DEFAULT_ADMIN_ROLE`). +- An external user cannot call this function. +- This is an input-validation guardrail for admin mistakes. + +**Verdict: Valid code improvement but NOT a security vulnerability. No external attacker involved. HIGH is a significant overstatement. LOW/Informational.** + +--- + +## H-5 (HIGH) -- Division by zero when oracle returns 0 + +**Code:** [src/Staking.sol](src/Staking.sol) line 142 + +```solidity +uint256 newCommittedStake = updatedPotentialStake / (OracleContract.currentPrice() * 2 ** _height); +``` + +**Bug:** If `OracleContract.currentPrice()` returns 0, division by zero. + +**Exploitable? NO.** + +- Solidity 0.8.x **already reverts** on division by zero (Panic(0x12)). The behavior is identical with or without the fix. +- PriceOracle enforces a minimum: `minimumPriceUpscaled = 24000 << 10 = 24,576,000`. After `>> 10`, minimum `currentPrice()` is 24,000. It **cannot** return 0 through normal operation. +- The only way to get price = 0 is a deliberately misconfigured or malicious oracle contract, which requires admin action. + +**Verdict: NOT exploitable. The fix provides a nicer error message but doesn't change behavior. HIGH is unjustified. Informational at best.** + +--- + +## M-1 (MEDIUM) -- No cap on commits per round (DoS vector) + +**Code:** [src/Redistribution.sol](src/Redistribution.sol) `commit()` pushes to `currentCommits` with no length check; `winnerSelection()` iterates all commits twice (truth + winner loops). + +**Exploitable? CONDITIONALLY YES -- but economically expensive.** + +- Each commit requires: (a) a unique staked address with >= 0.1 BZZ, (b) staked >= 2 rounds prior, (c) a unique overlay. +- `claim()` iterates commits twice in `getCurrentTruth()` and `winnerSelection()`. At ~5,000 gas per iteration, ~3,000 commits would exhaust a 30M gas block limit. +- Cost: 3,000 addresses x 0.1 BZZ = 300 BZZ, plus gas for 3,000 `manageStake` + 3,000 `commit` transactions, plus a 2-round wait. +- Unrevealed commits are frozen (not slashed), so the BZZ is recoverable after the penalty period. + +**Verdict: MEDIUM is fair. The DoS vector exists but has a moderate economic cost. An attacker could prevent any round from being claimed. Capping at 256 is a reasonable fix.** + +--- + +## M-3 (MEDIUM) -- `lastUpdatedBlockNumber` conflates freeze-until and stake-updated-at + +**Code:** [src/Staking.sol](src/Staking.sol) `freezeDeposit()` sets `lastUpdatedBlockNumber = block.number + _time`, but Redistribution's `commit()` also checks `_lastUpdate >= block.number - 2 * ROUND_LENGTH`. + +**Exploitable? NO direct attack, but unintended penalty extension.** + +- After a freeze expires, `lastUpdatedBlockNumber` is a recent-past value. The 2-round wait check in `commit()` sees this as a "recent stake update" and forces the node to call `manageStake()` again, then wait 2 more rounds. +- This makes freeze penalties harsher than intended (freeze duration + forced re-stake + 2-round cooldown). +- No attacker can exploit this for profit. It's a design coupling that hurts frozen nodes. + +**Verdict: Valid design issue. Not exploitable as an attack vector. MEDIUM is fair for a code-quality/design concern.** + +--- + +## M-7 (MEDIUM) -- `ecrecover` returning `address(0)` not checked + +**Code:** [src/Util/Signatures.sol](src/Util/Signatures.sol) line 45 + +```solidity +return ecrecover(_ethSignedMessageHash, v, r, s); // returns address(0) on failure +``` + +**Exploitable? NO for postage, VERY MARGINALLY for SOC.** + +- **Postage path:** `stampFunction()` in Redistribution.sol checks `batchOwner == address(0)` BEFORE calling `postageVerify`. So `batchOwner` is always non-zero when compared against the recovered address. A zero recovery never matches. +- **SOC path:** `socVerify` compares against user-provided `signer`. An attacker could set `signer = address(0)` and provide an invalid signature. But the subsequent `calculateSocAddress` check (`keccak256(identifier, address(0)) == proveSegment`) constrains this to a specific chunk address, making exploitation impractical. + +**Verdict: Not practically exploitable. Both call paths have secondary guards. MEDIUM is slightly overstated. LOW is more appropriate.** + +--- + +## M-8 (MEDIUM) -- Unbounded skipped-rounds loop in `adjustPrice()` + +**Code:** [src/PriceOracle.sol](src/PriceOracle.sol) lines 136-142 + +```solidity +if (skippedRounds > 0) { + _changeRate = changeRate[0]; + for (uint64 i = 0; i < skippedRounds; i++) { + _currentPriceUpScaled = (_changeRate * _currentPriceUpScaled) / _priceBase; + } +} +``` + +**Exploitable? YES -- after prolonged inactivity (~5 months).** + +- The real risk is not gas exhaustion (that would take years) but **uint64 overflow**. `_changeRate` (~~2^20) times `_currentPriceUpScaled` (uint64) overflows after the price grows by ~2^20x from minimum. This happens after roughly **16,900 skipped rounds (~~148 days)**. Solidity 0.8.x reverts on overflow, permanently bricking `adjustPrice()`. +- An attacker cannot force inactivity, but if the system goes idle for ~5 months (plausible on a less active chain), the oracle becomes unrecoverable. + +**Verdict: MEDIUM is fair. The overflow risk after ~5 months of inactivity is realistic. Capping at 256 iterations is a good fix.** + +--- + +## L-3 (LOW) -- `lastUpdatedBlock` not initialized in PostageStamp constructor + +**Code:** [src/PostageStamp.sol](src/PostageStamp.sol) constructor (line 166) + +**Bug:** `lastUpdatedBlock` defaults to 0. `currentTotalOutPayment()` computes `block.number - 0 = block.number` as the elapsed blocks. + +**Exploitable? NO.** + +- `lastPrice` also defaults to 0, so `increaseSinceLastUpdate = 0 * block.number = 0`. The result is correct. +- When `setPrice` is first called, it checks `lastPrice != 0` before updating `totalOutPayment`, correctly skipping the bogus period. +- The initialization order is safe under normal deployment flow (setPrice is called before meaningful batches exist). + +**Verdict: Not exploitable. Good hygiene to initialize, but no attack vector. LOW is appropriate but even that is generous.** + +--- + +## Summary Table + + +| ID | Reported | Actually Exploitable? | Justified Severity | +| --- | -------- | ---------------------------------------------------------------- | ------------------ | +| C-1 | CRITICAL | No (dead code + event-only) | Informational | +| H-1 | HIGH | No (requires admin-triggered PostageStamp failure, BZZ not lost) | Medium | +| H-2 | HIGH | No (requires misconfiguration, admin-fixable) | Low | +| H-3 | HIGH | No (BZZ has no hooks, access control prevents reentry) | Informational | +| H-4 | HIGH | No (admin-only function) | Informational | +| H-5 | HIGH | No (Solidity auto-reverts, oracle min-price prevents zero) | Informational | +| M-1 | MEDIUM | Conditionally (DoS with ~300 BZZ cost) | Medium | +| M-3 | MEDIUM | No (design coupling, no attack vector) | Low | +| M-7 | MEDIUM | No (secondary guards on both paths) | Low | +| M-8 | MEDIUM | Yes (oracle bricks after ~5 months idle) | Medium | +| L-3 | LOW | No (safe due to lastPrice=0 default) | Informational | + + +**Bottom line:** Of the 10 findings, only **M-1** (commit flooding DoS) and **M-8** (skipped-rounds overflow) represent plausible attack/failure scenarios against external users. The rest are either dead code (C-1), require admin/misconfiguration triggers (H-1, H-2, H-4), are already handled by the language/runtime (H-5), are unexploitable due to token properties and access control (H-3), or are design couplings with no attack vector (M-3, M-7, L-3). The fixes are good **defensive coding** practices but the severity ratings are significantly inflated. \ No newline at end of file diff --git a/echidna/README.md b/echidna/README.md index 112a1d02..3ff4e83e 100644 --- a/echidna/README.md +++ b/echidna/README.md @@ -147,7 +147,7 @@ High-signal properties per harness: - **Oracle harness** - Access control (admin-only + updater-only) and “paused means no changes” - - Price invariants: price never below minimum; downscaled vs upscaled consistency; lastAdjustedRound not in the future + - Price invariants: price never below minimum; lastAdjustedRound not in the future - Post-conditions for `setPrice` and `adjustPrice` (including skipped-round math), with overflow-aware modeling - **PostageStamp harness** @@ -181,7 +181,6 @@ High-signal properties per harness: - non-revealers are frozen during claim processing (`echidna_nonrevealers_frozen_after_claim_selection`) - **System/integration harness** - - Wiring invariants: correct addresses + roles across contracts - Oracle↔stamp invariant: `PostageStamp.lastPrice` tracks `PriceOracle.currentPrice()` after updates - Stamp accounting: internal `pot` does not exceed the stamp contract’s BZZ balance (`echidna_stamp_internal_pot_not_above_contract_balance`) - Redistribution happy-path consistency: tracked commit/reveal values appear in `Redistribution` storage diff --git a/src/echidna/EchidnaPostageStampHarness.sol b/src/echidna/EchidnaPostageStampHarness.sol index 562d0614..34a6d924 100644 --- a/src/echidna/EchidnaPostageStampHarness.sol +++ b/src/echidna/EchidnaPostageStampHarness.sol @@ -412,12 +412,6 @@ contract EchidnaPostageStampHarness { !potDecreasedUnexpectedly; } - function echidna_minimumInitialBalancePerChunk_matches_formula() external view returns (bool) { - return - stamp.minimumInitialBalancePerChunk() == - uint256(stamp.minimumValidityBlocks()) * uint256(stamp.lastPrice()); - } - function echidna_lastExpiryBalance_never_exceeds_currentTotalOutPayment() external view returns (bool) { return stamp.lastExpiryBalance() <= stamp.currentTotalOutPayment(); } diff --git a/src/echidna/EchidnaPriceOracleHarness.sol b/src/echidna/EchidnaPriceOracleHarness.sol index e9c32d23..ff28623b 100644 --- a/src/echidna/EchidnaPriceOracleHarness.sol +++ b/src/echidna/EchidnaPriceOracleHarness.sol @@ -223,10 +223,6 @@ contract EchidnaPriceOracleHarness { return oracle.currentPriceUpScaled() >= oracle.minimumPriceUpscaled(); } - function echidna_currentPrice_matches_upscaled() external view returns (bool) { - return oracle.currentPrice() == uint32(oracle.currentPriceUpScaled() >> 10); - } - function echidna_lastAdjustedRound_not_in_future() external view returns (bool) { return oracle.lastAdjustedRound() <= oracle.currentRound(); } diff --git a/src/echidna/EchidnaStakeRegistryHarness.sol b/src/echidna/EchidnaStakeRegistryHarness.sol index 481bcfbe..ba99631a 100644 --- a/src/echidna/EchidnaStakeRegistryHarness.sol +++ b/src/echidna/EchidnaStakeRegistryHarness.sol @@ -467,10 +467,6 @@ contract EchidnaStakeRegistryHarness { !actionInvariantViolated; } - function echidna_registry_token_is_expected() external view returns (bool) { - return registry.bzzToken() == address(token); - } - function echidna_registry_balance_covers_sum_potential() external view returns (bool) { uint256 sumPotential; for (uint256 i = 0; i < ACTOR_COUNT; i++) { diff --git a/src/echidna/EchidnaSystemHarness.sol b/src/echidna/EchidnaSystemHarness.sol index 03f75254..339e64d8 100644 --- a/src/echidna/EchidnaSystemHarness.sol +++ b/src/echidna/EchidnaSystemHarness.sol @@ -269,22 +269,6 @@ contract EchidnaSystemHarness { // Integration properties // ----------------------------- - function echidna_system_is_wired_correctly() external view returns (bool) { - if (address(stake.OracleContract()) != address(oracle)) return false; - if (address(redist.Stakes()) != address(stake)) return false; - if (address(redist.PostageContract()) != address(stamp)) return false; - if (address(redist.OracleContract()) != address(oracle)) return false; - return true; - } - - function echidna_roles_wired_correctly() external view returns (bool) { - if (!stamp.hasRole(stamp.PRICE_ORACLE_ROLE(), address(oracle))) return false; - if (!stake.hasRole(stake.REDISTRIBUTOR_ROLE(), address(redist))) return false; - if (!stamp.hasRole(stamp.REDISTRIBUTOR_ROLE(), address(redist))) return false; - if (!oracle.hasRole(oracle.PRICE_UPDATER_ROLE(), address(actors[0]))) return false; - return true; - } - function echidna_unauthorized_oracle_adjust_never_succeeds() external view returns (bool) { return !unauthorizedOracleAdjustSucceeded; } From 09d7105d4d82a7b5e788a4366842620d68010dae Mon Sep 17 00:00:00 2001 From: Cardinal Date: Thu, 16 Apr 2026 09:18:32 +0200 Subject: [PATCH 28/50] fix: lower maxBlockDelay to improve redistribution phase coverage 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. --- echidna/echidna.yaml | 7 ++++--- src/echidna/EchidnaRedistributionClaimHarness.sol | 4 ++++ src/echidna/EchidnaRedistributionHarness.sol | 4 ++++ src/echidna/EchidnaSystemHarness.sol | 4 ++++ 4 files changed, 16 insertions(+), 3 deletions(-) diff --git a/echidna/echidna.yaml b/echidna/echidna.yaml index acd0db94..b55b4468 100644 --- a/echidna/echidna.yaml +++ b/echidna/echidna.yaml @@ -10,10 +10,11 @@ testLimit: 25000 # Keep this modest; increase locally if you want a smaller reproducer. shrinkLimit: 1000 -# Bound random time/block jumps between transactions. -# This keeps PriceOracle.adjustPrice() (skippedRounds loop) from becoming extremely slow. +# Bound random block jumps between transactions. +# 38 = ROUND_LENGTH/4 (one phase width) so the fuzzer naturally walks through +# commit → reveal → claim within the same redistribution round. maxTimeDelay: 0 -maxBlockDelay: 1000 +maxBlockDelay: 38 # Persist interesting inputs between runs. corpusDir: echidna/corpus diff --git a/src/echidna/EchidnaRedistributionClaimHarness.sol b/src/echidna/EchidnaRedistributionClaimHarness.sol index 8c12ce86..8b57db72 100644 --- a/src/echidna/EchidnaRedistributionClaimHarness.sol +++ b/src/echidna/EchidnaRedistributionClaimHarness.sol @@ -179,6 +179,10 @@ contract EchidnaRedistributionClaimHarness { // Actions // ----------------------------- + /// @dev No-op that lets Echidna advance block.number without side effects, + /// helping the fuzzer walk through round phases. + function act_tick() external {} + function act_seedPot(uint256 amount) external { _clearClaimPending(); uint256 x = amount % 1e24; diff --git a/src/echidna/EchidnaRedistributionHarness.sol b/src/echidna/EchidnaRedistributionHarness.sol index e405e799..5702ccd1 100644 --- a/src/echidna/EchidnaRedistributionHarness.sol +++ b/src/echidna/EchidnaRedistributionHarness.sol @@ -228,6 +228,10 @@ contract EchidnaRedistributionHarness { // Actions // ----------------------------- + /// @dev No-op that lets Echidna advance block.number without side effects, + /// helping the fuzzer walk through round phases. + function act_tick() external {} + function act_setActorStake( uint8 actorId, bytes32 overlay, diff --git a/src/echidna/EchidnaSystemHarness.sol b/src/echidna/EchidnaSystemHarness.sol index 339e64d8..6ea813b8 100644 --- a/src/echidna/EchidnaSystemHarness.sol +++ b/src/echidna/EchidnaSystemHarness.sol @@ -146,6 +146,10 @@ contract EchidnaSystemHarness { // Integration actions // ----------------------------- + /// @dev No-op that lets Echidna advance block.number without side effects, + /// helping the fuzzer walk through round phases. + function act_tick() external {} + function act_actor_manageStake(uint8 actorId, bytes32 setNonce, uint256 addAmount, uint8 height) external { EchidnaSystemActor a = actors[uint256(actorId) % ACTOR_COUNT]; uint8 h = uint8(height % 32); From 1760ecb1c8b696e9ebeb3794d37710d7f69d4644 Mon Sep 17 00:00:00 2001 From: Cardinal Date: Thu, 16 Apr 2026 09:33:56 +0200 Subject: [PATCH 29/50] fix(echidna): address PR #306 review feedback (config, script, cleanup) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- ...y_findings_exploitability_8fd9957b.plan.md | 280 ------------------ .gitignore | 2 - echidna/README.md | 2 +- echidna/echidna.yaml | 6 +- scripts/echidna.sh | 15 +- 5 files changed, 12 insertions(+), 293 deletions(-) delete mode 100644 .cursor/plans/security_findings_exploitability_8fd9957b.plan.md diff --git a/.cursor/plans/security_findings_exploitability_8fd9957b.plan.md b/.cursor/plans/security_findings_exploitability_8fd9957b.plan.md deleted file mode 100644 index 6eddea69..00000000 --- a/.cursor/plans/security_findings_exploitability_8fd9957b.plan.md +++ /dev/null @@ -1,280 +0,0 @@ ---- -name: Security Findings Exploitability -overview: Detailed exploitability analysis of all 10 reported security findings against the current Staking, Redistribution, PostageStamp, and PriceOracle contracts. -todos: [] -isProject: false ---- - -# Exploitability Analysis of Reported Security Findings - -Below is a finding-by-finding assessment against the **current code on disk** (pre-fix). For each I state whether the vulnerability is real, whether it is actually exploitable by an external attacker, and whether the severity assigned is justified. - ---- - -## C-1 (CRITICAL) -- Overlay lost after `delete` in `slashDeposit` - -**Code:** [src/Staking.sol](src/Staking.sol) lines 225-237 - -```225:237:src/Staking.sol - function slashDeposit(address _owner, uint256 _amount) external { - if (!hasRole(REDISTRIBUTOR_ROLE, msg.sender)) revert OnlyRedistributor(); - - if (stakes[_owner].lastUpdatedBlockNumber != 0) { - if (stakes[_owner].potentialStake > _amount) { - stakes[_owner].potentialStake -= _amount; - stakes[_owner].lastUpdatedBlockNumber = block.number; - } else { - delete stakes[_owner]; - } - } - emit StakeSlashed(_owner, stakes[_owner].overlay, _amount); - } -``` - -**Bug:** After `delete stakes[_owner]` zeroes the struct, the `emit` on line 236 reads `stakes[_owner].overlay` which is now `bytes32(0)`. - -**Exploitable? NO.** - -- Impact is limited to an **incorrect event emission** (overlay logged as zero). No state corruption, no fund loss. -- `**slashDeposit` is dead code in the current system.** The Redistribution contract's `IStakeRegistry` interface does not include `slashDeposit`, and the only call site in Redistribution.sol is commented out (line 565). No contract currently holds `REDISTRIBUTOR_ROLE` and calls this function. -- Even if called, only off-chain event indexers would see the wrong overlay. No on-chain consequence. - -**Verdict: CRITICAL is a gross overstatement. LOW/Informational at most -- and only relevant once `slashDeposit` is wired into a live redistributor.** - ---- - -## H-1 (HIGH) -- `claim()` silently swallows PostageStamp withdrawal failure - -**Code:** [src/Redistribution.sol](src/Redistribution.sol) lines 485-491 - -```485:491:src/Redistribution.sol - (bool success, ) = address(PostageContract).call( - abi.encodeWithSignature("withdraw(address)", winnerSelected.owner) - ); - if (!success) { - emit WithdrawFailed(winnerSelected.owner); - } -``` - -**Bug:** If `PostageStamp.withdraw` reverts (paused, transfer failure, etc.), the round is still marked as claimed (`currentClaimRound = cr` at line 580). The winner gets no BZZ. - -**Exploitable? PARTIALLY -- but not by an external attacker.** - -- BZZ is NOT lost from the system. The `pot` in PostageStamp remains unchanged on revert, and it accumulates into the next round's pot. -- The failure requires PostageStamp to be in a broken state (paused, out of BZZ, etc.), which is an **admin-controlled condition**. -- No external attacker can trigger this without admin access to PostageStamp. -- The round's winner misses their reward, but the next round's winner gets a larger pot. - -**Verdict: Valid design concern. The fix (reverting on failure) is sensible. But HIGH overstates it since it requires PostageStamp malfunction and BZZ is not destroyed. MEDIUM is more appropriate.** - ---- - -## H-2 (HIGH) -- PriceOracle state desyncs from PostageStamp on failed `setPrice` - -**Code:** [src/PriceOracle.sol](src/PriceOracle.sol) `adjustPrice()` lines ~145-160 - -```solidity -currentPriceUpScaled = _currentPriceUpScaled; // state updated -lastAdjustedRound = currentRoundNumber; // state updated - -(bool success, ) = address(postageStamp).call( - abi.encodeWithSignature("setPrice(uint256)", uint256(currentPrice())) -); -if (!success) { - emit StampPriceUpdateFailed(currentPrice()); - return false; // but PostageStamp still has old price -} -``` - -**Bug:** Oracle's `currentPriceUpScaled` and `lastAdjustedRound` are committed before the PostageStamp call. On failure, oracle has new price, PostageStamp has old price, and `lastAdjustedRound` prevents retry in the same round. - -**Exploitable? NO -- not by an external attacker.** - -- Requires PostageStamp's `setPrice` to revert (wrong role setup, paused, etc.) -- all admin-controlled. -- Admin can fix the desync by calling `PriceOracle.setPrice()` directly. -- There is no way for a regular user to trigger the PostageStamp call failure. - -**Verdict: Valid defensive-coding improvement. But HIGH overstates it since it requires misconfiguration and is admin-recoverable. MEDIUM/LOW.** - ---- - -## H-3 (HIGH) -- CEI violation in `PostageStamp.withdraw()` - -**Code:** [src/PostageStamp.sol](src/PostageStamp.sol) lines 515-527 - -```515:527:src/PostageStamp.sol - function withdraw(address beneficiary) external { - if (!hasRole(REDISTRIBUTOR_ROLE, msg.sender)) { - revert OnlyRedistributor(); - } - - uint256 totalAmount = totalPot(); - if (!ERC20(bzzToken).transfer(beneficiary, totalAmount)) { - revert TransferFailed(); - } - - emit PotWithdrawn(beneficiary, totalAmount); - pot = 0; // <-- state updated AFTER external transfer - } -``` - -**Bug:** `pot = 0` happens after the ERC20 transfer. Classic Checks-Effects-Interactions violation. - -**Exploitable? NO.** - -1. `withdraw` can **only** be called by `REDISTRIBUTOR_ROLE` (the Redistribution contract). -2. BZZ is a standard ERC20 **without transfer hooks** (no ERC777, no callbacks). -3. Even if BZZ had hooks, the `beneficiary` (round winner) does NOT hold `REDISTRIBUTOR_ROLE`, so they cannot reenter `withdraw()`. -4. The Redistribution contract calls via a single low-level `.call()` and does not reenter. - -**Verdict: The CEI violation exists in the code, and the fix is good defensive practice. But it is NOT exploitable given the known token and access control. HIGH is overstated. LOW/Informational.** - ---- - -## H-4 (HIGH) -- `setPrice()` overflow for uint64 range - -**Code:** [src/PriceOracle.sol](src/PriceOracle.sol) lines 82-99 - -```solidity -function setPrice(uint32 _price) external returns (bool) { - ... - uint64 _currentPriceUpScaled = _price << 10; // shift on uint32, high bits lost - ... -} -``` - -**Bug:** `_price << 10` is computed in `uint32` arithmetic. For `_price >= 2^22 (4,194,304)`, the high bits are silently truncated (Solidity 0.8.x does NOT check overflow on shifts). - -**Exploitable? NO.** - -- `setPrice` is **admin-only** (`DEFAULT_ADMIN_ROLE`). -- An external user cannot call this function. -- This is an input-validation guardrail for admin mistakes. - -**Verdict: Valid code improvement but NOT a security vulnerability. No external attacker involved. HIGH is a significant overstatement. LOW/Informational.** - ---- - -## H-5 (HIGH) -- Division by zero when oracle returns 0 - -**Code:** [src/Staking.sol](src/Staking.sol) line 142 - -```solidity -uint256 newCommittedStake = updatedPotentialStake / (OracleContract.currentPrice() * 2 ** _height); -``` - -**Bug:** If `OracleContract.currentPrice()` returns 0, division by zero. - -**Exploitable? NO.** - -- Solidity 0.8.x **already reverts** on division by zero (Panic(0x12)). The behavior is identical with or without the fix. -- PriceOracle enforces a minimum: `minimumPriceUpscaled = 24000 << 10 = 24,576,000`. After `>> 10`, minimum `currentPrice()` is 24,000. It **cannot** return 0 through normal operation. -- The only way to get price = 0 is a deliberately misconfigured or malicious oracle contract, which requires admin action. - -**Verdict: NOT exploitable. The fix provides a nicer error message but doesn't change behavior. HIGH is unjustified. Informational at best.** - ---- - -## M-1 (MEDIUM) -- No cap on commits per round (DoS vector) - -**Code:** [src/Redistribution.sol](src/Redistribution.sol) `commit()` pushes to `currentCommits` with no length check; `winnerSelection()` iterates all commits twice (truth + winner loops). - -**Exploitable? CONDITIONALLY YES -- but economically expensive.** - -- Each commit requires: (a) a unique staked address with >= 0.1 BZZ, (b) staked >= 2 rounds prior, (c) a unique overlay. -- `claim()` iterates commits twice in `getCurrentTruth()` and `winnerSelection()`. At ~5,000 gas per iteration, ~3,000 commits would exhaust a 30M gas block limit. -- Cost: 3,000 addresses x 0.1 BZZ = 300 BZZ, plus gas for 3,000 `manageStake` + 3,000 `commit` transactions, plus a 2-round wait. -- Unrevealed commits are frozen (not slashed), so the BZZ is recoverable after the penalty period. - -**Verdict: MEDIUM is fair. The DoS vector exists but has a moderate economic cost. An attacker could prevent any round from being claimed. Capping at 256 is a reasonable fix.** - ---- - -## M-3 (MEDIUM) -- `lastUpdatedBlockNumber` conflates freeze-until and stake-updated-at - -**Code:** [src/Staking.sol](src/Staking.sol) `freezeDeposit()` sets `lastUpdatedBlockNumber = block.number + _time`, but Redistribution's `commit()` also checks `_lastUpdate >= block.number - 2 * ROUND_LENGTH`. - -**Exploitable? NO direct attack, but unintended penalty extension.** - -- After a freeze expires, `lastUpdatedBlockNumber` is a recent-past value. The 2-round wait check in `commit()` sees this as a "recent stake update" and forces the node to call `manageStake()` again, then wait 2 more rounds. -- This makes freeze penalties harsher than intended (freeze duration + forced re-stake + 2-round cooldown). -- No attacker can exploit this for profit. It's a design coupling that hurts frozen nodes. - -**Verdict: Valid design issue. Not exploitable as an attack vector. MEDIUM is fair for a code-quality/design concern.** - ---- - -## M-7 (MEDIUM) -- `ecrecover` returning `address(0)` not checked - -**Code:** [src/Util/Signatures.sol](src/Util/Signatures.sol) line 45 - -```solidity -return ecrecover(_ethSignedMessageHash, v, r, s); // returns address(0) on failure -``` - -**Exploitable? NO for postage, VERY MARGINALLY for SOC.** - -- **Postage path:** `stampFunction()` in Redistribution.sol checks `batchOwner == address(0)` BEFORE calling `postageVerify`. So `batchOwner` is always non-zero when compared against the recovered address. A zero recovery never matches. -- **SOC path:** `socVerify` compares against user-provided `signer`. An attacker could set `signer = address(0)` and provide an invalid signature. But the subsequent `calculateSocAddress` check (`keccak256(identifier, address(0)) == proveSegment`) constrains this to a specific chunk address, making exploitation impractical. - -**Verdict: Not practically exploitable. Both call paths have secondary guards. MEDIUM is slightly overstated. LOW is more appropriate.** - ---- - -## M-8 (MEDIUM) -- Unbounded skipped-rounds loop in `adjustPrice()` - -**Code:** [src/PriceOracle.sol](src/PriceOracle.sol) lines 136-142 - -```solidity -if (skippedRounds > 0) { - _changeRate = changeRate[0]; - for (uint64 i = 0; i < skippedRounds; i++) { - _currentPriceUpScaled = (_changeRate * _currentPriceUpScaled) / _priceBase; - } -} -``` - -**Exploitable? YES -- after prolonged inactivity (~5 months).** - -- The real risk is not gas exhaustion (that would take years) but **uint64 overflow**. `_changeRate` (~~2^20) times `_currentPriceUpScaled` (uint64) overflows after the price grows by ~2^20x from minimum. This happens after roughly **16,900 skipped rounds (~~148 days)**. Solidity 0.8.x reverts on overflow, permanently bricking `adjustPrice()`. -- An attacker cannot force inactivity, but if the system goes idle for ~5 months (plausible on a less active chain), the oracle becomes unrecoverable. - -**Verdict: MEDIUM is fair. The overflow risk after ~5 months of inactivity is realistic. Capping at 256 iterations is a good fix.** - ---- - -## L-3 (LOW) -- `lastUpdatedBlock` not initialized in PostageStamp constructor - -**Code:** [src/PostageStamp.sol](src/PostageStamp.sol) constructor (line 166) - -**Bug:** `lastUpdatedBlock` defaults to 0. `currentTotalOutPayment()` computes `block.number - 0 = block.number` as the elapsed blocks. - -**Exploitable? NO.** - -- `lastPrice` also defaults to 0, so `increaseSinceLastUpdate = 0 * block.number = 0`. The result is correct. -- When `setPrice` is first called, it checks `lastPrice != 0` before updating `totalOutPayment`, correctly skipping the bogus period. -- The initialization order is safe under normal deployment flow (setPrice is called before meaningful batches exist). - -**Verdict: Not exploitable. Good hygiene to initialize, but no attack vector. LOW is appropriate but even that is generous.** - ---- - -## Summary Table - - -| ID | Reported | Actually Exploitable? | Justified Severity | -| --- | -------- | ---------------------------------------------------------------- | ------------------ | -| C-1 | CRITICAL | No (dead code + event-only) | Informational | -| H-1 | HIGH | No (requires admin-triggered PostageStamp failure, BZZ not lost) | Medium | -| H-2 | HIGH | No (requires misconfiguration, admin-fixable) | Low | -| H-3 | HIGH | No (BZZ has no hooks, access control prevents reentry) | Informational | -| H-4 | HIGH | No (admin-only function) | Informational | -| H-5 | HIGH | No (Solidity auto-reverts, oracle min-price prevents zero) | Informational | -| M-1 | MEDIUM | Conditionally (DoS with ~300 BZZ cost) | Medium | -| M-3 | MEDIUM | No (design coupling, no attack vector) | Low | -| M-7 | MEDIUM | No (secondary guards on both paths) | Low | -| M-8 | MEDIUM | Yes (oracle bricks after ~5 months idle) | Medium | -| L-3 | LOW | No (safe due to lastPrice=0 default) | Informational | - - -**Bottom line:** Of the 10 findings, only **M-1** (commit flooding DoS) and **M-8** (skipped-rounds overflow) represent plausible attack/failure scenarios against external users. The rest are either dead code (C-1), require admin/misconfiguration triggers (H-1, H-2, H-4), are already handled by the language/runtime (H-5), are unexploitable due to token properties and access control (H-3), or are design couplings with no attack vector (M-3, M-7, L-3). The fixes are good **defensive coding** practices but the severity ratings are significantly inflated. \ No newline at end of file diff --git a/.gitignore b/.gitignore index e7339e56..61d01e91 100644 --- a/.gitignore +++ b/.gitignore @@ -42,6 +42,4 @@ tenderly.log # Echidna fuzzing echidna/corpus/ -echidna/coverage/ -echidna/out/ crytic-export/ \ No newline at end of file diff --git a/echidna/README.md b/echidna/README.md index 3ff4e83e..7e7dcf3c 100644 --- a/echidna/README.md +++ b/echidna/README.md @@ -246,7 +246,7 @@ These are ignored by git via `.gitignore`. Typical next steps: -- Add another harness under `src/echidna/` for other protocol contracts. +- Add another harness under `src/echidna/` following the naming convention `Echidna*Harness.sol`. The runner script auto-discovers files matching that pattern, so no manual script edits are needed. - Keep actions non-reverting and model only the roles/privileges you want to include. - Start with a few **obviously true** invariants, then iterate: - If Echidna finds a counterexample, decide whether that is a **bug** or a **property mismatch**. diff --git a/echidna/echidna.yaml b/echidna/echidna.yaml index b55b4468..db4cd1b5 100644 --- a/echidna/echidna.yaml +++ b/echidna/echidna.yaml @@ -1,7 +1,9 @@ testMode: property -# More steps per sequence helps stateful protocols. -seqLen: 100 +# Longer sequences give the fuzzer enough steps to (a) reach block 305+ +# where staking passes MustStake2Rounds, and (b) walk through multiple +# commit → reveal → claim cycles. +seqLen: 200 # Start small; can be increased once it’s stable in CI. testLimit: 25000 diff --git a/scripts/echidna.sh b/scripts/echidna.sh index 2f1575c4..ccbfe103 100755 --- a/scripts/echidna.sh +++ b/scripts/echidna.sh @@ -17,14 +17,13 @@ CONTRACT="${ECHIDNA_CONTRACT:-}" # and without Hardhat artifacts CryticCompile will try (and fail) to run `npx hardhat compile`. yarn -s hardhat compile --force >/dev/null -CONTRACTS_DEFAULT=( - "EchidnaStakeRegistryHarness" - "EchidnaPriceOracleHarness" - "EchidnaPostageStampHarness" - "EchidnaRedistributionHarness" - "EchidnaRedistributionClaimHarness" - "EchidnaSystemHarness" -) +# Auto-discover harness contracts from src/echidna/Echidna*Harness.sol. +CONTRACTS_DEFAULT=() +for f in src/echidna/Echidna*Harness.sol; do + [[ -f "$f" ]] || continue + name="$(basename "$f" .sol)" + CONTRACTS_DEFAULT+=("$name") +done if [[ -n "$CONTRACT" ]]; then CONTRACTS_TO_RUN=("$CONTRACT") From 1783b27e0caa830cd9990ae1349e54e934dc4530 Mon Sep 17 00:00:00 2001 From: Cardinal Date: Thu, 16 Apr 2026 09:54:36 +0200 Subject: [PATCH 30/50] chore(echidna): remove dead code from harnesses and mocks - 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 --- src/echidna/EchidnaMocks.sol | 6 +- .../EchidnaRedistributionClaimHarness.sol | 2 - src/echidna/EchidnaRedistributionHarness.sol | 71 +++---------------- 3 files changed, 10 insertions(+), 69 deletions(-) diff --git a/src/echidna/EchidnaMocks.sol b/src/echidna/EchidnaMocks.sol index 2a471887..bb00eff1 100644 --- a/src/echidna/EchidnaMocks.sol +++ b/src/echidna/EchidnaMocks.sol @@ -15,7 +15,6 @@ contract EchidnaStakeRegistryMock is IStakeRegistry { mapping(address => Node) internal nodes; mapping(address => uint256) public freezeCount; - mapping(address => uint256) public lastFreezeTime; function setNode( address owner, @@ -36,7 +35,6 @@ contract EchidnaStakeRegistryMock is IStakeRegistry { function freezeDeposit(address _owner, uint256 _time) external { if (!nodes[_owner].exists) return; freezeCount[_owner] += 1; - lastFreezeTime[_owner] = _time; nodes[_owner].lastUpdated = block.number + _time; } @@ -60,11 +58,9 @@ contract EchidnaStakeRegistryMock is IStakeRegistry { /// @notice Shared price oracle mock for redistribution harnesses. contract EchidnaPriceOracleMock is IPriceOracle { uint256 public calls; - uint16 public lastRedundancy; - function adjustPrice(uint16 redundancy) external returns (bool) { + function adjustPrice(uint16) external returns (bool) { calls += 1; - lastRedundancy = redundancy; return true; } } diff --git a/src/echidna/EchidnaRedistributionClaimHarness.sol b/src/echidna/EchidnaRedistributionClaimHarness.sol index 8b57db72..ead3c492 100644 --- a/src/echidna/EchidnaRedistributionClaimHarness.sol +++ b/src/echidna/EchidnaRedistributionClaimHarness.sol @@ -10,7 +10,6 @@ contract EchidnaPostageStampPotMock is IPostageStamp { TestToken internal immutable token; uint256 public pot; - uint256 public withdrawCalls; address public lastBeneficiary; uint256 public lastAmount; uint256 public validChunkCountValue; @@ -32,7 +31,6 @@ contract EchidnaPostageStampPotMock is IPostageStamp { function withdraw(address beneficiary) external { uint256 bal = token.balanceOf(address(this)); uint256 amt = pot < bal ? pot : bal; - withdrawCalls += 1; lastBeneficiary = beneficiary; lastAmount = amt; pot = 0; diff --git a/src/echidna/EchidnaRedistributionHarness.sol b/src/echidna/EchidnaRedistributionHarness.sol index 5702ccd1..32f2d92b 100644 --- a/src/echidna/EchidnaRedistributionHarness.sol +++ b/src/echidna/EchidnaRedistributionHarness.sol @@ -11,33 +11,10 @@ contract EchidnaPostageStampMock is IPostageStamp { address public lastBeneficiary; uint256 public validChunkCountValue; - // Minimal batch data for claim's stampFunction() access pattern. - mapping(bytes32 => Batch) internal _batches; - - struct Batch { - address owner; - uint8 depth; - uint8 bucketDepth; - bool immutableFlag; - uint256 normalisedBalance; - uint256 lastUpdatedBlockNumber; - } - function setValidChunkCount(uint256 v) external { validChunkCountValue = v; } - function seedBatch(bytes32 id, address owner, uint8 depth, uint8 bucketDepth) external { - _batches[id] = Batch({ - owner: owner, - depth: depth, - bucketDepth: bucketDepth, - immutableFlag: false, - normalisedBalance: 1, - lastUpdatedBlockNumber: block.number - }); - } - function withdraw(address beneficiary) external { withdrawCalls += 1; lastBeneficiary = beneficiary; @@ -49,31 +26,26 @@ contract EchidnaPostageStampMock is IPostageStamp { return validChunkCountValue; } - function batchOwner(bytes32 _batchId) external view returns (address) { - return _batches[_batchId].owner; + function batchOwner(bytes32) external pure returns (address) { + return address(0); } - - function batchDepth(bytes32 _batchId) external view returns (uint8) { - return _batches[_batchId].depth; + function batchDepth(bytes32) external pure returns (uint8) { + return 0; } - - function batchBucketDepth(bytes32 _batchId) external view returns (uint8) { - return _batches[_batchId].bucketDepth; + function batchBucketDepth(bytes32) external pure returns (uint8) { + return 0; } - function remainingBalance(bytes32) external pure returns (uint256) { return 1; } - function minimumInitialBalancePerChunk() external pure returns (uint256) { return 1; } - function batches( - bytes32 id + bytes32 ) external - view + pure returns ( address owner, uint8 depth, @@ -83,8 +55,7 @@ contract EchidnaPostageStampMock is IPostageStamp { uint256 lastUpdatedBlockNumber ) { - Batch memory b = _batches[id]; - return (b.owner, b.depth, b.bucketDepth, b.immutableFlag, b.normalisedBalance, b.lastUpdatedBlockNumber); + return (address(0), 0, 0, false, 0, 0); } } @@ -707,30 +678,6 @@ contract EchidnaRedistributionHarness { ); } - function _revealFull( - uint256 i - ) - internal - view - returns ( - bool ok, - bytes32 overlay, - address owner, - uint8 depth, - uint256 stake, - uint256 stakeDensity, - bytes32 hash - ) - { - bytes memory data; - (ok, data) = address(redist).staticcall(abi.encodeWithSignature("currentReveals(uint256)", i)); - if (!ok) return (false, bytes32(0), address(0), 0, 0, 0, bytes32(0)); - (overlay, owner, depth, stake, stakeDensity, hash) = abi.decode( - data, - (bytes32, address, uint8, uint256, uint256, bytes32) - ); - } - function _checkTrackedReveal(uint256 actorIdx) internal view returns (bool) { (bool ok, uint256 commitIdx) = _findCommit(trackedOverlay[actorIdx], trackedObfuscated[actorIdx]); if (!ok) return false; From 9b7dd77c1cc6bf85924d131eaeca48b1d3341aa5 Mon Sep 17 00:00:00 2001 From: Cardinal Date: Thu, 16 Apr 2026 10:10:16 +0200 Subject: [PATCH 31/50] feat(echidna): add failed-withdraw coverage for H-1 scenario in claim 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). --- .../EchidnaRedistributionClaimHarness.sol | 39 +++++++++++++++++-- 1 file changed, 36 insertions(+), 3 deletions(-) diff --git a/src/echidna/EchidnaRedistributionClaimHarness.sol b/src/echidna/EchidnaRedistributionClaimHarness.sol index ead3c492..593abdb4 100644 --- a/src/echidna/EchidnaRedistributionClaimHarness.sol +++ b/src/echidna/EchidnaRedistributionClaimHarness.sol @@ -13,22 +13,27 @@ contract EchidnaPostageStampPotMock is IPostageStamp { address public lastBeneficiary; uint256 public lastAmount; uint256 public validChunkCountValue; + bool public shouldRevertWithdraw; constructor(TestToken t) { token = t; } function seedPot(uint256 amount) external { - // Mint to this mock and treat it as withdrawable pot. token.mint(address(this), amount); pot += amount; } + function setShouldRevertWithdraw(bool v) external { + shouldRevertWithdraw = v; + } + function setValidChunkCount(uint256 v) external { validChunkCountValue = v; } function withdraw(address beneficiary) external { + if (shouldRevertWithdraw) revert("mock withdraw revert"); uint256 bal = token.balanceOf(address(this)); uint256 amt = pot < bal ? pot : bal; lastBeneficiary = beneficiary; @@ -146,6 +151,7 @@ contract EchidnaRedistributionClaimHarness { // Pending claim postconditions. bool internal pendingClaim; + bool internal pendingWithdrawShouldFail; uint64 internal pendingClaimRound; uint256 internal pendingPotBefore; uint256 internal pendingOracleCallsBefore; @@ -188,6 +194,11 @@ contract EchidnaRedistributionClaimHarness { stampMock.seedPot(x); } + function act_setWithdrawRevertMode(bool v) external { + _clearClaimPending(); + stampMock.setShouldRevertWithdraw(v); + } + function act_setActorNode( uint8 actorId, bytes32 overlay, @@ -251,6 +262,7 @@ contract EchidnaRedistributionClaimHarness { uint256 idx = uint256(actorId) % ACTOR_COUNT; pendingClaimRound = redist.currentRound(); pendingOracleCallsBefore = oracleMock.calls(); + pendingWithdrawShouldFail = stampMock.shouldRevertWithdraw(); // Snapshot pot + actor balances before claim. pendingPotBefore = stampMock.pot(); @@ -277,7 +289,8 @@ contract EchidnaRedistributionClaimHarness { function echidna_claim_withdraws_pot_to_winner_when_successful() external view returns (bool) { if (!pendingClaim) return true; - if (redist.currentClaimRound() != pendingClaimRound) return true; // stale + if (pendingWithdrawShouldFail) return true; + if (redist.currentClaimRound() != pendingClaimRound) return true; // Pot must be zeroed by our mock withdraw on success. if (stampMock.pot() != 0) return false; @@ -299,11 +312,31 @@ contract EchidnaRedistributionClaimHarness { increased += 1; } } - // If potBefore was 0, no balances should change. if (pendingPotBefore == 0) return increased == 0; return increased == 1; } + /// @notice H-1 scenario: when withdraw fails, claim still succeeds (round consumed) + /// but pot is preserved and no actor balances change. + function echidna_failed_withdraw_preserves_pot_and_consumes_round() external view returns (bool) { + if (!pendingClaim) return true; + if (!pendingWithdrawShouldFail) return true; + if (redist.currentClaimRound() != pendingClaimRound) return true; + + // Round must still be marked as claimed even though withdraw failed. + // (This is the H-1 behavior: currentClaimRound is set inside winnerSelection() + // before withdraw runs, and the .call() swallows the revert.) + + // Pot must be unchanged — withdraw reverted so no transfer happened. + if (stampMock.pot() != pendingPotBefore) return false; + + // No actor balances should have changed. + for (uint256 i = 0; i < ACTOR_COUNT; i++) { + if (token.balanceOf(address(actors[i])) != pendingActorBalBefore[i]) return false; + } + return true; + } + function echidna_claim_triggers_oracle_adjustPrice() external view returns (bool) { if (!pendingClaim) return true; if (oracleMock.calls() <= pendingOracleCallsBefore) return false; From 39b2c223968ea811de65c6814fb50b32cd812a6b Mon Sep 17 00:00:00 2001 From: Cardinal Date: Thu, 16 Apr 2026 13:57:56 +0200 Subject: [PATCH 32/50] feat(echidna): add fixture-based real claim harness 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. --- .gitignore | 1 + echidna/README.md | 50 ++ echidna/echidna-real-claim.yaml | 20 + scripts/echidna.sh | 3 +- .../EchidnaRedistributionRealClaimHarness.sol | 656 ++++++++++++++++++ 5 files changed, 729 insertions(+), 1 deletion(-) create mode 100644 echidna/echidna-real-claim.yaml create mode 100644 src/echidna/EchidnaRedistributionRealClaimHarness.sol diff --git a/.gitignore b/.gitignore index 61d01e91..4ce52ddd 100644 --- a/.gitignore +++ b/.gitignore @@ -42,4 +42,5 @@ tenderly.log # Echidna fuzzing echidna/corpus/ +echidna/corpus-real-claim/ crytic-export/ \ No newline at end of file diff --git a/echidna/README.md b/echidna/README.md index 7e7dcf3c..2b25dfd6 100644 --- a/echidna/README.md +++ b/echidna/README.md @@ -21,6 +21,7 @@ This repo currently contains multiple harnesses: - **PostageStamp harness**: `src/echidna/EchidnaPostageStampHarness.sol` - **Redistribution harness**: `src/echidna/EchidnaRedistributionHarness.sol` - **Redistribution claim-stub harness**: `src/echidna/EchidnaRedistributionClaimHarness.sol` +- **Redistribution real-claim harness**: `src/echidna/EchidnaRedistributionRealClaimHarness.sol` - **System/integration harness**: `src/echidna/EchidnaSystemHarness.sol` ### What each harness deploys @@ -68,6 +69,21 @@ The **redistribution claim-stub harness** deploys: This is meant to fuzz the **claim-phase state machine + pot withdrawal effects** end-to-end, without paying the cost of generating valid Merkle/SOC/postage proofs. +The **redistribution real-claim harness** deploys: + +- the real `Redistribution` contract +- the shared redistribution stake/oracle mocks +- a fixture-aware postage mock that returns batch metadata matching the fixed proof bundles + +This harness stores one fixed CAC proof bundle and one fixed SOC proof bundle, both derived from the existing +Hardhat proof fixtures, and then fuzzes: + +- the real `commit -> reveal -> claim` path needed to activate those fixtures +- mutations of selected proof fields (reserve-commitment inclusion roots/branches, postage indices, SOC identifier) + +The goal is not to randomly discover valid proofs. Instead, it uses **known-good proofs as seed fixtures** and lets Echidna +mutate the surrounding scenario and targeted proof bytes while the real on-chain verifier runs. + The **system/integration harness** deploys: - `TestToken` @@ -122,6 +138,12 @@ Key actions per harness: - Happy-path flow: `act_happyCommit`, `act_happyReveal`, `act_claimStub` - Pot seeding: `act_seedPot` +- **Redistribution real-claim harness** + - Fixture selection: `act_useCacFixture`, `act_useSocFixture` + - Fixture setup: `act_prepareFixtureCommit`, `act_prepareFixtureReveal`, `act_claimActiveFixture` + - Pot seeding: `act_seedPot` + - Proof mutations: `act_mutateReserveCommitmentRoot`, `act_mutateOriginalChunkBranch`, `act_mutateTransformedChunkBranch`, `act_mutatePostageIndexLow`, `act_mutatePostageIndexHigh`, `act_mutateSocIdentifier` + - **System/integration harness** - Stake actions: `act_actor_manageStake`, `act_actor_withdrawSurplus` - Postage actions: `act_actor_createBatch`, `act_actor_topUp`, `act_actor_increaseDepth`, `act_actor_expireAll` @@ -180,6 +202,11 @@ High-signal properties per harness: - claim triggers an oracle `adjustPrice` call (`echidna_claim_triggers_oracle_adjustPrice`) - non-revealers are frozen during claim processing (`echidna_nonrevealers_frozen_after_claim_selection`) +- **Redistribution real-claim harness** + - untouched CAC/SOC fixtures can complete the real `claim()` path (`echidna_unmutated_fixture_claim_succeeds`) + - corrupted proof fixtures do not successfully claim (`echidna_mutated_fixture_claim_does_not_succeed`) + - successful real claims trigger the expected withdraw/oracle side-effects (`echidna_successful_real_claim_effects_hold`) + - **System/integration harness** - Oracle↔stamp invariant: `PostageStamp.lastPrice` tracks `PriceOracle.currentPrice()` after updates - Stamp accounting: internal `pot` does not exceed the stamp contract’s BZZ balance (`echidna_stamp_internal_pot_not_above_contract_balance`) @@ -219,6 +246,9 @@ yarn echidna By default, this runs **all** Echidna harness contracts in `src/echidna/`. +By default, the runner uses `echidna/echidna.yaml`. You can override that with `ECHIDNA_CONFIG` if a harness needs its own +corpus or tuned fuzzing parameters. + To run only a specific harness contract: ```bash @@ -227,9 +257,18 @@ ECHIDNA_CONTRACT=EchidnaPriceOracleHarness yarn echidna ECHIDNA_CONTRACT=EchidnaPostageStampHarness yarn echidna ECHIDNA_CONTRACT=EchidnaRedistributionHarness yarn echidna ECHIDNA_CONTRACT=EchidnaRedistributionClaimHarness yarn echidna +ECHIDNA_CONTRACT=EchidnaRedistributionRealClaimHarness yarn echidna ECHIDNA_CONTRACT=EchidnaSystemHarness yarn echidna ``` +To run the real-claim harness with its isolated corpus/config: + +```bash +ECHIDNA_CONTRACT=EchidnaRedistributionRealClaimHarness \ +ECHIDNA_CONFIG=echidna/echidna-real-claim.yaml \ +yarn echidna +``` + This uses Docker and the image `ghcr.io/crytic/echidna/echidna:latest`. ### Output files @@ -237,11 +276,22 @@ This uses Docker and the image `ghcr.io/crytic/echidna/echidna:latest`. Echidna may write artifacts such as: - `echidna/corpus/` (saved interesting inputs) +- `echidna/corpus-real-claim/` (isolated corpus for the real-claim harness when using `echidna/echidna-real-claim.yaml`) - `echidna/coverage/` - `crytic-export/` (Crytic export artifacts) These are ignored by git via `.gitignore`. +### Config files + +- `echidna/echidna.yaml`: shared default config used by all harnesses unless overridden +- `echidna/echidna-real-claim.yaml`: isolated config for `EchidnaRedistributionRealClaimHarness` + +The dedicated real-claim config exists because that harness relies on fixed proof fixtures and benefits from: + +- an isolated corpus, so it does not replay unrelated sequences from other harnesses +- a slightly shorter sequence budget, since it only needs to reach one deterministic `commit -> reveal -> claim` path and mutate proof fields around it + ## How to extend this Typical next steps: diff --git a/echidna/echidna-real-claim.yaml b/echidna/echidna-real-claim.yaml new file mode 100644 index 00000000..2a62d717 --- /dev/null +++ b/echidna/echidna-real-claim.yaml @@ -0,0 +1,20 @@ +testMode: property + +# This harness uses fixed CAC/SOC proof fixtures, so it doesn't need the +# longer multi-round exploration budget used by the more general harnesses. +seqLen: 120 + +# Keep parity with the default config unless we find a reason to tune further. +testLimit: 25000 +shrinkLimit: 1000 + +# 38 = ROUND_LENGTH / 4. This still lets Echidna walk commit -> reveal -> claim +# within the same redistribution round. +maxTimeDelay: 0 +maxBlockDelay: 38 + +# Keep this harness isolated from the shared corpus used by other harnesses. +corpusDir: echidna/corpus-real-claim + +coverage: true +format: text diff --git a/scripts/echidna.sh b/scripts/echidna.sh index ccbfe103..841a41ef 100755 --- a/scripts/echidna.sh +++ b/scripts/echidna.sh @@ -12,6 +12,7 @@ cd "$ROOT_DIR" IMAGE="${ECHIDNA_IMAGE:-ghcr.io/crytic/echidna/echidna:latest}" CONTRACT="${ECHIDNA_CONTRACT:-}" +CONFIG="${ECHIDNA_CONFIG:-echidna/echidna.yaml}" # Compile on the host. The Echidna container image doesn't ship with Node/npx, # and without Hardhat artifacts CryticCompile will try (and fail) to run `npx hardhat compile`. @@ -41,5 +42,5 @@ for c in "${CONTRACTS_TO_RUN[@]}"; do -v "$ROOT_DIR":/src \ -w /src \ "$IMAGE" \ - -c "rm -rf crytic-export && echidna-test . --contract ${c} --config echidna/echidna.yaml" + -c "rm -rf crytic-export && echidna-test . --contract ${c} --config ${CONFIG}" done diff --git a/src/echidna/EchidnaRedistributionRealClaimHarness.sol b/src/echidna/EchidnaRedistributionRealClaimHarness.sol new file mode 100644 index 00000000..0ea655ed --- /dev/null +++ b/src/echidna/EchidnaRedistributionRealClaimHarness.sol @@ -0,0 +1,656 @@ +// SPDX-License-Identifier: BSD-3-Clause +pragma solidity ^0.8.19; + +import "../Redistribution.sol"; +import "../interface/IPostageStamp.sol"; +import "./EchidnaMocks.sol"; + +contract EchidnaFixturePostageStampMock is IPostageStamp { + struct BatchData { + address owner; + uint8 depth; + uint8 bucketDepth; + bool exists; + } + + mapping(bytes32 => BatchData) internal batchData; + + uint256 public withdrawCalls; + address public lastBeneficiary; + uint256 public lastAmount; + uint256 public pot = 1 ether; + uint256 public validChunkCountValue = 1; + + function setBatch(bytes32 batchId, address owner, uint8 depth, uint8 bucketDepth) external { + batchData[batchId] = BatchData({owner: owner, depth: depth, bucketDepth: bucketDepth, exists: true}); + } + + function seedPot(uint256 amount) external { + pot += amount; + } + + function withdraw(address beneficiary) external { + withdrawCalls += 1; + lastBeneficiary = beneficiary; + lastAmount = pot; + pot = 0; + } + + function setPrice(uint256) external {} + + function validChunkCount() external view returns (uint256) { + return validChunkCountValue; + } + + function batchOwner(bytes32 batchId) external view returns (address) { + return batchData[batchId].owner; + } + + function batchDepth(bytes32 batchId) external view returns (uint8) { + return batchData[batchId].depth; + } + + function batchBucketDepth(bytes32 batchId) external view returns (uint8) { + return batchData[batchId].bucketDepth; + } + + function remainingBalance(bytes32 batchId) external view returns (uint256) { + return batchData[batchId].exists ? 1 : 0; + } + + function minimumInitialBalancePerChunk() external pure returns (uint256) { + return 1; + } + + function batches( + bytes32 batchId + ) + external + view + returns ( + address owner, + uint8 depth, + uint8 bucketDepth, + bool immutableFlag, + uint256 normalisedBalance, + uint256 lastUpdatedBlockNumber + ) + { + BatchData memory b = batchData[batchId]; + if (!b.exists) { + return (address(0), 0, 0, false, 0, 0); + } + return (b.owner, b.depth, b.bucketDepth, true, 1, 1); + } +} + +/// @notice Fixture-based Echidna harness for the real `claim()` verifier path. +/// @dev Uses hardcoded CAC/SOC proof bundles generated by the existing Hardhat tests. +contract EchidnaRedistributionRealClaimHarness { + uint256 internal constant ROUND_LENGTH = 152; + uint64 internal constant FIXTURE_PREPARE_ROUND = 4; + + uint8 internal constant FIXTURE_NONE = 0; + uint8 internal constant FIXTURE_CAC = 1; + uint8 internal constant FIXTURE_SOC = 2; + + address internal constant FIXTURE_BATCH_OWNER = 0x26234a2ad3bA8B398A762f279B792cfAcd536a3f; + + bytes32 internal constant FIXTURE_REVEAL_ANCHOR = + 0x3617319a054d772f909f7c479a2cebe5066e836a939412e32403c99029b92eff; + bytes32 internal constant FIXTURE_CLAIM_SEED = + 0xa4541cb5a0f209fed7c786aac6865922446ed57fc0dcf8ad07c17afcd3c5efb8; + bytes32 internal constant FIXTURE_NONCE = + 0xb5555b33b5555b33b5555b33b5555b33b5555b33b5555b33b5555b33b5555b33; + + bytes32 internal constant CAC_BATCH_ID = + 0x5bee6f33f47fbe2c3ff4c853dbc95f1a6a4a4191a1a7e3ece999a76c2790a83f; + bytes32 internal constant SOC_BATCH_ID = + 0x6cccd65a68bc5f7c19a273e9567ebf4b968a13c9be74fc99ad90159730eff219; + + bytes32 internal constant CAC_HASH = + 0xcf27be680f2dada5bcc45506997f54804e583650e48c28514ccc95234ef4f9f3; + bytes32 internal constant SOC_HASH = + 0x9a6afe770410c1bd7cdc1f324cfcf73ba1b85e3860d4594522456be4fe6b1d80; + + uint8 internal constant CAC_DEPTH = 1; + uint8 internal constant SOC_DEPTH = 0; + + EchidnaStakeRegistryMock internal stakeMock; + EchidnaFixturePostageStampMock internal stampMock; + EchidnaPriceOracleMock internal oracleMock; + Redistribution internal redist; + + uint8 internal activeFixtureKind; + bytes32 internal activeHash; + uint8 internal activeDepth; + bool internal activeFixtureMutated; + + Redistribution.ChunkInclusionProof internal activeProof1; + Redistribution.ChunkInclusionProof internal activeProof2; + Redistribution.ChunkInclusionProof internal activeProofLast; + + bool internal commitPrepared; + bool internal claimReady; + uint64 internal preparedRound; + + bool internal lastClaimObserved; + bool internal lastClaimExpectedSuccess; + bool internal lastClaimSucceeded; + uint256 internal withdrawCallsBeforeClaim; + uint256 internal oracleCallsBeforeClaim; + + constructor() { + stakeMock = new EchidnaStakeRegistryMock(); + stampMock = new EchidnaFixturePostageStampMock(); + oracleMock = new EchidnaPriceOracleMock(); + redist = new Redistribution(address(stakeMock), address(stampMock), address(oracleMock)); + + stampMock.setBatch(CAC_BATCH_ID, FIXTURE_BATCH_OWNER, 27, 16); + stampMock.setBatch(SOC_BATCH_ID, FIXTURE_BATCH_OWNER, 27, 16); + + // The overlay is arbitrary for proof verification; we choose the fixture reveal anchor so + // commit/reveal proximity is guaranteed once we reach the target round. + stakeMock.setNode(address(this), FIXTURE_REVEAL_ANCHOR, 0, 1e18, 1); + _useCacFixture(); + } + + function act_tick() external {} + + function act_useCacFixture() external { + _useCacFixture(); + } + + function act_useSocFixture() external { + _useSocFixture(); + } + + function act_seedPot(uint256 amount) external { + stampMock.seedPot((amount % 1e24) + 1); + } + + function act_prepareFixtureCommit() external { + _clearLastClaim(); + if (activeFixtureKind == FIXTURE_NONE) return; + if (!redist.currentPhaseCommit()) return; + if (redist.currentRound() != FIXTURE_PREPARE_ROUND) return; + if (block.number % ROUND_LENGTH == (ROUND_LENGTH / 4) - 1) return; + if (redist.currentRoundAnchor() != FIXTURE_REVEAL_ANCHOR) return; + + stakeMock.setNode(address(this), FIXTURE_REVEAL_ANCHOR, 0, 1e18, _backdateLastUpdated()); + + bytes32 obfuscated = redist.wrapCommit(FIXTURE_REVEAL_ANCHOR, activeDepth, activeHash, FIXTURE_NONCE); + (bool ok, ) = address(redist).call(abi.encodeWithSelector(redist.commit.selector, obfuscated, redist.currentRound())); + if (!ok) return; + + commitPrepared = true; + claimReady = false; + preparedRound = redist.currentRound(); + } + + function act_prepareFixtureReveal() external { + _clearLastClaim(); + if (!commitPrepared) return; + if (!redist.currentPhaseReveal()) return; + if (redist.currentRound() != preparedRound) return; + if (redist.currentCommitRound() != preparedRound) return; + + (bool ok, ) = address(redist).call(abi.encodeWithSelector(redist.reveal.selector, activeDepth, activeHash, FIXTURE_NONCE)); + if (!ok) return; + + claimReady = redist.currentSeed() == FIXTURE_CLAIM_SEED; + } + + function act_claimActiveFixture() external { + _clearLastClaim(); + if (!claimReady) return; + if (!redist.currentPhaseClaim()) return; + if (redist.currentRound() != preparedRound) return; + + lastClaimObserved = true; + lastClaimExpectedSuccess = !activeFixtureMutated; + withdrawCallsBeforeClaim = stampMock.withdrawCalls(); + oracleCallsBeforeClaim = oracleMock.calls(); + + (lastClaimSucceeded, ) = address(redist).call( + abi.encodeWithSelector(redist.claim.selector, activeProof1, activeProof2, activeProofLast) + ); + } + + function act_mutateReserveCommitmentRoot(bytes32 replacement) external { + if (activeFixtureKind == FIXTURE_NONE || activeProof1.proofSegments.length == 0) return; + activeProof1.proofSegments[0] = _tweak(replacement); + activeFixtureMutated = true; + _clearLastClaim(); + } + + function act_mutateOriginalChunkBranch(bytes32 replacement) external { + if (activeFixtureKind == FIXTURE_NONE || activeProof1.proofSegments2.length < 2) return; + activeProof1.proofSegments2[1] = _tweak(replacement); + activeFixtureMutated = true; + _clearLastClaim(); + } + + function act_mutateTransformedChunkBranch(bytes32 replacement) external { + if (activeFixtureKind == FIXTURE_NONE || activeProof1.proofSegments3.length < 2) return; + activeProof1.proofSegments3[1] = _tweak(replacement); + activeFixtureMutated = true; + _clearLastClaim(); + } + + function act_mutatePostageIndexLow(uint32 lowWord) external { + if (activeFixtureKind == FIXTURE_NONE) return; + uint64 current = activeProof1.postageProof.index; + uint64 next = (current & 0xffffffff00000000) | uint64(lowWord); + if (next == current) next ^= 1; + activeProof1.postageProof.index = next; + activeFixtureMutated = true; + _clearLastClaim(); + } + + function act_mutatePostageIndexHigh(uint32 highWord) external { + if (activeFixtureKind == FIXTURE_NONE) return; + uint64 current = activeProof1.postageProof.index; + uint64 next = (uint64(highWord) << 32) | (current & 0xffffffff); + if (next == current) next ^= uint64(1) << 32; + activeProof1.postageProof.index = next; + activeFixtureMutated = true; + _clearLastClaim(); + } + + function act_mutateSocIdentifier(bytes32 replacement) external { + if (activeFixtureKind != FIXTURE_SOC || activeProof1.socProof.length == 0) return; + activeProof1.socProof[0].identifier = _tweak(replacement); + activeFixtureMutated = true; + _clearLastClaim(); + } + + function echidna_unmutated_fixture_claim_succeeds() external view returns (bool) { + if (!lastClaimObserved) return true; + if (!lastClaimExpectedSuccess) return true; + return lastClaimSucceeded; + } + + function echidna_mutated_fixture_claim_does_not_succeed() external view returns (bool) { + if (!lastClaimObserved) return true; + if (lastClaimExpectedSuccess) return true; + return !lastClaimSucceeded; + } + + function echidna_successful_real_claim_effects_hold() external view returns (bool) { + if (!lastClaimObserved || !lastClaimExpectedSuccess || !lastClaimSucceeded) return true; + if (stampMock.withdrawCalls() != withdrawCallsBeforeClaim + 1) return false; + if (oracleMock.calls() != oracleCallsBeforeClaim + 1) return false; + if (stampMock.lastBeneficiary() != address(this)) return false; + if (stampMock.pot() != 0) return false; + if (redist.currentClaimRound() != preparedRound) return false; + return true; + } + + function _useCacFixture() internal { + _resetFixtureState(); + activeFixtureKind = FIXTURE_CAC; + activeHash = CAC_HASH; + activeDepth = CAC_DEPTH; + _writeProof(activeProof1, _cacProof1()); + _writeProof(activeProof2, _cacProof2()); + _writeProof(activeProofLast, _cacProofLast()); + } + + function _useSocFixture() internal { + _resetFixtureState(); + activeFixtureKind = FIXTURE_SOC; + activeHash = SOC_HASH; + activeDepth = SOC_DEPTH; + _writeProof(activeProof1, _socProof1()); + _writeProof(activeProof2, _socProof2()); + _writeProof(activeProofLast, _socProofLast()); + } + + function _resetFixtureState() internal { + delete activeProof1; + delete activeProof2; + delete activeProofLast; + activeFixtureMutated = false; + commitPrepared = false; + claimReady = false; + preparedRound = 0; + _clearLastClaim(); + } + + function _clearLastClaim() internal { + lastClaimObserved = false; + lastClaimExpectedSuccess = false; + lastClaimSucceeded = false; + withdrawCallsBeforeClaim = 0; + oracleCallsBeforeClaim = 0; + } + + function _backdateLastUpdated() internal view returns (uint256) { + uint256 twoRounds = 2 * ROUND_LENGTH; + if (block.number > twoRounds + 1) return block.number - twoRounds - 1; + return 1; + } + + function _tweak(bytes32 value) internal pure returns (bytes32) { + if (value == bytes32(0)) return bytes32(uint256(1)); + return value; + } + + function _writeProof( + Redistribution.ChunkInclusionProof storage dst, + Redistribution.ChunkInclusionProof memory src + ) internal { + delete dst.proofSegments; + delete dst.proveSegment; + delete dst.proofSegments2; + delete dst.proveSegment2; + delete dst.chunkSpan; + delete dst.proofSegments3; + delete dst.postageProof; + delete dst.socProof; + + _writeBytes32Array(dst.proofSegments, src.proofSegments); + dst.proveSegment = src.proveSegment; + _writeBytes32Array(dst.proofSegments2, src.proofSegments2); + dst.proveSegment2 = src.proveSegment2; + dst.chunkSpan = src.chunkSpan; + _writeBytes32Array(dst.proofSegments3, src.proofSegments3); + + dst.postageProof.signature = src.postageProof.signature; + dst.postageProof.postageId = src.postageProof.postageId; + dst.postageProof.index = src.postageProof.index; + dst.postageProof.timeStamp = src.postageProof.timeStamp; + + for (uint256 i = 0; i < src.socProof.length; i++) { + dst.socProof.push(); + dst.socProof[i].signer = src.socProof[i].signer; + dst.socProof[i].signature = src.socProof[i].signature; + dst.socProof[i].identifier = src.socProof[i].identifier; + dst.socProof[i].chunkAddr = src.socProof[i].chunkAddr; + } + } + + function _writeBytes32Array(bytes32[] storage dst, bytes32[] memory src) internal { + for (uint256 i = 0; i < src.length; i++) { + dst.push(src[i]); + } + } + + function _arr7( + bytes32 a0, + bytes32 a1, + bytes32 a2, + bytes32 a3, + bytes32 a4, + bytes32 a5, + bytes32 a6 + ) internal pure returns (bytes32[] memory arr) { + arr = new bytes32[](7); + arr[0] = a0; + arr[1] = a1; + arr[2] = a2; + arr[3] = a3; + arr[4] = a4; + arr[5] = a5; + arr[6] = a6; + } + + function _cacProof1() internal pure returns (Redistribution.ChunkInclusionProof memory p) { + p.proofSegments = _arr7( + 0x000057515f1f37c0136197a45bd50b4618e3ebe272c4fb34b7f00e8972b84630, + 0x24d4e8fa9840ee525cf13d85ec07ad6b6ac88a180c5e420a7e57f9a3bf0a0578, + 0x85e4c229f557648ecbd01e0d768a1786bd3df8bb248a395c5768fad9d8d56f74, + 0x19042aeaca66e2bf5e961ff1429b51d0b566700e94154d3ff2addea8910a857f, + 0x931e95ed8ea3efe7c961602b8b7888b59947124f190da163b85246c1d8dc28bc, + 0x0eb01ebfc9ed27500cd4dfc979272d1f0913cc9f66540d7e8005811109e1cf2d, + 0x887c22bd8750d34016ac3c66b5ff102dacdd73f6b014e710b51e8022af9a1968 + ); + p.proveSegment = 0x78fbc965b296a7e146ae2cfb03f4df22a84779078538905dde74327bf4e36545; + p.proofSegments2 = _arr7( + 0x6e21b55f5f30ea3d77ca7ee24fe43928f309f19916978a98eb75bd8dfbf9e9fb, + 0xe82ab3bce42ee51c670eadc57c5d7eab70a902e977ac2a820d82d1910b49c30a, + 0x0abb1479acb3314a30ecdc03016e6ef8900aa48882885f33918b8740d7766e26, + 0xb07cfba2224b8d59a81006711d8a4c1b8e342e7bfc8355cb7a1907793663b7cb, + 0xecf121c97385aed4648d1eca4eda8b7d4d1e7094c9a29b44ec8cd759b562bba8, + 0x03dac69fb24d1c455fe32e866dec762944d198a7fb38aa5744030a8b0157edf1, + 0x6eb22b1022d18748bd1f3b2f0afe6ec82d7457406012ea91728452602ebc44b2 + ); + p.proveSegment2 = 0x575c434071a58a272ed3d3c9c73782c66b680650540409b53c05db21a21db577; + p.chunkSpan = 4096; + p.proofSegments3 = _arr7( + 0x6e21b55f5f30ea3d77ca7ee24fe43928f309f19916978a98eb75bd8dfbf9e9fb, + 0x3ff0f0743b3d169981cc5f67cbcf8725917e43dd4aea9bfee4e22abe4d18ecf9, + 0x50ffd8a56337e576f0b56c2563082dc4776d099503479bb918548f21b0d55daa, + 0x3ccc3ad084a22f20f06e9b388ddcfeda2c6781f9c9f0e4e6c896ab78e9c1270e, + 0xb5fdcfb93b778f5ae8fa2564633e31f195b518c63d00831d9a57556ac43760db, + 0x04ad7a29fe63d0373e47f0184950cb538dd82482a23c757095b4adac2a5a9357, + 0xc2ef7172b9c978b3e579a3424d03db71f907932fc9b9b031bfa5c7dd9713ba64 + ); + p.postageProof = Redistribution.PostageProof({ + signature: hex"f6b2fbdaf5a59b55303e98d9cfb8f5d730cd4cec601abf28f62cbd2c2e3f0dcc3d2c82144b2332c52961290b3a64dd25231fc5afa3ad31541c6a0a64ee1954ac1c", + postageId: CAC_BATCH_ID, + index: 0x000078fb00000017, + timeStamp: 0x1785462106d88f33 + }); + p.socProof = new Redistribution.SOCProof[](0); + } + + function _cacProof2() internal pure returns (Redistribution.ChunkInclusionProof memory p) { + p.proofSegments = _arr7( + 0x00009dab88dcb65c9eb3028506d26b918b33b740d689e2c8d215a675c4fa8891, + 0xf85d5e81948dbb24f33bbf6af7efceb547c47d5a64e8b11f676b893751a1bfd0, + 0x7d1b3e453e4c08496f30ed71a866ac28916e27cd3885a16a961d1dc87c8befa8, + 0xbb651df2741f07c6aed8a68af8d08c32ab556bb05403473dc6ab3d0d68087785, + 0x931e95ed8ea3efe7c961602b8b7888b59947124f190da163b85246c1d8dc28bc, + 0x0eb01ebfc9ed27500cd4dfc979272d1f0913cc9f66540d7e8005811109e1cf2d, + 0x887c22bd8750d34016ac3c66b5ff102dacdd73f6b014e710b51e8022af9a1968 + ); + p.proveSegment = 0x724fc91ed7318f5c04ce30d33cc9444eb4937974285af8cbc87097a40edccd09; + p.proofSegments2 = _arr7( + 0xa5853628094e0da25bc9f8ef2e56e0b43367e8f1644781eab6f0d8adceb37de1, + 0x8c7699a136deeef563d257530044ad3421ebea67faadbe5670c6a53b1da95681, + 0x37141d349d016b61787ea8d50d246d40d5c47dafd2b618150eb2735c9e147336, + 0xd620b8cba4e9c6d0258d900756f0156a92baf7093fd71101c2205a1ee11484a5, + 0x33482c710b3838c0db16022f226aa1aa5dc449d7b086e42e39220ef38cc8b9b9, + 0xf345ba3b2bae408d46585568803ba75a5b551701f488172449455700ec1a0f97, + 0x634dded701a03c7b13b8d3180df87fbbfa185f5f9b8dafad09d2f297a2b45e31 + ); + p.proveSegment2 = 0xeff9bac0c51082ff8d4380bf8cdca4fa9c20bcdc4190f2a2f3a999726d77f9f1; + p.chunkSpan = 4096; + p.proofSegments3 = _arr7( + 0xa5853628094e0da25bc9f8ef2e56e0b43367e8f1644781eab6f0d8adceb37de1, + 0xd453c8dca4cf42a72f1839330b5714b1f06eb476a4085d6dd65725b39544ed5c, + 0x73dc1145525816ba07842f433c81e2d9afd088fbff91b41f8378e04ecdccd2c0, + 0xff12b1e18bca6906208368d3572f6bc8c1ea638621c4a448424e1a190ad2aadd, + 0x3e6d67361b3762a2b0a15cf3af86681bbead5ee282c19dab5fa96df5b398685b, + 0x6bd935f8dcbd319861b13f1c0283d699b454bb20b5492393fec71ba6bc67d8a7, + 0x5bcf6be70e14ef138cb6f686163cf19406f943312679f1ae3f9573f0205dcdb9 + ); + p.postageProof = Redistribution.PostageProof({ + signature: hex"0d683b47c584585f00462c943c745d317d6354841b5fa78e8f50793e01e0f0ee7f6cb4a320e45382b92ecdbe6e3db380e309ce1279c8b15a2ab6e09a94f752f21c", + postageId: CAC_BATCH_ID, + index: 0x0000724f00000023, + timeStamp: 0x178551f31887a7e0 + }); + p.socProof = new Redistribution.SOCProof[](0); + } + + function _cacProofLast() internal pure returns (Redistribution.ChunkInclusionProof memory p) { + p.proofSegments = _arr7( + 0x0000a386b3d729becb0f7c5a938eff58dc4c5b45cb48876c0aabe38b3ec61511, + 0x13b90286a980a173a93f8d99f91eceaa5cfba622b787d7e47d799c93b728d8f0, + 0xe9d29a6a168d71c4601b5a03f0250ffff95dea7a89a0eeea57270fc14b5e28f0, + 0xbb651df2741f07c6aed8a68af8d08c32ab556bb05403473dc6ab3d0d68087785, + 0x931e95ed8ea3efe7c961602b8b7888b59947124f190da163b85246c1d8dc28bc, + 0x0eb01ebfc9ed27500cd4dfc979272d1f0913cc9f66540d7e8005811109e1cf2d, + 0x887c22bd8750d34016ac3c66b5ff102dacdd73f6b014e710b51e8022af9a1968 + ); + p.proveSegment = 0x7cfd4723785c35784b3a9fc7e9627e05b4ba2d175bb27cc36457c5abfa3487b7; + p.proofSegments2 = _arr7( + 0x09b6a9039fd92221244a46d66c5daa640ce2b4df4d8664c60393c006c8466241, + 0x926473068fb33428e2ba2b5ef06a4030f7d28840b3f6c39ff39e5c29c3c3d572, + 0xe280f788a94a73ba383aef1a375ce551c50397457631b7f192a094cf93754df2, + 0x7cae69170533f9610e78b1872811feb26765bedef8194232d1f83b3d6c4f0550, + 0x45603497bee8141ce3a62fe57500d1857e4c9be9c88a435e688f9d79031ac200, + 0xa5d5238098387e9fb8a0d2f834f06e32a941949cc8981e7001b4f237540dc72d, + 0x66854bd0e7aecd6abbe45b5f65944b54cc2cf6162ea821d03c1a766997d047ba + ); + p.proveSegment2 = 0x318aa90994bc17e443e67fd083d8ab251c1d17ced4e8ab983ddf02890d002605; + p.chunkSpan = 4096; + p.proofSegments3 = _arr7( + 0x09b6a9039fd92221244a46d66c5daa640ce2b4df4d8664c60393c006c8466241, + 0x1243fc9df50961e8024851eb386470b4fe740a696c4dc5f06ac3ae268abaaf93, + 0x1a3bb6274281c057e5c5cf67316a36919c7796dc2db102137f936b7641cbe1ff, + 0x43047f57784dc25563f3049ebf918d8a8da731a2c98c6d8ea1883dc0a3e815b2, + 0x4a28eaa92b6b420cfd863f49e69ce3399f239d4c708a9747f7fdf2d4435605e4, + 0x4f3dd2aad4e6357ebc9d0bec8107601b7203dd32c279036a37b1823f976d3172, + 0xfe68d10d01f82c29eb44448659fed31a5f89d49dc7a553f23be89cc67d5bdece + ); + p.postageProof = Redistribution.PostageProof({ + signature: hex"185e49bdb4c3a50178d8e14da8f402b0df7f7608b6658e92e6e0c2dc1a653e7c14e8e9d4ccd743019ed78bf6ad1733f065ec7540f717dea4d0ae389bb15bc2841c", + postageId: CAC_BATCH_ID, + index: 0x00007cfd00000020, + timeStamp: 0x178548a18495b42a + }); + p.socProof = new Redistribution.SOCProof[](0); + } + + function _socProof1() internal pure returns (Redistribution.ChunkInclusionProof memory p) { + p.proofSegments = _arr7( + 0x00007f4036a4e72c49e6d1c90889d604194485062dbf05143335da5824972c9d, + 0x13cba79217e582fe2ddd0b8edbf6293ef98635bee9c76cb254952d468c031d08, + 0x015507653c37aec258fd3ca4fbf8dc3741986697cb1eb42c3245c9314e6dd272, + 0x55f1711fe7f802faf26d24ff80ee9dc5d3881972d9ce070b81d1eaee387ff62f, + 0xcbfd07f8af7baa634e60c2b296ef08347bd639023ce674b8e00415173c78951a, + 0x0eb01ebfc9ed27500cd4dfc979272d1f0913cc9f66540d7e8005811109e1cf2d, + 0x887c22bd8750d34016ac3c66b5ff102dacdd73f6b014e710b51e8022af9a1968 + ); + p.proveSegment = 0x71842d5d67339a6a0f096c5a9983308d4b6596baeb016696b3512c6fa95f9d89; + p.proofSegments2 = _arr7( + bytes32(0), + 0xad3228b676f7d3cd4284a5443f17f1962b36e491b30a40b2405849e597ba5fb5, + 0xb4c11951957c6f8f642c4af61cd6b24640fec6dc7fc607ee8206a99e92410d30, + 0x21ddb9a356815c3fac1026b6dec5df3124afbadb485c9ba5a3e3398a04b7ba85, + 0xe58769b32a1beaf1ea27375a44095a0d1fb664ce2dd358e7fcbfb78c26a19344, + 0xf583106353614658f33a983eeead8a2d4d20b7b6d90227eaeff9a01bb5ffb909, + 0x887c22bd8750d34016ac3c66b5ff102dacdd73f6b014e710b51e8022af9a1968 + ); + p.proveSegment2 = bytes32(0); + p.chunkSpan = 3; + p.proofSegments3 = _arr7( + bytes32(0), + 0x67b9c124b55528ea0f5bcb5db716513af90deeb860e6ffd4b04ff0cd5cf68fb1, + 0xd769d9f556cb6ff5100712c5a9ffd2872bf7469a78f8ec96815173473857b57b, + 0x92a902cf7bbe37528d886bfb4c4e304572b65920a7e8fa413bc336031c7b10e9, + 0x21f21466fe5abe61950d97653c028a940ba863ac39b9ad8de388a159c0d81651, + 0x19134f518cf4e7b0a4eb34cfd97e6fe201c97b4d255330ca226b22b93a224389, + 0xb6c8532228cd44b9e59febed68f8038e1059fa779813d6bfe30dd9f150ab6b99 + ); + p.postageProof = Redistribution.PostageProof({ + signature: hex"9d6a824eee86bc6d107b1ff917f7bacd9004751000e75f34dc78a0ef4230685935fb0808494583a4c62284562bacac394534bb83cb837034c1a5d2034679ce681c", + postageId: SOC_BATCH_ID, + index: 0x0000718400000000, + timeStamp: 0x1787d532f407197a + }); + p.socProof = new Redistribution.SOCProof[](1); + p.socProof[0] = Redistribution.SOCProof({ + signer: 0x316104Fe34dF9A02d8f93412A2e4b0973D63cC31, + signature: hex"e3d66649291e1e805b7be2a2e6585eca7df8a0e10c960230f55b1fec83805c032955d6708cde4a7b0d205201b9fd0a6e484174d56903ddce34b356d3a691fad91b", + identifier: 0x0000000000000000000000000003042500000000000000000000000000000000, + chunkAddr: 0x2387e8e7d8a48c2a9339c97c1dc3461a9a7aa07e994c5cb8b38fd7c1b3e6ea48 + }); + } + + function _socProof2() internal pure returns (Redistribution.ChunkInclusionProof memory p) { + p.proofSegments = _arr7( + 0x000092031489ee631d69963039604310bc1a6fc1762bc98bf26ac4b8e2257595, + 0x05bb617234eeb399cfc1e53ab8aec230c5c60f01c51f28cf255227dc87973b3d, + 0x748eb1b53190df4ec58a8fcc36d50180ab307925f3b3b3c98f7196ca97362a25, + 0x0a3cea527135006ec5468fcd91f265775635fdbb0d2d377542fd2c89cc866534, + 0xcbfd07f8af7baa634e60c2b296ef08347bd639023ce674b8e00415173c78951a, + 0x0eb01ebfc9ed27500cd4dfc979272d1f0913cc9f66540d7e8005811109e1cf2d, + 0x887c22bd8750d34016ac3c66b5ff102dacdd73f6b014e710b51e8022af9a1968 + ); + p.proveSegment = 0x7f68a817240e4e5d7941948de6c252c4b34c8b39b08e2d519ca35e68519d7cae; + p.proofSegments2 = _arr7( + bytes32(0), + 0xad3228b676f7d3cd4284a5443f17f1962b36e491b30a40b2405849e597ba5fb5, + 0xb4c11951957c6f8f642c4af61cd6b24640fec6dc7fc607ee8206a99e92410d30, + 0x21ddb9a356815c3fac1026b6dec5df3124afbadb485c9ba5a3e3398a04b7ba85, + 0xe58769b32a1beaf1ea27375a44095a0d1fb664ce2dd358e7fcbfb78c26a19344, + 0xf583106353614658f33a983eeead8a2d4d20b7b6d90227eaeff9a01bb5ffb909, + 0x887c22bd8750d34016ac3c66b5ff102dacdd73f6b014e710b51e8022af9a1968 + ); + p.proveSegment2 = bytes32(0); + p.chunkSpan = 3; + p.proofSegments3 = _arr7( + bytes32(0), + 0x67b9c124b55528ea0f5bcb5db716513af90deeb860e6ffd4b04ff0cd5cf68fb1, + 0xd769d9f556cb6ff5100712c5a9ffd2872bf7469a78f8ec96815173473857b57b, + 0x92a902cf7bbe37528d886bfb4c4e304572b65920a7e8fa413bc336031c7b10e9, + 0x21f21466fe5abe61950d97653c028a940ba863ac39b9ad8de388a159c0d81651, + 0x19134f518cf4e7b0a4eb34cfd97e6fe201c97b4d255330ca226b22b93a224389, + 0xb6c8532228cd44b9e59febed68f8038e1059fa779813d6bfe30dd9f150ab6b99 + ); + p.postageProof = Redistribution.PostageProof({ + signature: hex"5c14de70eab0f55b06d7f3cb474f37786b36930b2652f96dcdc33305c23f7861196d03b7e68216184ed883aac1538af8a33401e989c328f31bc03f7d0f5ebaf21b", + postageId: SOC_BATCH_ID, + index: 0x00007f6800000000, + timeStamp: 0x1787d532e7720d47 + }); + p.socProof = new Redistribution.SOCProof[](1); + p.socProof[0] = Redistribution.SOCProof({ + signer: 0x316104Fe34dF9A02d8f93412A2e4b0973D63cC31, + signature: hex"83404406b178c034da21949e4aff2f371ba8bac0ceb373263160007ba01230776ffa188dfa8abe15b0e63fe56fd0ecbddd3cdfd36abaa7f1696f31a5dd64a6731b", + identifier: 0x000000000000000000000000000000000000000000000000000124a900000000, + chunkAddr: 0x2387e8e7d8a48c2a9339c97c1dc3461a9a7aa07e994c5cb8b38fd7c1b3e6ea48 + }); + } + + function _socProofLast() internal pure returns (Redistribution.ChunkInclusionProof memory p) { + p.proofSegments = _arr7( + 0x0000abb415738db50ec1b83a9f670130c158ab7001e9b452bd928f3eb715f432, + 0xbe6083fead93f365617744e0cfceed4c83c1cdf25c54f45a9b39e913ac61de72, + 0x5396f7a2a7d5d0c6c33ceae5fd2089fd38e1f5e03131bf80257773da087c0fc2, + 0x0a3cea527135006ec5468fcd91f265775635fdbb0d2d377542fd2c89cc866534, + 0xcbfd07f8af7baa634e60c2b296ef08347bd639023ce674b8e00415173c78951a, + 0x0eb01ebfc9ed27500cd4dfc979272d1f0913cc9f66540d7e8005811109e1cf2d, + 0x887c22bd8750d34016ac3c66b5ff102dacdd73f6b014e710b51e8022af9a1968 + ); + p.proveSegment = 0x465f8c4bd9089cf0315ec603d5ad81ec27ebd4056ea714c91e4fc1c2bbd03a08; + p.proofSegments2 = _arr7( + bytes32(0), + 0xad3228b676f7d3cd4284a5443f17f1962b36e491b30a40b2405849e597ba5fb5, + 0xb4c11951957c6f8f642c4af61cd6b24640fec6dc7fc607ee8206a99e92410d30, + 0x21ddb9a356815c3fac1026b6dec5df3124afbadb485c9ba5a3e3398a04b7ba85, + 0xe58769b32a1beaf1ea27375a44095a0d1fb664ce2dd358e7fcbfb78c26a19344, + 0xf583106353614658f33a983eeead8a2d4d20b7b6d90227eaeff9a01bb5ffb909, + 0x887c22bd8750d34016ac3c66b5ff102dacdd73f6b014e710b51e8022af9a1968 + ); + p.proveSegment2 = bytes32(0); + p.chunkSpan = 3; + p.proofSegments3 = _arr7( + bytes32(0), + 0x67b9c124b55528ea0f5bcb5db716513af90deeb860e6ffd4b04ff0cd5cf68fb1, + 0xd769d9f556cb6ff5100712c5a9ffd2872bf7469a78f8ec96815173473857b57b, + 0x92a902cf7bbe37528d886bfb4c4e304572b65920a7e8fa413bc336031c7b10e9, + 0x21f21466fe5abe61950d97653c028a940ba863ac39b9ad8de388a159c0d81651, + 0x19134f518cf4e7b0a4eb34cfd97e6fe201c97b4d255330ca226b22b93a224389, + 0xb6c8532228cd44b9e59febed68f8038e1059fa779813d6bfe30dd9f150ab6b99 + ); + p.postageProof = Redistribution.PostageProof({ + signature: hex"befadfdceb1076fe27cc08152aac4f91b5245f4e911a323c81eef79faeff7a652de99192554e37d99d615d1a693df3632d0b660cba6285f79ca29c8f457359761c", + postageId: SOC_BATCH_ID, + index: 0x0000465f00000000, + timeStamp: 0x1787d532e3d9be0f + }); + p.socProof = new Redistribution.SOCProof[](1); + p.socProof[0] = Redistribution.SOCProof({ + signer: 0x316104Fe34dF9A02d8f93412A2e4b0973D63cC31, + signature: hex"fb3d1660aa1a42bb342073914c4a74f098a0fab772baec7f18f60154feeecb35625f1b2b6d101902c0cc62abbf7ab71c5ddacb786a2841ce36bd2e3534566b5d1c", + identifier: 0x0000000000000000000054040000000000000000000000000000000000000000, + chunkAddr: 0x2387e8e7d8a48c2a9339c97c1dc3461a9a7aa07e994c5cb8b38fd7c1b3e6ea48 + }); + } +} From 127089754412689719609d7409ef38461adb1a51 Mon Sep 17 00:00:00 2001 From: Cardinal Date: Thu, 16 Apr 2026 14:32:13 +0200 Subject: [PATCH 33/50] chore(echidna): format real claim harness docs 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. --- echidna/README.md | 13 +++++++++- .../EchidnaRedistributionRealClaimHarness.sol | 26 +++++++++---------- 2 files changed, 24 insertions(+), 15 deletions(-) diff --git a/echidna/README.md b/echidna/README.md index 2b25dfd6..ee1893ac 100644 --- a/echidna/README.md +++ b/echidna/README.md @@ -105,6 +105,7 @@ Harness action functions are intentionally written to be **mostly non-reverting* Key actions per harness: - **Staking harness** + - Stake actions: `act_actor_manageStake`, `act_actor_withdrawSurplus`, `act_actor_migrateStake` - Admin actions: `act_admin_pause`, `act_admin_unpause`, `act_admin_changeNetworkId` - Redistributor actions: `act_redistributor_freeze`, `act_redistributor_slash` @@ -112,12 +113,14 @@ Key actions per harness: - Funding: `act_fundActor` - **Oracle harness** + - Admin actions: `act_admin_setPrice`, `act_admin_pause`, `act_admin_unpause` - Updater actions: `act_updater_adjustPrice` - Negative tests: `act_rando_try*` - PostageStamp mock behavior: `act_setStampRevertMode` - **PostageStamp harness** + - Batch actions: `act_createBatch`, `act_topUp`, `act_increaseDepth`, `act_expireAll` - Price update: `act_oracle_setPrice` - Pot withdrawal: `act_redistributor_withdraw` @@ -126,6 +129,7 @@ Key actions per harness: - Funding: `act_fundActor` - **Redistribution harness (base)** + - Stake configuration: `act_setActorStake` - Game entrypoints: `act_commit`, `act_reveal`, `act_claim` (often reverts early; still useful to shake out panics/state bugs) - Happy-path flow: `act_happyCommit`, `act_happyReveal` @@ -135,10 +139,12 @@ Key actions per harness: - Pause gating checks: `act_tryCommitWhilePaused`, `act_tryRevealWhilePaused` - **Redistribution claim-stub harness** + - Happy-path flow: `act_happyCommit`, `act_happyReveal`, `act_claimStub` - Pot seeding: `act_seedPot` - **Redistribution real-claim harness** + - Fixture selection: `act_useCacFixture`, `act_useSocFixture` - Fixture setup: `act_prepareFixtureCommit`, `act_prepareFixtureReveal`, `act_claimActiveFixture` - Pot seeding: `act_seedPot` @@ -162,17 +168,20 @@ Common patterns used across harnesses: High-signal properties per harness: - **Staking harness** + - Access control + “must never happen” flags (`echidna_never_performed_forbidden_calls`) - Registry accounting (ERC20 balance covers sum of potential stake) - Per-actor invariants (commitment monotonicity, effective stake/freeze semantics, overlay derivation) - Post-conditions for `manageStake(add>0)`, `freezeDeposit`, `slashDeposit`, `migrateStake` - **Oracle harness** + - Access control (admin-only + updater-only) and “paused means no changes” - Price invariants: price never below minimum; lastAdjustedRound not in the future - Post-conditions for `setPrice` and `adjustPrice` (including skipped-round math), with overflow-aware modeling - **PostageStamp harness** + - Access control (oracle-only price updates, redistributor-only withdraw, pauser-only pause/unpause) - Pause-mode negative tests (batch mutations must not succeed while paused) - Batch post-conditions (`createBatch`, `topUp`, `increaseDepth`) and expiry sanity (`expireAll`) @@ -181,6 +190,7 @@ High-signal properties per harness: - Pot monotonicity: pot must never decrease except by a successful withdraw-to-zero (`echidna_pot_never_decreases_except_withdraw`) - **Redistribution harness (base)** + - Access control “must never happen” flag (`echidna_never_performed_forbidden_calls`) - Pause gating: `echidna_never_succeeded_while_paused` - Phase sanity: exactly one of commit/reveal/claim is active (`echidna_phase_partitions_round`) @@ -197,12 +207,14 @@ High-signal properties per harness: - `echidna_tracked_reveal_matches_storage` - **Redistribution claim-stub harness** + - claim can only succeed once per round (`echidna_claim_only_once_per_round`) - successful claim withdraws the entire pot to the selected winner (`echidna_claim_withdraws_pot_to_winner_when_successful`) - claim triggers an oracle `adjustPrice` call (`echidna_claim_triggers_oracle_adjustPrice`) - non-revealers are frozen during claim processing (`echidna_nonrevealers_frozen_after_claim_selection`) - **Redistribution real-claim harness** + - untouched CAC/SOC fixtures can complete the real `claim()` path (`echidna_unmutated_fixture_claim_succeeds`) - corrupted proof fixtures do not successfully claim (`echidna_mutated_fixture_claim_does_not_succeed`) - successful real claims trigger the expected withdraw/oracle side-effects (`echidna_successful_real_claim_effects_hold`) @@ -301,4 +313,3 @@ Typical next steps: - Start with a few **obviously true** invariants, then iterate: - If Echidna finds a counterexample, decide whether that is a **bug** or a **property mismatch**. - Tighten properties only when you’re confident the protocol/design guarantees them. - diff --git a/src/echidna/EchidnaRedistributionRealClaimHarness.sol b/src/echidna/EchidnaRedistributionRealClaimHarness.sol index 0ea655ed..42c10f5a 100644 --- a/src/echidna/EchidnaRedistributionRealClaimHarness.sol +++ b/src/echidna/EchidnaRedistributionRealClaimHarness.sol @@ -98,20 +98,14 @@ contract EchidnaRedistributionRealClaimHarness { bytes32 internal constant FIXTURE_REVEAL_ANCHOR = 0x3617319a054d772f909f7c479a2cebe5066e836a939412e32403c99029b92eff; - bytes32 internal constant FIXTURE_CLAIM_SEED = - 0xa4541cb5a0f209fed7c786aac6865922446ed57fc0dcf8ad07c17afcd3c5efb8; - bytes32 internal constant FIXTURE_NONCE = - 0xb5555b33b5555b33b5555b33b5555b33b5555b33b5555b33b5555b33b5555b33; + bytes32 internal constant FIXTURE_CLAIM_SEED = 0xa4541cb5a0f209fed7c786aac6865922446ed57fc0dcf8ad07c17afcd3c5efb8; + bytes32 internal constant FIXTURE_NONCE = 0xb5555b33b5555b33b5555b33b5555b33b5555b33b5555b33b5555b33b5555b33; - bytes32 internal constant CAC_BATCH_ID = - 0x5bee6f33f47fbe2c3ff4c853dbc95f1a6a4a4191a1a7e3ece999a76c2790a83f; - bytes32 internal constant SOC_BATCH_ID = - 0x6cccd65a68bc5f7c19a273e9567ebf4b968a13c9be74fc99ad90159730eff219; + bytes32 internal constant CAC_BATCH_ID = 0x5bee6f33f47fbe2c3ff4c853dbc95f1a6a4a4191a1a7e3ece999a76c2790a83f; + bytes32 internal constant SOC_BATCH_ID = 0x6cccd65a68bc5f7c19a273e9567ebf4b968a13c9be74fc99ad90159730eff219; - bytes32 internal constant CAC_HASH = - 0xcf27be680f2dada5bcc45506997f54804e583650e48c28514ccc95234ef4f9f3; - bytes32 internal constant SOC_HASH = - 0x9a6afe770410c1bd7cdc1f324cfcf73ba1b85e3860d4594522456be4fe6b1d80; + bytes32 internal constant CAC_HASH = 0xcf27be680f2dada5bcc45506997f54804e583650e48c28514ccc95234ef4f9f3; + bytes32 internal constant SOC_HASH = 0x9a6afe770410c1bd7cdc1f324cfcf73ba1b85e3860d4594522456be4fe6b1d80; uint8 internal constant CAC_DEPTH = 1; uint8 internal constant SOC_DEPTH = 0; @@ -180,7 +174,9 @@ contract EchidnaRedistributionRealClaimHarness { stakeMock.setNode(address(this), FIXTURE_REVEAL_ANCHOR, 0, 1e18, _backdateLastUpdated()); bytes32 obfuscated = redist.wrapCommit(FIXTURE_REVEAL_ANCHOR, activeDepth, activeHash, FIXTURE_NONCE); - (bool ok, ) = address(redist).call(abi.encodeWithSelector(redist.commit.selector, obfuscated, redist.currentRound())); + (bool ok, ) = address(redist).call( + abi.encodeWithSelector(redist.commit.selector, obfuscated, redist.currentRound()) + ); if (!ok) return; commitPrepared = true; @@ -195,7 +191,9 @@ contract EchidnaRedistributionRealClaimHarness { if (redist.currentRound() != preparedRound) return; if (redist.currentCommitRound() != preparedRound) return; - (bool ok, ) = address(redist).call(abi.encodeWithSelector(redist.reveal.selector, activeDepth, activeHash, FIXTURE_NONCE)); + (bool ok, ) = address(redist).call( + abi.encodeWithSelector(redist.reveal.selector, activeDepth, activeHash, FIXTURE_NONCE) + ); if (!ok) return; claimReady = redist.currentSeed() == FIXTURE_CLAIM_SEED; From 70345fb2f6c533ee471a95c321949403c9465ab0 Mon Sep 17 00:00:00 2001 From: Cardinal Date: Wed, 29 Apr 2026 16:22:03 +0200 Subject: [PATCH 34/50] remove custom harness use just default one --- .gitignore | 1 - echidna/README.md | 17 +---------------- echidna/echidna-real-claim.yaml | 20 -------------------- 3 files changed, 1 insertion(+), 37 deletions(-) delete mode 100644 echidna/echidna-real-claim.yaml diff --git a/.gitignore b/.gitignore index 4ce52ddd..61d01e91 100644 --- a/.gitignore +++ b/.gitignore @@ -42,5 +42,4 @@ tenderly.log # Echidna fuzzing echidna/corpus/ -echidna/corpus-real-claim/ crytic-export/ \ No newline at end of file diff --git a/echidna/README.md b/echidna/README.md index ee1893ac..6337fed4 100644 --- a/echidna/README.md +++ b/echidna/README.md @@ -273,14 +273,6 @@ ECHIDNA_CONTRACT=EchidnaRedistributionRealClaimHarness yarn echidna ECHIDNA_CONTRACT=EchidnaSystemHarness yarn echidna ``` -To run the real-claim harness with its isolated corpus/config: - -```bash -ECHIDNA_CONTRACT=EchidnaRedistributionRealClaimHarness \ -ECHIDNA_CONFIG=echidna/echidna-real-claim.yaml \ -yarn echidna -``` - This uses Docker and the image `ghcr.io/crytic/echidna/echidna:latest`. ### Output files @@ -288,7 +280,6 @@ This uses Docker and the image `ghcr.io/crytic/echidna/echidna:latest`. Echidna may write artifacts such as: - `echidna/corpus/` (saved interesting inputs) -- `echidna/corpus-real-claim/` (isolated corpus for the real-claim harness when using `echidna/echidna-real-claim.yaml`) - `echidna/coverage/` - `crytic-export/` (Crytic export artifacts) @@ -296,13 +287,7 @@ These are ignored by git via `.gitignore`. ### Config files -- `echidna/echidna.yaml`: shared default config used by all harnesses unless overridden -- `echidna/echidna-real-claim.yaml`: isolated config for `EchidnaRedistributionRealClaimHarness` - -The dedicated real-claim config exists because that harness relies on fixed proof fixtures and benefits from: - -- an isolated corpus, so it does not replay unrelated sequences from other harnesses -- a slightly shorter sequence budget, since it only needs to reach one deterministic `commit -> reveal -> claim` path and mutate proof fields around it +- `echidna/echidna.yaml`: default config for all harness runs (override with `ECHIDNA_CONFIG` if needed) ## How to extend this diff --git a/echidna/echidna-real-claim.yaml b/echidna/echidna-real-claim.yaml deleted file mode 100644 index 2a62d717..00000000 --- a/echidna/echidna-real-claim.yaml +++ /dev/null @@ -1,20 +0,0 @@ -testMode: property - -# This harness uses fixed CAC/SOC proof fixtures, so it doesn't need the -# longer multi-round exploration budget used by the more general harnesses. -seqLen: 120 - -# Keep parity with the default config unless we find a reason to tune further. -testLimit: 25000 -shrinkLimit: 1000 - -# 38 = ROUND_LENGTH / 4. This still lets Echidna walk commit -> reveal -> claim -# within the same redistribution round. -maxTimeDelay: 0 -maxBlockDelay: 38 - -# Keep this harness isolated from the shared corpus used by other harnesses. -corpusDir: echidna/corpus-real-claim - -coverage: true -format: text From 6393afecaf060bc941c08f49e528a9cb7a4c2b11 Mon Sep 17 00:00:00 2001 From: Cardinal Date: Fri, 1 May 2026 00:04:40 +0200 Subject: [PATCH 35/50] feat(echidna): per-harness corpus and safer runner Use echidna/corpus/by-contract// 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. --- echidna/README.md | 7 +++++-- echidna/echidna.yaml | 11 +++++++---- scripts/echidna.sh | 23 ++++++++++++++++++++++- 3 files changed, 34 insertions(+), 7 deletions(-) diff --git a/echidna/README.md b/echidna/README.md index 6337fed4..0f6807bc 100644 --- a/echidna/README.md +++ b/echidna/README.md @@ -279,12 +279,15 @@ This uses Docker and the image `ghcr.io/crytic/echidna/echidna:latest`. Echidna may write artifacts such as: -- `echidna/corpus/` (saved interesting inputs) -- `echidna/coverage/` +- `echidna/corpus/by-contract//` — per-harness corpus, coverage reproducers, and `covered.*.html` (the runner passes `--corpus-dir` / `--coverage-dir` here so sequences from one harness are not mixed with another) - `crytic-export/` (Crytic export artifacts) +Older flat files under `echidna/corpus/` (if any) are from previous runs before per-harness dirs were used. + These are ignored by git via `.gitignore`. +Optional environment variables (see `scripts/echidna.sh`): `ECHIDNA_TEST_LIMIT`, `ECHIDNA_SEQ_LEN`, `ECHIDNA_WORKERS` to override the YAML for a single invocation. + ### Config files - `echidna/echidna.yaml`: default config for all harness runs (override with `ECHIDNA_CONFIG` if needed) diff --git a/echidna/echidna.yaml b/echidna/echidna.yaml index db4cd1b5..d8b969fa 100644 --- a/echidna/echidna.yaml +++ b/echidna/echidna.yaml @@ -3,10 +3,10 @@ testMode: property # Longer sequences give the fuzzer enough steps to (a) reach block 305+ # where staking passes MustStake2Rounds, and (b) walk through multiple # commit → reveal → claim cycles. -seqLen: 200 +seqLen: 240 -# Start small; can be increased once it’s stable in CI. -testLimit: 25000 +# Default budget; override per run with ECHIDNA_TEST_LIMIT (see scripts/echidna.sh). +testLimit: 35000 # Shrinking a counterexample can be *much* slower than fuzzing. # Keep this modest; increase locally if you want a smaller reproducer. @@ -18,11 +18,14 @@ shrinkLimit: 1000 maxTimeDelay: 0 maxBlockDelay: 38 -# Persist interesting inputs between runs. +# Persist interesting inputs between runs (scripts/echidna.sh uses a per-harness subdir). corpusDir: echidna/corpus # Useful while iterating on invariants. coverage: true +# Explicit parallelism inside Docker (host CPU may allow more). +workers: 4 + # Keep output readable in CI. format: text diff --git a/scripts/echidna.sh b/scripts/echidna.sh index 841a41ef..cd025e81 100755 --- a/scripts/echidna.sh +++ b/scripts/echidna.sh @@ -32,9 +32,29 @@ else CONTRACTS_TO_RUN=("${CONTRACTS_DEFAULT[@]}") fi +# Optional CLI overrides (see `echidna-test --help`). Examples: +# ECHIDNA_TEST_LIMIT=50000 ECHIDNA_SEQ_LEN=300 yarn echidna +# ECHIDNA_WORKERS=8 ECHIDNA_CONTRACT=EchidnaSystemHarness yarn echidna +# Use a string (not an array) so `set -u` never trips on empty `${arr[*]}` on older Bash. +ECHIDNA_EXTRA_CLI="" +if [[ -n "${ECHIDNA_TEST_LIMIT:-}" ]]; then + ECHIDNA_EXTRA_CLI+=" --test-limit ${ECHIDNA_TEST_LIMIT}" +fi +if [[ -n "${ECHIDNA_SEQ_LEN:-}" ]]; then + ECHIDNA_EXTRA_CLI+=" --seq-len ${ECHIDNA_SEQ_LEN}" +fi +if [[ -n "${ECHIDNA_WORKERS:-}" ]]; then + ECHIDNA_EXTRA_CLI+=" --workers ${ECHIDNA_WORKERS}" +fi + for c in "${CONTRACTS_TO_RUN[@]}"; do echo "==> echidna: running contract $c" >&2 + # One corpus + coverage tree per harness so saved sequences stay relevant to + # that contract (shared corpus mixed unrelated call shapes and diluted learning). + CORPUS_DIR="echidna/corpus/by-contract/${c}" + mkdir -p "${ROOT_DIR}/${CORPUS_DIR}" + # Drop stale Crytic output inside Docker (same uid as container root). A host # `rm -rf crytic-export` often fails after Docker created the dir as root. docker run --rm \ @@ -42,5 +62,6 @@ for c in "${CONTRACTS_TO_RUN[@]}"; do -v "$ROOT_DIR":/src \ -w /src \ "$IMAGE" \ - -c "rm -rf crytic-export && echidna-test . --contract ${c} --config ${CONFIG}" + -c "rm -rf crytic-export && echidna-test . --contract ${c} --config ${CONFIG} \ + --corpus-dir ${CORPUS_DIR} --coverage-dir ${CORPUS_DIR}/coverage${ECHIDNA_EXTRA_CLI}" done From b4294c735ebf82ca5d7e317f0e4d42f2c4c166cf Mon Sep 17 00:00:00 2001 From: Cardinal Date: Tue, 5 May 2026 15:11:18 +0200 Subject: [PATCH 36/50] Fix echidna so it reaches reveal and claim --- echidna/echidna.yaml | 6 ++--- src/Redistribution.sol | 9 ++++++-- .../EchidnaRedistributionRealClaimHarness.sol | 13 +++++++---- .../RedistributionFixtureRandomness.sol | 23 +++++++++++++++++++ 4 files changed, 42 insertions(+), 9 deletions(-) create mode 100644 src/echidna/RedistributionFixtureRandomness.sol diff --git a/echidna/echidna.yaml b/echidna/echidna.yaml index d8b969fa..c862b4f9 100644 --- a/echidna/echidna.yaml +++ b/echidna/echidna.yaml @@ -13,10 +13,10 @@ testLimit: 35000 shrinkLimit: 1000 # Bound random block jumps between transactions. -# 38 = ROUND_LENGTH/4 (one phase width) so the fuzzer naturally walks through -# commit → reveal → claim within the same redistribution round. +# Up to a full round (ROUND_LENGTH) helps reach later `currentRound()` values with fewer txs; +# sub-round delays still let the same sequence walk commit → reveal → claim. maxTimeDelay: 0 -maxBlockDelay: 38 +maxBlockDelay: 152 # Persist interesting inputs between runs (scripts/echidna.sh uses a per-harness subdir). corpusDir: echidna/corpus diff --git a/src/Redistribution.sol b/src/Redistribution.sol index e168aa05..d4d5c234 100644 --- a/src/Redistribution.sol +++ b/src/Redistribution.sol @@ -671,8 +671,13 @@ contract Redistribution is AccessControl, Pausable { * @notice Updates the source of randomness. Uses block.difficulty in pre-merge chains, this is substituted * to block.prevrandao in post merge chains. */ - function updateRandomness() private { - seed = keccak256(abi.encode(seed, block.prevrandao)); + function updateRandomness() internal virtual { + seed = _nextSeedValue(); + } + + /// @dev Extracted for fuzz harnesses that must pin post-reveal randomness to fixture data. + function _nextSeedValue() internal view virtual returns (bytes32) { + return keccak256(abi.encode(seed, block.prevrandao)); } /** diff --git a/src/echidna/EchidnaRedistributionRealClaimHarness.sol b/src/echidna/EchidnaRedistributionRealClaimHarness.sol index 42c10f5a..d603bed8 100644 --- a/src/echidna/EchidnaRedistributionRealClaimHarness.sol +++ b/src/echidna/EchidnaRedistributionRealClaimHarness.sol @@ -4,6 +4,7 @@ pragma solidity ^0.8.19; import "../Redistribution.sol"; import "../interface/IPostageStamp.sol"; import "./EchidnaMocks.sol"; +import "./RedistributionFixtureRandomness.sol"; contract EchidnaFixturePostageStampMock is IPostageStamp { struct BatchData { @@ -88,7 +89,6 @@ contract EchidnaFixturePostageStampMock is IPostageStamp { /// @dev Uses hardcoded CAC/SOC proof bundles generated by the existing Hardhat tests. contract EchidnaRedistributionRealClaimHarness { uint256 internal constant ROUND_LENGTH = 152; - uint64 internal constant FIXTURE_PREPARE_ROUND = 4; uint8 internal constant FIXTURE_NONE = 0; uint8 internal constant FIXTURE_CAC = 1; @@ -138,7 +138,12 @@ contract EchidnaRedistributionRealClaimHarness { stakeMock = new EchidnaStakeRegistryMock(); stampMock = new EchidnaFixturePostageStampMock(); oracleMock = new EchidnaPriceOracleMock(); - redist = new Redistribution(address(stakeMock), address(stampMock), address(oracleMock)); + redist = new RedistributionFixtureRandomness( + address(stakeMock), + address(stampMock), + address(oracleMock), + FIXTURE_CLAIM_SEED + ); stampMock.setBatch(CAC_BATCH_ID, FIXTURE_BATCH_OWNER, 27, 16); stampMock.setBatch(SOC_BATCH_ID, FIXTURE_BATCH_OWNER, 27, 16); @@ -167,9 +172,9 @@ contract EchidnaRedistributionRealClaimHarness { _clearLastClaim(); if (activeFixtureKind == FIXTURE_NONE) return; if (!redist.currentPhaseCommit()) return; - if (redist.currentRound() != FIXTURE_PREPARE_ROUND) return; - if (block.number % ROUND_LENGTH == (ROUND_LENGTH / 4) - 1) return; + // Pristine `seed == 0` and no prior reveals: anchor hits this iff `currentRound() == 4`. if (redist.currentRoundAnchor() != FIXTURE_REVEAL_ANCHOR) return; + if (block.number % ROUND_LENGTH == (ROUND_LENGTH / 4) - 1) return; stakeMock.setNode(address(this), FIXTURE_REVEAL_ANCHOR, 0, 1e18, _backdateLastUpdated()); diff --git a/src/echidna/RedistributionFixtureRandomness.sol b/src/echidna/RedistributionFixtureRandomness.sol new file mode 100644 index 00000000..419472da --- /dev/null +++ b/src/echidna/RedistributionFixtureRandomness.sol @@ -0,0 +1,23 @@ +// SPDX-License-Identifier: BSD-3-Clause +pragma solidity ^0.8.19; + +import "../Redistribution.sol"; + +/// @notice Fuzz-only deployment target: pins the post-reveal `seed` so Hardhat-derived +/// `claim()` fixtures stay valid when Echidna/hevm use a different `block.prevrandao` than the original test run. +contract RedistributionFixtureRandomness is Redistribution { + bytes32 internal immutable fixedPostRevealSeed; + + constructor( + address staking, + address postageContract, + address oracleContract, + bytes32 _fixedPostRevealSeed + ) Redistribution(staking, postageContract, oracleContract) { + fixedPostRevealSeed = _fixedPostRevealSeed; + } + + function _nextSeedValue() internal view override returns (bytes32) { + return fixedPostRevealSeed; + } +} From cc88220273b569a2e02f9525e897d4f51d9863ed Mon Sep 17 00:00:00 2001 From: Cardinal Date: Tue, 5 May 2026 15:18:18 +0200 Subject: [PATCH 37/50] feat(echidna): default 60k/320 and fixture E2E guard Real-claim harness: progress flags, tx counter, strict property after warmup; README and script document defaults and overrides. --- echidna/README.md | 13 +++- echidna/echidna.yaml | 11 ++-- scripts/echidna.sh | 5 +- .../EchidnaRedistributionRealClaimHarness.sol | 59 +++++++++++++++---- 4 files changed, 66 insertions(+), 22 deletions(-) diff --git a/echidna/README.md b/echidna/README.md index 0f6807bc..178ce7f5 100644 --- a/echidna/README.md +++ b/echidna/README.md @@ -218,6 +218,7 @@ High-signal properties per harness: - untouched CAC/SOC fixtures can complete the real `claim()` path (`echidna_unmutated_fixture_claim_succeeds`) - corrupted proof fixtures do not successfully claim (`echidna_mutated_fixture_claim_does_not_succeed`) - successful real claims trigger the expected withdraw/oracle side-effects (`echidna_successful_real_claim_effects_hold`) + - **Coverage guard** (non-vacuous): after a warmup of many `act_*` transactions, at least one successful unmutated end-to-end `claim` must occur (`echidna_unmutated_fixture_end_to_end_must_succeed_at_least_once`). Echidna also evaluates properties on the post-deploy state before any transaction, so this property stays vacuously true until `fuzzActTxCount` crosses `MIN_FUZZ_TXS_BEFORE_FIXTURE_E2E_REQUIRED` (see `EchidnaRedistributionRealClaimHarness.sol`). With the repo defaults (`seqLen` 320, `testLimit` 60000), the warmup threshold is hit almost immediately relative to total work, then the fuzzer must keep finding the fixture path or the run fails. - **System/integration harness** - Oracle↔stamp invariant: `PostageStamp.lastPrice` tracks `PriceOracle.currentPrice()` after updates @@ -261,6 +262,16 @@ By default, this runs **all** Echidna harness contracts in `src/echidna/`. By default, the runner uses `echidna/echidna.yaml`. You can override that with `ECHIDNA_CONFIG` if a harness needs its own corpus or tuned fuzzing parameters. +### Default campaign settings (`echidna/echidna.yaml`) + +| Setting | Default | Notes | +|----------------|---------|--------| +| `testLimit` | `60000` | Sequences tried per harness (each sequence uses at most `seqLen` calls). | +| `seqLen` | `320` | Enough depth for redistribution rounds + fixture `commit`→`reveal`→`claim` exploration. | +| `maxBlockDelay`| `152` | Full `ROUND_LENGTH`; helps `currentRound()` advance without enormous sequences. | + +Shorter smoke runs: set `ECHIDNA_TEST_LIMIT` / `ECHIDNA_SEQ_LEN` when invoking `yarn echidna` (see `scripts/echidna.sh`). If you lower limits so total `act_*` traffic never reaches `MIN_FUZZ_TXS_BEFORE_FIXTURE_E2E_REQUIRED` on the **real-claim** harness, the strict end-to-end property never arms (by design). + To run only a specific harness contract: ```bash @@ -286,7 +297,7 @@ Older flat files under `echidna/corpus/` (if any) are from previous runs before These are ignored by git via `.gitignore`. -Optional environment variables (see `scripts/echidna.sh`): `ECHIDNA_TEST_LIMIT`, `ECHIDNA_SEQ_LEN`, `ECHIDNA_WORKERS` to override the YAML for a single invocation. +Optional environment variables (see `scripts/echidna.sh`): `ECHIDNA_TEST_LIMIT`, `ECHIDNA_SEQ_LEN`, `ECHIDNA_WORKERS` to override the YAML for a single invocation (CLI wins over `echidna.yaml` when both apply). ### Config files diff --git a/echidna/echidna.yaml b/echidna/echidna.yaml index c862b4f9..2f98c383 100644 --- a/echidna/echidna.yaml +++ b/echidna/echidna.yaml @@ -1,12 +1,11 @@ testMode: property -# Longer sequences give the fuzzer enough steps to (a) reach block 305+ -# where staking passes MustStake2Rounds, and (b) walk through multiple -# commit → reveal → claim cycles. -seqLen: 240 +# Longer sequences help (a) reach high `block.number` / `currentRound()` for redistribution, +# and (b) walk commit → reveal → claim in one campaign. Tuned with real-claim fixture harness in mind. +seqLen: 320 -# Default budget; override per run with ECHIDNA_TEST_LIMIT (see scripts/echidna.sh). -testLimit: 35000 +# Default budget per harness (one sequence = up to seqLen txs). Override with ECHIDNA_TEST_LIMIT. +testLimit: 60000 # Shrinking a counterexample can be *much* slower than fuzzing. # Keep this modest; increase locally if you want a smaller reproducer. diff --git a/scripts/echidna.sh b/scripts/echidna.sh index cd025e81..541336d1 100755 --- a/scripts/echidna.sh +++ b/scripts/echidna.sh @@ -32,8 +32,9 @@ else CONTRACTS_TO_RUN=("${CONTRACTS_DEFAULT[@]}") fi -# Optional CLI overrides (see `echidna-test --help`). Examples: -# ECHIDNA_TEST_LIMIT=50000 ECHIDNA_SEQ_LEN=300 yarn echidna +# Optional CLI overrides (see `echidna-test --help`). Defaults live in ECHIDNA_CONFIG (typically +# echidna/echidna.yaml: testLimit 60000, seqLen 320). Examples: +# ECHIDNA_TEST_LIMIT=20000 ECHIDNA_SEQ_LEN=200 yarn echidna # faster smoke # ECHIDNA_WORKERS=8 ECHIDNA_CONTRACT=EchidnaSystemHarness yarn echidna # Use a string (not an array) so `set -u` never trips on empty `${arr[*]}` on older Bash. ECHIDNA_EXTRA_CLI="" diff --git a/src/echidna/EchidnaRedistributionRealClaimHarness.sol b/src/echidna/EchidnaRedistributionRealClaimHarness.sol index d603bed8..5f84a12f 100644 --- a/src/echidna/EchidnaRedistributionRealClaimHarness.sol +++ b/src/echidna/EchidnaRedistributionRealClaimHarness.sol @@ -89,6 +89,10 @@ contract EchidnaFixturePostageStampMock is IPostageStamp { /// @dev Uses hardcoded CAC/SOC proof bundles generated by the existing Hardhat tests. contract EchidnaRedistributionRealClaimHarness { uint256 internal constant ROUND_LENGTH = 152; + /// @dev Echidna evaluates properties on the deploy state (0 txs); defer strict checks until fuzzing runs. + /// Align with `echidna/echidna.yaml` `seqLen` (default 320): ~78 `act_*` calls ≈ first full sequence. + /// Must stay well below `testLimit` × `seqLen` so the strict property arms during a normal campaign. + uint256 internal constant MIN_FUZZ_TXS_BEFORE_FIXTURE_E2E_REQUIRED = 25_000; uint8 internal constant FIXTURE_NONE = 0; uint8 internal constant FIXTURE_CAC = 1; @@ -134,6 +138,22 @@ contract EchidnaRedistributionRealClaimHarness { uint256 internal withdrawCallsBeforeClaim; uint256 internal oracleCallsBeforeClaim; + /// @dev Monotonic progress markers (never weakened by mutation helpers) so we can tell + /// whether Echidna is only burning gas on early returns vs. driving the real pipeline. + bool public fixtureCommitSucceededOnce; + bool public fixtureRevealMadeClaimReadyOnce; + bool public unmutatedFixtureClaimSucceededOnce; + + /// @dev Incremented once per `act_*` transaction (not per internal call). + uint256 public fuzzActTxCount; + + modifier countFuzzTx() { + unchecked { + ++fuzzActTxCount; + } + _; + } + constructor() { stakeMock = new EchidnaStakeRegistryMock(); stampMock = new EchidnaFixturePostageStampMock(); @@ -154,21 +174,21 @@ contract EchidnaRedistributionRealClaimHarness { _useCacFixture(); } - function act_tick() external {} + function act_tick() external countFuzzTx {} - function act_useCacFixture() external { + function act_useCacFixture() external countFuzzTx { _useCacFixture(); } - function act_useSocFixture() external { + function act_useSocFixture() external countFuzzTx { _useSocFixture(); } - function act_seedPot(uint256 amount) external { + function act_seedPot(uint256 amount) external countFuzzTx { stampMock.seedPot((amount % 1e24) + 1); } - function act_prepareFixtureCommit() external { + function act_prepareFixtureCommit() external countFuzzTx { _clearLastClaim(); if (activeFixtureKind == FIXTURE_NONE) return; if (!redist.currentPhaseCommit()) return; @@ -184,12 +204,13 @@ contract EchidnaRedistributionRealClaimHarness { ); if (!ok) return; + fixtureCommitSucceededOnce = true; commitPrepared = true; claimReady = false; preparedRound = redist.currentRound(); } - function act_prepareFixtureReveal() external { + function act_prepareFixtureReveal() external countFuzzTx { _clearLastClaim(); if (!commitPrepared) return; if (!redist.currentPhaseReveal()) return; @@ -202,9 +223,10 @@ contract EchidnaRedistributionRealClaimHarness { if (!ok) return; claimReady = redist.currentSeed() == FIXTURE_CLAIM_SEED; + if (claimReady) fixtureRevealMadeClaimReadyOnce = true; } - function act_claimActiveFixture() external { + function act_claimActiveFixture() external countFuzzTx { _clearLastClaim(); if (!claimReady) return; if (!redist.currentPhaseClaim()) return; @@ -218,30 +240,34 @@ contract EchidnaRedistributionRealClaimHarness { (lastClaimSucceeded, ) = address(redist).call( abi.encodeWithSelector(redist.claim.selector, activeProof1, activeProof2, activeProofLast) ); + + if (lastClaimSucceeded && lastClaimExpectedSuccess) { + unmutatedFixtureClaimSucceededOnce = true; + } } - function act_mutateReserveCommitmentRoot(bytes32 replacement) external { + function act_mutateReserveCommitmentRoot(bytes32 replacement) external countFuzzTx { if (activeFixtureKind == FIXTURE_NONE || activeProof1.proofSegments.length == 0) return; activeProof1.proofSegments[0] = _tweak(replacement); activeFixtureMutated = true; _clearLastClaim(); } - function act_mutateOriginalChunkBranch(bytes32 replacement) external { + function act_mutateOriginalChunkBranch(bytes32 replacement) external countFuzzTx { if (activeFixtureKind == FIXTURE_NONE || activeProof1.proofSegments2.length < 2) return; activeProof1.proofSegments2[1] = _tweak(replacement); activeFixtureMutated = true; _clearLastClaim(); } - function act_mutateTransformedChunkBranch(bytes32 replacement) external { + function act_mutateTransformedChunkBranch(bytes32 replacement) external countFuzzTx { if (activeFixtureKind == FIXTURE_NONE || activeProof1.proofSegments3.length < 2) return; activeProof1.proofSegments3[1] = _tweak(replacement); activeFixtureMutated = true; _clearLastClaim(); } - function act_mutatePostageIndexLow(uint32 lowWord) external { + function act_mutatePostageIndexLow(uint32 lowWord) external countFuzzTx { if (activeFixtureKind == FIXTURE_NONE) return; uint64 current = activeProof1.postageProof.index; uint64 next = (current & 0xffffffff00000000) | uint64(lowWord); @@ -251,7 +277,7 @@ contract EchidnaRedistributionRealClaimHarness { _clearLastClaim(); } - function act_mutatePostageIndexHigh(uint32 highWord) external { + function act_mutatePostageIndexHigh(uint32 highWord) external countFuzzTx { if (activeFixtureKind == FIXTURE_NONE) return; uint64 current = activeProof1.postageProof.index; uint64 next = (uint64(highWord) << 32) | (current & 0xffffffff); @@ -261,7 +287,7 @@ contract EchidnaRedistributionRealClaimHarness { _clearLastClaim(); } - function act_mutateSocIdentifier(bytes32 replacement) external { + function act_mutateSocIdentifier(bytes32 replacement) external countFuzzTx { if (activeFixtureKind != FIXTURE_SOC || activeProof1.socProof.length == 0) return; activeProof1.socProof[0].identifier = _tweak(replacement); activeFixtureMutated = true; @@ -290,6 +316,13 @@ contract EchidnaRedistributionRealClaimHarness { return true; } + /// @notice Non-vacuous over a full campaign: after warmup, requires at least one successful unmutated fixture `claim`. + /// @dev Lower `MIN_FUZZ_TXS_BEFORE_FIXTURE_E2E_REQUIRED` only for short smoke runs; defaults assume `echidna.yaml` (60k × 320). + function echidna_unmutated_fixture_end_to_end_must_succeed_at_least_once() external view returns (bool) { + if (fuzzActTxCount < MIN_FUZZ_TXS_BEFORE_FIXTURE_E2E_REQUIRED) return true; + return unmutatedFixtureClaimSucceededOnce; + } + function _useCacFixture() internal { _resetFixtureState(); activeFixtureKind = FIXTURE_CAC; From 6a671dbcef75df6fed99b3c01b3d200bde8265e6 Mon Sep 17 00:00:00 2001 From: Cardinal Date: Tue, 5 May 2026 16:10:54 +0200 Subject: [PATCH 38/50] Oracle fix for upper bound limit --- src/PriceOracle.sol | 31 +++++++++++++++-------- src/echidna/EchidnaPriceOracleHarness.sol | 2 ++ 2 files changed, 22 insertions(+), 11 deletions(-) diff --git a/src/PriceOracle.sol b/src/PriceOracle.sol index e7c78509..298c2c24 100644 --- a/src/PriceOracle.sol +++ b/src/PriceOracle.sol @@ -41,6 +41,12 @@ contract PriceOracle is AccessControl { // The length of a round in blocks. uint8 private constant ROUND_LENGTH = 152; + /// @dev Upper bound for upscaled price so `(currentPriceUpScaled >> 10)` fits in `uint32`. + /// Without this, `currentPrice()`'s `uint32(... >> 10)` truncates and can disagree with + /// `currentPriceUpScaled` and under-report vs `minimumPrice()`. + uint64 public constant MAX_CURRENT_PRICE_UPSCALED = + uint64(uint256(type(uint32).max) << 10); + // ----------------------------- Events ------------------------------ /** @@ -80,12 +86,7 @@ contract PriceOracle is AccessControl { // Cast before shifting to avoid uint32 overflow/truncation. uint64 _currentPriceUpScaled = uint64(_price) << 10; - uint64 _minimumPriceUpscaled = minimumPriceUpscaled; - - // Enforce minimum price - if (_currentPriceUpScaled < _minimumPriceUpscaled) { - _currentPriceUpScaled = _minimumPriceUpscaled; - } + _currentPriceUpScaled = _clampPriceUpscaled(_currentPriceUpScaled); currentPriceUpScaled = _currentPriceUpScaled; // Check if the setting of price in postagestamp succeded @@ -125,7 +126,6 @@ contract PriceOracle is AccessControl { } uint64 _currentPriceUpScaled = currentPriceUpScaled; - uint64 _minimumPriceUpscaled = minimumPriceUpscaled; uint32 _priceBase = priceBase; // Set the number of rounds that were skipped, we substract 1 as lastAdjustedRound is set below and default result is 1 @@ -143,10 +143,7 @@ contract PriceOracle is AccessControl { } } - // Enforce minimum price - if (_currentPriceUpScaled < _minimumPriceUpscaled) { - _currentPriceUpScaled = _minimumPriceUpscaled; - } + _currentPriceUpScaled = _clampPriceUpscaled(_currentPriceUpScaled); currentPriceUpScaled = _currentPriceUpScaled; lastAdjustedRound = currentRoundNumber; @@ -183,6 +180,18 @@ contract PriceOracle is AccessControl { // STATE READING // //////////////////////////////////////// + /// @notice Clamp upscaled price to [minimumPriceUpscaled, MAX_CURRENT_PRICE_UPSCALED]. + function _clampPriceUpscaled(uint64 priceUpScaled) private view returns (uint64) { + uint64 minU = uint64(minimumPriceUpscaled); + if (priceUpScaled < minU) { + priceUpScaled = minU; + } + if (priceUpScaled > MAX_CURRENT_PRICE_UPSCALED) { + priceUpScaled = MAX_CURRENT_PRICE_UPSCALED; + } + return priceUpScaled; + } + /** * @notice Return the number of the current round. */ diff --git a/src/echidna/EchidnaPriceOracleHarness.sol b/src/echidna/EchidnaPriceOracleHarness.sol index ff28623b..da121cdb 100644 --- a/src/echidna/EchidnaPriceOracleHarness.sol +++ b/src/echidna/EchidnaPriceOracleHarness.sol @@ -300,6 +300,8 @@ contract EchidnaPriceOracleHarness { uint256 minUp = uint256(oracle.minimumPriceUpscaled()); if (price < minUp) price = minUp; + uint256 maxUp = uint256(oracle.MAX_CURRENT_PRICE_UPSCALED()); + if (price > maxUp) price = maxUp; if (price > type(uint64).max) return (false, 0); return (true, uint64(price)); } From 8c8813e5ff5336421839649651f1ec88e43c57b6 Mon Sep 17 00:00:00 2001 From: Cardinal Date: Wed, 6 May 2026 13:06:54 +0200 Subject: [PATCH 39/50] test(echidna): drop duplicate system harness props --- echidna/README.md | 3 ++- src/echidna/EchidnaSystemHarness.sol | 22 ---------------------- 2 files changed, 2 insertions(+), 23 deletions(-) diff --git a/echidna/README.md b/echidna/README.md index 178ce7f5..e5bc75cf 100644 --- a/echidna/README.md +++ b/echidna/README.md @@ -220,9 +220,10 @@ High-signal properties per harness: - successful real claims trigger the expected withdraw/oracle side-effects (`echidna_successful_real_claim_effects_hold`) - **Coverage guard** (non-vacuous): after a warmup of many `act_*` transactions, at least one successful unmutated end-to-end `claim` must occur (`echidna_unmutated_fixture_end_to_end_must_succeed_at_least_once`). Echidna also evaluates properties on the post-deploy state before any transaction, so this property stays vacuously true until `fuzzActTxCount` crosses `MIN_FUZZ_TXS_BEFORE_FIXTURE_E2E_REQUIRED` (see `EchidnaRedistributionRealClaimHarness.sol`). With the repo defaults (`seqLen` 320, `testLimit` 60000), the warmup threshold is hit almost immediately relative to total work, then the fuzzer must keep finding the fixture path or the run fails. -- **System/integration harness** +- **System/integration harness** (only invariants that require real cross-contract wiring; single-contract checks live in their unit harness) - Oracle↔stamp invariant: `PostageStamp.lastPrice` tracks `PriceOracle.currentPrice()` after updates - Stamp accounting: internal `pot` does not exceed the stamp contract’s BZZ balance (`echidna_stamp_internal_pot_not_above_contract_balance`) + - Role isolation under real wiring: only the granted updater can `adjustPrice` (`echidna_unauthorized_oracle_adjust_never_succeeds`) - Redistribution happy-path consistency: tracked commit/reveal values appear in `Redistribution` storage These are “sanity properties”: they’re meant to detect obvious bugs and unintended state corruption early. diff --git a/src/echidna/EchidnaSystemHarness.sol b/src/echidna/EchidnaSystemHarness.sol index 6ea813b8..7ee68734 100644 --- a/src/echidna/EchidnaSystemHarness.sol +++ b/src/echidna/EchidnaSystemHarness.sol @@ -286,28 +286,6 @@ contract EchidnaSystemHarness { return uint32(lp) == oracle.currentPrice(); } - function echidna_oracle_price_never_below_minimum() external view returns (bool) { - // Both representations should respect the minimum. - if (oracle.currentPriceUpScaled() < oracle.minimumPriceUpscaled()) return false; - if (oracle.currentPrice() < oracle.minimumPrice()) return false; - return true; - } - - function echidna_stamp_lastPrice_never_below_oracle_minimum_when_set() external view returns (bool) { - uint64 lp = stamp.lastPrice(); - if (lp == 0) return true; - return lp >= uint64(oracle.minimumPrice()); - } - - function echidna_stake_balance_covers_sum_potential() external view returns (bool) { - uint256 sumPotential = 0; - for (uint256 i = 0; i < ACTOR_COUNT; i++) { - (, , uint256 potentialStake, , ) = stake.stakes(address(actors[i])); - sumPotential += potentialStake; - } - return token.balanceOf(address(stake)) >= sumPotential; - } - function echidna_stamp_internal_pot_not_above_contract_balance() external view returns (bool) { // Raw `pot` tracks accrued liability; it must not exceed ERC20 balance held by the stamp contract. // (`totalPot()` caps at balance but is non-view; this is the meaningful accounting check.) From b2e7a2f387febf36aefc5cd4cff2f7e9ce13fb8f Mon Sep 17 00:00:00 2001 From: Cardinal Date: Mon, 18 May 2026 14:12:16 +0200 Subject: [PATCH 40/50] refactor(echidna): slim redistribution fuzz and fix Docker compile 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. --- echidna/README.md | 54 +- scripts/echidna.sh | 7 +- src/echidna/EchidnaRedistributionHarness.sol | 109 +-- .../EchidnaRedistributionRealClaimHarness.sol | 692 ------------------ .../RedistributionFixtureRandomness.sol | 23 - 5 files changed, 20 insertions(+), 865 deletions(-) delete mode 100644 src/echidna/EchidnaRedistributionRealClaimHarness.sol delete mode 100644 src/echidna/RedistributionFixtureRandomness.sol diff --git a/echidna/README.md b/echidna/README.md index e5bc75cf..0b5da740 100644 --- a/echidna/README.md +++ b/echidna/README.md @@ -21,9 +21,12 @@ This repo currently contains multiple harnesses: - **PostageStamp harness**: `src/echidna/EchidnaPostageStampHarness.sol` - **Redistribution harness**: `src/echidna/EchidnaRedistributionHarness.sol` - **Redistribution claim-stub harness**: `src/echidna/EchidnaRedistributionClaimHarness.sol` -- **Redistribution real-claim harness**: `src/echidna/EchidnaRedistributionRealClaimHarness.sol` - **System/integration harness**: `src/echidna/EchidnaSystemHarness.sol` +> The real `claim()` proof-verification path is covered by the Hardhat suite (`test/Redistribution.test.ts`), +> not by Echidna. Echidna can't generate valid Merkle/SOC/postage proofs, so fuzzing that path adds little signal +> over targeted unit tests with known-good fixtures. + ### What each harness deploys The **staking harness** deploys: @@ -69,21 +72,6 @@ The **redistribution claim-stub harness** deploys: This is meant to fuzz the **claim-phase state machine + pot withdrawal effects** end-to-end, without paying the cost of generating valid Merkle/SOC/postage proofs. -The **redistribution real-claim harness** deploys: - -- the real `Redistribution` contract -- the shared redistribution stake/oracle mocks -- a fixture-aware postage mock that returns batch metadata matching the fixed proof bundles - -This harness stores one fixed CAC proof bundle and one fixed SOC proof bundle, both derived from the existing -Hardhat proof fixtures, and then fuzzes: - -- the real `commit -> reveal -> claim` path needed to activate those fixtures -- mutations of selected proof fields (reserve-commitment inclusion roots/branches, postage indices, SOC identifier) - -The goal is not to randomly discover valid proofs. Instead, it uses **known-good proofs as seed fixtures** and lets Echidna -mutate the surrounding scenario and targeted proof bytes while the real on-chain verifier runs. - The **system/integration harness** deploys: - `TestToken` @@ -135,21 +123,12 @@ Key actions per harness: - Happy-path flow: `act_happyCommit`, `act_happyReveal` - Winner selection (fuzz-only exposure): `act_winnerSelection` - Admin actions: `act_admin_pause`, `act_admin_unpause`, `act_admin_setSampleMaxValue`, `act_admin_setFreezingParams` - - Negative tests: `act_rando_try*` (unauthorized attempts) - - Pause gating checks: `act_tryCommitWhilePaused`, `act_tryRevealWhilePaused` - **Redistribution claim-stub harness** - Happy-path flow: `act_happyCommit`, `act_happyReveal`, `act_claimStub` - Pot seeding: `act_seedPot` -- **Redistribution real-claim harness** - - - Fixture selection: `act_useCacFixture`, `act_useSocFixture` - - Fixture setup: `act_prepareFixtureCommit`, `act_prepareFixtureReveal`, `act_claimActiveFixture` - - Pot seeding: `act_seedPot` - - Proof mutations: `act_mutateReserveCommitmentRoot`, `act_mutateOriginalChunkBranch`, `act_mutateTransformedChunkBranch`, `act_mutatePostageIndexLow`, `act_mutatePostageIndexHigh`, `act_mutateSocIdentifier` - - **System/integration harness** - Stake actions: `act_actor_manageStake`, `act_actor_withdrawSurplus` - Postage actions: `act_actor_createBatch`, `act_actor_topUp`, `act_actor_increaseDepth`, `act_actor_expireAll` @@ -191,13 +170,9 @@ High-signal properties per harness: - **Redistribution harness (base)** - - Access control “must never happen” flag (`echidna_never_performed_forbidden_calls`) - - Pause gating: `echidna_never_succeeded_while_paused` - - Phase sanity: exactly one of commit/reveal/claim is active (`echidna_phase_partitions_round`) - - Round bookkeeping sanity (`currentCommitRound/currentRevealRound` never in the future) - Commit/reveal internal consistency: - - committed overlays remain unique - - if a commit is marked as revealed, its `revealIndex` points to a reveal with the same overlay/owner + - committed overlays remain unique (`echidna_commit_overlays_unique`) + - if a commit is marked as revealed, its `revealIndex` points to a reveal with the same overlay/owner (`echidna_revealed_commit_indices_valid`) - every reveal entry must correspond to a revealed commit (`echidna_reveal_entries_imply_matching_commit`) - Claim-phase state machine (using a fuzz-only exposed `winnerSelection()`): - winner selection cannot succeed twice in the same round (`echidna_winnerSelection_only_once_per_round`) @@ -206,20 +181,18 @@ High-signal properties per harness: - `echidna_tracked_commit_matches_storage` - `echidna_tracked_reveal_matches_storage` + Trivial library-level checks (access control via `AccessControl`, pause gating via `Pausable`, phase + arithmetic from `block.number`, and `currentCommitRound`/`currentRevealRound` monotonicity) are + intentionally **not** fuzzed here — they're deterministic and already covered by `test/Redistribution.test.ts`. + - **Redistribution claim-stub harness** - claim can only succeed once per round (`echidna_claim_only_once_per_round`) - successful claim withdraws the entire pot to the selected winner (`echidna_claim_withdraws_pot_to_winner_when_successful`) + - **H-1 scenario**: if the postage `withdraw()` reverts, the pot is preserved but the round is still consumed (`echidna_failed_withdraw_preserves_pot_and_consumes_round`) - claim triggers an oracle `adjustPrice` call (`echidna_claim_triggers_oracle_adjustPrice`) - non-revealers are frozen during claim processing (`echidna_nonrevealers_frozen_after_claim_selection`) -- **Redistribution real-claim harness** - - - untouched CAC/SOC fixtures can complete the real `claim()` path (`echidna_unmutated_fixture_claim_succeeds`) - - corrupted proof fixtures do not successfully claim (`echidna_mutated_fixture_claim_does_not_succeed`) - - successful real claims trigger the expected withdraw/oracle side-effects (`echidna_successful_real_claim_effects_hold`) - - **Coverage guard** (non-vacuous): after a warmup of many `act_*` transactions, at least one successful unmutated end-to-end `claim` must occur (`echidna_unmutated_fixture_end_to_end_must_succeed_at_least_once`). Echidna also evaluates properties on the post-deploy state before any transaction, so this property stays vacuously true until `fuzzActTxCount` crosses `MIN_FUZZ_TXS_BEFORE_FIXTURE_E2E_REQUIRED` (see `EchidnaRedistributionRealClaimHarness.sol`). With the repo defaults (`seqLen` 320, `testLimit` 60000), the warmup threshold is hit almost immediately relative to total work, then the fuzzer must keep finding the fixture path or the run fails. - - **System/integration harness** (only invariants that require real cross-contract wiring; single-contract checks live in their unit harness) - Oracle↔stamp invariant: `PostageStamp.lastPrice` tracks `PriceOracle.currentPrice()` after updates - Stamp accounting: internal `pot` does not exceed the stamp contract’s BZZ balance (`echidna_stamp_internal_pot_not_above_contract_balance`) @@ -268,10 +241,10 @@ corpus or tuned fuzzing parameters. | Setting | Default | Notes | |----------------|---------|--------| | `testLimit` | `60000` | Sequences tried per harness (each sequence uses at most `seqLen` calls). | -| `seqLen` | `320` | Enough depth for redistribution rounds + fixture `commit`→`reveal`→`claim` exploration. | +| `seqLen` | `320` | Enough depth for redistribution rounds and `commit`→`reveal`→`claim` exploration. | | `maxBlockDelay`| `152` | Full `ROUND_LENGTH`; helps `currentRound()` advance without enormous sequences. | -Shorter smoke runs: set `ECHIDNA_TEST_LIMIT` / `ECHIDNA_SEQ_LEN` when invoking `yarn echidna` (see `scripts/echidna.sh`). If you lower limits so total `act_*` traffic never reaches `MIN_FUZZ_TXS_BEFORE_FIXTURE_E2E_REQUIRED` on the **real-claim** harness, the strict end-to-end property never arms (by design). +Shorter smoke runs: set `ECHIDNA_TEST_LIMIT` / `ECHIDNA_SEQ_LEN` when invoking `yarn echidna` (see `scripts/echidna.sh`). To run only a specific harness contract: @@ -281,7 +254,6 @@ ECHIDNA_CONTRACT=EchidnaPriceOracleHarness yarn echidna ECHIDNA_CONTRACT=EchidnaPostageStampHarness yarn echidna ECHIDNA_CONTRACT=EchidnaRedistributionHarness yarn echidna ECHIDNA_CONTRACT=EchidnaRedistributionClaimHarness yarn echidna -ECHIDNA_CONTRACT=EchidnaRedistributionRealClaimHarness yarn echidna ECHIDNA_CONTRACT=EchidnaSystemHarness yarn echidna ``` diff --git a/scripts/echidna.sh b/scripts/echidna.sh index 541336d1..9a86f998 100755 --- a/scripts/echidna.sh +++ b/scripts/echidna.sh @@ -14,6 +14,10 @@ IMAGE="${ECHIDNA_IMAGE:-ghcr.io/crytic/echidna/echidna:latest}" CONTRACT="${ECHIDNA_CONTRACT:-}" CONFIG="${ECHIDNA_CONFIG:-echidna/echidna.yaml}" +# Crytic-compile reads artifacts/build-info when using --hardhat-ignore-compile inside Docker (no Node/npx). +# Stale build-info from deleted Solidity sources causes "Unknown file" failures. +rm -rf artifacts/build-info + # Compile on the host. The Echidna container image doesn't ship with Node/npx, # and without Hardhat artifacts CryticCompile will try (and fail) to run `npx hardhat compile`. yarn -s hardhat compile --force >/dev/null @@ -64,5 +68,6 @@ for c in "${CONTRACTS_TO_RUN[@]}"; do -w /src \ "$IMAGE" \ -c "rm -rf crytic-export && echidna-test . --contract ${c} --config ${CONFIG} \ - --corpus-dir ${CORPUS_DIR} --coverage-dir ${CORPUS_DIR}/coverage${ECHIDNA_EXTRA_CLI}" + --corpus-dir ${CORPUS_DIR} --coverage-dir ${CORPUS_DIR}/coverage${ECHIDNA_EXTRA_CLI} \ + --crytic-args '--hardhat-ignore-compile'" done diff --git a/src/echidna/EchidnaRedistributionHarness.sol b/src/echidna/EchidnaRedistributionHarness.sol index 32f2d92b..0631e744 100644 --- a/src/echidna/EchidnaRedistributionHarness.sol +++ b/src/echidna/EchidnaRedistributionHarness.sol @@ -88,26 +88,10 @@ contract EchidnaRedistributionActor { function callWinnerSelection() external returns (bool ok) { (ok, ) = address(redist).call(abi.encodeWithSelector(redist.exposedWinnerSelection.selector)); } - - function tryPause() external returns (bool ok) { - (ok, ) = address(redist).call(abi.encodeWithSelector(redist.pause.selector)); - } - - function tryUnpause() external returns (bool ok) { - (ok, ) = address(redist).call(abi.encodeWithSelector(redist.unPause.selector)); - } - - function trySetSampleMaxValue(uint256 v) external returns (bool ok) { - (ok, ) = address(redist).call(abi.encodeWithSelector(redist.setSampleMaxValue.selector, v)); - } - - function trySetFreezingParams(uint8 a, uint8 b, uint8 c) external returns (bool ok) { - (ok, ) = address(redist).call(abi.encodeWithSelector(redist.setFreezingParams.selector, a, b, c)); - } } /// @notice Base Echidna harness for Redistribution. -/// @dev Focuses on wiring dependencies, access control, and basic internal consistency. +/// @dev Focuses on commit/reveal state-machine consistency and winnerSelection postconditions. contract EchidnaRedistributionHarness { EchidnaStakeRegistryMock internal immutable stakeMock; EchidnaPostageStampMock internal immutable stampMock; @@ -119,10 +103,6 @@ contract EchidnaRedistributionHarness { uint256 internal constant MAX_COMMIT_REVEAL_SCAN = 25; EchidnaRedistributionActor[3] internal actors; - // Forbidden-call flags. - bool internal unauthorizedAdminCallSucceeded; - bool internal commitSucceededWhilePaused; - bool internal revealSucceededWhilePaused; bool internal winnerSelectionSucceededTwiceSameRound; // Pending winnerSelection postconditions. @@ -262,30 +242,6 @@ contract EchidnaRedistributionHarness { redist.setFreezingParams(a, b, c); } - function act_rando_tryPause(uint8 actorId) external { - _clearWinnerSelectionPending(); - bool ok = actors[uint256(actorId) % ACTOR_COUNT].tryPause(); - if (ok) unauthorizedAdminCallSucceeded = true; - } - - function act_rando_tryUnpause(uint8 actorId) external { - _clearWinnerSelectionPending(); - bool ok = actors[uint256(actorId) % ACTOR_COUNT].tryUnpause(); - if (ok) unauthorizedAdminCallSucceeded = true; - } - - function act_rando_trySetSampleMaxValue(uint8 actorId, uint256 v) external { - _clearWinnerSelectionPending(); - bool ok = actors[uint256(actorId) % ACTOR_COUNT].trySetSampleMaxValue(v); - if (ok) unauthorizedAdminCallSucceeded = true; - } - - function act_rando_trySetFreezingParams(uint8 actorId, uint8 a, uint8 b, uint8 c) external { - _clearWinnerSelectionPending(); - bool ok = actors[uint256(actorId) % ACTOR_COUNT].trySetFreezingParams(a, b, c); - if (ok) unauthorizedAdminCallSucceeded = true; - } - // ----------------------------- // Advanced actions (aim for successful commit/reveal) // ----------------------------- @@ -366,48 +322,6 @@ contract EchidnaRedistributionHarness { trackedHasReveal[idx] = true; } - function act_tryCommitWhilePaused(uint8 actorId, bytes32 reserveHash, bytes32 nonce) external { - _clearWinnerSelectionPending(); - if (!redist.paused()) return; - if (!redist.currentPhaseCommit()) return; - if (block.number % 152 == (152 / 4) - 1) return; - - uint256 idx = uint256(actorId) % ACTOR_COUNT; - EchidnaRedistributionActor a = actors[idx]; - - bytes32 anchor = redist.currentRoundAnchor(); - bytes32 overlay = keccak256(abi.encodePacked("paused-overlay", idx, anchor)); - uint8 h = 0; - uint8 d = 0; - stakeMock.setNode(address(a), overlay, h, 1e18, _backdateLastUpdated()); - - bytes32 obfuscated = redist.wrapCommit(overlay, d, reserveHash, nonce); - bool ok = a.callCommit(obfuscated, redist.currentRound()); - if (ok) commitSucceededWhilePaused = true; - } - - function act_tryRevealWhilePaused(uint8 actorId) external { - _clearWinnerSelectionPending(); - if (!redist.paused()) return; - if (!redist.currentPhaseReveal()) return; - - uint256 idx = uint256(actorId) % ACTOR_COUNT; - if (!trackedHasCommit[idx]) return; - if (redist.currentRound() != trackedRound[idx]) return; - if (redist.currentCommitRound() != trackedRound[idx]) return; - - EchidnaRedistributionActor a = actors[idx]; - stakeMock.setNode( - address(a), - trackedOverlay[idx], - trackedHeight[idx], - trackedStake[idx], - _backdateLastUpdated() - ); - bool ok = a.callReveal(trackedDepth[idx], trackedReserveHash[idx], trackedNonce[idx]); - if (ok) revealSucceededWhilePaused = true; - } - function act_winnerSelection(uint8 actorId) external { _clearWinnerSelectionPending(); uint256 idx = uint256(actorId) % ACTOR_COUNT; @@ -442,22 +356,6 @@ contract EchidnaRedistributionHarness { // Properties // ----------------------------- - function echidna_never_performed_forbidden_calls() external view returns (bool) { - return !unauthorizedAdminCallSucceeded; - } - - function echidna_never_succeeded_while_paused() external view returns (bool) { - return !commitSucceededWhilePaused && !revealSucceededWhilePaused; - } - - function echidna_phase_partitions_round() external view returns (bool) { - bool c = redist.currentPhaseCommit(); - bool r = redist.currentPhaseReveal(); - bool cl = redist.currentPhaseClaim(); - // Exactly one phase must be true for any block. - return (c && !r && !cl) || (!c && r && !cl) || (!c && !r && cl); - } - function echidna_reveal_entries_imply_matching_commit() external view returns (bool) { // For each reveal entry, there must exist a commit marked revealed with matching overlay/owner and revealIndex pointing here. // @@ -512,11 +410,6 @@ contract EchidnaRedistributionHarness { return true; } - function echidna_round_counters_not_in_future() external view returns (bool) { - uint64 cr = redist.currentRound(); - return redist.currentCommitRound() <= cr && redist.currentRevealRound() <= cr; - } - function echidna_commit_overlays_unique() external view returns (bool) { (uint256 n, bytes32[25] memory overlays, , , ) = _scanCommits(); for (uint256 i = 0; i < n; i++) { diff --git a/src/echidna/EchidnaRedistributionRealClaimHarness.sol b/src/echidna/EchidnaRedistributionRealClaimHarness.sol deleted file mode 100644 index 5f84a12f..00000000 --- a/src/echidna/EchidnaRedistributionRealClaimHarness.sol +++ /dev/null @@ -1,692 +0,0 @@ -// SPDX-License-Identifier: BSD-3-Clause -pragma solidity ^0.8.19; - -import "../Redistribution.sol"; -import "../interface/IPostageStamp.sol"; -import "./EchidnaMocks.sol"; -import "./RedistributionFixtureRandomness.sol"; - -contract EchidnaFixturePostageStampMock is IPostageStamp { - struct BatchData { - address owner; - uint8 depth; - uint8 bucketDepth; - bool exists; - } - - mapping(bytes32 => BatchData) internal batchData; - - uint256 public withdrawCalls; - address public lastBeneficiary; - uint256 public lastAmount; - uint256 public pot = 1 ether; - uint256 public validChunkCountValue = 1; - - function setBatch(bytes32 batchId, address owner, uint8 depth, uint8 bucketDepth) external { - batchData[batchId] = BatchData({owner: owner, depth: depth, bucketDepth: bucketDepth, exists: true}); - } - - function seedPot(uint256 amount) external { - pot += amount; - } - - function withdraw(address beneficiary) external { - withdrawCalls += 1; - lastBeneficiary = beneficiary; - lastAmount = pot; - pot = 0; - } - - function setPrice(uint256) external {} - - function validChunkCount() external view returns (uint256) { - return validChunkCountValue; - } - - function batchOwner(bytes32 batchId) external view returns (address) { - return batchData[batchId].owner; - } - - function batchDepth(bytes32 batchId) external view returns (uint8) { - return batchData[batchId].depth; - } - - function batchBucketDepth(bytes32 batchId) external view returns (uint8) { - return batchData[batchId].bucketDepth; - } - - function remainingBalance(bytes32 batchId) external view returns (uint256) { - return batchData[batchId].exists ? 1 : 0; - } - - function minimumInitialBalancePerChunk() external pure returns (uint256) { - return 1; - } - - function batches( - bytes32 batchId - ) - external - view - returns ( - address owner, - uint8 depth, - uint8 bucketDepth, - bool immutableFlag, - uint256 normalisedBalance, - uint256 lastUpdatedBlockNumber - ) - { - BatchData memory b = batchData[batchId]; - if (!b.exists) { - return (address(0), 0, 0, false, 0, 0); - } - return (b.owner, b.depth, b.bucketDepth, true, 1, 1); - } -} - -/// @notice Fixture-based Echidna harness for the real `claim()` verifier path. -/// @dev Uses hardcoded CAC/SOC proof bundles generated by the existing Hardhat tests. -contract EchidnaRedistributionRealClaimHarness { - uint256 internal constant ROUND_LENGTH = 152; - /// @dev Echidna evaluates properties on the deploy state (0 txs); defer strict checks until fuzzing runs. - /// Align with `echidna/echidna.yaml` `seqLen` (default 320): ~78 `act_*` calls ≈ first full sequence. - /// Must stay well below `testLimit` × `seqLen` so the strict property arms during a normal campaign. - uint256 internal constant MIN_FUZZ_TXS_BEFORE_FIXTURE_E2E_REQUIRED = 25_000; - - uint8 internal constant FIXTURE_NONE = 0; - uint8 internal constant FIXTURE_CAC = 1; - uint8 internal constant FIXTURE_SOC = 2; - - address internal constant FIXTURE_BATCH_OWNER = 0x26234a2ad3bA8B398A762f279B792cfAcd536a3f; - - bytes32 internal constant FIXTURE_REVEAL_ANCHOR = - 0x3617319a054d772f909f7c479a2cebe5066e836a939412e32403c99029b92eff; - bytes32 internal constant FIXTURE_CLAIM_SEED = 0xa4541cb5a0f209fed7c786aac6865922446ed57fc0dcf8ad07c17afcd3c5efb8; - bytes32 internal constant FIXTURE_NONCE = 0xb5555b33b5555b33b5555b33b5555b33b5555b33b5555b33b5555b33b5555b33; - - bytes32 internal constant CAC_BATCH_ID = 0x5bee6f33f47fbe2c3ff4c853dbc95f1a6a4a4191a1a7e3ece999a76c2790a83f; - bytes32 internal constant SOC_BATCH_ID = 0x6cccd65a68bc5f7c19a273e9567ebf4b968a13c9be74fc99ad90159730eff219; - - bytes32 internal constant CAC_HASH = 0xcf27be680f2dada5bcc45506997f54804e583650e48c28514ccc95234ef4f9f3; - bytes32 internal constant SOC_HASH = 0x9a6afe770410c1bd7cdc1f324cfcf73ba1b85e3860d4594522456be4fe6b1d80; - - uint8 internal constant CAC_DEPTH = 1; - uint8 internal constant SOC_DEPTH = 0; - - EchidnaStakeRegistryMock internal stakeMock; - EchidnaFixturePostageStampMock internal stampMock; - EchidnaPriceOracleMock internal oracleMock; - Redistribution internal redist; - - uint8 internal activeFixtureKind; - bytes32 internal activeHash; - uint8 internal activeDepth; - bool internal activeFixtureMutated; - - Redistribution.ChunkInclusionProof internal activeProof1; - Redistribution.ChunkInclusionProof internal activeProof2; - Redistribution.ChunkInclusionProof internal activeProofLast; - - bool internal commitPrepared; - bool internal claimReady; - uint64 internal preparedRound; - - bool internal lastClaimObserved; - bool internal lastClaimExpectedSuccess; - bool internal lastClaimSucceeded; - uint256 internal withdrawCallsBeforeClaim; - uint256 internal oracleCallsBeforeClaim; - - /// @dev Monotonic progress markers (never weakened by mutation helpers) so we can tell - /// whether Echidna is only burning gas on early returns vs. driving the real pipeline. - bool public fixtureCommitSucceededOnce; - bool public fixtureRevealMadeClaimReadyOnce; - bool public unmutatedFixtureClaimSucceededOnce; - - /// @dev Incremented once per `act_*` transaction (not per internal call). - uint256 public fuzzActTxCount; - - modifier countFuzzTx() { - unchecked { - ++fuzzActTxCount; - } - _; - } - - constructor() { - stakeMock = new EchidnaStakeRegistryMock(); - stampMock = new EchidnaFixturePostageStampMock(); - oracleMock = new EchidnaPriceOracleMock(); - redist = new RedistributionFixtureRandomness( - address(stakeMock), - address(stampMock), - address(oracleMock), - FIXTURE_CLAIM_SEED - ); - - stampMock.setBatch(CAC_BATCH_ID, FIXTURE_BATCH_OWNER, 27, 16); - stampMock.setBatch(SOC_BATCH_ID, FIXTURE_BATCH_OWNER, 27, 16); - - // The overlay is arbitrary for proof verification; we choose the fixture reveal anchor so - // commit/reveal proximity is guaranteed once we reach the target round. - stakeMock.setNode(address(this), FIXTURE_REVEAL_ANCHOR, 0, 1e18, 1); - _useCacFixture(); - } - - function act_tick() external countFuzzTx {} - - function act_useCacFixture() external countFuzzTx { - _useCacFixture(); - } - - function act_useSocFixture() external countFuzzTx { - _useSocFixture(); - } - - function act_seedPot(uint256 amount) external countFuzzTx { - stampMock.seedPot((amount % 1e24) + 1); - } - - function act_prepareFixtureCommit() external countFuzzTx { - _clearLastClaim(); - if (activeFixtureKind == FIXTURE_NONE) return; - if (!redist.currentPhaseCommit()) return; - // Pristine `seed == 0` and no prior reveals: anchor hits this iff `currentRound() == 4`. - if (redist.currentRoundAnchor() != FIXTURE_REVEAL_ANCHOR) return; - if (block.number % ROUND_LENGTH == (ROUND_LENGTH / 4) - 1) return; - - stakeMock.setNode(address(this), FIXTURE_REVEAL_ANCHOR, 0, 1e18, _backdateLastUpdated()); - - bytes32 obfuscated = redist.wrapCommit(FIXTURE_REVEAL_ANCHOR, activeDepth, activeHash, FIXTURE_NONCE); - (bool ok, ) = address(redist).call( - abi.encodeWithSelector(redist.commit.selector, obfuscated, redist.currentRound()) - ); - if (!ok) return; - - fixtureCommitSucceededOnce = true; - commitPrepared = true; - claimReady = false; - preparedRound = redist.currentRound(); - } - - function act_prepareFixtureReveal() external countFuzzTx { - _clearLastClaim(); - if (!commitPrepared) return; - if (!redist.currentPhaseReveal()) return; - if (redist.currentRound() != preparedRound) return; - if (redist.currentCommitRound() != preparedRound) return; - - (bool ok, ) = address(redist).call( - abi.encodeWithSelector(redist.reveal.selector, activeDepth, activeHash, FIXTURE_NONCE) - ); - if (!ok) return; - - claimReady = redist.currentSeed() == FIXTURE_CLAIM_SEED; - if (claimReady) fixtureRevealMadeClaimReadyOnce = true; - } - - function act_claimActiveFixture() external countFuzzTx { - _clearLastClaim(); - if (!claimReady) return; - if (!redist.currentPhaseClaim()) return; - if (redist.currentRound() != preparedRound) return; - - lastClaimObserved = true; - lastClaimExpectedSuccess = !activeFixtureMutated; - withdrawCallsBeforeClaim = stampMock.withdrawCalls(); - oracleCallsBeforeClaim = oracleMock.calls(); - - (lastClaimSucceeded, ) = address(redist).call( - abi.encodeWithSelector(redist.claim.selector, activeProof1, activeProof2, activeProofLast) - ); - - if (lastClaimSucceeded && lastClaimExpectedSuccess) { - unmutatedFixtureClaimSucceededOnce = true; - } - } - - function act_mutateReserveCommitmentRoot(bytes32 replacement) external countFuzzTx { - if (activeFixtureKind == FIXTURE_NONE || activeProof1.proofSegments.length == 0) return; - activeProof1.proofSegments[0] = _tweak(replacement); - activeFixtureMutated = true; - _clearLastClaim(); - } - - function act_mutateOriginalChunkBranch(bytes32 replacement) external countFuzzTx { - if (activeFixtureKind == FIXTURE_NONE || activeProof1.proofSegments2.length < 2) return; - activeProof1.proofSegments2[1] = _tweak(replacement); - activeFixtureMutated = true; - _clearLastClaim(); - } - - function act_mutateTransformedChunkBranch(bytes32 replacement) external countFuzzTx { - if (activeFixtureKind == FIXTURE_NONE || activeProof1.proofSegments3.length < 2) return; - activeProof1.proofSegments3[1] = _tweak(replacement); - activeFixtureMutated = true; - _clearLastClaim(); - } - - function act_mutatePostageIndexLow(uint32 lowWord) external countFuzzTx { - if (activeFixtureKind == FIXTURE_NONE) return; - uint64 current = activeProof1.postageProof.index; - uint64 next = (current & 0xffffffff00000000) | uint64(lowWord); - if (next == current) next ^= 1; - activeProof1.postageProof.index = next; - activeFixtureMutated = true; - _clearLastClaim(); - } - - function act_mutatePostageIndexHigh(uint32 highWord) external countFuzzTx { - if (activeFixtureKind == FIXTURE_NONE) return; - uint64 current = activeProof1.postageProof.index; - uint64 next = (uint64(highWord) << 32) | (current & 0xffffffff); - if (next == current) next ^= uint64(1) << 32; - activeProof1.postageProof.index = next; - activeFixtureMutated = true; - _clearLastClaim(); - } - - function act_mutateSocIdentifier(bytes32 replacement) external countFuzzTx { - if (activeFixtureKind != FIXTURE_SOC || activeProof1.socProof.length == 0) return; - activeProof1.socProof[0].identifier = _tweak(replacement); - activeFixtureMutated = true; - _clearLastClaim(); - } - - function echidna_unmutated_fixture_claim_succeeds() external view returns (bool) { - if (!lastClaimObserved) return true; - if (!lastClaimExpectedSuccess) return true; - return lastClaimSucceeded; - } - - function echidna_mutated_fixture_claim_does_not_succeed() external view returns (bool) { - if (!lastClaimObserved) return true; - if (lastClaimExpectedSuccess) return true; - return !lastClaimSucceeded; - } - - function echidna_successful_real_claim_effects_hold() external view returns (bool) { - if (!lastClaimObserved || !lastClaimExpectedSuccess || !lastClaimSucceeded) return true; - if (stampMock.withdrawCalls() != withdrawCallsBeforeClaim + 1) return false; - if (oracleMock.calls() != oracleCallsBeforeClaim + 1) return false; - if (stampMock.lastBeneficiary() != address(this)) return false; - if (stampMock.pot() != 0) return false; - if (redist.currentClaimRound() != preparedRound) return false; - return true; - } - - /// @notice Non-vacuous over a full campaign: after warmup, requires at least one successful unmutated fixture `claim`. - /// @dev Lower `MIN_FUZZ_TXS_BEFORE_FIXTURE_E2E_REQUIRED` only for short smoke runs; defaults assume `echidna.yaml` (60k × 320). - function echidna_unmutated_fixture_end_to_end_must_succeed_at_least_once() external view returns (bool) { - if (fuzzActTxCount < MIN_FUZZ_TXS_BEFORE_FIXTURE_E2E_REQUIRED) return true; - return unmutatedFixtureClaimSucceededOnce; - } - - function _useCacFixture() internal { - _resetFixtureState(); - activeFixtureKind = FIXTURE_CAC; - activeHash = CAC_HASH; - activeDepth = CAC_DEPTH; - _writeProof(activeProof1, _cacProof1()); - _writeProof(activeProof2, _cacProof2()); - _writeProof(activeProofLast, _cacProofLast()); - } - - function _useSocFixture() internal { - _resetFixtureState(); - activeFixtureKind = FIXTURE_SOC; - activeHash = SOC_HASH; - activeDepth = SOC_DEPTH; - _writeProof(activeProof1, _socProof1()); - _writeProof(activeProof2, _socProof2()); - _writeProof(activeProofLast, _socProofLast()); - } - - function _resetFixtureState() internal { - delete activeProof1; - delete activeProof2; - delete activeProofLast; - activeFixtureMutated = false; - commitPrepared = false; - claimReady = false; - preparedRound = 0; - _clearLastClaim(); - } - - function _clearLastClaim() internal { - lastClaimObserved = false; - lastClaimExpectedSuccess = false; - lastClaimSucceeded = false; - withdrawCallsBeforeClaim = 0; - oracleCallsBeforeClaim = 0; - } - - function _backdateLastUpdated() internal view returns (uint256) { - uint256 twoRounds = 2 * ROUND_LENGTH; - if (block.number > twoRounds + 1) return block.number - twoRounds - 1; - return 1; - } - - function _tweak(bytes32 value) internal pure returns (bytes32) { - if (value == bytes32(0)) return bytes32(uint256(1)); - return value; - } - - function _writeProof( - Redistribution.ChunkInclusionProof storage dst, - Redistribution.ChunkInclusionProof memory src - ) internal { - delete dst.proofSegments; - delete dst.proveSegment; - delete dst.proofSegments2; - delete dst.proveSegment2; - delete dst.chunkSpan; - delete dst.proofSegments3; - delete dst.postageProof; - delete dst.socProof; - - _writeBytes32Array(dst.proofSegments, src.proofSegments); - dst.proveSegment = src.proveSegment; - _writeBytes32Array(dst.proofSegments2, src.proofSegments2); - dst.proveSegment2 = src.proveSegment2; - dst.chunkSpan = src.chunkSpan; - _writeBytes32Array(dst.proofSegments3, src.proofSegments3); - - dst.postageProof.signature = src.postageProof.signature; - dst.postageProof.postageId = src.postageProof.postageId; - dst.postageProof.index = src.postageProof.index; - dst.postageProof.timeStamp = src.postageProof.timeStamp; - - for (uint256 i = 0; i < src.socProof.length; i++) { - dst.socProof.push(); - dst.socProof[i].signer = src.socProof[i].signer; - dst.socProof[i].signature = src.socProof[i].signature; - dst.socProof[i].identifier = src.socProof[i].identifier; - dst.socProof[i].chunkAddr = src.socProof[i].chunkAddr; - } - } - - function _writeBytes32Array(bytes32[] storage dst, bytes32[] memory src) internal { - for (uint256 i = 0; i < src.length; i++) { - dst.push(src[i]); - } - } - - function _arr7( - bytes32 a0, - bytes32 a1, - bytes32 a2, - bytes32 a3, - bytes32 a4, - bytes32 a5, - bytes32 a6 - ) internal pure returns (bytes32[] memory arr) { - arr = new bytes32[](7); - arr[0] = a0; - arr[1] = a1; - arr[2] = a2; - arr[3] = a3; - arr[4] = a4; - arr[5] = a5; - arr[6] = a6; - } - - function _cacProof1() internal pure returns (Redistribution.ChunkInclusionProof memory p) { - p.proofSegments = _arr7( - 0x000057515f1f37c0136197a45bd50b4618e3ebe272c4fb34b7f00e8972b84630, - 0x24d4e8fa9840ee525cf13d85ec07ad6b6ac88a180c5e420a7e57f9a3bf0a0578, - 0x85e4c229f557648ecbd01e0d768a1786bd3df8bb248a395c5768fad9d8d56f74, - 0x19042aeaca66e2bf5e961ff1429b51d0b566700e94154d3ff2addea8910a857f, - 0x931e95ed8ea3efe7c961602b8b7888b59947124f190da163b85246c1d8dc28bc, - 0x0eb01ebfc9ed27500cd4dfc979272d1f0913cc9f66540d7e8005811109e1cf2d, - 0x887c22bd8750d34016ac3c66b5ff102dacdd73f6b014e710b51e8022af9a1968 - ); - p.proveSegment = 0x78fbc965b296a7e146ae2cfb03f4df22a84779078538905dde74327bf4e36545; - p.proofSegments2 = _arr7( - 0x6e21b55f5f30ea3d77ca7ee24fe43928f309f19916978a98eb75bd8dfbf9e9fb, - 0xe82ab3bce42ee51c670eadc57c5d7eab70a902e977ac2a820d82d1910b49c30a, - 0x0abb1479acb3314a30ecdc03016e6ef8900aa48882885f33918b8740d7766e26, - 0xb07cfba2224b8d59a81006711d8a4c1b8e342e7bfc8355cb7a1907793663b7cb, - 0xecf121c97385aed4648d1eca4eda8b7d4d1e7094c9a29b44ec8cd759b562bba8, - 0x03dac69fb24d1c455fe32e866dec762944d198a7fb38aa5744030a8b0157edf1, - 0x6eb22b1022d18748bd1f3b2f0afe6ec82d7457406012ea91728452602ebc44b2 - ); - p.proveSegment2 = 0x575c434071a58a272ed3d3c9c73782c66b680650540409b53c05db21a21db577; - p.chunkSpan = 4096; - p.proofSegments3 = _arr7( - 0x6e21b55f5f30ea3d77ca7ee24fe43928f309f19916978a98eb75bd8dfbf9e9fb, - 0x3ff0f0743b3d169981cc5f67cbcf8725917e43dd4aea9bfee4e22abe4d18ecf9, - 0x50ffd8a56337e576f0b56c2563082dc4776d099503479bb918548f21b0d55daa, - 0x3ccc3ad084a22f20f06e9b388ddcfeda2c6781f9c9f0e4e6c896ab78e9c1270e, - 0xb5fdcfb93b778f5ae8fa2564633e31f195b518c63d00831d9a57556ac43760db, - 0x04ad7a29fe63d0373e47f0184950cb538dd82482a23c757095b4adac2a5a9357, - 0xc2ef7172b9c978b3e579a3424d03db71f907932fc9b9b031bfa5c7dd9713ba64 - ); - p.postageProof = Redistribution.PostageProof({ - signature: hex"f6b2fbdaf5a59b55303e98d9cfb8f5d730cd4cec601abf28f62cbd2c2e3f0dcc3d2c82144b2332c52961290b3a64dd25231fc5afa3ad31541c6a0a64ee1954ac1c", - postageId: CAC_BATCH_ID, - index: 0x000078fb00000017, - timeStamp: 0x1785462106d88f33 - }); - p.socProof = new Redistribution.SOCProof[](0); - } - - function _cacProof2() internal pure returns (Redistribution.ChunkInclusionProof memory p) { - p.proofSegments = _arr7( - 0x00009dab88dcb65c9eb3028506d26b918b33b740d689e2c8d215a675c4fa8891, - 0xf85d5e81948dbb24f33bbf6af7efceb547c47d5a64e8b11f676b893751a1bfd0, - 0x7d1b3e453e4c08496f30ed71a866ac28916e27cd3885a16a961d1dc87c8befa8, - 0xbb651df2741f07c6aed8a68af8d08c32ab556bb05403473dc6ab3d0d68087785, - 0x931e95ed8ea3efe7c961602b8b7888b59947124f190da163b85246c1d8dc28bc, - 0x0eb01ebfc9ed27500cd4dfc979272d1f0913cc9f66540d7e8005811109e1cf2d, - 0x887c22bd8750d34016ac3c66b5ff102dacdd73f6b014e710b51e8022af9a1968 - ); - p.proveSegment = 0x724fc91ed7318f5c04ce30d33cc9444eb4937974285af8cbc87097a40edccd09; - p.proofSegments2 = _arr7( - 0xa5853628094e0da25bc9f8ef2e56e0b43367e8f1644781eab6f0d8adceb37de1, - 0x8c7699a136deeef563d257530044ad3421ebea67faadbe5670c6a53b1da95681, - 0x37141d349d016b61787ea8d50d246d40d5c47dafd2b618150eb2735c9e147336, - 0xd620b8cba4e9c6d0258d900756f0156a92baf7093fd71101c2205a1ee11484a5, - 0x33482c710b3838c0db16022f226aa1aa5dc449d7b086e42e39220ef38cc8b9b9, - 0xf345ba3b2bae408d46585568803ba75a5b551701f488172449455700ec1a0f97, - 0x634dded701a03c7b13b8d3180df87fbbfa185f5f9b8dafad09d2f297a2b45e31 - ); - p.proveSegment2 = 0xeff9bac0c51082ff8d4380bf8cdca4fa9c20bcdc4190f2a2f3a999726d77f9f1; - p.chunkSpan = 4096; - p.proofSegments3 = _arr7( - 0xa5853628094e0da25bc9f8ef2e56e0b43367e8f1644781eab6f0d8adceb37de1, - 0xd453c8dca4cf42a72f1839330b5714b1f06eb476a4085d6dd65725b39544ed5c, - 0x73dc1145525816ba07842f433c81e2d9afd088fbff91b41f8378e04ecdccd2c0, - 0xff12b1e18bca6906208368d3572f6bc8c1ea638621c4a448424e1a190ad2aadd, - 0x3e6d67361b3762a2b0a15cf3af86681bbead5ee282c19dab5fa96df5b398685b, - 0x6bd935f8dcbd319861b13f1c0283d699b454bb20b5492393fec71ba6bc67d8a7, - 0x5bcf6be70e14ef138cb6f686163cf19406f943312679f1ae3f9573f0205dcdb9 - ); - p.postageProof = Redistribution.PostageProof({ - signature: hex"0d683b47c584585f00462c943c745d317d6354841b5fa78e8f50793e01e0f0ee7f6cb4a320e45382b92ecdbe6e3db380e309ce1279c8b15a2ab6e09a94f752f21c", - postageId: CAC_BATCH_ID, - index: 0x0000724f00000023, - timeStamp: 0x178551f31887a7e0 - }); - p.socProof = new Redistribution.SOCProof[](0); - } - - function _cacProofLast() internal pure returns (Redistribution.ChunkInclusionProof memory p) { - p.proofSegments = _arr7( - 0x0000a386b3d729becb0f7c5a938eff58dc4c5b45cb48876c0aabe38b3ec61511, - 0x13b90286a980a173a93f8d99f91eceaa5cfba622b787d7e47d799c93b728d8f0, - 0xe9d29a6a168d71c4601b5a03f0250ffff95dea7a89a0eeea57270fc14b5e28f0, - 0xbb651df2741f07c6aed8a68af8d08c32ab556bb05403473dc6ab3d0d68087785, - 0x931e95ed8ea3efe7c961602b8b7888b59947124f190da163b85246c1d8dc28bc, - 0x0eb01ebfc9ed27500cd4dfc979272d1f0913cc9f66540d7e8005811109e1cf2d, - 0x887c22bd8750d34016ac3c66b5ff102dacdd73f6b014e710b51e8022af9a1968 - ); - p.proveSegment = 0x7cfd4723785c35784b3a9fc7e9627e05b4ba2d175bb27cc36457c5abfa3487b7; - p.proofSegments2 = _arr7( - 0x09b6a9039fd92221244a46d66c5daa640ce2b4df4d8664c60393c006c8466241, - 0x926473068fb33428e2ba2b5ef06a4030f7d28840b3f6c39ff39e5c29c3c3d572, - 0xe280f788a94a73ba383aef1a375ce551c50397457631b7f192a094cf93754df2, - 0x7cae69170533f9610e78b1872811feb26765bedef8194232d1f83b3d6c4f0550, - 0x45603497bee8141ce3a62fe57500d1857e4c9be9c88a435e688f9d79031ac200, - 0xa5d5238098387e9fb8a0d2f834f06e32a941949cc8981e7001b4f237540dc72d, - 0x66854bd0e7aecd6abbe45b5f65944b54cc2cf6162ea821d03c1a766997d047ba - ); - p.proveSegment2 = 0x318aa90994bc17e443e67fd083d8ab251c1d17ced4e8ab983ddf02890d002605; - p.chunkSpan = 4096; - p.proofSegments3 = _arr7( - 0x09b6a9039fd92221244a46d66c5daa640ce2b4df4d8664c60393c006c8466241, - 0x1243fc9df50961e8024851eb386470b4fe740a696c4dc5f06ac3ae268abaaf93, - 0x1a3bb6274281c057e5c5cf67316a36919c7796dc2db102137f936b7641cbe1ff, - 0x43047f57784dc25563f3049ebf918d8a8da731a2c98c6d8ea1883dc0a3e815b2, - 0x4a28eaa92b6b420cfd863f49e69ce3399f239d4c708a9747f7fdf2d4435605e4, - 0x4f3dd2aad4e6357ebc9d0bec8107601b7203dd32c279036a37b1823f976d3172, - 0xfe68d10d01f82c29eb44448659fed31a5f89d49dc7a553f23be89cc67d5bdece - ); - p.postageProof = Redistribution.PostageProof({ - signature: hex"185e49bdb4c3a50178d8e14da8f402b0df7f7608b6658e92e6e0c2dc1a653e7c14e8e9d4ccd743019ed78bf6ad1733f065ec7540f717dea4d0ae389bb15bc2841c", - postageId: CAC_BATCH_ID, - index: 0x00007cfd00000020, - timeStamp: 0x178548a18495b42a - }); - p.socProof = new Redistribution.SOCProof[](0); - } - - function _socProof1() internal pure returns (Redistribution.ChunkInclusionProof memory p) { - p.proofSegments = _arr7( - 0x00007f4036a4e72c49e6d1c90889d604194485062dbf05143335da5824972c9d, - 0x13cba79217e582fe2ddd0b8edbf6293ef98635bee9c76cb254952d468c031d08, - 0x015507653c37aec258fd3ca4fbf8dc3741986697cb1eb42c3245c9314e6dd272, - 0x55f1711fe7f802faf26d24ff80ee9dc5d3881972d9ce070b81d1eaee387ff62f, - 0xcbfd07f8af7baa634e60c2b296ef08347bd639023ce674b8e00415173c78951a, - 0x0eb01ebfc9ed27500cd4dfc979272d1f0913cc9f66540d7e8005811109e1cf2d, - 0x887c22bd8750d34016ac3c66b5ff102dacdd73f6b014e710b51e8022af9a1968 - ); - p.proveSegment = 0x71842d5d67339a6a0f096c5a9983308d4b6596baeb016696b3512c6fa95f9d89; - p.proofSegments2 = _arr7( - bytes32(0), - 0xad3228b676f7d3cd4284a5443f17f1962b36e491b30a40b2405849e597ba5fb5, - 0xb4c11951957c6f8f642c4af61cd6b24640fec6dc7fc607ee8206a99e92410d30, - 0x21ddb9a356815c3fac1026b6dec5df3124afbadb485c9ba5a3e3398a04b7ba85, - 0xe58769b32a1beaf1ea27375a44095a0d1fb664ce2dd358e7fcbfb78c26a19344, - 0xf583106353614658f33a983eeead8a2d4d20b7b6d90227eaeff9a01bb5ffb909, - 0x887c22bd8750d34016ac3c66b5ff102dacdd73f6b014e710b51e8022af9a1968 - ); - p.proveSegment2 = bytes32(0); - p.chunkSpan = 3; - p.proofSegments3 = _arr7( - bytes32(0), - 0x67b9c124b55528ea0f5bcb5db716513af90deeb860e6ffd4b04ff0cd5cf68fb1, - 0xd769d9f556cb6ff5100712c5a9ffd2872bf7469a78f8ec96815173473857b57b, - 0x92a902cf7bbe37528d886bfb4c4e304572b65920a7e8fa413bc336031c7b10e9, - 0x21f21466fe5abe61950d97653c028a940ba863ac39b9ad8de388a159c0d81651, - 0x19134f518cf4e7b0a4eb34cfd97e6fe201c97b4d255330ca226b22b93a224389, - 0xb6c8532228cd44b9e59febed68f8038e1059fa779813d6bfe30dd9f150ab6b99 - ); - p.postageProof = Redistribution.PostageProof({ - signature: hex"9d6a824eee86bc6d107b1ff917f7bacd9004751000e75f34dc78a0ef4230685935fb0808494583a4c62284562bacac394534bb83cb837034c1a5d2034679ce681c", - postageId: SOC_BATCH_ID, - index: 0x0000718400000000, - timeStamp: 0x1787d532f407197a - }); - p.socProof = new Redistribution.SOCProof[](1); - p.socProof[0] = Redistribution.SOCProof({ - signer: 0x316104Fe34dF9A02d8f93412A2e4b0973D63cC31, - signature: hex"e3d66649291e1e805b7be2a2e6585eca7df8a0e10c960230f55b1fec83805c032955d6708cde4a7b0d205201b9fd0a6e484174d56903ddce34b356d3a691fad91b", - identifier: 0x0000000000000000000000000003042500000000000000000000000000000000, - chunkAddr: 0x2387e8e7d8a48c2a9339c97c1dc3461a9a7aa07e994c5cb8b38fd7c1b3e6ea48 - }); - } - - function _socProof2() internal pure returns (Redistribution.ChunkInclusionProof memory p) { - p.proofSegments = _arr7( - 0x000092031489ee631d69963039604310bc1a6fc1762bc98bf26ac4b8e2257595, - 0x05bb617234eeb399cfc1e53ab8aec230c5c60f01c51f28cf255227dc87973b3d, - 0x748eb1b53190df4ec58a8fcc36d50180ab307925f3b3b3c98f7196ca97362a25, - 0x0a3cea527135006ec5468fcd91f265775635fdbb0d2d377542fd2c89cc866534, - 0xcbfd07f8af7baa634e60c2b296ef08347bd639023ce674b8e00415173c78951a, - 0x0eb01ebfc9ed27500cd4dfc979272d1f0913cc9f66540d7e8005811109e1cf2d, - 0x887c22bd8750d34016ac3c66b5ff102dacdd73f6b014e710b51e8022af9a1968 - ); - p.proveSegment = 0x7f68a817240e4e5d7941948de6c252c4b34c8b39b08e2d519ca35e68519d7cae; - p.proofSegments2 = _arr7( - bytes32(0), - 0xad3228b676f7d3cd4284a5443f17f1962b36e491b30a40b2405849e597ba5fb5, - 0xb4c11951957c6f8f642c4af61cd6b24640fec6dc7fc607ee8206a99e92410d30, - 0x21ddb9a356815c3fac1026b6dec5df3124afbadb485c9ba5a3e3398a04b7ba85, - 0xe58769b32a1beaf1ea27375a44095a0d1fb664ce2dd358e7fcbfb78c26a19344, - 0xf583106353614658f33a983eeead8a2d4d20b7b6d90227eaeff9a01bb5ffb909, - 0x887c22bd8750d34016ac3c66b5ff102dacdd73f6b014e710b51e8022af9a1968 - ); - p.proveSegment2 = bytes32(0); - p.chunkSpan = 3; - p.proofSegments3 = _arr7( - bytes32(0), - 0x67b9c124b55528ea0f5bcb5db716513af90deeb860e6ffd4b04ff0cd5cf68fb1, - 0xd769d9f556cb6ff5100712c5a9ffd2872bf7469a78f8ec96815173473857b57b, - 0x92a902cf7bbe37528d886bfb4c4e304572b65920a7e8fa413bc336031c7b10e9, - 0x21f21466fe5abe61950d97653c028a940ba863ac39b9ad8de388a159c0d81651, - 0x19134f518cf4e7b0a4eb34cfd97e6fe201c97b4d255330ca226b22b93a224389, - 0xb6c8532228cd44b9e59febed68f8038e1059fa779813d6bfe30dd9f150ab6b99 - ); - p.postageProof = Redistribution.PostageProof({ - signature: hex"5c14de70eab0f55b06d7f3cb474f37786b36930b2652f96dcdc33305c23f7861196d03b7e68216184ed883aac1538af8a33401e989c328f31bc03f7d0f5ebaf21b", - postageId: SOC_BATCH_ID, - index: 0x00007f6800000000, - timeStamp: 0x1787d532e7720d47 - }); - p.socProof = new Redistribution.SOCProof[](1); - p.socProof[0] = Redistribution.SOCProof({ - signer: 0x316104Fe34dF9A02d8f93412A2e4b0973D63cC31, - signature: hex"83404406b178c034da21949e4aff2f371ba8bac0ceb373263160007ba01230776ffa188dfa8abe15b0e63fe56fd0ecbddd3cdfd36abaa7f1696f31a5dd64a6731b", - identifier: 0x000000000000000000000000000000000000000000000000000124a900000000, - chunkAddr: 0x2387e8e7d8a48c2a9339c97c1dc3461a9a7aa07e994c5cb8b38fd7c1b3e6ea48 - }); - } - - function _socProofLast() internal pure returns (Redistribution.ChunkInclusionProof memory p) { - p.proofSegments = _arr7( - 0x0000abb415738db50ec1b83a9f670130c158ab7001e9b452bd928f3eb715f432, - 0xbe6083fead93f365617744e0cfceed4c83c1cdf25c54f45a9b39e913ac61de72, - 0x5396f7a2a7d5d0c6c33ceae5fd2089fd38e1f5e03131bf80257773da087c0fc2, - 0x0a3cea527135006ec5468fcd91f265775635fdbb0d2d377542fd2c89cc866534, - 0xcbfd07f8af7baa634e60c2b296ef08347bd639023ce674b8e00415173c78951a, - 0x0eb01ebfc9ed27500cd4dfc979272d1f0913cc9f66540d7e8005811109e1cf2d, - 0x887c22bd8750d34016ac3c66b5ff102dacdd73f6b014e710b51e8022af9a1968 - ); - p.proveSegment = 0x465f8c4bd9089cf0315ec603d5ad81ec27ebd4056ea714c91e4fc1c2bbd03a08; - p.proofSegments2 = _arr7( - bytes32(0), - 0xad3228b676f7d3cd4284a5443f17f1962b36e491b30a40b2405849e597ba5fb5, - 0xb4c11951957c6f8f642c4af61cd6b24640fec6dc7fc607ee8206a99e92410d30, - 0x21ddb9a356815c3fac1026b6dec5df3124afbadb485c9ba5a3e3398a04b7ba85, - 0xe58769b32a1beaf1ea27375a44095a0d1fb664ce2dd358e7fcbfb78c26a19344, - 0xf583106353614658f33a983eeead8a2d4d20b7b6d90227eaeff9a01bb5ffb909, - 0x887c22bd8750d34016ac3c66b5ff102dacdd73f6b014e710b51e8022af9a1968 - ); - p.proveSegment2 = bytes32(0); - p.chunkSpan = 3; - p.proofSegments3 = _arr7( - bytes32(0), - 0x67b9c124b55528ea0f5bcb5db716513af90deeb860e6ffd4b04ff0cd5cf68fb1, - 0xd769d9f556cb6ff5100712c5a9ffd2872bf7469a78f8ec96815173473857b57b, - 0x92a902cf7bbe37528d886bfb4c4e304572b65920a7e8fa413bc336031c7b10e9, - 0x21f21466fe5abe61950d97653c028a940ba863ac39b9ad8de388a159c0d81651, - 0x19134f518cf4e7b0a4eb34cfd97e6fe201c97b4d255330ca226b22b93a224389, - 0xb6c8532228cd44b9e59febed68f8038e1059fa779813d6bfe30dd9f150ab6b99 - ); - p.postageProof = Redistribution.PostageProof({ - signature: hex"befadfdceb1076fe27cc08152aac4f91b5245f4e911a323c81eef79faeff7a652de99192554e37d99d615d1a693df3632d0b660cba6285f79ca29c8f457359761c", - postageId: SOC_BATCH_ID, - index: 0x0000465f00000000, - timeStamp: 0x1787d532e3d9be0f - }); - p.socProof = new Redistribution.SOCProof[](1); - p.socProof[0] = Redistribution.SOCProof({ - signer: 0x316104Fe34dF9A02d8f93412A2e4b0973D63cC31, - signature: hex"fb3d1660aa1a42bb342073914c4a74f098a0fab772baec7f18f60154feeecb35625f1b2b6d101902c0cc62abbf7ab71c5ddacb786a2841ce36bd2e3534566b5d1c", - identifier: 0x0000000000000000000054040000000000000000000000000000000000000000, - chunkAddr: 0x2387e8e7d8a48c2a9339c97c1dc3461a9a7aa07e994c5cb8b38fd7c1b3e6ea48 - }); - } -} diff --git a/src/echidna/RedistributionFixtureRandomness.sol b/src/echidna/RedistributionFixtureRandomness.sol deleted file mode 100644 index 419472da..00000000 --- a/src/echidna/RedistributionFixtureRandomness.sol +++ /dev/null @@ -1,23 +0,0 @@ -// SPDX-License-Identifier: BSD-3-Clause -pragma solidity ^0.8.19; - -import "../Redistribution.sol"; - -/// @notice Fuzz-only deployment target: pins the post-reveal `seed` so Hardhat-derived -/// `claim()` fixtures stay valid when Echidna/hevm use a different `block.prevrandao` than the original test run. -contract RedistributionFixtureRandomness is Redistribution { - bytes32 internal immutable fixedPostRevealSeed; - - constructor( - address staking, - address postageContract, - address oracleContract, - bytes32 _fixedPostRevealSeed - ) Redistribution(staking, postageContract, oracleContract) { - fixedPostRevealSeed = _fixedPostRevealSeed; - } - - function _nextSeedValue() internal view override returns (bytes32) { - return fixedPostRevealSeed; - } -} From 9a6231d7d8f562be1b8ba206a19e0bed8fbcc1c9 Mon Sep 17 00:00:00 2001 From: Cardinal Date: Mon, 18 May 2026 14:19:01 +0200 Subject: [PATCH 41/50] fix for preetier --- src/PriceOracle.sol | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/PriceOracle.sol b/src/PriceOracle.sol index 298c2c24..ad12cecf 100644 --- a/src/PriceOracle.sol +++ b/src/PriceOracle.sol @@ -44,8 +44,7 @@ contract PriceOracle is AccessControl { /// @dev Upper bound for upscaled price so `(currentPriceUpScaled >> 10)` fits in `uint32`. /// Without this, `currentPrice()`'s `uint32(... >> 10)` truncates and can disagree with /// `currentPriceUpScaled` and under-report vs `minimumPrice()`. - uint64 public constant MAX_CURRENT_PRICE_UPSCALED = - uint64(uint256(type(uint32).max) << 10); + uint64 public constant MAX_CURRENT_PRICE_UPSCALED = uint64(uint256(type(uint32).max) << 10); // ----------------------------- Events ------------------------------ From aa6edad3fdda092e23b6dc99c3a744e852973610 Mon Sep 17 00:00:00 2001 From: Cardinal Date: Wed, 20 May 2026 21:38:08 +0200 Subject: [PATCH 42/50] optimize readme --- echidna/README.md | 323 ++++++++++------------------------------------ 1 file changed, 69 insertions(+), 254 deletions(-) diff --git a/echidna/README.md b/echidna/README.md index 0b5da740..046ff5d8 100644 --- a/echidna/README.md +++ b/echidna/README.md @@ -1,287 +1,102 @@ # Echidna fuzzing in this repo -This directory contains a **minimal, stateful fuzz-testing setup** using [Echidna](https://github.com/crytic/echidna). +Stateful fuzzing with [Echidna](https://github.com/crytic/echidna): deploy a **harness**, call its `act_*` functions in random **sequences**, and check that `echidna_*` **properties** stay `true`. A failing property prints a **reproducer** (call sequence + inputs). -Echidna works by: +Source: `src/echidna/` (harnesses), `echidna/echidna.yaml` (defaults), `scripts/echidna.sh` (Docker runner). -- Deploying a “harness” contract. -- Calling its public/external **action functions** with many randomized inputs, building **sequences** of calls. -- After (and during) those sequences, checking that `echidna_*` **property functions** always return `true`. +## Concepts -If a property returns `false`, Echidna prints a **reproducer** (a short sequence of calls/inputs that triggers the failure). +| Piece | Role | +|-------|------| +| `act_*` | Fuzz actions — drive state on deployed contracts. | +| `echidna_*` | Invariants — must always return `true`. | +| `Echidna*Actor` | Separate `msg.sender` for role tests; usually `.call()` so expected reverts don’t abort the `act_*` step. | +| `act_happy*` | Pre-conditioned inputs (tracked preimages, mock stake, phase/round) so commit/reveal are **likely** to succeed. | +| Harness stack | `act_claimStub` → actor `callClaimStub` → `RedistributionClaimStub.claimStub()`. | -## What we are testing right now +**Mocks** trim dependencies to what the unit under test needs (e.g. oracle harness: postage `setPrice` + optional revert only). **System harness** uses real cross-contract wiring. -### Harness +**If a property fails:** real on-chain bug, too-strong property, or bad harness setup (roles/assumptions). Continuing after an expected revert only means the **next** fuzz step runs on unchanged storage — not that the protocol ignored the revert. -This repo currently contains multiple harnesses: +## Harnesses -- **Staking harness**: `src/echidna/EchidnaStakeRegistryHarness.sol` -- **Oracle harness**: `src/echidna/EchidnaPriceOracleHarness.sol` -- **PostageStamp harness**: `src/echidna/EchidnaPostageStampHarness.sol` -- **Redistribution harness**: `src/echidna/EchidnaRedistributionHarness.sol` -- **Redistribution claim-stub harness**: `src/echidna/EchidnaRedistributionClaimHarness.sol` -- **System/integration harness**: `src/echidna/EchidnaSystemHarness.sol` +| Harness | File | Under test | Focus | +|---------|------|------------|--------| +| Staking | `EchidnaStakeRegistryHarness.sol` | `StakeRegistry` | stake, freeze, slash, migrate, roles | +| Oracle | `EchidnaPriceOracleHarness.sol` | `PriceOracle` | price, pause, `adjustPrice`, postage callback fail/revert | +| Postage | `EchidnaPostageStampHarness.sol` | `PostageStamp` | batches, pot, expiry, roles | +| Redistribution (base) | `EchidnaRedistributionHarness.sol` | `RedistributionExposed` | commit/reveal ledger, `winnerSelection`, dummy `claim()` | +| Redistribution (claim) | `EchidnaRedistributionClaimHarness.sol` | `RedistributionClaimStub` | claim-phase pot, withdraw, rounds, H-1 | +| System | `EchidnaSystemHarness.sol` | full wired stack | cross-contract invariants only | -> The real `claim()` proof-verification path is covered by the Hardhat suite (`test/Redistribution.test.ts`), -> not by Echidna. Echidna can't generate valid Merkle/SOC/postage proofs, so fuzzing that path adds little signal -> over targeted unit tests with known-good fixtures. +**Support (not Echidna targets):** `RedistributionExposed.sol` (`winnerSelection`, safe array lengths); `EchidnaMocks.sol` (stake + oracle mocks for redistribution harnesses). -### What each harness deploys +**Proof verification:** real `claim()` with Merkle/SOC/postage proofs → Hardhat `test/Redistribution.test.ts`. Echidna cannot generate valid proofs; base harness uses dummy calldata (`act_claim`) only to stress panics/guards. -The **staking harness** deploys: +### Redistribution: base vs claim-stub -- `TestToken` (a mintable ERC20 preset used as BZZ stand-in) -- `StakeRegistry` (from `src/Staking.sol`) -- a small constant-price oracle used by `StakeRegistry` +| | Base | Claim-stub | +|--|------|------------| +| Deploy | `RedistributionExposed` + mocks (withdraw counter, no token pot) | `RedistributionClaimStub` + `TestToken` + pot mock (balance, optional withdraw revert) | +| Claim | `act_claim` → real `claim()`, proofs almost always revert | `act_claimStub` → `claimStub()` = `winnerSelection()` + `withdraw` (no proof checks) | +| Winner | `act_winnerSelection` | inside `claimStub()` | +| Happy path | `act_happyCommit` → `act_happyReveal` | + `act_claimStub` | +| Also | random commit/reveal, admin tuning | `act_seedPot`, `act_setWithdrawRevertMode` | -It also deploys several **actor contracts** (`EchidnaStakeActor`) which behave like independent users (each has its own address and token balance), plus a dedicated actor that receives the `REDISTRIBUTOR_ROLE` so we can fuzz freeze/slash flows. - -The **oracle harness** deploys: - -- `PriceOracle` (from `src/PriceOracle.sol`) -- a `PostageStamp` mock that can succeed or revert on `setPrice(uint256)` -- an updater actor (has `PRICE_UPDATER_ROLE`) and a random actor (no roles) to fuzz access control - -The **postage stamp harness** deploys: - -- `TestToken` (ERC20 used as BZZ stand-in) -- `PostageStamp` (from `src/PostageStamp.sol`) -- actor contracts with roles: - - a price oracle actor (has `PRICE_ORACLE_ROLE`) - - a redistributor actor (has `REDISTRIBUTOR_ROLE`) - - a pauser actor (has `PAUSER_ROLE`) - -The **redistribution harness** (base) deploys: - -- `Redistribution` (from `src/Redistribution.sol`) -- mocks for its dependencies: - - `IStakeRegistry` (overlay/height/effective stake + `freezeDeposit` tracking) - - `IPostageStamp` (tracks `withdraw()` calls; provides minimal `batches()`/`validChunkCount()` access) - - `IPriceOracle` (tracks `adjustPrice()` calls) -- a small set of actor contracts (independent `msg.sender`s) to fuzz access control and commit/reveal/claim entrypoints - -It also includes “happy-path” actions (`act_happyCommit`, `act_happyReveal`) that try to **increase the rate of successful** -`commit → reveal` sequences by pre-conditioning the mocked stake/overlay inputs (so we can assert stronger post-conditions). - -The **redistribution claim-stub harness** deploys: - -- a fuzz-only `RedistributionClaimStub` that runs the real `winnerSelection()` but exposes `claimStub()` which **bypasses** - inclusion/SOC/stamp proof verification and directly calls `withdraw(winner)` on a small pot mock. - -This is meant to fuzz the **claim-phase state machine + pot withdrawal effects** end-to-end, without paying the cost of generating -valid Merkle/SOC/postage proofs. - -The **system/integration harness** deploys: - -- `TestToken` -- `PostageStamp` -- `PriceOracle` (wired as the `PRICE_ORACLE_ROLE` on `PostageStamp`) -- `StakeRegistry` (wired to `PriceOracle.currentPrice()`) -- `Redistribution` (wired to `StakeRegistry`, `PostageStamp`, `PriceOracle`) - -and grants: - -- `StakeRegistry.REDISTRIBUTOR_ROLE` to `Redistribution` (so it can `freezeDeposit`) -- `PostageStamp.REDISTRIBUTOR_ROLE` to `Redistribution` (so it can `withdraw`) -- `PriceOracle.PRICE_UPDATER_ROLE` to one actor (to fuzz `adjustPrice`) - -### Actions (what Echidna mutates) - -Harness action functions are intentionally written to be **mostly non-reverting**, so Echidna can explore longer state sequences. - -Key actions per harness: - -- **Staking harness** - - - Stake actions: `act_actor_manageStake`, `act_actor_withdrawSurplus`, `act_actor_migrateStake` - - Admin actions: `act_admin_pause`, `act_admin_unpause`, `act_admin_changeNetworkId` - - Redistributor actions: `act_redistributor_freeze`, `act_redistributor_slash` - - Negative tests: `act_actor_try*` (unauthorized attempts) - - Funding: `act_fundActor` - -- **Oracle harness** - - - Admin actions: `act_admin_setPrice`, `act_admin_pause`, `act_admin_unpause` - - Updater actions: `act_updater_adjustPrice` - - Negative tests: `act_rando_try*` - - PostageStamp mock behavior: `act_setStampRevertMode` - -- **PostageStamp harness** - - - Batch actions: `act_createBatch`, `act_topUp`, `act_increaseDepth`, `act_expireAll` - - Price update: `act_oracle_setPrice` - - Pot withdrawal: `act_redistributor_withdraw` - - Pause/unpause: `act_pauser_pause`, `act_pauser_unpause` - - Negative tests: `act_rando_try*` - - Funding: `act_fundActor` - -- **Redistribution harness (base)** - - - Stake configuration: `act_setActorStake` - - Game entrypoints: `act_commit`, `act_reveal`, `act_claim` (often reverts early; still useful to shake out panics/state bugs) - - Happy-path flow: `act_happyCommit`, `act_happyReveal` - - Winner selection (fuzz-only exposure): `act_winnerSelection` - - Admin actions: `act_admin_pause`, `act_admin_unpause`, `act_admin_setSampleMaxValue`, `act_admin_setFreezingParams` - -- **Redistribution claim-stub harness** - - - Happy-path flow: `act_happyCommit`, `act_happyReveal`, `act_claimStub` - - Pot seeding: `act_seedPot` - -- **System/integration harness** - - Stake actions: `act_actor_manageStake`, `act_actor_withdrawSurplus` - - Postage actions: `act_actor_createBatch`, `act_actor_topUp`, `act_actor_increaseDepth`, `act_actor_expireAll` - - Oracle actions: `act_admin_setOraclePrice`, `act_updater_adjustOraclePrice`, `act_rando_tryAdjustOraclePrice` - - Redistribution flow: `act_redist_happyCommit`, `act_redist_happyReveal` - -### Properties (what must always hold) - -Each harness defines `echidna_*` properties that Echidna checks continuously. - -Common patterns used across harnesses: - -- **Authorization (“must never happen”)**: calls that should be role-gated must never succeed for unauthorized actors. -- **Post-conditions**: for successful state transitions, the immediate post-state must match expected math and accounting. - -High-signal properties per harness: - -- **Staking harness** - - - Access control + “must never happen” flags (`echidna_never_performed_forbidden_calls`) - - Registry accounting (ERC20 balance covers sum of potential stake) - - Per-actor invariants (commitment monotonicity, effective stake/freeze semantics, overlay derivation) - - Post-conditions for `manageStake(add>0)`, `freezeDeposit`, `slashDeposit`, `migrateStake` - -- **Oracle harness** - - - Access control (admin-only + updater-only) and “paused means no changes” - - Price invariants: price never below minimum; lastAdjustedRound not in the future - - Post-conditions for `setPrice` and `adjustPrice` (including skipped-round math), with overflow-aware modeling - -- **PostageStamp harness** - - - Access control (oracle-only price updates, redistributor-only withdraw, pauser-only pause/unpause) - - Pause-mode negative tests (batch mutations must not succeed while paused) - - Batch post-conditions (`createBatch`, `topUp`, `increaseDepth`) and expiry sanity (`expireAll`) - - Pot/withdraw post-conditions (beneficiary receives exactly the withdrawn amount; `pot` resets) - - Non-interference checks for unrelated tracked batches during targeted operations (now checks multiple other batches) - - Pot monotonicity: pot must never decrease except by a successful withdraw-to-zero (`echidna_pot_never_decreases_except_withdraw`) - -- **Redistribution harness (base)** - - - Commit/reveal internal consistency: - - committed overlays remain unique (`echidna_commit_overlays_unique`) - - if a commit is marked as revealed, its `revealIndex` points to a reveal with the same overlay/owner (`echidna_revealed_commit_indices_valid`) - - every reveal entry must correspond to a revealed commit (`echidna_reveal_entries_imply_matching_commit`) - - Claim-phase state machine (using a fuzz-only exposed `winnerSelection()`): - - winner selection cannot succeed twice in the same round (`echidna_winnerSelection_only_once_per_round`) - - successful winner selection freezes all non-revealers (`echidna_last_winnerSelection_freezes_nonrevealed`) - - Happy-path post-conditions (only asserted for the currently active commit round): - - `echidna_tracked_commit_matches_storage` - - `echidna_tracked_reveal_matches_storage` - - Trivial library-level checks (access control via `AccessControl`, pause gating via `Pausable`, phase - arithmetic from `block.number`, and `currentCommitRound`/`currentRevealRound` monotonicity) are - intentionally **not** fuzzed here — they're deterministic and already covered by `test/Redistribution.test.ts`. - -- **Redistribution claim-stub harness** - - - claim can only succeed once per round (`echidna_claim_only_once_per_round`) - - successful claim withdraws the entire pot to the selected winner (`echidna_claim_withdraws_pot_to_winner_when_successful`) - - **H-1 scenario**: if the postage `withdraw()` reverts, the pot is preserved but the round is still consumed (`echidna_failed_withdraw_preserves_pot_and_consumes_round`) - - claim triggers an oracle `adjustPrice` call (`echidna_claim_triggers_oracle_adjustPrice`) - - non-revealers are frozen during claim processing (`echidna_nonrevealers_frozen_after_claim_selection`) - -- **System/integration harness** (only invariants that require real cross-contract wiring; single-contract checks live in their unit harness) - - Oracle↔stamp invariant: `PostageStamp.lastPrice` tracks `PriceOracle.currentPrice()` after updates - - Stamp accounting: internal `pot` does not exceed the stamp contract’s BZZ balance (`echidna_stamp_internal_pot_not_above_contract_balance`) - - Role isolation under real wiring: only the granted updater can `adjustPrice` (`echidna_unauthorized_oracle_adjust_never_succeeds`) - - Redistribution happy-path consistency: tracked commit/reveal values appear in `Redistribution` storage +```text +Base: commit ─ reveal ─ [winnerSelection] ─ act_claim(dummy → revert) +Claim: happy commit ─ happy reveal ─ claimStub (winner + pot) +System: real contracts; happy commit/reveal only +``` -These are “sanity properties”: they’re meant to detect obvious bugs and unintended state corruption early. +## Actions (by harness) -## What we expect (and what can go wrong) +Written to be **mostly non-reverting** (bounded inputs, low-level calls) so sequences stay long. -### When a property fails +- **Staking:** `act_actor_manageStake`, `withdrawSurplus`, `migrateStake`; admin pause/unpause/networkId; redistributor freeze/slash; `act_actor_try*`; `act_fundActor` +- **Oracle:** `act_admin_setPrice`, pause/unpause; `act_updater_adjustPrice`; `act_rando_try*`; `act_setStampRevertMode` +- **Postage:** `act_createBatch`, `topUp`, `increaseDepth`, `expireAll`; `act_oracle_setPrice`; `act_redistributor_withdraw`; pauser pause/unpause; `act_rando_try*`; `act_fundActor` +- **Redistribution (base):** `act_commit`, `reveal`, `claim`; `act_happyCommit`, `happyReveal`; `act_winnerSelection`; `act_setActorStake`; admin pause/unpause/sample/freezing +- **Redistribution (claim):** `act_happyCommit`, `happyReveal`, `claimStub`; `act_seedPot`, `setWithdrawRevertMode`, `setActorNode`; `act_tick` +- **System:** stake/postage/oracle actions above + `act_redist_happyCommit`, `happyReveal` -A failure means one of two things: +## Properties (by harness) -- **Real bug**: there is a reachable sequence of calls that violates an intended invariant. -- **Bad/too-strong property**: the property is not actually guaranteed by the contract’s design. +Patterns: **must-never-happen** (auth), **global invariants**, **post-conditions** on last successful action (`pending*` flags). -Example of the second case (we hit this during bring-up): +- **Staking:** `echidna_never_performed_forbidden_calls`; registry balance vs potential stake; per-actor stake/overlay/freeze; post-conditions for manageStake/freeze/slash/migrate +- **Oracle:** forbidden calls; price ≥ minimum; `lastAdjustedRound` not in future; post-conditions for `setPrice` / `adjustPrice` +- **Postage:** forbidden calls; batch post-conditions; `expireAll`; withdraw/pot; `echidna_pot_never_decreases_except_withdraw` +- **Redistribution (base):** `echidna_commit_overlays_unique`, `revealed_commit_indices_valid`, `reveal_entries_imply_matching_commit`, `winnerSelection_only_once_per_round`, `last_winnerSelection_freezes_nonrevealed`, `tracked_commit_matches_storage`, `tracked_reveal_matches_storage` + _(AccessControl/Pausable/phase math: Hardhat, not fuzzed here.)_ +- **Redistribution (claim):** `echidna_claim_only_once_per_round`, `claim_withdraws_pot_to_winner_when_successful`, `failed_withdraw_preserves_pot_and_consumes_round`, `claim_triggers_oracle_adjustPrice`, `nonrevealers_frozen_after_claim_selection` +- **System:** oracle price ↔ stamp `lastPrice`; stamp pot ≤ balance; unauthorized oracle adjust fails; tracked commit/reveal in storage -- It is possible to change `height` with `_addAmount == 0` in `StakeRegistry.manageStake()`. -- In that case `committedStake` is **not recomputed**, so a property like - \( committedStake \cdot 2^{height} \le potentialStake \) - is **not guaranteed** and will correctly fail. +## Triage example -### Common sources of “false positives” +`manageStake` with `_addAmount == 0` can change `height` without recomputing `committedStake` — so \( committedStake \cdot 2^{height} \le potentialStake \) is **not** a valid invariant (property failed correctly during bring-up). -- **Role-gated functions**: if an invariant assumes some privileged function cannot be called, make sure the harness never grants itself those roles (or explicitly models them). -- **Reverts shortening sequences**: if actions revert too often, Echidna explores fewer interesting states. Prefer bounding inputs and using low-level calls (as the current harness does). -- **Time/block effects**: some contracts depend on `block.number`. Echidna can advance time with `--delay`/`--wait`, but invariants should be designed with that in mind. +Other false-positive sources: harness grants roles it shouldn’t; property assumes unreachable state; too many action reverts (weak exploration). ## How to run -From repo root: - ```bash -yarn echidna +yarn echidna # all harnesses; needs Docker ``` -By default, this runs **all** Echidna harness contracts in `src/echidna/`. - -By default, the runner uses `echidna/echidna.yaml`. You can override that with `ECHIDNA_CONFIG` if a harness needs its own -corpus or tuned fuzzing parameters. - -### Default campaign settings (`echidna/echidna.yaml`) - -| Setting | Default | Notes | -|----------------|---------|--------| -| `testLimit` | `60000` | Sequences tried per harness (each sequence uses at most `seqLen` calls). | -| `seqLen` | `320` | Enough depth for redistribution rounds and `commit`→`reveal`→`claim` exploration. | -| `maxBlockDelay`| `152` | Full `ROUND_LENGTH`; helps `currentRound()` advance without enormous sequences. | - -Shorter smoke runs: set `ECHIDNA_TEST_LIMIT` / `ECHIDNA_SEQ_LEN` when invoking `yarn echidna` (see `scripts/echidna.sh`). - -To run only a specific harness contract: - -```bash -ECHIDNA_CONTRACT=EchidnaStakeRegistryHarness yarn echidna -ECHIDNA_CONTRACT=EchidnaPriceOracleHarness yarn echidna -ECHIDNA_CONTRACT=EchidnaPostageStampHarness yarn echidna -ECHIDNA_CONTRACT=EchidnaRedistributionHarness yarn echidna -ECHIDNA_CONTRACT=EchidnaRedistributionClaimHarness yarn echidna -ECHIDNA_CONTRACT=EchidnaSystemHarness yarn echidna -``` - -This uses Docker and the image `ghcr.io/crytic/echidna/echidna:latest`. - -### Output files - -Echidna may write artifacts such as: - -- `echidna/corpus/by-contract//` — per-harness corpus, coverage reproducers, and `covered.*.html` (the runner passes `--corpus-dir` / `--coverage-dir` here so sequences from one harness are not mixed with another) -- `crytic-export/` (Crytic export artifacts) - -Older flat files under `echidna/corpus/` (if any) are from previous runs before per-harness dirs were used. - -These are ignored by git via `.gitignore`. - -Optional environment variables (see `scripts/echidna.sh`): `ECHIDNA_TEST_LIMIT`, `ECHIDNA_SEQ_LEN`, `ECHIDNA_WORKERS` to override the YAML for a single invocation (CLI wins over `echidna.yaml` when both apply). - -### Config files +| Setting | Default | Override env | +|---------|---------|----------------| +| `testLimit` | 60000 | `ECHIDNA_TEST_LIMIT` | +| `seqLen` | 320 | `ECHIDNA_SEQ_LEN` | +| `maxBlockDelay` | 152 | — | +| workers | yaml | `ECHIDNA_WORKERS` | -- `echidna/echidna.yaml`: default config for all harness runs (override with `ECHIDNA_CONFIG` if needed) +Single harness: `ECHIDNA_CONTRACT=EchidnaRedistributionHarness yarn echidna` (also: `EchidnaStakeRegistryHarness`, `EchidnaPriceOracleHarness`, `EchidnaPostageStampHarness`, `EchidnaRedistributionClaimHarness`, `EchidnaSystemHarness`). -## How to extend this +Config: `echidna/echidna.yaml` (`ECHIDNA_CONFIG` to override). Corpus/coverage: `echidna/corpus/by-contract//` (gitignored). Crytic: `crytic-export/`. -Typical next steps: +## Extend -- Add another harness under `src/echidna/` following the naming convention `Echidna*Harness.sol`. The runner script auto-discovers files matching that pattern, so no manual script edits are needed. -- Keep actions non-reverting and model only the roles/privileges you want to include. -- Start with a few **obviously true** invariants, then iterate: - - If Echidna finds a counterexample, decide whether that is a **bug** or a **property mismatch**. - - Tighten properties only when you’re confident the protocol/design guarantees them. +1. Add `src/echidna/Echidna*Harness.sol` — auto-discovered by `scripts/echidna.sh`. +2. Prefer non-reverting `act_*`, explicit roles, a few solid properties first. +3. On counterexample: bug vs property vs harness — then fix code or narrow the invariant. From 1bc951eb149bbbd60173c27839a91bc26beb704f Mon Sep 17 00:00:00 2001 From: Cardinal Date: Fri, 22 May 2026 11:14:52 +0200 Subject: [PATCH 43/50] add extra info how it works --- echidna/README.md | 35 +++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/echidna/README.md b/echidna/README.md index 046ff5d8..130bb3f0 100644 --- a/echidna/README.md +++ b/echidna/README.md @@ -4,6 +4,41 @@ Stateful fuzzing with [Echidna](https://github.com/crytic/echidna): deploy a **h Source: `src/echidna/` (harnesses), `echidna/echidna.yaml` (defaults), `scripts/echidna.sh` (Docker runner). +## How a campaign works (newcomers) + +Echidna does **not** run all actions at once. Each **sequence** is one fresh harness deploy, then up to **`seqLen` steps** (default **320**). Each step = **one** `act_*` call with pseudo-random arguments. Between steps Echidna may advance `block.number` by up to **`maxBlockDelay`** (default **152**, one redistribution round). + +Example sequence: + +```text +deploy harness +→ act_happyCommit(0, …) +→ act_tick() +→ act_updater_adjustPrice(3) +→ act_rando_tryAdjustPrice(1, 2) +→ act_happyReveal(0) +→ … (up to 320 steps) +``` + +After **every** step, **all** `echidna_*` properties (invariants) are checked. They must return `true`. If one fails, Echidna saves that prefix as a **reproducer**: + +```text +act_A() → check all echidna_* ✓ +act_B() → check all echidna_* ✓ +act_C() → check all echidna_* ✗ → FAIL, report sequence A → B → C +``` + +Echidna then tries many such sequences (**`testLimit`**, default **60 000**). Each sequence starts from a **new deploy** (constructor runs again). Which action runs next is guided by randomness + coverage/corpus—not a fixed script. + +| Term | Meaning | +|------|---------| +| **One sequence** | Up to 320 txs on one deployment | +| **One tx** | One `act_*` (or other callable on the harness) | +| **Properties** | Invariants checked **after each tx**, not only at the end | +| **Campaign** | 60 000 sequences × up to 320 steps (per harness) | + +**Actions** = moves in a long random game. **Properties** = rules that must hold after every move. + ## Concepts | Piece | Role | From 2aba35ab969be79e36002c2eb1aeb8d18e0e56c4 Mon Sep 17 00:00:00 2001 From: Cardinal Date: Fri, 22 May 2026 12:18:58 +0200 Subject: [PATCH 44/50] feat(echidna): add action-only coverage summary 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. --- scripts/echidna-coverage-summary.ts | 189 ++++++++++++++++++++++++++++ scripts/echidna.sh | 3 + 2 files changed, 192 insertions(+) create mode 100644 scripts/echidna-coverage-summary.ts diff --git a/scripts/echidna-coverage-summary.ts b/scripts/echidna-coverage-summary.ts new file mode 100644 index 00000000..6be7454d --- /dev/null +++ b/scripts/echidna-coverage-summary.ts @@ -0,0 +1,189 @@ +/** + * Summarize Echidna LCOV coverage with action-only metrics. + * + * Echidna records coverage during fuzz transactions (act_*). echidna_* property + * checks run afterward via eth_call and do not appear in coverage, which makes + * raw file percentages look worse than action exploration really is. + * + * Usage: + * yarn ts-node scripts/echidna-coverage-summary.ts + * yarn ts-node scripts/echidna-coverage-summary.ts EchidnaRedistributionHarness + * yarn ts-node scripts/echidna-coverage-summary.ts EchidnaRedistributionHarness --coverage-dir echidna/corpus/by-contract/EchidnaRedistributionHarness/coverage + */ + +import * as fs from 'fs'; +import * as path from 'path'; + +const ROOT = path.resolve(__dirname, '..'); +const HARNESS_DIR = path.join(ROOT, 'src', 'echidna'); + +type CoverageTriple = [pct: number, covered: number, total: number]; + +interface Summary { + harness: string; + lcov: string; + fileTotal: CoverageTriple; + actions: CoverageTriple; + properties: CoverageTriple; + propertiesLine: number; +} + +function discoverHarnesses(): string[] { + return fs + .readdirSync(HARNESS_DIR) + .filter((f) => /^Echidna.*Harness\.sol$/.test(f)) + .map((f) => path.basename(f, '.sol')) + .sort(); +} + +function harnessContractStart(lines: string[], harness: string): number { + const needle = `contract ${harness}`; + for (let i = 0; i < lines.length; i++) { + if (lines[i].startsWith(needle)) return i + 1; + } + throw new Error(`contract ${harness} not found in src/echidna/${harness}.sol`); +} + +function propertiesSectionStart(lines: string[], harnessStart: number): number { + for (let i = harnessStart; i <= lines.length; i++) { + const line = lines[i - 1].trim(); + if (line.startsWith('//') && line.includes('Properties')) return i; + } + for (let i = harnessStart; i <= lines.length; i++) { + if (lines[i - 1].includes('function echidna_')) return i; + } + return lines.length + 1; +} + +function latestLcov(coverageDir: string): string | null { + if (!fs.existsSync(coverageDir)) return null; + const files = fs + .readdirSync(coverageDir) + .filter((f) => /^covered\..*\.lcov$/.test(f)) + .map((f) => path.join(coverageDir, f)) + .sort((a, b) => fs.statSync(a).mtimeMs - fs.statSync(b).mtimeMs); + return files.length > 0 ? files[files.length - 1] : null; +} + +function parseLcovHits(lcovPath: string, harness: string): Map { + const text = fs.readFileSync(lcovPath, 'utf8'); + const suffix = `/${harness}.sol`; + let blockStart = -1; + for (const match of text.matchAll(/^SF:(.+)$/gm)) { + if (match[1].endsWith(suffix)) blockStart = match.index ?? -1; + } + if (blockStart < 0) { + throw new Error(`${harness}.sol not present in ${path.basename(lcovPath)}`); + } + + const rest = text.slice(blockStart); + const end = rest.indexOf('end_of_record'); + const block = end >= 0 ? rest.slice(0, end) : rest; + + const hits = new Map(); + for (const line of block.split('\n')) { + if (!line.startsWith('DA:')) continue; + const [lnS, cntS] = line.slice(3).split(',', 2); + hits.set(Number(lnS), Number(cntS)); + } + return hits; +} + +function coveragePct(hits: Map, lo: number, hi: number): CoverageTriple { + const lines: number[] = []; + for (let ln = lo; ln <= hi; ln++) { + if (hits.has(ln)) lines.push(ln); + } + if (lines.length === 0) return [0, 0, 0]; + const covered = lines.filter((ln) => (hits.get(ln) ?? 0) > 0).length; + return [(100 * covered) / lines.length, covered, lines.length]; +} + +function summarize(harness: string, coverageDir: string): Summary | null { + const srcPath = path.join(HARNESS_DIR, `${harness}.sol`); + if (!fs.existsSync(srcPath)) { + throw new Error(`missing source file ${srcPath}`); + } + + const lcovPath = latestLcov(coverageDir); + if (!lcovPath) return null; + + const lines = fs.readFileSync(srcPath, 'utf8').split('\n'); + const harnessStart = harnessContractStart(lines, harness); + const propStart = propertiesSectionStart(lines, harnessStart); + const hits = parseLcovHits(lcovPath, harness); + + return { + harness, + lcov: path.basename(lcovPath), + fileTotal: coveragePct(hits, 1, lines.length), + actions: coveragePct(hits, harnessStart, propStart - 1), + properties: coveragePct(hits, propStart, lines.length), + propertiesLine: propStart, + }; +} + +function fmtPct([pct, covered, total]: CoverageTriple): string { + return `${pct.toFixed(1).padStart(5)}% (${covered}/${total})`; +} + +function printSummary(result: Summary): void { + console.log(`==> echidna coverage: ${result.harness} (${result.lcov})`); + console.log(` harness file total: ${fmtPct(result.fileTotal)}`); + console.log(` actions only: ${fmtPct(result.actions)}`); + console.log( + ` properties block: ${fmtPct(result.properties)} (from line ${result.propertiesLine}; not measured during fuzz txs)` + ); +} + +function parseArgs(argv: string[]): { harnesses: string[]; coverageDir?: string } { + const harnesses: string[] = []; + let coverageDir: string | undefined; + + for (let i = 0; i < argv.length; i++) { + const arg = argv[i]; + if (arg === '--coverage-dir') { + coverageDir = argv[++i]; + if (!coverageDir) throw new Error('--coverage-dir requires a path'); + continue; + } + if (arg.startsWith('-')) { + throw new Error(`unknown option ${arg}`); + } + harnesses.push(arg); + } + + return { harnesses, coverageDir }; +} + +function main(): number { + const { harnesses: argHarnesses, coverageDir: globalCoverageDir } = parseArgs(process.argv.slice(2)); + const harnesses = argHarnesses.length > 0 ? argHarnesses : discoverHarnesses(); + if (harnesses.length === 0) { + console.error('no harness contracts found'); + return 1; + } + + let exitCode = 0; + for (const harness of harnesses) { + const coverageDir = + globalCoverageDir ?? path.join(ROOT, 'echidna', 'corpus', 'by-contract', harness, 'coverage'); + + try { + const result = summarize(harness, coverageDir); + if (!result) { + console.error(`==> echidna coverage: ${harness}: no covered.*.lcov in ${coverageDir}`); + continue; + } + printSummary(result); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + console.error(`==> echidna coverage: ${harness}: ${msg}`); + exitCode = 1; + } + } + + return exitCode; +} + +process.exit(main()); diff --git a/scripts/echidna.sh b/scripts/echidna.sh index 9a86f998..50d454b4 100755 --- a/scripts/echidna.sh +++ b/scripts/echidna.sh @@ -70,4 +70,7 @@ for c in "${CONTRACTS_TO_RUN[@]}"; do -c "rm -rf crytic-export && echidna-test . --contract ${c} --config ${CONFIG} \ --corpus-dir ${CORPUS_DIR} --coverage-dir ${CORPUS_DIR}/coverage${ECHIDNA_EXTRA_CLI} \ --crytic-args '--hardhat-ignore-compile'" + + yarn -s ts-node "${ROOT_DIR}/scripts/echidna-coverage-summary.ts" "${c}" \ + --coverage-dir "${ROOT_DIR}/${CORPUS_DIR}/coverage" || true done From 0285ef902d099944b5e7300c79384c1a99b28862 Mon Sep 17 00:00:00 2001 From: Cardinal Date: Fri, 22 May 2026 12:21:06 +0200 Subject: [PATCH 45/50] refactor(redis): drop unused fuzz randomness hook The virtual _nextSeedValue override was only used by the removed fixture-based real-claim harness; inline private updateRandomness is enough. --- src/Redistribution.sol | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/src/Redistribution.sol b/src/Redistribution.sol index d4d5c234..e168aa05 100644 --- a/src/Redistribution.sol +++ b/src/Redistribution.sol @@ -671,13 +671,8 @@ contract Redistribution is AccessControl, Pausable { * @notice Updates the source of randomness. Uses block.difficulty in pre-merge chains, this is substituted * to block.prevrandao in post merge chains. */ - function updateRandomness() internal virtual { - seed = _nextSeedValue(); - } - - /// @dev Extracted for fuzz harnesses that must pin post-reveal randomness to fixture data. - function _nextSeedValue() internal view virtual returns (bytes32) { - return keccak256(abi.encode(seed, block.prevrandao)); + function updateRandomness() private { + seed = keccak256(abi.encode(seed, block.prevrandao)); } /** From 2e53478a2ec9ad83bad08833d06ee4da92327f08 Mon Sep 17 00:00:00 2001 From: Cardinal Date: Fri, 22 May 2026 12:23:35 +0200 Subject: [PATCH 46/50] refactor(echidna): drop properties line from coverage summary Property checks run via eth_call and are not reflected in LCOV, so reporting a properties block percentage was misleading. --- scripts/echidna-coverage-summary.ts | 19 +++++-------------- 1 file changed, 5 insertions(+), 14 deletions(-) diff --git a/scripts/echidna-coverage-summary.ts b/scripts/echidna-coverage-summary.ts index 6be7454d..49980142 100644 --- a/scripts/echidna-coverage-summary.ts +++ b/scripts/echidna-coverage-summary.ts @@ -2,8 +2,7 @@ * Summarize Echidna LCOV coverage with action-only metrics. * * Echidna records coverage during fuzz transactions (act_*). echidna_* property - * checks run afterward via eth_call and do not appear in coverage, which makes - * raw file percentages look worse than action exploration really is. + * checks run afterward via eth_call and are omitted from this summary. * * Usage: * yarn ts-node scripts/echidna-coverage-summary.ts @@ -24,8 +23,6 @@ interface Summary { lcov: string; fileTotal: CoverageTriple; actions: CoverageTriple; - properties: CoverageTriple; - propertiesLine: number; } function discoverHarnesses(): string[] { @@ -44,7 +41,7 @@ function harnessContractStart(lines: string[], harness: string): number { throw new Error(`contract ${harness} not found in src/echidna/${harness}.sol`); } -function propertiesSectionStart(lines: string[], harnessStart: number): number { +function actionsSectionEnd(lines: string[], harnessStart: number): number { for (let i = harnessStart; i <= lines.length; i++) { const line = lines[i - 1].trim(); if (line.startsWith('//') && line.includes('Properties')) return i; @@ -110,16 +107,14 @@ function summarize(harness: string, coverageDir: string): Summary | null { const lines = fs.readFileSync(srcPath, 'utf8').split('\n'); const harnessStart = harnessContractStart(lines, harness); - const propStart = propertiesSectionStart(lines, harnessStart); + const actionsEnd = actionsSectionEnd(lines, harnessStart); const hits = parseLcovHits(lcovPath, harness); return { harness, lcov: path.basename(lcovPath), fileTotal: coveragePct(hits, 1, lines.length), - actions: coveragePct(hits, harnessStart, propStart - 1), - properties: coveragePct(hits, propStart, lines.length), - propertiesLine: propStart, + actions: coveragePct(hits, harnessStart, actionsEnd - 1), }; } @@ -131,9 +126,6 @@ function printSummary(result: Summary): void { console.log(`==> echidna coverage: ${result.harness} (${result.lcov})`); console.log(` harness file total: ${fmtPct(result.fileTotal)}`); console.log(` actions only: ${fmtPct(result.actions)}`); - console.log( - ` properties block: ${fmtPct(result.properties)} (from line ${result.propertiesLine}; not measured during fuzz txs)` - ); } function parseArgs(argv: string[]): { harnesses: string[]; coverageDir?: string } { @@ -166,8 +158,7 @@ function main(): number { let exitCode = 0; for (const harness of harnesses) { - const coverageDir = - globalCoverageDir ?? path.join(ROOT, 'echidna', 'corpus', 'by-contract', harness, 'coverage'); + const coverageDir = globalCoverageDir ?? path.join(ROOT, 'echidna', 'corpus', 'by-contract', harness, 'coverage'); try { const result = summarize(harness, coverageDir); From 27dbd09e5346a2d8f389d5042b53e590c821ff43 Mon Sep 17 00:00:00 2001 From: Cardinal Date: Fri, 22 May 2026 12:41:11 +0200 Subject: [PATCH 47/50] chore(echidna): drop PriceOracle production fixes from fuzz branch Move oracle clamp/cast fixes to #311; keep harness expectations aligned with master PriceOracle until that PR lands. --- src/PriceOracle.sol | 33 +++++++++-------------- src/echidna/EchidnaPriceOracleHarness.sol | 2 -- 2 files changed, 12 insertions(+), 23 deletions(-) diff --git a/src/PriceOracle.sol b/src/PriceOracle.sol index ad12cecf..5675f4f9 100644 --- a/src/PriceOracle.sol +++ b/src/PriceOracle.sol @@ -41,11 +41,6 @@ contract PriceOracle is AccessControl { // The length of a round in blocks. uint8 private constant ROUND_LENGTH = 152; - /// @dev Upper bound for upscaled price so `(currentPriceUpScaled >> 10)` fits in `uint32`. - /// Without this, `currentPrice()`'s `uint32(... >> 10)` truncates and can disagree with - /// `currentPriceUpScaled` and under-report vs `minimumPrice()`. - uint64 public constant MAX_CURRENT_PRICE_UPSCALED = uint64(uint256(type(uint32).max) << 10); - // ----------------------------- Events ------------------------------ /** @@ -83,9 +78,13 @@ contract PriceOracle is AccessControl { revert CallerNotAdmin(); } - // Cast before shifting to avoid uint32 overflow/truncation. - uint64 _currentPriceUpScaled = uint64(_price) << 10; - _currentPriceUpScaled = _clampPriceUpscaled(_currentPriceUpScaled); + uint64 _currentPriceUpScaled = _price << 10; + uint64 _minimumPriceUpscaled = minimumPriceUpscaled; + + // Enforce minimum price + if (_currentPriceUpScaled < _minimumPriceUpscaled) { + _currentPriceUpScaled = _minimumPriceUpscaled; + } currentPriceUpScaled = _currentPriceUpScaled; // Check if the setting of price in postagestamp succeded @@ -125,6 +124,7 @@ contract PriceOracle is AccessControl { } uint64 _currentPriceUpScaled = currentPriceUpScaled; + uint64 _minimumPriceUpscaled = minimumPriceUpscaled; uint32 _priceBase = priceBase; // Set the number of rounds that were skipped, we substract 1 as lastAdjustedRound is set below and default result is 1 @@ -142,7 +142,10 @@ contract PriceOracle is AccessControl { } } - _currentPriceUpScaled = _clampPriceUpscaled(_currentPriceUpScaled); + // Enforce minimum price + if (_currentPriceUpScaled < _minimumPriceUpscaled) { + _currentPriceUpScaled = _minimumPriceUpscaled; + } currentPriceUpScaled = _currentPriceUpScaled; lastAdjustedRound = currentRoundNumber; @@ -179,18 +182,6 @@ contract PriceOracle is AccessControl { // STATE READING // //////////////////////////////////////// - /// @notice Clamp upscaled price to [minimumPriceUpscaled, MAX_CURRENT_PRICE_UPSCALED]. - function _clampPriceUpscaled(uint64 priceUpScaled) private view returns (uint64) { - uint64 minU = uint64(minimumPriceUpscaled); - if (priceUpScaled < minU) { - priceUpScaled = minU; - } - if (priceUpScaled > MAX_CURRENT_PRICE_UPSCALED) { - priceUpScaled = MAX_CURRENT_PRICE_UPSCALED; - } - return priceUpScaled; - } - /** * @notice Return the number of the current round. */ diff --git a/src/echidna/EchidnaPriceOracleHarness.sol b/src/echidna/EchidnaPriceOracleHarness.sol index da121cdb..ff28623b 100644 --- a/src/echidna/EchidnaPriceOracleHarness.sol +++ b/src/echidna/EchidnaPriceOracleHarness.sol @@ -300,8 +300,6 @@ contract EchidnaPriceOracleHarness { uint256 minUp = uint256(oracle.minimumPriceUpscaled()); if (price < minUp) price = minUp; - uint256 maxUp = uint256(oracle.MAX_CURRENT_PRICE_UPSCALED()); - if (price > maxUp) price = maxUp; if (price > type(uint64).max) return (false, 0); return (true, uint64(price)); } From 21ef16da92256d0b72a97f90bf783fe123b30694 Mon Sep 17 00:00:00 2001 From: Cardinal Date: Fri, 22 May 2026 12:46:23 +0200 Subject: [PATCH 48/50] chore(echidna): drop PostageStamp production fix from fuzz branch Move minimumInitialBalancePerChunk overflow fix to #313. --- src/PostageStamp.sol | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/PostageStamp.sol b/src/PostageStamp.sol index 23e4114e..00cdaa3f 100644 --- a/src/PostageStamp.sol +++ b/src/PostageStamp.sol @@ -566,8 +566,7 @@ contract PostageStamp is AccessControl, Pausable { } function minimumInitialBalancePerChunk() public view returns (uint256) { - // Cast to uint256 before multiplying to avoid uint64 overflow. - return uint256(minimumValidityBlocks) * uint256(lastPrice); + return minimumValidityBlocks * lastPrice; } /** From bf5c5af5a2bc507b45b0211be3ccf49b1c0221c0 Mon Sep 17 00:00:00 2001 From: Cardinal Date: Fri, 22 May 2026 12:50:55 +0200 Subject: [PATCH 49/50] fix(test): stabilize copyBatch normalised balance assertion 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. --- test/PostageStamp.test.ts | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/test/PostageStamp.test.ts b/test/PostageStamp.test.ts index 36b9026b..4cc93323 100644 --- a/test/PostageStamp.test.ts +++ b/test/PostageStamp.test.ts @@ -1161,6 +1161,9 @@ describe('PostageStamp', function () { describe('when copyBatch creates a batch', function () { beforeEach(async function () { + postageStampStamper = await ethers.getContract('PostageStamp', stamper); + token = await ethers.getContract('TestToken', deployer); + const postageStampDeployer = await ethers.getContract('PostageStamp', deployer); const admin = await postageStampStamper.DEFAULT_ADMIN_ROLE(); await postageStampDeployer.grantRole(admin, stamper); @@ -1376,6 +1379,11 @@ describe('PostageStamp', function () { const price = 100; await setPrice(price); + const currentTotalOutPayment = parseInt(await postageStampStamper.currentTotalOutPayment()); + const lastPrice = parseInt(await postageStampStamper.lastPrice()); + const expectedNormalisedBalance = + batch.initialPaymentPerChunk + currentTotalOutPayment + lastPrice; + await expect( postageStampStamper.copyBatch( stamper, @@ -1390,14 +1398,14 @@ describe('PostageStamp', function () { .withArgs( batch.id, transferAmount, - price + batch.initialPaymentPerChunk, + expectedNormalisedBalance, stamper, batch.depth, batch.bucketDepth, batch.immutable ); const stamp = await postageStampStamper.batches(batch.id); - expect(stamp[4]).to.equal(price + batch.initialPaymentPerChunk); + expect(stamp[4]).to.equal(expectedNormalisedBalance); }); it('should include pending totalOutpayment in the normalised balance', async function () { From 37a30c53ba077dcefc8163f5f3dc4f6b9eba80af Mon Sep 17 00:00:00 2001 From: Cardinal Date: Fri, 22 May 2026 12:52:35 +0200 Subject: [PATCH 50/50] style(test): format PostageStamp.test.ts --- test/PostageStamp.test.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/test/PostageStamp.test.ts b/test/PostageStamp.test.ts index 4cc93323..4f05706b 100644 --- a/test/PostageStamp.test.ts +++ b/test/PostageStamp.test.ts @@ -1381,8 +1381,7 @@ describe('PostageStamp', function () { const currentTotalOutPayment = parseInt(await postageStampStamper.currentTotalOutPayment()); const lastPrice = parseInt(await postageStampStamper.lastPrice()); - const expectedNormalisedBalance = - batch.initialPaymentPerChunk + currentTotalOutPayment + lastPrice; + const expectedNormalisedBalance = batch.initialPaymentPerChunk + currentTotalOutPayment + lastPrice; await expect( postageStampStamper.copyBatch(