Skip to content

Commit b00a6d1

Browse files
committed
docs: expand README invariants section to match the fuzz-tested property set
Restructures the existing bullet list into labelled A/B/C/D/E groups (validator set, nonce discipline, pause state machine, isWhitelisted semantics, ownership irreversibility). The labels track the invariant harness so a failing run points straight at the clause that broke. Also gitignores the local .claude tooling directory.
1 parent e6f8b5a commit b00a6d1

2 files changed

Lines changed: 64 additions & 19 deletions

File tree

‎.gitignore‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,3 +12,4 @@ docs/
1212

1313
# Dotenv file
1414
.env
15+
.claude

‎README.md‎

Lines changed: 63 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -35,26 +35,70 @@ revocable).
3535

3636
### Key invariants
3737

38-
- `1 <= quorum < validators.length <= MAX_VALIDATORS` (256). The strict
39-
inequality between `quorum` and `validators.length` guarantees that no
40-
quorum-sized coalition can brick or fully capture the registry by
41-
shrinking the set down to its own membership.
42-
- Each validator signature carries a signer-chosen nonce. The nonce is
43-
recorded in a per-validator bitmap on successful verify and permanently
44-
single-use; sigs at any unused nonce above the validator's floor can be
38+
Every property below holds across every reachable state transition. The
39+
A/B/C/D/E grouping is reused in the invariant test harness so a failing
40+
run points straight at the clause that broke.
41+
42+
**A. Validator set & quorum structure**
43+
44+
- **A1.** `1 <= quorum < validators.length <= MAX_VALIDATORS` (256). The
45+
strict inequality between `quorum` and `validators.length` guarantees
46+
that no quorum-sized coalition can brick or fully capture the registry
47+
by shrinking the set down to its own membership.
48+
- **A2.** The validator set never contains duplicates and never contains
49+
`address(0)`. `_addValidator` rejects both; the set itself is a
50+
solady `EnumerableSetLib.AddressSet` so duplicates are structurally
51+
impossible.
52+
- **A3.** The three public views over the validator set
53+
(`validators()`, `validatorAt(i)`, `isValidator(a)`) agree on every
54+
element. `validatorsLength()` equals `validators().length`.
55+
56+
**B. Nonce discipline**
57+
58+
- **B1.** `validatorNonceFloor[v]` is monotonically non-decreasing.
59+
`setValidatorNonce` is strict-increase; `_removeValidator` and
60+
`revokeAllRoles` saturate to `type(uint256).max`; no other writer
61+
exists.
62+
- **B2.** Once a validator has ever been removed, their nonce floor is
63+
`type(uint256).max` forever — even if the same address is later
64+
re-added. Combined with the bitmap's permanent-set property this
65+
closes the re-add replay window: no signature the retired address
66+
ever produced can pass `NonceBelowFloor` again.
67+
- **B3.** Each validator signature carries a signer-chosen nonce. The
68+
nonce is recorded in a per-validator bitmap on successful verify and
69+
permanently single-use — a bit set to `1` never returns to `0`. Sigs
70+
at any unused nonce at or above the validator's current floor can be
4571
submitted **in any order**.
46-
- A per-validator `nonceFloor` is the mass-invalidate lever. Sigs at a
47-
nonce strictly below the floor are rejected `NonceBelowFloor`. The
48-
validator themselves raises the floor via `setValidatorNonce`; removal
49-
saturates it to `type(uint256).max` so a removed validator's address is
50-
permanently retired.
51-
- `isWhitelisted(a)` never returns `WhitelistStatus.Whitelisted` while
52-
`block.timestamp < pausedUntil`, regardless of underlying storage.
53-
- While paused, `whitelist` and `unwhitelist` revert with `ContractPaused`.
54-
Compliance `pauseFor` / `unpause` and every rotation path
55-
(`setValidatorNonce`, all `*BySig` admin functions) remain reachable — the
56-
circuit breaker exists to buy the quorum time to rotate, not to freeze
57-
rotation itself.
72+
73+
**C. Pause state machine**
74+
75+
- **C1.** `pausedUntil` moves forward via `pauseFor` (extend-only,
76+
reverts `PauseNotExtended` on any call that wouldn't strictly extend
77+
the current window) and drops to `0` via `unpause`. It never shrinks
78+
to a non-zero value.
79+
- **C2.** `isPaused()` is a pure function of `pausedUntil` and
80+
`block.timestamp` — no cached pause state anywhere.
81+
- **C3.** While paused, `whitelist` and `unwhitelist` revert with
82+
`ContractPaused`. Compliance `pauseFor` / `unpause` and every
83+
rotation path (`setValidatorNonce`, all `*BySig` admin functions)
84+
remain reachable — the circuit breaker exists to buy the quorum time
85+
to rotate, not to freeze rotation itself.
86+
87+
**D. `isWhitelisted` semantics**
88+
89+
- **D1.** While paused, `isWhitelisted(a)` never returns
90+
`Whitelisted` or `NotWhitelisted` — the return is always one of the
91+
`Paused*` variants, regardless of the underlying attestation bit.
92+
- **D2.** While live (not paused), `isWhitelisted(a)` returns exactly
93+
one of `Whitelisted` / `NotWhitelisted`, matching the `_isWhitelisted`
94+
bit.
95+
96+
**E. Ownership irreversibility**
97+
98+
- **E1.** Once `owner()` becomes `address(0)`, it stays there.
99+
`renounceOwnership()` is the only writer to zero and has no inverse;
100+
the Phase-1 admin surface cannot be re-opened after handover to the
101+
validator quorum.
58102

59103
### Return-value enum
60104

0 commit comments

Comments
 (0)