diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..dac9080
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2026 blockful
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/README.md b/README.md
index 56e9d72..5361b41 100644
--- a/README.md
+++ b/README.md
@@ -1,54 +1,398 @@
-# nexus
+# Governor Nexus
Production implementation of **Governor Nexus** — blockful's modular security upgrade
for ENS governance ([RFC](https://discuss.ens.domains/t/rfc-governor-nexus-modular-security-upgrade-for-ens-governance/21942)).
-Current milestone (Nexus 1): `GovernorNexus`, a modular governor core that replaces a
-stock governor's baked-in settings/counting/quorum with a vote-governed registry of
-proposal types, each dispatching vote-counting to a pluggable external `IRuleset`.
-Behavioral parity against the live deployed ENS governor is proven on a mainnet fork,
-both for the bootstrap ruleset's counting semantics and for the governor's day-to-day
-surface. Nexus mechanisms continue to land milestone by milestone.
-
-## Architecture (Nexus 1)
-
-`GovernorNexus` generalizes the single hard-coded configuration of a stock governor into
-a vote-governed, append-only registry of proposal types: each type pins an external
-`IRuleset` plus its own voting delay, voting period, and proposal threshold, and once
-registered a type's ruleset and parameters never change — only its `active` flag and the
-registry's default pointer can move, both gated behind governance. Every proposal is
-pinned to exactly one type at creation, for its lifetime; the pin is looked up
-transiently (EIP-1153) only while the stock proposal-creation body runs, so the
-type-scoped delay/period never leak into externally observable state. Counting itself is
-never done by the core — `countVote`, `quorumReached`, `voteSucceeded`, and `hasVoted`
-all dispatch to the proposal's pinned ruleset, an immutable, single-purpose contract the
-DAO can swap per type without touching the governor. `StandardRuleset` is the bootstrap
-ruleset (registered as type 0, the initial default): it reproduces the live ENS
-governor's Bravo-style vote buckets (Against/For/Abstain) and fractional quorum exactly,
-so a migrated DAO sees identical outcomes until it opts into new types. Untyped surface —
-`votingDelay()`, `votingPeriod()`, `quorum()`, `COUNTING_MODE()` — reads the current
-default type's row, so the governor stays a drop-in `IGovernor` even though its real
-behavior is per-type.
+Governor Nexus is a modular governance framework that modernizes the ENS Governor while
+preserving full compatibility with the existing Timelock contract. It combines security
+hardening, improved operational UX for delegates, and a ruleset architecture that lets
+different proposal classes follow different approval logic — reducing governance attack
+surface now while making future governance evolution safer and easier.
+
+In code terms: `GovernorNexus` replaces the stock governor's baked-in
+settings/counting/quorum with a vote-governed registry of proposal types, each
+dispatching vote-counting to a pluggable external `IRuleset`, and layers the security
+mechanisms on the core — **mutable votes**, the **anti-snipe late-vote extension**, a
+per-proposer **spam limit**, a hardened **cancellation policy**, **batch voting**.
+Behavioral parity against the live deployed ENS governor is proven on a mainnet fork;
+deliberate divergences are pinned as such by the fork suite.
+
+## Architecture
+
+Governor Nexus uses a modular router architecture:
+
+```mermaid
+flowchart TD
+ U(("Users")) -->|"propose · castVote"| CORE["Governor Nexus Core
proposal lifecycle · type registry · timelock admin"]
+ CORE -->|"queue · execute"| TL["ENS Timelock"]
+ CORE <--> RS
+
+ subgraph RS["Pluggable rulesets — one per proposal type"]
+ direction LR
+ S["Standard
type 0 · live-ENS parity"] ~~~ O["Optimistic
pass-unless-vetoed"] ~~~ B["Bond
lock-to-propose"]
+ end
+```
+
+**Governor Nexus Core responsibilities:**
+
+- Owns the proposal lifecycle and the Timelock admin rights — the existing ENS Timelock
+ is kept as-is
+- Maintains the vote-governed, append-only proposal-type registry; every proposal is
+ pinned to exactly one type at creation, for its lifetime
+- Dispatches counting, quorum/success checks, and propose-time validation to the pinned
+ ruleset — the core never counts votes itself
+
+**Ruleset responsibilities:**
+
+- Define quorum, approval thresholds, and type-specific counting logic
+- Own the vote buckets and per-voter receipts — every ruleset inherits the
+ `RulesetCounting` base (Bravo buckets, mutable votes)
+- Stay individually swappable through governance: each ruleset is an immutable,
+ single-purpose contract; the DAO evolves by registering new types, never by mutating
+ live ones
+
+**Proposal types** shipped in this repo (others can be introduced later through
+governance):
+
+| Proposal type | Condition to propose | Approval | Quorum |
+|---|---|---|---|
+| Standard (type 0, default) | Voting power ≥ proposal threshold | Simple majority | Fractional, 1% of supply — live-ENS parity |
+| Optimistic | Allowlisted proposer + allowlisted actions | Passes unless Against reaches the veto threshold | None |
+| Bond | Lock `bondAmount` of ENS — no voting-power gate | Simple majority + spam-slash predicate on defeat | Fractional, 1% of supply |
+
+Registry mechanics, precisely:
+
+- **Each type pins an external `IRuleset`** plus its own voting delay, voting period, and
+ proposal threshold. Once registered, a type's ruleset and parameters never change — only
+ its `active` flag and the registry's default pointer can move, both gated behind
+ governance.
+- **The per-proposal type pin is read transiently** (EIP-1153) only while the stock
+ proposal-creation body runs, so the type-scoped delay/period never leak into externally
+ observable state — safe because that body makes no state-committing external call while
+ the context is set (its only external dispatch, the duplicate-proposal check, reverts
+ unconditionally), so no reentrant reader can ever observe the typed values.
+- **Every counting read dispatches to the pinned ruleset** — `countVote`, `quorumReached`,
+ `voteSucceeded`, and `hasVoted` — so the DAO swaps counting per type without ever
+ touching the governor.
+- **`StandardRuleset` is the bootstrap ruleset** (registered as type 0, the initial
+ default): it reproduces the live ENS governor's Bravo-style vote buckets
+ (Against/For/Abstain) and fractional quorum exactly.
+- **The governor stays a drop-in `IGovernor`:** untyped surface — `votingDelay()`,
+ `votingPeriod()`, `quorum()`, `COUNTING_MODE()` — reads the current default type's row,
+ so the stock interface holds even though the real behavior is per-type.
+
+## Mutable votes
+
+While a proposal is open, casting again replaces your standing vote. This is a deliberate
+behavioral divergence from the live ENS governor, which rejects a second vote; the fork
+suite pins it as such.
+
+Counting mechanics live in `RulesetCounting`, the abstract base every ruleset inherits: it
+owns the vote buckets and a per-voter receipt (`hasVoted`, `support`, `weight`), and it makes
+re-voting a **replace** — `countVote` debits the receipt's recorded weight from its recorded
+bucket before crediting the new vote, in the same call, so a voter's weight is never
+double-counted nor transiently missing. `hasVoted` therefore means "has a standing vote" and
+stays true across re-votes.
+
+Two consequences follow for integrators:
+
+- **Indexers:** a re-vote emits another stock `VoteCast` for the same (proposal, voter); the
+ **latest one in log order is canonical** — earlier ones are superseded, not additive.
+ `voteReceipt(proposalId, voter)` returns the current standing vote directly.
+- **Tallies are non-monotonic:** quorum and success can flip in *both* directions while voting
+ is open, so no consumer can arm one-shot state on a tally-crossing event — an attacker could
+ otherwise cross a threshold early, re-vote back below it, and burn a once-only trigger before
+ the crossing that matters. Mechanisms needing finality (e.g. the anti-snipe extension below)
+ evaluate the outcome at the deadline, bar re-votes inside their own window, or gate
+ early finality.
+- **Gasless relayers:** every applied cast spends the voter's **per-proposal** EIP-712 ballot
+ nonce, so voting directly invalidates the voter's outstanding signed ballots **for that
+ proposal only** — held signatures for other open proposals stay valid. A stale pre-signed
+ ballot therefore cannot override a later cast on the same proposal; a relayer needs a fresh
+ signature once the voter acts on that proposal. Ballots must be built with
+ `voteNonce(proposalId, account)` — the account-global `nonces(address)` inherited from OZ is
+ not used for ballots and stays 0 (OZ-standard tooling that reads it still produces valid
+ signatures for a voter's first cast on a proposal, since both counters start at 0). For any
+ later cast on that proposal, a ballot built from `nonces(address)` reverts with
+ `GovernorInvalidSignature` — relayers must read `voteNonce`.
+
+## Anti-snipe late-vote extension
+
+If a proposal flips from failing to passing inside the final 24h (`extensionWindow`), voting
+is extended once by 48h (`extensionDuration`) — measured from the **original** deadline, so
+flip timing buys no extra calendar time. Both params are constructor immutables in clock
+units; the mechanism lives in the core and reads the pinned ruleset's
+`quorumReached && voteSucceeded`, so every proposal type gets it under its own semantics.
+
+The trigger is a **window low-water mark**, not a one-shot slot: the extension fires iff the
+proposal was observed failing at any point inside the window AND would pass at the original
+deadline. Nothing is armed on a tally crossing — the pattern the counting layer's
+non-monotonicity note forbids — so re-vote oscillation cannot burn the protection; the only
+way to avoid the extension is holding the proposal visibly passing for the entire final
+window, which is itself the intended response time. Voting stays free in both directions
+during the extension; the tally at the extended deadline decides.
+
+Integrator notes:
+
+- **`proposalDeadline` is authoritative** and grows lazily: it returns the original deadline
+ until that deadline passes, then the extended one if the extension holds. No tentative
+ extension is ever shown mid-window (a flip can still revert before the deadline).
+- **`ProposalExtended(proposalId, extendedDeadline)`** (OZ `GovernorPreventLateQuorum` ABI)
+ is emitted by the first cast after the original deadline. If nobody votes during the
+ extension the event never fires — the views (or replaying `VoteCast` tallies against the
+ immutable params) remain the source of truth.
+
+## Batch voting
+
+`castVoteWithReasonAndParamsBatch` casts votes on several proposals in one transaction,
+all-or-nothing. A batch is a direct cast: each item spends the voter's ballot nonce on that item's proposal, so — like any
+direct vote — it invalidates the voter's outstanding signed ballots for exactly the proposals voted in the batch. Duplicate ids inside a batch are ordinary re-votes, last-wins. Empty
+`reasons[i]`/`params[i]` entries mean "none" — OZ emits `VoteCast` for empty params and
+`VoteCastWithParams` otherwise.
+
+Batching is an explicit function rather than OZ's `Multicall` mixin: the governor's payable
+surface (`execute`/`relay`/`receive`) is exactly what makes Multicall the msg.value-reuse
+bug class, and an explicit signature keeps the batch semantics (per-item nonce spend,
+all-or-nothing) auditable in one place.
+
+## Spam limit
+
+`GovernorNexus` caps how many proposals a single proposer can hold concurrently live:
+
+- **Live means `Pending` or `Active`, nothing else:** a proposal that already survived its
+ vote (`Queued`) does not occupy a slot, and one that's `Canceled`/`Defeated`/`Executed`
+ frees its slot immediately.
+- **A concurrency cap, not a rate limit** — it bounds a key's in-flight
+ governance-attention footprint, not how often it can propose over time.
+- **Enforcement is lazy:** on each propose, the governor drops any of the proposer's
+ tracked ids that left the live set, then reverts if the survivors already fill the cap;
+ a proposal is added to the tracked set only after that check passes.
+- **Governance-settable** (`setMaxActiveProposals`) within
+ `1..MAX_ACTIVE_PROPOSALS_CEILING` (10) — zero is rejected because it would revert every
+ propose, including the governance proposal needed to raise it back — and deploys at 2
+ for the ENS migration (`ENSParams.MAX_ACTIVE_PROPOSALS`).
+- **Per-address**, and, like `proposalThreshold`, it does not resist an attacker willing
+ to split voting power across multiple addresses — accepted, consistent with every
+ per-address proposal cap in production governance (Bravo/Nouns/Uniswap all share this
+ property).
+- **The liveness probe (`_isLive`) is deliberately ruleset-free.** Because the lazy prune
+ runs on *every* propose, a probe that dispatched to the pinned ruleset would let a
+ ruleset with poisoned (reverting) views brick its own proposer's next propose. So the
+ probe reads only core storage: within the original deadline it consults `state()` (which
+ resolves purely from `Pending`/`Active` there), and past the original deadline it decides
+ from the late-flip stage alone — a `None` stage can never extend, so the id is dead;
+ otherwise the id may still sit in its one-shot extension window and is treated as live
+ until `originalDeadline + extensionDuration`. It never calls `_wouldPass` (the only
+ ruleset-dependent path). The cost is a deliberate over-approximation: a `FailingObserved`
+ id that ends up failing holds its slot up to `extensionDuration` longer than strictly
+ necessary, because its true deadline can only be known by asking the ruleset the probe
+ must not call.
+
+## Optimistic ruleset
+
+`OptimisticRuleset` is a second production ruleset: proposals under its type **pass by
+default** — there is no quorum, and the vote fails only if the Against bucket reaches an
+absolute veto threshold (500k ENS at the intended ENS registration) by the deadline. A
+proposal nobody voted on executes. Because the "voters judge the content" filter is gone,
+safety moves to propose time — the validator enforces that:
+
+- the **proposer is allowlisted**;
+- every **`(target, selector)` action is allowlisted**;
+- no action carries **ETH value**;
+- every action has at least a 4-byte selector — checking the three array lengths itself,
+ before any indexing, with no reliance on downstream validation.
+
+The ruleset deploys with **empty allowlists**: day one the optimistic path can do nothing,
+and the DAO votes entries in through standard full-quorum governance (the setters answer
+only to the timelock). The action setter permanently refuses the governance core as a
+target — the governor, the timelock, and the ruleset itself — so a zero-vote proposal can
+never reconfigure the system that created it.
+
+The propose-time hook is the core's one addition: a ruleset advertising
+`IProposalValidator` via ERC165 has `validateProposal(proposalId, proposer, targets,
+values, calldatas)` called before the proposal is created, and a revert blocks creation.
+Detection happens once, at `registerType`, pinned as `hasProposalValidation` on the content-immutable
+type line and never re-queried — types whose rulesets don't opt in keep a byte-identical
+propose path. A misbehaving validator can only brick proposing its own type (a revert
+*is* the gate's behavior); other types and the default path never reach it.
+
+Two properties are deliberate and documented rather than solved in code:
+
+- **Selector allowlisting bounds *which function* a proposal may call, never what that
+ call semantically does** — allowlisting a token's `approve` is allowlisting the spend.
+ Curating entries down to genuinely low-risk operations is the DAO's responsibility.
+- **The veto is withdrawable** — under mutable votes, a vetoer re-voting For/Abstain
+ drains the Against bucket, so the outcome is non-monotonic in both directions. The
+ snipe this enables (withdraw a standing veto at the last block) is exactly the
+ failing→passing flip the anti-snipe extension fires on: the community gets the full
+ extension window to re-assemble the veto.
+
+`COUNTING_MODE` is `"support=bravo&quorum=against,for,abstain"`, verbatim the string
+Optimism's audited optimistic module advertises, so existing indexer support carries over.
+
+## Cancellation
+
+Stock OZ lets only the proposer cancel, and only before voting starts. `GovernorNexus`
+replaces that (via the `_validateCancel` hook — no fork): **cancellation is possible only
+while the proposal is `Pending` or `Active`** — once the voting process finishes, no one
+can cancel, in any state — and within that window two rules apply:
+
+- **Self-cancel:** the proposer can cancel their own proposal at any point after the
+ propose block, recovering from mistakes without burning a full voting cycle. The
+ propose-block bar is deliberate: it makes the atomic propose→cancel round-trip
+ unrepresentable, so a flash-borrowed bond can never enter and leave custody inside
+ one transaction.
+- **Continuous threshold:** the propose-time threshold is a standing obligation. If the
+ proposer's voting power drops below the **pinned type's** `proposalThreshold`, `cancel()`
+ becomes permissionless — anyone can kill the proposal while it is still votable. Types
+ registered with a zero threshold (future bond-style or allowlisted paths) never expose
+ this rule.
+
+The voting-power read is `getVotes(proposer, clock() - 1)` — byte-for-byte the propose-time
+check, so "cancellable by anyone" is exactly "could not create this proposal now". The
+clause structure and the prior-block read follow Compound Governor Bravo's production
+semantics (shipped since 2021); the window is deliberately narrower than Bravo's, which
+keeps below-threshold cancel open through `Succeeded`/`Queued` — here a proposal that
+survived its vote is settled, and post-vote outcomes (including a proposer who dips after
+voting ends) belong to execution or to a fresh governance action, not to `cancel()`.
+Design consequences, accepted deliberately:
+
+- **Single-block dips count.** A proposer below threshold for one block (a re-delegation in
+ transit, a transfer-and-return) leaves the proposal cancellable at the next block, even
+ if their power is already back. Griefing-only (nothing is stolen; the proposer can
+ re-propose) and proposer-controlled (keeping the threshold backed is their obligation).
+ No hysteresis and no guardian-exemption role, matching the no-privileged-actors design.
+- **No post-vote backstop.** Bravo's wide window lets anyone cancel a queued proposal whose
+ proposer drained their power during the timelock delay; this design trades that backstop
+ away for the guarantee that a passed proposal cannot be griefed out of the queue. The
+ timelock delay remains the DAO's reaction window through its own governance paths.
+- **The threshold is the pinned one.** The check reads the proposal's registered type row —
+ content-immutable — never live config and never the ruleset, so a later governance change
+ (new types, moved default) cannot retroactively change any live proposal's cancel
+ exposure, and a malicious ruleset has no say in cancel authorization.
+
+## Bond ruleset
+
+`BondRuleset` is a lock-to-propose proposal type: it registers with `proposalThreshold =
+0`, so anyone can propose through it by locking `bondAmount` of ENS — no voting-power gate
+at all. Counting adds a fourth ballot option to the Bravo triple, `AgainstAndSlash`, cast
+through the same vote as any other option. The bond is
+forfeited to the DAO treasury exactly when the vote judges the proposal to be spam, per
+the predicate the DAO ratified on Snapshot (EP 5.15), applied verbatim on the raw buckets:
+
+```
+slashed ⟺ (Against + AgainstAndSlash > For) ∧ (AgainstAndSlash > Against)
+```
+
+Confiscation fires only when the combined rejections strictly beat support (`For`) AND
+slash-weight strictly beats plain rejection (`Against`); a tie in either comparison
+refunds, and `Abstain` (declared neutrality) neither protects nor punishes. Plain
+rejection is deliberately not a confiscation mandate: a community that votes a proposal
+down without a slash plurality refunds the bond. There is no per-address exclusion of the
+proposer's own vote: the ratified text defines the rule over the raw buckets, and the
+exclusion an earlier revision layered on top (an unratified implementation amendment) was
+removed as ineffective — an address-keyed exclusion is sybil-bypassable, inconveniencing
+only the naive while a second wallet walks around it. Defending a bond with real voting
+weight is design: `Against` weight matching the slash bucket, or `For` weight matching the
+combined rejections, blocks confiscation at real capital cost. There is also deliberately
+no participation floor on the slash bucket: the per-proposer cap is per-address and each
+sybil identity locks a full bond, so a spam wave is bounded by capital, and the DAO must
+be able to slash each spam proposal without gathering a quorum on every one.
+
+Cancellation interacts with the bond through the same partition the cancellation policy
+draws between `Pending` and `Active`:
+
+| Path | Outcome |
+|---|---|
+| Self-cancel while `Pending` | Full refund — no vote existed yet, nothing to evade |
+| Self-cancel while `Active` | Full forfeit — once voting is live, exiting costs as much as losing it |
+| Canceled directly on the timelock (security-council veto) | Full forfeit — the ratified default; best-effort since the bond may already be settled (refunds open at `Succeeded`) |
+
+`resolveBond` is permissionless and one-shot, and pays out as soon as the vote can no
+longer slash — `Succeeded`, `Queued`, `Executed`, `Defeated`, or `Canceled`; only
+`Pending`/`Active` revert. Refunding from `Succeeded` onward is a deliberate product
+decision (2026-08-03): the bond is an anti-spam instrument, and surviving the vote
+fulfills its purpose — a passed proposal's bond is not held hostage to execution. The
+cost is accepted openly: the timelock-veto forfeit below is best-effort, reaching only
+bonds still unsettled when the veto lands — and since resolution is permissionless,
+anyone can settle a passed proposal's bond before a veto arrives.
+
+Every BondRuleset parameter — `token`, `quorumNumerator`, `bondAmount`, `treasury` — is
+`immutable`, with no setters, matching every other ruleset in this repo. 1,000 ENS is
+the ratified initial value ("1,000 ENS is the right initial value"). The DAO
+re-prices the bond, or moves the treasury, by deploying a new `BondRuleset` and calling
+`registerType` — never by adding a setter to this one; proposals already locked against the
+old ruleset keep resolving against it.
+
+Accepted residuals:
+
+- **Whale force-slash.** A large holder can vote `AgainstAndSlash` on an honestly-defeated
+ proposal and confiscate the bond at zero marginal cost of their own; the predicate's
+ strict comparisons bound this but don't eliminate it. This is the ratified mandate
+ itself, not an implementation gap.
+- **Zero-turnout grief.** With no other votes cast at all, a single wei of
+ `AgainstAndSlash` weight satisfies both comparisons and confiscates an honest proposer's
+ bond. The defense is attracting any single vote in either expressive bucket (`For` or
+ `Against`), each of which the proposer wants anyway. A participation floor was
+ deliberately rejected: it would let a sybil spam wave outrun the DAO's capacity to
+ reach the floor on every spam proposal, neutering the deterrent exactly when it matters.
+- **Whale shield.** The mirror of force-slash: a proposer (or ally) blocks confiscation
+ with real weight — `Against` weight matching the slash bucket, or `For` weight matching
+ the combined rejections — and since voting spends no capital, one whale shields every
+ proposal they back simultaneously. Defense with real voting weight is the design; the
+ whale cases are its two symmetric extremes.
+- **Sybil vs. the bond.** Splitting proposals across multiple identities doesn't reduce
+ total cost the way it can against a voting-power threshold: each identity still locks a
+ full `bondAmount`, so the bond scales spam cost linearly with proposal count regardless
+ of how it's split across addresses.
+- **Gated-ruleset trust.** A ruleset that implements the propose-time hook is fully trusted
+ by governance — registering it is a governance action, and it already controls its type's
+ counting, quorum, and success. Its hook is the first external call in the governor's
+ propose path, so a *malicious* gated ruleset could reenter and exceed its own
+ per-proposer active-proposal cap (never a victim's — the reentrant proposer is the ruleset
+ itself). No reentrancy guard is added: the production `BondRuleset` transfers hook-free
+ ENS, and the exposure is bounded to a self-inflicted cap on a governance-approved contract.
+- **Veto forfeit evadable by early settle.** Refunds open at `Succeeded`, and resolution
+ is permissionless — so a proposer (or anyone) can settle the bond before the security
+ council vetoes from the timelock, making the veto forfeit reach only bonds still
+ unsettled when the veto lands.
## Layout
| Path | What |
|---|---|
-| `src/GovernorNexus.sol` | Nexus 1 governor core — proposal-type registry, per-proposal pin, ruleset dispatch |
-| `src/IRuleset.sol` | Interface a pluggable ruleset implements (counting, quorum, vote success) |
-| `src/StandardRuleset.sol` | Bootstrap ruleset — live-ENS-parity counting (Bravo buckets, fractional quorum) |
-| `src/ENSGovernor.sol` | Nexus 0 baseline (kept for reference) — stock OZ v5.6.1 composition, zero custom logic |
-| `src/ENSParams.sol` | Live ENS addresses + current governor parameters (single source of truth) |
+| `src/GovernorNexus.sol` | Governor core — proposal-type registry, per-proposal pin, ruleset dispatch |
+| `src/GovernorPreventLateFlip.sol` | **Anti-snipe extension**, an abstract Governor module (window low-water mark, lazy deadline extension) — reusable by any OZ v5 governor, hardened for mutable votes |
+| `src/interfaces/IRuleset.sol` | Interface a pluggable ruleset implements (counting, quorum, vote success) |
+| `src/RulesetCounting.sol` | Counting base every ruleset inherits — Bravo buckets, per-voter receipts, **mutable votes** (a re-vote replaces the standing vote) |
+| `src/RulesetQuorumFraction.sol` | Shared fractional-quorum base — `pastTotalSupply × numerator / 100`; which buckets count stays in the inheriting ruleset |
+| `src/rulesets/StandardRuleset.sol` | Bootstrap ruleset — live-ENS-parity quorum/success rules on top of the counting base |
+| `src/interfaces/IProposalValidator.sol` | Optional ruleset extension — propose-time content-validation hook (carries the governor-computed `proposalId`), ERC165-detected at registration; drives the optimistic gate and `BondRuleset`'s bond lock |
+| `src/rulesets/OptimisticRuleset.sol` | Optimistic ruleset — pass-unless-vetoed outcome + propose-time proposer/action allowlists |
+| `src/rulesets/BondRuleset.sol` | **Lock-to-propose ruleset** — fourth ballot option, bond custody (lock/refund/forfeit), spam-slash predicate |
+| `src/ENSParams.sol` | Live ENS addresses, current governor parameters, and the intended registration values for the new rulesets (single source of truth) |
| `script/Deploy.s.sol` | Deploys `StandardRuleset` + `GovernorNexus` (two-contract, CREATE-address-precompute deploy) against the real ENS token + timelock |
-| `test/GovernorNexus.registry.t.sol` | Unit suite: type registration, activation, default-pointer moves |
-| `test/GovernorNexus.propose.t.sol` | Unit suite: both propose doors, type pinning, per-type parameters |
-| `test/GovernorNexus.lifecycle.t.sol` | Unit suite: full propose → vote → queue → execute lifecycle |
-| `test/GovernorNexus.adversarial.t.sol` | Unit suite: malicious/misbehaving ruleset blast-radius containment |
-| `test/GovernorNexusTestBase.sol` | Shared fixture the suites above inherit (deploy wiring + governance-loop helpers) |
-| `test/StandardRuleset.t.sol` | Unit suite for the bootstrap ruleset |
-| `test/ENSGovernor.t.sol` | Unit suite for the Nexus 0 baseline (mock token, ENS-scale params) |
+| `test/governor/GovernorNexus.registry.t.sol` | Unit suite: type registration, activation, default-pointer moves |
+| `test/governor/GovernorNexus.propose.t.sol` | Unit suite: both propose doors, type pinning, per-type parameters |
+| `test/governor/GovernorNexus.lifecycle.t.sol` | Unit suite: full propose → vote → queue → execute lifecycle |
+| `test/governor/GovernorNexus.adversarial.t.sol` | Unit suite: malicious/misbehaving ruleset blast-radius containment |
+| `test/governor/GovernorNexus.spamlimit.t.sol` | Unit suite: per-proposer live-proposal cap |
+| `test/governor/GovernorNexus.batch.t.sol` | Unit suite: batch voting — all-or-nothing atomicity, per-item nonce spend, duplicate-id re-votes |
+| `test/governor/GovernorNexus.voteNonce.t.sol` | Unit suite: per-proposal ballot nonces — spend on every applied cast, stale-signature invalidation |
+| `test/governor/GovernorNexus.cancel.t.sol` | Unit suite: cancellation policy — self-cancel + continuous-threshold permissionless cancel |
+| `test/governor/GovernorNexus.bond.t.sol` | Unit suite: bond ruleset wired into the governor — lock at propose, cancel-partition resolution |
+| `test/rulesets/BondRuleset.t.sol` | Unit suite: bond custody, slash predicate table, cancel partition, constructor guards |
+| `test/rulesets/BondRuleset.invariant.t.sol` | Invariant/fuzz suite: bond custody solvency across randomized propose/vote/cancel/resolve sequences |
+| `test/rulesets/BondRulesetTestBase.sol` | Shared fixture for the bond suites above |
+| `test/governor/GovernorNexusTestBase.sol` | Shared fixture the suites above inherit (deploy wiring + governance-loop helpers) |
+| `test/governor/GovernorNexus.lateFlip.t.sol` | Unit + fuzz suite for the late-flip extension: trigger matrix, oscillation/burn attempts, lazy materialization, model-checked fuzz |
+| `test/rulesets/RulesetCounting.t.sol` | Unit + fuzz suite for the counting base: re-vote replace mechanics, tally conservation, receipt width guard |
+| `test/rulesets/StandardRuleset.t.sol` | Unit suite for the bootstrap ruleset |
+| `test/rulesets/OptimisticRuleset.t.sol` | Unit + fuzz suite for the optimistic ruleset: veto boundary, validator rules, allowlist setters |
+| `test/governor/GovernorNexus.proposalValidation.t.sol` | Integration suite for the propose-time validation gate (mock validators only): detection/pinning, revert propagation, misbehaving-validator containment |
+| `test/governor/GovernorNexus.optimistic.t.sol` | Integration suite for the optimistic type: validation rules through the gate, allowlist governance loop, e2e lifecycle, veto-withdrawal × anti-snipe |
| `test/Deploy.t.sol` | Unit suite for the deploy script |
-| `test/mocks/` | `MockENSToken`, `MockGovernor`, `MaliciousRulesets`, `Box` test target |
+| `test/mocks/` | `MockENSToken`, `MockGovernor`, `MaliciousRulesets`, `ValidatorRulesets`, `FeeOnTransferToken`, `Box` test target |
| `test/fork/` | Mainnet-fork suites: behavioral parity (live governor vs GovernorNexus) + A/B gas benchmark |
## Build & test
@@ -62,3 +406,41 @@ forge coverage --no-match-path "test/fork/*" --report summary
Fork tests pin block 25,445,220 and default to a public archive RPC; set
`MAINNET_RPC_URL` for a dedicated endpoint (also the name of the CI secret).
+
+## Nexus vs. the live ENS governor
+
+The live ENS governor is a 2021, OZ-v4, Bravo-style deployment with everything fixed at
+deploy time; Governor Nexus rebuilds it on OZ v5.6.1 while keeping its day-to-day
+surface — behavioral parity is proven on a mainnet fork against the live bytecode, with
+each deliberate divergence pinned by the fork suite. What changes is the risk profile:
+the RFC's security assessment under the [Anticapture](https://app.anticapture.com/ens/)
+framework places the current setup at **Stage 0**, and the mechanisms below move ENS
+governance to **Stage 1**.
+
+| Exposure in the live governor | Severity | Governor Nexus answer |
+|---|---|---|
+| Proposal spam can force a war of attrition | **Critical** | Per-proposer cap on concurrently live proposals — deploys at 2, governance-settable |
+| Insufficient voting delay — the pre-vote coordination window is one block | **Critical** | Voting delay is a per-type registry parameter; the migration raises it by governance, with no code change |
+| No continuous threshold enforcement — a proposer can dump their tokens right after submitting | **Critical** | A proposal whose proposer drops below threshold becomes cancellable by anyone while still votable |
+| Vote immutability — no correction path if a voting interface is compromised | **Medium** | Mutable votes: casting again replaces the standing vote |
+| No late-vote extension — last-minute flips can pass without response time | **Medium** | Anti-snipe extension: a failing→passing flip in the final 24h extends voting by 48h |
+| Routine operations require a full governance vote | **Low** | Optimistic pass-unless-vetoed type, gated by proposer/action allowlists |
+| Uniform approval thresholds for every proposal class | **Low** | Per-type thresholds and quorum via the ruleset registry |
+| High operational friction for delegates under proposal load | QoL | Batch voting — many proposals, one transaction |
+| Proposing requires 100k ENS of voting power, full stop | QoL | Bond ruleset — lock 1,000 ENS instead, slashed only under the DAO-ratified spam predicate |
+
+### Gas benchmarks
+
+What the features above cost per operation: `test/fork/GasBench.t.sol` runs an A/B
+benchmark on the same mainnet fork — the live ENS governor (real deployed bytecode, real
+token checkpoint history) vs GovernorNexus, both running identical payloads through the
+same helpers. Gas is the `gasleft()` delta around the single measured call, excluding
+setup/fixture cost. Reference numbers at block 25,445,220 (regenerate with
+`forge test --match-contract GasBench -vv`):
+
+| op | live gov | GovernorNexus | delta | attribution |
+|---|---:|---:|---:|---|
+| propose | 115,052 | 138,778 | +23,726 | Type-pin SSTORE + transient-context writes + the extra `ProposalTypedCreated` event, plus the spam-limit bookkeeping (active-set append + lazy prune) and the propose-time validation hook — partially offset by OZ v5's packed `ProposalCore` beating the live governor's storage layout. |
+| castVote | 106,982 | 135,842 | +28,860 | One external CALL into the pinned ruleset's `countVote` (cold account access + its own tally SSTORE), the anti-snipe low-water evaluation around the cast (outcome views call back into the governor and out to the token), and the per-proposal ballot-nonce spend on every applied cast. |
+| queue | 102,244 | 121,931 | +19,687 | `queue()`'s state-bitmap check re-derives quorum/success by calling out to the ruleset, which itself calls back into the governor (`proposalSnapshot`) and out to the token (`getPastTotalSupply`) — a multi-hop CALL chain the live governor's local tally doesn't pay. |
+| execute | 79,188 | 61,606 | -17,582 | Net cheaper; `execute()`'s state check re-runs the same ruleset CALL chain as `queue()`, so the sign flip is attributed to the live governor's own (opaque, bytecode-only) execute-path bookkeeping rather than anything ruleset-side. |
diff --git a/foundry.lock b/foundry.lock
new file mode 100644
index 0000000..5c5cd85
--- /dev/null
+++ b/foundry.lock
@@ -0,0 +1,8 @@
+{
+ "lib/forge-std": {
+ "rev": "bf647bd6046f2f7da30d0c2bf435e5c76a780c1b"
+ },
+ "lib/openzeppelin-contracts": {
+ "rev": "5fd1781b1454fd1ef8e722282f86f9293cacf256"
+ }
+}
\ No newline at end of file
diff --git a/script/Deploy.s.sol b/script/Deploy.s.sol
index 6e5e3d3..848f54b 100644
--- a/script/Deploy.s.sol
+++ b/script/Deploy.s.sol
@@ -7,7 +7,7 @@ import {TimelockController} from "@openzeppelin/contracts/governance/TimelockCon
import {IVotes} from "@openzeppelin/contracts/governance/utils/IVotes.sol";
import {GovernorNexus} from "../src/GovernorNexus.sol";
-import {StandardRuleset} from "../src/StandardRuleset.sol";
+import {StandardRuleset} from "../src/rulesets/StandardRuleset.sol";
import {ENSParams} from "../src/ENSParams.sol";
/// @notice Deploys the Nexus system — `StandardRuleset` + `GovernorNexus` — wired to the
@@ -15,7 +15,7 @@ import {ENSParams} from "../src/ENSParams.sol";
/// contract is granted timelock roles here; migration onto the live timelock is a
/// DAO proposal granting PROPOSER + EXECUTOR (the live timelock is OZ v4.3:
/// CANCELLER_ROLE does not exist there).
-/// @dev Wiring (spec §Wiring note): `StandardRuleset.countVote` is `onlyGovernor` and
+/// @dev Wiring: `StandardRuleset.countVote` is `onlyGovernor` and
/// `quorumReached` reads `governor.proposalSnapshot`, so the ruleset must be
/// constructed with the governor's address — but the governor's constructor needs the
/// ruleset (it registers row 0 with it). Break the cycle by precomputing the
@@ -46,7 +46,7 @@ contract Deploy is Script {
standardRuleset = new StandardRuleset(predictedGovernor, IVotes(ENSParams.TOKEN), ENSParams.QUORUM_NUMERATOR);
// Name "ENS Governor" so `name()` and the EIP-712 vote-by-sig domain match the live
- // governor (spec D11).
+ // governor.
governor = new GovernorNexus(
"ENS Governor",
IVotes(ENSParams.TOKEN),
@@ -54,7 +54,10 @@ contract Deploy is Script {
standardRuleset,
ENSParams.VOTING_DELAY,
ENSParams.VOTING_PERIOD,
- ENSParams.PROPOSAL_THRESHOLD
+ ENSParams.PROPOSAL_THRESHOLD,
+ ENSParams.MAX_ACTIVE_PROPOSALS,
+ ENSParams.EXTENSION_WINDOW,
+ ENSParams.EXTENSION_DURATION
);
require(address(governor) == predictedGovernor, "Deploy: governor address prediction failed");
diff --git a/src/ENSGovernor.sol b/src/ENSGovernor.sol
deleted file mode 100644
index 6bb65f4..0000000
--- a/src/ENSGovernor.sol
+++ /dev/null
@@ -1,107 +0,0 @@
-// SPDX-License-Identifier: MIT
-pragma solidity 0.8.30;
-
-import {Governor} from "@openzeppelin/contracts/governance/Governor.sol";
-import {GovernorSettings} from "@openzeppelin/contracts/governance/extensions/GovernorSettings.sol";
-import {GovernorCountingSimple} from "@openzeppelin/contracts/governance/extensions/GovernorCountingSimple.sol";
-import {GovernorVotes} from "@openzeppelin/contracts/governance/extensions/GovernorVotes.sol";
-import {
- GovernorVotesQuorumFraction
-} from "@openzeppelin/contracts/governance/extensions/GovernorVotesQuorumFraction.sol";
-import {GovernorTimelockControl} from "@openzeppelin/contracts/governance/extensions/GovernorTimelockControl.sol";
-import {TimelockController} from "@openzeppelin/contracts/governance/TimelockController.sol";
-import {IVotes} from "@openzeppelin/contracts/governance/utils/IVotes.sol";
-
-/// @title ENSGovernor (stock scaffold)
-/// @notice Unmodified OZ v5.6.1 governor composition — the production baseline for
-/// Governor Nexus (milestone 0). Zero custom logic on purpose: every mechanism
-/// lands in later milestones on top of this contract, and the fork suite proves
-/// this baseline behaves like the live ENS governor before anything is added.
-contract ENSGovernor is
- Governor,
- GovernorSettings,
- GovernorCountingSimple,
- GovernorVotes,
- GovernorVotesQuorumFraction,
- GovernorTimelockControl
-{
- constructor(
- IVotes token_,
- TimelockController timelock_,
- uint48 votingDelay_,
- uint32 votingPeriod_,
- uint256 proposalThreshold_,
- uint256 quorumNumerator_
- )
- Governor("ENS Governor")
- GovernorSettings(votingDelay_, votingPeriod_, proposalThreshold_)
- GovernorVotes(token_)
- GovernorVotesQuorumFraction(quorumNumerator_)
- GovernorTimelockControl(timelock_)
- {}
-
- // ─────────────────────────── Required overrides ───────────────────────────
- // Pure disambiguation between inherited modules; no behavior added.
-
- function votingDelay() public view override(Governor, GovernorSettings) returns (uint256) {
- return super.votingDelay();
- }
-
- function votingPeriod() public view override(Governor, GovernorSettings) returns (uint256) {
- return super.votingPeriod();
- }
-
- function proposalThreshold() public view override(Governor, GovernorSettings) returns (uint256) {
- return super.proposalThreshold();
- }
-
- function quorum(uint256 timepoint) public view override(Governor, GovernorVotesQuorumFraction) returns (uint256) {
- return super.quorum(timepoint);
- }
-
- function state(uint256 proposalId) public view override(Governor, GovernorTimelockControl) returns (ProposalState) {
- return super.state(proposalId);
- }
-
- function proposalNeedsQueuing(uint256 proposalId)
- public
- view
- override(Governor, GovernorTimelockControl)
- returns (bool)
- {
- return super.proposalNeedsQueuing(proposalId);
- }
-
- function _queueOperations(
- uint256 proposalId,
- address[] memory targets,
- uint256[] memory values,
- bytes[] memory calldatas,
- bytes32 descriptionHash
- ) internal override(Governor, GovernorTimelockControl) returns (uint48) {
- return super._queueOperations(proposalId, targets, values, calldatas, descriptionHash);
- }
-
- function _executeOperations(
- uint256 proposalId,
- address[] memory targets,
- uint256[] memory values,
- bytes[] memory calldatas,
- bytes32 descriptionHash
- ) internal override(Governor, GovernorTimelockControl) {
- super._executeOperations(proposalId, targets, values, calldatas, descriptionHash);
- }
-
- function _cancel(
- address[] memory targets,
- uint256[] memory values,
- bytes[] memory calldatas,
- bytes32 descriptionHash
- ) internal override(Governor, GovernorTimelockControl) returns (uint256) {
- return super._cancel(targets, values, calldatas, descriptionHash);
- }
-
- function _executor() internal view override(Governor, GovernorTimelockControl) returns (address) {
- return super._executor();
- }
-}
diff --git a/src/ENSParams.sol b/src/ENSParams.sol
index 5c26d8b..21eb591 100644
--- a/src/ENSParams.sol
+++ b/src/ENSParams.sol
@@ -13,8 +13,19 @@ library ENSParams {
uint48 internal constant VOTING_DELAY = 1; // blocks
uint32 internal constant VOTING_PERIOD = 45_818; // blocks (~1 week)
uint256 internal constant PROPOSAL_THRESHOLD = 100_000e18; // 100k ENS
+ // Not read from the live governor (it has no such mechanism): per-proposer cap on
+ // concurrently live proposals.
+ uint8 internal constant MAX_ACTIVE_PROPOSALS = 2;
// Live governor expresses quorum as 100/10000; OZ v5's default denominator is 100,
- // so numerator 1 encodes the same 1%. Parity is asserted on quorum() output, which
- // is denominator-independent.
+ // so numerator 1 encodes the same 1%.
uint256 internal constant QUORUM_NUMERATOR = 1;
+
+ // Intended ENS registration values for the additional rulesets.
+ uint256 internal constant BOND_AMOUNT = 1_000e18; // 1,000 ENS
+ uint256 internal constant VETO_THRESHOLD = 500_000e18; // 500k ENS
+
+ // Late-flip extension: final-24h trigger window and 48h extension, in
+ // blocks (~12s/block), matching the block-denominated voting period above.
+ uint48 internal constant EXTENSION_WINDOW = 7200; // 24h
+ uint48 internal constant EXTENSION_DURATION = 14_400; // 48h
}
diff --git a/src/GovernorNexus.sol b/src/GovernorNexus.sol
index 8a999b5..3dece4f 100644
--- a/src/GovernorNexus.sol
+++ b/src/GovernorNexus.sol
@@ -8,28 +8,32 @@ import {IGovernor} from "@openzeppelin/contracts/governance/IGovernor.sol";
import {TimelockController} from "@openzeppelin/contracts/governance/TimelockController.sol";
import {IVotes} from "@openzeppelin/contracts/governance/utils/IVotes.sol";
import {ERC165Checker} from "@openzeppelin/contracts/utils/introspection/ERC165Checker.sol";
+import {SignatureChecker} from "@openzeppelin/contracts/utils/cryptography/SignatureChecker.sol";
-import {IRuleset} from "./IRuleset.sol";
+import {GovernorPreventLateFlip} from "./GovernorPreventLateFlip.sol";
+import {IProposalValidator} from "./interfaces/IProposalValidator.sol";
+import {IRuleset} from "./interfaces/IRuleset.sol";
/// @title GovernorNexus
/// @notice Modular ENS governor core. Replaces OZ's baked-in settings/counting/quorum
/// extensions with a governed table of proposal types, each pinning a pluggable
/// `IRuleset` plus the propose-time parameters (delay, period, threshold).
-/// @dev Stock OZ v5.6.1 `Governor` + `GovernorVotes` + `GovernorTimelockControl`; the
-/// dropped extensions (`GovernorSettings`, `GovernorCountingSimple`,
-/// `GovernorVotesQuorumFraction`) are supplied here — settings from the default type
-/// row, counting via ruleset dispatch (Task 4). The type table is append-only and
-/// content-immutable (spec D5): only `active` toggles and the default pointer move.
-contract GovernorNexus is Governor, GovernorVotes, GovernorTimelockControl {
- /// @notice A registered proposal type. `ruleset`, `votingDelay`, `votingPeriod` and
- /// `proposalThreshold` are set once at registration and never mutated;
- /// `active` is the only mutable field and gates NEW proposals only.
+/// @dev Stock OZ v5.6.1 `Governor` + `GovernorVotes` + `GovernorTimelockControl` plus the
+/// in-house `GovernorPreventLateFlip`. Settings come from the default type row and
+/// counting is dispatched to rulesets. The type table is append-only and
+/// content-immutable: only `active` toggles and the default pointer move.
+contract GovernorNexus is Governor, GovernorVotes, GovernorTimelockControl, GovernorPreventLateFlip {
+ /// @notice A registered proposal type. `ruleset`, `votingDelay`, `votingPeriod`,
+ /// `hasProposalValidation` and `proposalThreshold` are set once at registration and
+ /// never mutated; `active` is the only mutable field and gates NEW proposals
+ /// only.
struct TypeConfig {
IRuleset ruleset;
uint48 votingDelay;
uint32 votingPeriod;
- uint256 proposalThreshold;
bool active;
+ bool hasProposalValidation;
+ uint256 proposalThreshold;
}
mapping(uint8 => TypeConfig) private _types;
@@ -44,11 +48,30 @@ contract GovernorNexus is Governor, GovernorVotes, GovernorTimelockControl {
/// @dev Proposal-to-type pin, written exactly once at propose time.
mapping(uint256 proposalId => uint8) private _proposalType;
- /// @dev Transaction-scoped propose-time type context (EIP-1153 transient storage, spec
- /// D10). Holds `typeId + 1` only while `_proposeWithType` runs `super._propose`, so
- /// `votingDelay()`/`votingPeriod()` serve the typed line values to the stock
- /// `_propose` body without a persistent-storage handoff; 0 means "unset", keeping
- /// type 0 distinguishable from "no context". `uint16` so `typeId + 1` cannot wrap.
+ /// @dev Timepoint of the governor-path cancel, 0 if never canceled through the governor.
+ mapping(uint256 proposalId => uint48) private _canceledAt;
+
+ /// @dev Per-proposal EIP-712 ballot nonces. Vote signatures validate against this,
+ /// not the inherited account-global `Nonces` (which stays orphaned at 0).
+ mapping(uint256 proposalId => mapping(address voter => uint256)) private _voteNonces;
+
+ /// @dev Ids of the proposer's tracked proposals, lazily pruned on their next propose.
+ /// An id is pushed only after {_pruneAndCheckActiveLimit} passes, so length is
+ /// bounded by the cap in effect at push time (never above the ceiling). Lowering
+ /// the cap does not retroactively prune, so length can transiently exceed it.
+ mapping(address proposer => uint256[] proposalIds) private _activeProposals;
+
+ /// @dev Per-proposer cap on concurrently live (Pending|Active) proposals.
+ uint8 private _maxActiveProposals;
+
+ /// @notice Hard ceiling `setMaxActiveProposals` can never exceed; bounds the
+ /// propose-time prune to at most 10 `state()` reads.
+ uint8 public constant MAX_ACTIVE_PROPOSALS_CEILING = 10;
+
+ /// @dev Transaction-scoped propose-time type context (EIP-1153). Holds `typeId + 1`
+ /// only while `_proposeWithType` runs `super._propose`, so `votingDelay()`/
+ /// `votingPeriod()` serve the typed line values; 0 means "unset". `uint16` so
+ /// `typeId + 1` cannot wrap.
uint16 private transient _typeContext;
/// @notice A new type was appended to the table.
@@ -63,6 +86,8 @@ contract GovernorNexus is Governor, GovernorVotes, GovernorTimelockControl {
event TypeActiveSet(uint8 indexed typeId, bool active);
/// @notice The default type pointer moved.
event DefaultTypeSet(uint8 indexed typeId);
+ /// @notice The per-proposer live-proposal cap was set.
+ event MaxActiveProposalsSet(uint8 maxActiveProposals);
/// @notice A proposal was created and pinned to `typeId` (companion to the stock
/// `ProposalCreated`, emitted in the same call).
event ProposalTypedCreated(uint256 indexed proposalId, uint8 indexed typeId, IRuleset indexed ruleset);
@@ -71,25 +96,44 @@ contract GovernorNexus is Governor, GovernorVotes, GovernorTimelockControl {
error RulesetZeroAddress();
/// @notice `ruleset` does not advertise `IRuleset` via ERC165.
error RulesetInterfaceUnsupported(address ruleset);
+ /// @notice `ruleset` is bound to `boundGovernor`, not this governor — it would revert
+ /// `Unauthorized` on first countVote/validateProposal, bricking the type.
+ error RulesetGovernorMismatch(address ruleset, address boundGovernor);
/// @notice `votingPeriod` is zero, which would open a proposal with no voting window.
error InvalidVotingPeriod();
+ /// @notice `votingDelay` is zero, which would put the snapshot in the propose block
+ /// (flash-loanable voting power) and erase the pre-vote cancel window.
+ error InvalidVotingDelay();
/// @notice `typeId` has never been registered (`typeId >= typeCount`).
error NonexistentType(uint8 typeId);
/// @notice `typeId` is the current default and cannot be deactivated.
error CannotDeactivateDefaultType(uint8 typeId);
/// @notice `typeId` cannot become the default while inactive.
error TypeInactive(uint8 typeId);
+ /// @notice `proposer` already has `maxActiveProposals` live (Pending|Active) proposals.
+ error ProposerActiveLimitReached(address proposer, uint8 maxActiveProposals);
+ /// @notice The cap is zero (bricks every propose) or above the ceiling.
+ error InvalidMaxActiveProposals(uint8 maxActiveProposals);
+ /// @notice `votingPeriod` does not exceed `extensionWindow`, which would make the
+ /// "final window" span the entire vote.
+ error VotingPeriodTooShort(uint32 votingPeriod, uint48 extensionWindow);
+ /// @notice `castVoteWithReasonAndParamsBatch` was called with zero items.
+ error EmptyBatch();
+ /// @notice `castVoteWithReasonAndParamsBatch` array arguments have different lengths.
+ error BatchLengthMismatch();
/// @param name_ Governor name; feeds `name()` and the EIP-712 domain separator that
- /// vote-by-sig is bound to. The deploy chooses the domain (`"ENS Governor"` for
- /// the ENS deployment, so vote-by-sig signatures match the live governor's
- /// domain), leaving the contract itself reusable across deployments (spec D11).
+ /// vote-by-sig is bound to (`"ENS Governor"` for the ENS deployment).
/// @param token Voting token (block-number or timestamp clock, per the token).
/// @param timelock Executor holding queued proposals; also the sole governance caller.
/// @param standardRuleset Ruleset for the bootstrap type (row 0), the default.
/// @param votingDelay_ Bootstrap type voting delay.
/// @param votingPeriod_ Bootstrap type voting period; must be non-zero.
/// @param proposalThreshold_ Bootstrap type proposal threshold.
+ /// @param maxActiveProposals_ Per-proposer live-proposal cap;
+ /// `1..MAX_ACTIVE_PROPOSALS_CEILING`, enforced by the same guard as the setter.
+ /// @param extensionWindow_ Late-flip trigger window (see `GovernorPreventLateFlip`).
+ /// @param extensionDuration_ Late-flip extension length (see `GovernorPreventLateFlip`).
/// @dev Registers row 0 under the same guardrails as `registerType` and sets it as the
/// default, atomically. No deployer-privileged post-deploy setup exists.
constructor(
@@ -99,16 +143,27 @@ contract GovernorNexus is Governor, GovernorVotes, GovernorTimelockControl {
IRuleset standardRuleset,
uint48 votingDelay_,
uint32 votingPeriod_,
- uint256 proposalThreshold_
- ) Governor(name_) GovernorVotes(token) GovernorTimelockControl(timelock) {
+ uint256 proposalThreshold_,
+ uint8 maxActiveProposals_,
+ uint48 extensionWindow_,
+ uint48 extensionDuration_
+ )
+ Governor(name_)
+ GovernorVotes(token)
+ GovernorTimelockControl(timelock)
+ GovernorPreventLateFlip(extensionWindow_, extensionDuration_)
+ {
_registerType(standardRuleset, votingDelay_, votingPeriod_, proposalThreshold_);
defaultTypeId = 0;
+ emit DefaultTypeSet(0);
+ _setMaxActiveProposals(maxActiveProposals_);
}
// ─────────────────────────── Type registry ───────────────────────────
/// @notice Append a new proposal type at `typeCount`, registered active.
- /// @param ruleset Non-zero address advertising `IRuleset` via ERC165.
+ /// @param ruleset Non-zero address advertising `IRuleset` via ERC165 and bound to this
+ /// governor (`ruleset.governor() == address(this)`).
/// @param votingDelay_ Blocks/seconds between propose and snapshot.
/// @param votingPeriod_ Voting window length; must be non-zero.
/// @param proposalThreshold_ Minimum proposer voting power.
@@ -141,6 +196,23 @@ contract GovernorNexus is Governor, GovernorVotes, GovernorTimelockControl {
emit DefaultTypeSet(typeId);
}
+ /// @notice Set the per-proposer live-proposal cap.
+ /// @param maxActiveProposals_ New cap; `1..MAX_ACTIVE_PROPOSALS_CEILING`.
+ function setMaxActiveProposals(uint8 maxActiveProposals_) external onlyGovernance {
+ _setMaxActiveProposals(maxActiveProposals_);
+ }
+
+ /// @dev Shared by the constructor and {setMaxActiveProposals} so the guard cannot drift.
+ /// Zero is rejected: it would revert every propose forever, including the
+ /// governance proposal needed to raise the cap back.
+ function _setMaxActiveProposals(uint8 maxActiveProposals_) private {
+ if (maxActiveProposals_ == 0 || maxActiveProposals_ > MAX_ACTIVE_PROPOSALS_CEILING) {
+ revert InvalidMaxActiveProposals(maxActiveProposals_);
+ }
+ _maxActiveProposals = maxActiveProposals_;
+ emit MaxActiveProposalsSet(maxActiveProposals_);
+ }
+
/// @dev Single registration path shared by the constructor and `registerType`, so
/// guardrails and the `TypeRegistered` event cannot drift. Ids are never reused;
/// `typeCount++` on a `uint8` panics once `typeCount == 255`, so the last
@@ -153,15 +225,23 @@ contract GovernorNexus is Governor, GovernorVotes, GovernorTimelockControl {
if (!ERC165Checker.supportsInterface(address(ruleset), type(IRuleset).interfaceId)) {
revert RulesetInterfaceUnsupported(address(ruleset));
}
+ address boundGovernor = ruleset.governor();
+ if (boundGovernor != address(this)) revert RulesetGovernorMismatch(address(ruleset), boundGovernor);
+ if (votingDelay_ == 0) revert InvalidVotingDelay();
if (votingPeriod_ == 0) revert InvalidVotingPeriod();
+ // Enforces GovernorPreventLateFlip's integration requirement at type registration.
+ if (votingPeriod_ <= extensionWindow) revert VotingPeriodTooShort(votingPeriod_, extensionWindow);
id = typeCount++;
_types[id] = TypeConfig({
ruleset: ruleset,
votingDelay: votingDelay_,
votingPeriod: votingPeriod_,
- proposalThreshold: proposalThreshold_,
- active: true
+ active: true,
+ hasProposalValidation: ERC165Checker.supportsInterface(
+ address(ruleset), type(IProposalValidator).interfaceId
+ ),
+ proposalThreshold: proposalThreshold_
});
emit TypeRegistered(id, ruleset, votingDelay_, votingPeriod_, proposalThreshold_);
}
@@ -189,20 +269,26 @@ contract GovernorNexus is Governor, GovernorVotes, GovernorTimelockControl {
return _types[proposalType(proposalId)].ruleset;
}
+ /// @notice Timepoint `proposalId` was canceled through the governor; 0 if it never was.
+ /// @dev 0 is a double-duty sentinel: it also covers a proposal canceled directly on the
+ /// timelock (security-council veto), which never runs the governor's `_cancel`.
+ /// Consumers disambiguate by checking `state(proposalId) == Canceled` first — see
+ /// BondRuleset's cancel partition.
+ function proposalCanceledAt(uint256 proposalId) external view returns (uint48) {
+ return _canceledAt[proposalId];
+ }
+
// ─────────────────────────── Propose paths ───────────────────────────
/// @notice Create a proposal governed by type `typeId`, pinning it for its lifetime.
- /// @dev Mirrors the stock `propose()` pre-checks with per-type parameters: the
- /// `#proposer=` suffix defense, type existence + `active`, and the type line's
- /// `proposalThreshold` against the proposer's votes at `clock() - 1`. Everything
- /// else (length/duplicate validation, storage, `ProposalCreated`) runs in the
- /// stock `_propose` via {_proposeWithType}.
+ /// @dev Mirrors the stock `propose()` pre-checks with per-type parameters; everything
+ /// else runs in the stock `_propose` via {_proposeWithType}.
/// @param targets Call targets, one per action.
/// @param values ETH values, one per action.
/// @param calldatas Encoded calls, one per action.
/// @param description Human-readable description; hashed into the proposal id.
/// @param typeId Registered, active proposal type to pin.
- /// @return proposalId Stock type-agnostic proposal id (typeId is NOT hashed — spec D2).
+ /// @return proposalId Stock type-agnostic proposal id (typeId is not hashed).
function proposeWithType(
address[] memory targets,
uint256[] memory values,
@@ -245,18 +331,12 @@ contract GovernorNexus is Governor, GovernorVotes, GovernorTimelockControl {
return proposeWithType(targets, values, calldatas, description, defaultTypeId);
}
- /// @dev Creates the proposal through the stock `_propose` (sole `ProposalCore` writer —
- /// it is `private` storage in OZ v5.6.1) under a transient type context, then pins.
- ///
- /// Safety of the transient handoff (spec D10): the only external calls reachable
- /// under the context are staticcalls inside stock `_propose`'s (Governor.sol:305-341)
- /// duplicate-proposal branch (`state(proposalId)`, which can staticcall the ruleset
- /// past-deadline or the timelock when queued) — and that branch reverts
- /// unconditionally, so no committed state is ever produced while the context is
- /// set. There is no reentrancy window in which `votingDelay()`/`votingPeriod()`
- /// could mislead an external reader, and at rest they remain honest default-type
- /// views. The clear after the `super` call is belt-and-braces on top of the
- /// EIP-1153 end-of-transaction reset.
+ /// @dev Creates the proposal through the stock `_propose` (sole `ProposalCore` writer)
+ /// under the transient type context, then pins. Invariant the context relies on:
+ /// while the context is set, no external call that could observe `votingDelay()`/
+ /// `votingPeriod()` and commit state is reachable — stock `_propose`'s only
+ /// external dispatch sits in its duplicate-proposal branch, which reverts
+ /// unconditionally. Any change that opens such a call breaks this.
function _proposeWithType(
address[] memory targets,
uint256[] memory values,
@@ -265,18 +345,86 @@ contract GovernorNexus is Governor, GovernorVotes, GovernorTimelockControl {
address proposer,
uint8 typeId
) internal virtual returns (uint256 proposalId) {
+ // Check-then-record inside the single ProposalCore-writing chokepoint, so no
+ // creation door — present or future — can miss either half.
+ _pruneAndCheckActiveLimit(proposer);
+
+ TypeConfig storage config = _types[typeId];
+ if (config.hasProposalValidation) {
+ IProposalValidator(address(config.ruleset))
+ .validateProposal(
+ hashProposal(targets, values, calldatas, keccak256(bytes(description))),
+ proposer,
+ targets,
+ values,
+ calldatas
+ );
+ }
+
_typeContext = uint16(typeId) + 1;
proposalId = super._propose(targets, values, calldatas, description, proposer);
_typeContext = 0;
_proposalType[proposalId] = typeId;
+ _activeProposals[proposer].push(proposalId);
emit ProposalTypedCreated(proposalId, typeId, _types[typeId].ruleset);
}
+ // ─────────────────────────── Spam limit ───────────────────────────
+
+ /// @dev Drops every tracked id that left the live set, then enforces the cap. The live
+ /// set is a positive whitelist — `Pending` or `Active`, nothing else — so new
+ /// lifecycle states fail closed.
+ function _pruneAndCheckActiveLimit(address proposer) private {
+ uint256[] storage ids = _activeProposals[proposer];
+ uint256 length = ids.length;
+ uint256 i = 0;
+ while (i < length) {
+ if (_isLive(ids[i])) {
+ ++i;
+ } else {
+ ids[i] = ids[length - 1];
+ ids.pop();
+ --length;
+ }
+ }
+ if (length >= _maxActiveProposals) {
+ revert ProposerActiveLimitReached(proposer, _maxActiveProposals);
+ }
+ }
+
+ /// @dev Liveness probe kept deliberately ruleset-free: a ruleset with poisoned views must
+ /// never be able to brick its proposer's next propose (this runs in the prune loop on
+ /// every propose). Hence it never routes through `_wouldPass`, and is conservative for a
+ /// `FailingObserved` id — holding the slot up to `extensionDuration` longer than needed.
+ function _isLive(uint256 proposalId) private view returns (bool) {
+ uint256 originalDeadline = _originalDeadline(proposalId);
+ if (clock() <= originalDeadline) {
+ ProposalState s = state(proposalId);
+ return s == ProposalState.Pending || s == ProposalState.Active;
+ }
+ if (_lateFlipStageOf(proposalId) == LateFlipStage.None) return false;
+ return clock() <= originalDeadline + extensionDuration;
+ }
+
+ /// @notice Current per-proposer live-proposal cap.
+ function maxActiveProposals() public view returns (uint8) {
+ return _maxActiveProposals;
+ }
+
+ /// @notice Number of `proposer`'s proposals currently Pending|Active; ids awaiting
+ /// their lazy prune are never counted.
+ function activeProposalCount(address proposer) external view returns (uint256 count) {
+ uint256[] storage ids = _activeProposals[proposer];
+ uint256 length = ids.length;
+ for (uint256 i = 0; i < length; ++i) {
+ if (_isLive(ids[i])) ++count;
+ }
+ }
+
// ─────────────────────── Default-type settings views ───────────────────────
- // Final spec form: the governor's propose-time parameters read the default type row —
- // except under the transient propose-time context, when they serve the typed line
- // (see `_proposeWithType`; never observable externally).
+ // Propose-time parameters read the default type row — except under the transient
+ // propose-time context, when they serve the typed line (see `_proposeWithType`).
/// @inheritdoc Governor
function votingDelay() public view virtual override returns (uint256) {
@@ -297,26 +445,21 @@ contract GovernorNexus is Governor, GovernorVotes, GovernorTimelockControl {
}
/// @inheritdoc Governor
- /// @dev Always the default type row — unlike `votingDelay`/`votingPeriod`, this is never
- /// served from the transient propose-time context, since `_propose` never reads
- /// `proposalThreshold()` (the threshold check runs upstream, in
- /// {proposeWithType}, against the pinned type's own line).
+ /// @dev Always the default type row — never served from the transient context; the
+ /// threshold check runs upstream in {proposeWithType} against the typed line.
function proposalThreshold() public view virtual override returns (uint256) {
return _types[defaultTypeId].proposalThreshold;
}
- // ─────────────────────────── Counting dispatch (Task 4) ───────────────────────────
+ // ─────────────────────────── Counting dispatch ───────────────────────────
// The core never tallies: every counting hook forwards to the ruleset pinned to the
- // proposal's type. `COUNTING_MODE`/`quorum` take no proposal id, so they are documented
- // default-type views over `defaultTypeId`'s ruleset (per-proposal answers are reachable
- // via `proposalRuleset(id)`).
-
- /// @dev The ruleset governing `proposalId`, resolved through its propose-time type pin.
- /// Safe without an existence check on the hot path: the pin is written once at
- /// creation and the type row's ruleset is content-immutable, and stock `Governor`
- /// state checks reject votes/queries on nonexistent proposals before counting is
- /// reached. A read-only `hasVoted` on a never-created id is the sole exception (see
- /// its natspec).
+ // proposal's type. `COUNTING_MODE`/`quorum` take no proposal id, so they are
+ // default-type views over `defaultTypeId`'s ruleset.
+
+ /// @dev The ruleset governing `proposalId`, via its propose-time pin. No existence
+ /// check on the hot path: stock `Governor` state checks reject nonexistent
+ /// proposals before counting is reached (sole exception: read-only `hasVoted`,
+ /// see its natspec).
function _rulesetOf(uint256 proposalId) private view returns (IRuleset) {
return _types[_proposalType[proposalId]].ruleset;
}
@@ -330,10 +473,8 @@ contract GovernorNexus is Governor, GovernorVotes, GovernorTimelockControl {
}
/// @inheritdoc IGovernor
- /// @dev Delegates to the proposal's ruleset. For a never-created `proposalId` this reads
- /// the type-0 ruleset's (empty) tally and returns false rather than reverting — no
- /// existence guard is added, since the answer is harmless and the hot path stays
- /// cheap; use `proposalType`/`proposalRuleset` when an existence check is required.
+ /// @dev Delegates to the proposal's ruleset. A never-created `proposalId` reads the
+ /// type-0 ruleset's empty tally and returns false rather than reverting.
function hasVoted(uint256 proposalId, address account) public view virtual override returns (bool) {
return _rulesetOf(proposalId).hasVoted(proposalId, account);
}
@@ -355,9 +496,8 @@ contract GovernorNexus is Governor, GovernorVotes, GovernorTimelockControl {
}
/// @dev Routes a cast vote to the proposal's ruleset, which owns tallying and rule
- /// enforcement (one-vote-per-voter, valid support). `totalWeight` is the core's
- /// token-checkpoint weight at the frozen snapshot; the ruleset buckets it and can
- /// never invent it. The returned counted weight bubbles back to `_castVote`.
+ /// enforcement. `totalWeight` is the core's token-checkpoint weight at the frozen
+ /// snapshot; the ruleset buckets it and can never invent it.
function _countVote(uint256 proposalId, address account, uint8 support, uint256 totalWeight, bytes memory params)
internal
virtual
@@ -367,6 +507,154 @@ contract GovernorNexus is Governor, GovernorVotes, GovernorTimelockControl {
return _rulesetOf(proposalId).countVote(proposalId, account, support, totalWeight, params);
}
+ // ─────────────────────────── Per-proposal ballot nonce ───────────────────────────
+ // Under mutable votes the last-applied cast wins, so an outstanding signed ballot could
+ // be submitted AFTER a later cast and override it. Every applied cast (direct, bySig,
+ // or batch item) spends the (proposalId, voter) nonce in `_castVote`, and signatures
+ // validate against the current value — so a cast invalidates the voter's outstanding
+ // signed ballots for THAT proposal only. The account-global `Nonces` inherited through
+ // OZ `Governor` is never spent and stays 0.
+
+ /// @notice Next expected EIP-712 ballot nonce for `account` on `proposalId`.
+ /// @dev Source of truth for building vote signatures; increments on every applied cast.
+ /// The inherited `nonces(address)` is NOT used for ballots.
+ function voteNonce(uint256 proposalId, address account) public view virtual returns (uint256) {
+ return _voteNonces[proposalId][account];
+ }
+
+ /// @dev Ballot digest bound to the per-proposal nonce — a read, not a spend; the spend
+ /// happens in `_castVote` when the vote is applied. Tightened to `view` (the OZ base
+ /// is nonpayable because it spends a nonce; this override only reads).
+ function _validateVoteSig(uint256 proposalId, uint8 support, address voter, bytes memory signature)
+ internal
+ view
+ virtual
+ override
+ returns (bool)
+ {
+ return SignatureChecker.isValidSignatureNow(
+ voter,
+ _hashTypedDataV4(
+ keccak256(abi.encode(BALLOT_TYPEHASH, proposalId, support, voter, voteNonce(proposalId, voter)))
+ ),
+ signature
+ );
+ }
+
+ /// @dev Extended-ballot digest bound to the per-proposal nonce; see {_validateVoteSig}.
+ function _validateExtendedVoteSig(
+ uint256 proposalId,
+ uint8 support,
+ address voter,
+ string memory reason,
+ bytes memory params,
+ bytes memory signature
+ ) internal view virtual override returns (bool) {
+ return SignatureChecker.isValidSignatureNow(
+ voter,
+ _hashTypedDataV4(
+ keccak256(
+ abi.encode(
+ EXTENDED_BALLOT_TYPEHASH,
+ proposalId,
+ support,
+ voter,
+ voteNonce(proposalId, voter),
+ keccak256(bytes(reason)),
+ keccak256(params)
+ )
+ )
+ ),
+ signature
+ );
+ }
+
+ // ──────────────── Governor / extension overrides (pure disambiguation) ────────────────
+
+ /// @inheritdoc IGovernor
+ function proposalDeadline(uint256 proposalId)
+ public
+ view
+ virtual
+ override(Governor, GovernorPreventLateFlip)
+ returns (uint256)
+ {
+ return super.proposalDeadline(proposalId);
+ }
+
+ /// @dev Every cast path converges here; spending the per-proposal ballot nonce on each
+ /// applied cast is what invalidates outstanding signed ballots for this proposal.
+ /// Spent BEFORE `super._castVote` dispatches to the ruleset's external `countVote`,
+ /// so the in-flight signature is already dead during that call; a revert unwinds
+ /// the spend and the cast atomically either way.
+ function _castVote(uint256 proposalId, address account, uint8 support, string memory reason, bytes memory params)
+ internal
+ virtual
+ override(Governor, GovernorPreventLateFlip)
+ returns (uint256 weight)
+ {
+ // Increment-only, +1 per cast: cannot realistically overflow.
+ unchecked {
+ ++_voteNonces[proposalId][account];
+ }
+ weight = super._castVote(proposalId, account, support, reason, params);
+ }
+
+ function _tallyUpdated(uint256 proposalId) internal virtual override(Governor, GovernorPreventLateFlip) {
+ super._tallyUpdated(proposalId);
+ }
+
+ // ─────────────────────────── Batch voting ───────────────────────────
+
+ /// @notice Casts votes on several proposals in one transaction.
+ /// @dev All-or-nothing: any failing item reverts the whole batch. Duplicate ids are
+ /// valid intra-tx re-votes, last-wins. Empty `reasons[i]`/`params[i]` entries mean
+ /// "none". Explicit function rather than `Multicall`: the governor's payable
+ /// surface makes Multicall the msg.value-reuse bug class.
+ function castVoteWithReasonAndParamsBatch(
+ uint256[] calldata proposalIds,
+ uint8[] calldata supportValues,
+ string[] calldata reasons,
+ bytes[] calldata params
+ ) public virtual returns (uint256[] memory weights) {
+ uint256 n = proposalIds.length;
+ if (n == 0) revert EmptyBatch();
+ if (n != supportValues.length || n != reasons.length || n != params.length) {
+ revert BatchLengthMismatch();
+ }
+
+ address voter = _msgSender();
+
+ weights = new uint256[](n);
+ for (uint256 i = 0; i < n; ++i) {
+ weights[i] = _castVote(proposalIds[i], voter, supportValues[i], reasons[i], params[i]);
+ }
+ }
+
+ // ─────────────────────────── Cancel policy ───────────────────────────
+
+ /// @dev Cancel authorization: only while the proposal is Pending or Active, and never in
+ /// the propose block — by the proposer, or by anyone when the pinned type's
+ /// `proposalThreshold` is nonzero and the proposer's prior-block votes fall below it.
+ /// The propose-block bar makes the atomic propose→cancel→settle round-trip (which
+ /// would flash-borrow away a proposal bond's capital cost) unrepresentable, and keeps
+ /// a depth-1 reorg from changing a cancel's economic outcome.
+ function _validateCancel(uint256 proposalId, address caller) internal view virtual override returns (bool) {
+ ProposalState s = state(proposalId);
+ if (s != ProposalState.Pending && s != ProposalState.Active) return false;
+
+ // snapshot − delay = the propose block; both operands come from core storage, so the
+ // probe stays ruleset-free. `state()` above already rejected nonexistent ids.
+ TypeConfig storage config = _types[proposalType(proposalId)];
+ if (clock() == proposalSnapshot(proposalId) - config.votingDelay) return false;
+
+ address proposer = proposalProposer(proposalId);
+ if (caller == proposer) return true;
+
+ uint256 votesThreshold = config.proposalThreshold;
+ return votesThreshold > 0 && getVotes(proposer, clock() - 1) < votesThreshold;
+ }
+
// ─────────────────── Governor / GovernorTimelockControl overrides ───────────────────
// Pure disambiguation between inherited modules; no behavior added.
@@ -418,7 +706,9 @@ contract GovernorNexus is Governor, GovernorVotes, GovernorTimelockControl {
bytes[] memory calldatas,
bytes32 descriptionHash
) internal virtual override(Governor, GovernorTimelockControl) returns (uint256) {
- return super._cancel(targets, values, calldatas, descriptionHash);
+ uint256 proposalId = super._cancel(targets, values, calldatas, descriptionHash);
+ _canceledAt[proposalId] = clock();
+ return proposalId;
}
function _executor() internal view virtual override(Governor, GovernorTimelockControl) returns (address) {
diff --git a/src/GovernorPreventLateFlip.sol b/src/GovernorPreventLateFlip.sol
new file mode 100644
index 0000000..23b27bc
--- /dev/null
+++ b/src/GovernorPreventLateFlip.sol
@@ -0,0 +1,130 @@
+// SPDX-License-Identifier: MIT
+pragma solidity 0.8.30;
+
+import {Governor} from "@openzeppelin/contracts/governance/Governor.sol";
+
+/// @title GovernorPreventLateFlip
+/// @notice Governor extension that counters last-minute outcome flips ("sniping"): a
+/// proposal that flips from failing to passing inside the final `extensionWindow`
+/// of its voting period has its deadline extended once, by `extensionDuration`
+/// past the ORIGINAL deadline — never past flip time, so placing the flip later
+/// buys no extra calendar time. Voting stays unrestricted during the extension;
+/// the tally at the extended deadline decides.
+/// @dev Hardened for mutable (non-monotonic) tallies: the trigger is a window low-water
+/// mark, and a proposal's {LateFlipStage} only moves toward GRANTING the extension.
+/// Integration requirement: every proposal's voting period must exceed
+/// `extensionWindow` — this contract cannot enforce that generically; validate it
+/// wherever voting periods are configured.
+abstract contract GovernorPreventLateFlip is Governor {
+ /// @notice Final-window length of the late-flip trigger, in clock units.
+ uint48 public immutable extensionWindow;
+ /// @notice Length added past the ORIGINAL deadline when the extension fires, in clock
+ /// units.
+ uint48 public immutable extensionDuration;
+
+ /// @dev Monotone ladder: a proposal's stage only ever moves forward, so no vote
+ /// sequence can consume the protection. `Extended` is reachable only through
+ /// `FailingObserved` — "extended without a failing witness" is unrepresentable.
+ enum LateFlipStage {
+ None,
+ FailingObserved,
+ Extended
+ }
+
+ mapping(uint256 proposalId => LateFlipStage) private _lateFlipStage;
+
+ /// @notice A proposal's voting period was extended by a late failing→passing flip.
+ /// @dev Same ABI as OZ `GovernorPreventLateQuorum`'s event, so stock tooling decodes it.
+ event ProposalExtended(uint256 indexed proposalId, uint64 extendedDeadline);
+
+ /// @notice A late-flip extension parameter is zero.
+ error InvalidExtensionConfig();
+
+ /// @param extensionWindow_ Final-window length of the trigger, in clock units; non-zero.
+ /// @param extensionDuration_ Extension length past the original deadline, in clock
+ /// units; non-zero.
+ constructor(uint48 extensionWindow_, uint48 extensionDuration_) {
+ if (extensionWindow_ == 0 || extensionDuration_ == 0) revert InvalidExtensionConfig();
+ extensionWindow = extensionWindow_;
+ extensionDuration = extensionDuration_;
+ }
+
+ /// @dev "Would the proposal pass if voting closed now" — the exact conjunction
+ /// `state()`'s post-deadline branch evaluates.
+ function _wouldPass(uint256 proposalId) private view returns (bool) {
+ return _quorumReached(proposalId) && _voteSucceeded(proposalId);
+ }
+
+ /// @dev The single observation point, run pre-count (from `_castVote`) and post-count
+ /// (from `_tallyUpdated`). Must never revert on its own arithmetic (`_tallyUpdated`
+ /// hard rule); the in-window bound is computed additively so a nonexistent id
+ /// (deadline 0) cannot underflow. It does NOT defend against `_quorumReached`/
+ /// `_voteSucceeded` themselves reverting — if the governor's implementation of those
+ /// hooks can revert (e.g. dispatch to pluggable external code), a cast landing inside
+ /// the final window reverts too, not just post-deadline queries.
+ function _observeLateFlip(uint256 proposalId) private {
+ uint256 originalDeadline = super.proposalDeadline(proposalId);
+ uint256 current = clock();
+ bool votingOpen = current <= originalDeadline;
+
+ if (votingOpen) {
+ bool inFinalWindow = current + extensionWindow >= originalDeadline;
+ if (inFinalWindow && _lateFlipStage[proposalId] == LateFlipStage.None && !_wouldPass(proposalId)) {
+ _lateFlipStage[proposalId] = LateFlipStage.FailingObserved;
+ }
+ } else if (_lateFlipStage[proposalId] == LateFlipStage.FailingObserved && _wouldPass(proposalId)) {
+ _lateFlipStage[proposalId] = LateFlipStage.Extended;
+ // originalDeadline + extensionDuration ≪ 2^64 (both derive from uint48 domains).
+ // forge-lint: disable-next-line(unsafe-typecast)
+ emit ProposalExtended(proposalId, uint64(originalDeadline + extensionDuration));
+ }
+ }
+
+ /// @dev Pre-count observation. Internal, so every cast path is covered — including the
+ /// `bySig` variants, which public `castVote*` overrides in inheritors do not
+ /// intercept.
+ function _castVote(uint256 proposalId, address account, uint8 support, string memory reason, bytes memory params)
+ internal
+ virtual
+ override
+ returns (uint256)
+ {
+ _observeLateFlip(proposalId);
+ return super._castVote(proposalId, account, support, reason, params);
+ }
+
+ /// @dev Post-count observation: catches the vote that itself CREATES a failing state
+ /// inside the window (e.g. the dip of a dip-and-recover sequence).
+ function _tallyUpdated(uint256 proposalId) internal virtual override {
+ super._tallyUpdated(proposalId);
+ _observeLateFlip(proposalId);
+ }
+
+ /// @dev The ORIGINAL deadline from core storage, before any late-flip extension — never
+ /// reaches a ruleset. Callers that must stay ruleset-free (e.g. a governor's liveness
+ /// probe) read this instead of {proposalDeadline}, whose post-deadline branch
+ /// dispatches to `_wouldPass` and therefore to the pinned ruleset.
+ function _originalDeadline(uint256 proposalId) internal view returns (uint256) {
+ return super.proposalDeadline(proposalId);
+ }
+
+ /// @dev A proposal's late-flip stage from core storage. Lets ruleset-free callers bound the
+ /// real (possibly extended) deadline without evaluating `_wouldPass`.
+ function _lateFlipStageOf(uint256 proposalId) internal view returns (LateFlipStage) {
+ return _lateFlipStage[proposalId];
+ }
+
+ /// @inheritdoc Governor
+ /// @dev Extended lazily past the original deadline: the answer comes from the
+ /// materialized stage or, until the first extension-period cast sets it, a live read.
+ function proposalDeadline(uint256 proposalId) public view virtual override returns (uint256) {
+ uint256 originalDeadline = super.proposalDeadline(proposalId);
+ if (clock() <= originalDeadline) return originalDeadline;
+
+ LateFlipStage stage = _lateFlipStage[proposalId];
+ if (stage == LateFlipStage.Extended || (stage == LateFlipStage.FailingObserved && _wouldPass(proposalId))) {
+ return originalDeadline + extensionDuration;
+ }
+ return originalDeadline;
+ }
+}
diff --git a/src/RulesetCounting.sol b/src/RulesetCounting.sol
new file mode 100644
index 0000000..3cbf6cc
--- /dev/null
+++ b/src/RulesetCounting.sol
@@ -0,0 +1,145 @@
+// SPDX-License-Identifier: MIT
+pragma solidity 0.8.30;
+
+import {IRuleset} from "./interfaces/IRuleset.sol";
+
+/// @title RulesetCounting
+/// @notice Shared vote-counting mechanics for every GovernorNexus ruleset: support buckets,
+/// per-voter receipts, and **mutable votes** — re-voting while the poll is open replaces
+/// the voter's standing vote instead of reverting.
+/// @dev Rules (which support values exist, quorum, success, counting mode) belong to the
+/// inheriting ruleset; this base owns only the arithmetic and the `onlyGovernor` trust
+/// boundary. Buckets are keyed by the raw `support` value rather than a fixed
+/// Against/For/Abstain struct, so a ruleset with extra options — Bond's AgainstAndSlash —
+/// reuses this counting layer without a storage-layout change. Which values are legal
+/// is the ruleset's call, via `_isValidSupport`.
+///
+/// **Non-monotonicity — read this before building on the tallies.** Because a re-vote
+/// debits the voter's previous bucket, tallies can *fall* as well as rise while voting is
+/// open. Any quantity derived from them (quorum reached, vote succeeded) may therefore flip
+/// in both directions until the deadline. No consumer may arm one-shot state on a
+/// tally-crossing event — an attacker can cross a threshold early, re-vote back below it,
+/// and so burn a once-only trigger before the crossing that actually matters.
+/// Mechanisms needing finality must evaluate the outcome at (or near) the deadline, bar
+/// re-votes inside their own window, or gate early finality entirely.
+///
+/// Voting-window enforcement stays in the core: the governor only calls `countVote`
+/// while the proposal is Active, and supplies the weight from the frozen snapshot — this
+/// base never reads the clock and never sources weight of its own.
+abstract contract RulesetCounting is IRuleset {
+ /// @notice A voter's standing vote on a proposal.
+ /// @dev `weight` is the amount currently credited to `support`'s bucket — the debit side of a
+ /// re-vote reads it back, so it must be exact. Packed to `uint240` to fit the receipt in
+ /// one slot alongside `hasVoted` + `support`; `countVote` guards the bound rather than
+ /// truncating (see `WeightOverflow`).
+ struct VoteReceipt {
+ bool hasVoted;
+ uint8 support;
+ uint240 weight;
+ }
+
+ /// @notice The single GovernorNexus this ruleset counts for; `countVote` is restricted to it.
+ address public immutable governor;
+
+ mapping(uint256 proposalId => mapping(uint8 support => uint256 weight)) private _tallies;
+ mapping(uint256 proposalId => mapping(address voter => VoteReceipt)) private _receipts;
+
+ /// @notice `support` is not a vote option this ruleset accepts.
+ error InvalidVoteType();
+ /// @notice `caller` is not the governor this ruleset was deployed for.
+ error Unauthorized(address caller);
+ /// @notice `weight` does not fit the receipt's `uint240` field, so it could not be recorded
+ /// exactly — and a weight that cannot be recorded cannot be debited on a re-vote.
+ /// @dev Unreachable for any real voting token (ENS total supply ≈ 1e26 ≪ 2^240 ≈ 1.8e72);
+ /// the guard exists so a hypothetical wider-supply token fails loudly instead of
+ /// silently truncating the receipt and breaking tally conservation.
+ error WeightOverflow(uint256 weight);
+
+ modifier onlyGovernor() {
+ if (msg.sender != governor) revert Unauthorized(msg.sender);
+ _;
+ }
+
+ /// @param governor_ The GovernorNexus this ruleset is deployed for.
+ constructor(address governor_) {
+ governor = governor_;
+ }
+
+ /// @notice Counts `voter`'s vote on `proposalId`, **replacing their previous vote** if any.
+ /// @dev The replace is atomic within this call: the recorded weight is debited from the
+ /// recorded bucket before the passed weight is credited to the new one, so no observer
+ /// can ever see the voter's weight double-counted or missing. Re-voting the same support
+ /// is the degenerate case (debit and credit cancel out) and is allowed — no special path.
+ /// @return The weight now standing for `voter` on this proposal (what the core reports in
+ /// `VoteCast`; the latest such event per (proposal, voter) is canonical).
+ function countVote(
+ uint256 proposalId,
+ address voter,
+ uint8 support,
+ uint256 weight,
+ bytes calldata /* params */
+ )
+ external
+ onlyGovernor
+ returns (uint256)
+ {
+ if (!_isValidSupport(support)) revert InvalidVoteType();
+ if (weight > type(uint240).max) revert WeightOverflow(weight);
+
+ VoteReceipt storage receipt = _receipts[proposalId][voter];
+ if (receipt.hasVoted) _tallies[proposalId][receipt.support] -= receipt.weight;
+ _tallies[proposalId][support] += weight;
+
+ receipt.hasVoted = true;
+ receipt.support = support;
+ // forge-lint: disable-next-line(unsafe-typecast) — bounds-checked above (WeightOverflow).
+ receipt.weight = uint240(weight);
+
+ return weight;
+ }
+
+ /// @notice Whether `voter` has a standing vote on `proposalId`.
+ /// @dev Stays `true` across re-votes — it answers "does this voter have a vote", not "how
+ /// many times did they cast". Never reverts on an id this ruleset never counted
+ /// (empty-receipt default, `false`), per the `IRuleset` interface contract.
+ function hasVoted(uint256 proposalId, address voter) public view returns (bool) {
+ return _receipts[proposalId][voter].hasVoted;
+ }
+
+ /// @notice `voter`'s standing vote on `proposalId`: whether one exists, its support bucket,
+ /// and the weight currently credited to that bucket.
+ /// @dev Lets tooling read current standing state without replaying `VoteCast` logs. Same
+ /// no-revert contract as `hasVoted`: an unknown (proposal, voter) reads as all-zero.
+ function voteReceipt(uint256 proposalId, address voter)
+ public
+ view
+ returns (bool voted, uint8 support, uint256 weight)
+ {
+ VoteReceipt storage receipt = _receipts[proposalId][voter];
+ return (receipt.hasVoted, receipt.support, receipt.weight);
+ }
+
+ /// @notice Weight standing in one support bucket of `proposalId`.
+ /// @dev The single, validated read path — internal outcome logic and external tooling both use
+ /// it. Reverts `InvalidVoteType` for a support value this ruleset does not accept: there is
+ /// no such bucket, and answering zero would read as "no votes" instead. `countVote` writes
+ /// only to buckets it has already validated, so a populated bucket is always readable here;
+ /// the revert only fires on a value that was never writable, turning a would-be silent zero
+ /// into a loud failure. An id this ruleset never counted reads as zero, never reverts.
+ /// Non-monotonic under re-votes.
+ function tally(uint256 proposalId, uint8 support) public view returns (uint256) {
+ if (!_isValidSupport(support)) revert InvalidVoteType();
+ return _tallies[proposalId][support];
+ }
+
+ /// @dev The support values this ruleset accepts. Standard/Optimistic use the three Bravo
+ /// options; Bond adds AgainstAndSlash. Declared `pure` so an override physically cannot read
+ /// storage — a stateful check would make `tally`/`countVote` state-dependent and could
+ /// break the unknown-id no-revert contract.
+ ///
+ /// **Obligation:** every support value an override accepts here MUST be accounted for in
+ /// that ruleset's `quorumReached`/`voteSucceeded`. Weight cast for an accepted-but-unread
+ /// bucket is conserved in storage yet silently excluded from the outcome — no revert, no
+ /// test failure unless the exact case is written. (Bond's AgainstAndSlash is the live example.)
+ function _isValidSupport(uint8 support) internal pure virtual returns (bool);
+}
diff --git a/src/RulesetQuorumFraction.sol b/src/RulesetQuorumFraction.sol
new file mode 100644
index 0000000..7f44d6c
--- /dev/null
+++ b/src/RulesetQuorumFraction.sol
@@ -0,0 +1,45 @@
+// SPDX-License-Identifier: MIT
+pragma solidity 0.8.30;
+
+import {IVotes} from "@openzeppelin/contracts/governance/utils/IVotes.sol";
+
+import {IRuleset} from "./interfaces/IRuleset.sol";
+
+/// @title RulesetQuorumFraction
+/// @notice Shared fractional-quorum machinery for rulesets that anchor quorum to the voting
+/// token's past total supply: `quorum(timepoint) = pastTotalSupply * numerator / 100`.
+/// @dev Owns only the mechanical fraction. Which buckets count toward quorum
+/// (`quorumReached`) stays in the inheriting ruleset — that is per-ruleset semantics,
+/// not shared arithmetic. Immutable by design: no setters, matching the ruleset pattern.
+abstract contract RulesetQuorumFraction is IRuleset {
+ /// @dev Fixed at 100 so a numerator of 1 encodes 1%, matching OZ's default
+ /// `GovernorVotesQuorumFraction` denominator. Not exposed and not overridable.
+ uint256 private constant QUORUM_DENOMINATOR = 100;
+
+ /// @notice Voting token whose past total supply anchors `quorum`.
+ IVotes public immutable token;
+ /// @notice Quorum numerator over the fixed 100 denominator (e.g. `1` = 1%).
+ uint256 public immutable quorumNumerator;
+
+ /// @notice `numerator` is zero (disables the quorum gate entirely) or exceeds the
+ /// denominator (100, a quorum > 100%).
+ error InvalidQuorumFraction(uint256 numerator, uint256 denominator);
+
+ /// @param token_ Voting token backing `quorum`'s past-total-supply lookup.
+ /// @param quorumNumerator_ Numerator over the fixed 100 denominator; reverts
+ /// `InvalidQuorumFraction` at zero (would make `quorumReached` unconditionally
+ /// true) and above 100.
+ constructor(IVotes token_, uint256 quorumNumerator_) {
+ if (quorumNumerator_ == 0 || quorumNumerator_ > QUORUM_DENOMINATOR) {
+ revert InvalidQuorumFraction(quorumNumerator_, QUORUM_DENOMINATOR);
+ }
+ token = token_;
+ quorumNumerator = quorumNumerator_;
+ }
+
+ /// @inheritdoc IRuleset
+ /// @dev Fraction of the token's past total supply at `timepoint`.
+ function quorum(uint256 timepoint) public view returns (uint256) {
+ return token.getPastTotalSupply(timepoint) * quorumNumerator / QUORUM_DENOMINATOR;
+ }
+}
diff --git a/src/StandardRuleset.sol b/src/StandardRuleset.sol
deleted file mode 100644
index f317773..0000000
--- a/src/StandardRuleset.sol
+++ /dev/null
@@ -1,165 +0,0 @@
-// SPDX-License-Identifier: MIT
-pragma solidity 0.8.30;
-
-import {IERC165} from "@openzeppelin/contracts/utils/introspection/IERC165.sol";
-import {IVotes} from "@openzeppelin/contracts/governance/utils/IVotes.sol";
-
-import {IRuleset} from "./IRuleset.sol";
-
-/// @dev Minimal governor surface StandardRuleset consumes — only `proposalSnapshot`, so a
-/// registry or test can satisfy this with a trivial stand-in instead of a full governor.
-interface IRulesetGovernor {
- function proposalSnapshot(uint256 proposalId) external view returns (uint256);
-}
-
-/// @title StandardRuleset
-/// @notice Replicates the live ENS governor's counting semantics (OZ `GovernorCountingSimple`
-/// + `GovernorVotesQuorumFraction`) as a standalone, governor-agnostic ruleset.
-/// @dev Immutable by design (D7: "What the DAO audited is what runs forever") — no setters,
-/// including for the quorum numerator. `countVote` is state-changing and therefore
-/// restricted to `governor`, so third parties cannot stuff vote tallies.
-contract StandardRuleset is IRuleset {
- /// @dev Bravo-style bucket ordering: 0=Against, 1=For, 2=Abstain.
- enum VoteType {
- Against,
- For,
- Abstain
- }
-
- /// @dev Per-proposal tally, keyed by proposal id. `for_` has the trailing underscore
- /// because `for` is a reserved word.
- struct ProposalVote {
- uint256 against;
- uint256 for_;
- uint256 abstain;
- mapping(address => bool) hasVoted;
- }
-
- /// @dev Fixed at 100 so a numerator of 1 encodes 1%, matching OZ's default
- /// `GovernorVotesQuorumFraction` denominator. Not exposed — the brief calls for no
- /// surface beyond `IRuleset`, and this value is not overridable.
- uint256 private constant QUORUM_DENOMINATOR = 100;
-
- /// @notice The single GovernorNexus this ruleset counts for; `countVote` is restricted
- /// to it and `quorumReached` reads its `proposalSnapshot`.
- address public immutable governor;
- /// @notice Voting token whose past total supply anchors `quorum`.
- IVotes public immutable token;
- /// @notice Quorum numerator over the fixed 100 denominator (e.g. `1` = 1%).
- uint256 public immutable quorumNumerator;
-
- mapping(uint256 => ProposalVote) private _proposalVotes;
-
- /// @notice `voter` already cast a vote on this proposal under this ruleset.
- error AlreadyVoted(address voter);
- /// @notice `support` is not one of Against(0)/For(1)/Abstain(2).
- error InvalidVoteType();
- /// @notice `caller` is not the governor this ruleset was deployed for.
- error Unauthorized(address caller);
- /// @notice `numerator` exceeds the denominator (100), which would yield a quorum > 100%.
- error InvalidQuorumFraction(uint256 numerator, uint256 denominator);
-
- modifier onlyGovernor() {
- if (msg.sender != governor) revert Unauthorized(msg.sender);
- _;
- }
-
- /// @param governor_ The GovernorNexus this ruleset is deployed for; immutable and never
- /// revisited, so it must be the address the governor will actually deploy to (see
- /// the deploy script's CREATE-address precompute for the chicken-and-egg fix).
- /// @param token_ Voting token backing `quorum`'s past-total-supply lookup.
- /// @param quorumNumerator_ Numerator over the fixed 100 denominator; reverts
- /// `InvalidQuorumFraction` above 100.
- constructor(address governor_, IVotes token_, uint256 quorumNumerator_) {
- if (quorumNumerator_ > QUORUM_DENOMINATOR) {
- revert InvalidQuorumFraction(quorumNumerator_, QUORUM_DENOMINATOR);
- }
- governor = governor_;
- token = token_;
- quorumNumerator = quorumNumerator_;
- }
-
- /// @inheritdoc IRuleset
- function countVote(
- uint256 proposalId,
- address voter,
- uint8 support,
- uint256 weight,
- bytes calldata /* params */
- )
- external
- onlyGovernor
- returns (uint256)
- {
- ProposalVote storage proposalVote = _proposalVotes[proposalId];
- if (proposalVote.hasVoted[voter]) revert AlreadyVoted(voter);
- proposalVote.hasVoted[voter] = true;
-
- if (support == uint8(VoteType.Against)) {
- proposalVote.against += weight;
- } else if (support == uint8(VoteType.For)) {
- proposalVote.for_ += weight;
- } else if (support == uint8(VoteType.Abstain)) {
- proposalVote.abstain += weight;
- } else {
- revert InvalidVoteType();
- }
-
- return weight;
- }
-
- /// @inheritdoc IRuleset
- /// @dev A `proposalId` this ruleset never counted reads from empty-tally defaults, same
- /// as `hasVoted`. That can make this return `true` for an uncounted id whenever
- /// `quorum(0) == 0` (e.g. a zero quorum numerator, or a token with no supply at
- /// timepoint 0) — callers must gate on proposal existence; the governor does this
- /// via `state()`.
- function quorumReached(uint256 proposalId) external view returns (bool) {
- ProposalVote storage proposalVote = _proposalVotes[proposalId];
- uint256 snapshot = IRulesetGovernor(governor).proposalSnapshot(proposalId);
- return proposalVote.for_ + proposalVote.abstain >= quorum(snapshot);
- }
-
- /// @inheritdoc IRuleset
- function voteSucceeded(uint256 proposalId) external view returns (bool) {
- ProposalVote storage proposalVote = _proposalVotes[proposalId];
- return proposalVote.for_ > proposalVote.against;
- }
-
- /// @inheritdoc IRuleset
- /// @dev A `proposalId` this ruleset never counted returns `false` (empty-tally mapping
- /// default) rather than reverting.
- function hasVoted(uint256 proposalId, address voter) external view returns (bool) {
- return _proposalVotes[proposalId].hasVoted[voter];
- }
-
- /// @notice Per-bucket tally for `proposalId`, mirroring OZ `GovernorCountingSimple`'s
- /// `proposalVotes` (same name, same return order) so tooling pointed at the
- /// governor via `governor.proposalRuleset(id)` and then this getter just works.
- /// @dev A `proposalId` this ruleset never counted returns all-zero (empty-tally default,
- /// same no-revert contract as `hasVoted`), never reverts.
- function proposalVotes(uint256 proposalId)
- external
- view
- returns (uint256 againstVotes, uint256 forVotes, uint256 abstainVotes)
- {
- ProposalVote storage proposalVote = _proposalVotes[proposalId];
- return (proposalVote.against, proposalVote.for_, proposalVote.abstain);
- }
-
- /// @inheritdoc IRuleset
- function quorum(uint256 timepoint) public view returns (uint256) {
- return token.getPastTotalSupply(timepoint) * quorumNumerator / QUORUM_DENOMINATOR;
- }
-
- /// @inheritdoc IRuleset
- // solhint-disable-next-line func-name-mixedcase
- function COUNTING_MODE() external pure returns (string memory) {
- return "support=bravo&quorum=for,abstain";
- }
-
- /// @inheritdoc IERC165
- function supportsInterface(bytes4 interfaceId) external pure returns (bool) {
- return interfaceId == type(IRuleset).interfaceId || interfaceId == type(IERC165).interfaceId;
- }
-}
diff --git a/src/interfaces/IProposalValidator.sol b/src/interfaces/IProposalValidator.sol
new file mode 100644
index 0000000..71143d7
--- /dev/null
+++ b/src/interfaces/IProposalValidator.sol
@@ -0,0 +1,24 @@
+// SPDX-License-Identifier: MIT
+pragma solidity 0.8.30;
+
+/// @title IProposalValidator
+/// @notice Optional ruleset extension: propose-time validation of a proposal's content.
+/// A ruleset advertising this interface via ERC165 gets `validateProposal` called
+/// by the governor before the proposal is created; reverting blocks creation.
+interface IProposalValidator {
+ /// @notice Validates a proposal's content before creation; MUST revert iff the
+ /// proposal must not be created under this ruleset's type.
+ /// @param proposalId The canonical id the governor computed for this proposal
+ /// (`hashProposal(targets, values, calldatas, descriptionHash)`).
+ /// @param proposer The account creating the proposal.
+ /// @param targets Call targets, one per action.
+ /// @param values ETH values, one per action.
+ /// @param calldatas Encoded calls, one per action.
+ function validateProposal(
+ uint256 proposalId,
+ address proposer,
+ address[] calldata targets,
+ uint256[] calldata values,
+ bytes[] calldata calldatas
+ ) external;
+}
diff --git a/src/IRuleset.sol b/src/interfaces/IRuleset.sol
similarity index 66%
rename from src/IRuleset.sol
rename to src/interfaces/IRuleset.sol
index d127dac..ef65c6f 100644
--- a/src/IRuleset.sol
+++ b/src/interfaces/IRuleset.sol
@@ -22,9 +22,19 @@ interface IRuleset is IERC165 {
/// counted is answered from empty-tally defaults, never a revert. That means this
/// can read `true` for an uncounted id whenever `quorum(0) == 0` — callers must
/// gate on proposal existence (the governor does via `state()`).
+ ///
+ /// **MAY be non-monotonic.** A mutable-vote ruleset moves weight between buckets while
+ /// voting is open, so this can flip in *both* directions before the deadline (an
+ /// immutable-vote ruleset is monotonic — the guarantee is not part of this interface
+ /// either way). A consumer requiring finality MUST evaluate at/near the deadline and
+ /// MUST NOT arm one-shot state on a tally-crossing event — an attacker could cross the
+ /// threshold early, re-vote back below it, and burn a once-only trigger before the
+ /// crossing that matters.
function quorumReached(uint256 proposalId) external view returns (bool);
/// @notice Whether `proposalId`'s tallied votes satisfy this ruleset's pass/fail rule.
+ /// @dev MAY be non-monotonic under a mutable-vote ruleset — see `quorumReached`. Consumers
+ /// needing finality must read it at/near the deadline, never arm one-shot state on a flip.
function voteSucceeded(uint256 proposalId) external view returns (bool);
/// @notice Whether `voter` has already cast a vote on `proposalId` under this ruleset.
@@ -33,6 +43,12 @@ interface IRuleset is IERC165 {
/// (empty-tally default), never as an error.
function hasVoted(uint256 proposalId, address voter) external view returns (bool);
+ /// @notice The governor this ruleset is bound to — its sole authorized `countVote` caller.
+ /// @dev Read once at type registration: a governor refuses rulesets bound elsewhere, so a
+ /// mis-wired deployment reverts at `registerType` instead of shipping a type that
+ /// bricks on first propose/vote.
+ function governor() external view returns (address);
+
/// Tooling/view support only — never used for outcome logic (that is `quorumReached`).
function quorum(uint256 timepoint) external view returns (uint256);
diff --git a/src/rulesets/BondRuleset.sol b/src/rulesets/BondRuleset.sol
new file mode 100644
index 0000000..de6478f
--- /dev/null
+++ b/src/rulesets/BondRuleset.sol
@@ -0,0 +1,235 @@
+// SPDX-License-Identifier: MIT
+pragma solidity 0.8.30;
+
+import {IERC165} from "@openzeppelin/contracts/utils/introspection/IERC165.sol";
+import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
+import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
+import {IGovernor} from "@openzeppelin/contracts/governance/IGovernor.sol";
+import {IVotes} from "@openzeppelin/contracts/governance/utils/IVotes.sol";
+
+import {IRuleset} from "../interfaces/IRuleset.sol";
+import {IProposalValidator} from "../interfaces/IProposalValidator.sol";
+import {RulesetCounting} from "../RulesetCounting.sol";
+import {RulesetQuorumFraction} from "../RulesetQuorumFraction.sol";
+
+/// @dev Minimal governor surface BondRuleset consumes (StandardRuleset's IRulesetGovernor
+/// pattern, extended with the two reads the settle path needs).
+interface IBondGovernor {
+ function proposalSnapshot(uint256 proposalId) external view returns (uint256);
+ function state(uint256 proposalId) external view returns (IGovernor.ProposalState);
+ function proposalCanceledAt(uint256 proposalId) external view returns (uint48);
+}
+
+/// @title BondRuleset
+/// @notice Lock-to-propose ruleset: anyone proposes without the voting-power
+/// threshold by locking `bondAmount` of ENS, forfeited to the DAO treasury iff the
+/// vote deems the proposal spam, or the proposal is canceled
+/// after voting opened / vetoed from the timelock.
+/// @dev Immutable by design: no setters. Custody invariant: the ruleset's token
+/// balance always covers every unsettled bond. Resolution is permissionless and
+/// one-shot; refunds release once the vote can no longer slash — from `Succeeded`
+/// onward — so the timelock-veto forfeit reaches only bonds still unsettled.
+contract BondRuleset is RulesetCounting, RulesetQuorumFraction, IProposalValidator {
+ using SafeERC20 for IERC20;
+
+ /// @dev Bravo ordering plus the slash option: 0=Against, 1=For, 2=Abstain,
+ /// 3=AgainstAndSlash.
+ enum VoteType {
+ Against,
+ For,
+ Abstain,
+ AgainstAndSlash
+ }
+
+ /// @notice Why a bond was forfeited; `None` marks a refund (no forfeit).
+ enum SlashReason {
+ SlashVote,
+ ActiveSelfCancel,
+ TimelockVeto,
+ None
+ }
+
+ /// @notice A locked proposal bond. The locked amount is not stored — `bondAmount` is
+ /// immutable and under-delivery reverts at lock, so every bond holds exactly
+ /// `bondAmount`. Packs into a single slot.
+ struct Bond {
+ address proposer;
+ bool settled;
+ }
+
+ /// @notice ENS locked per proposal.
+ uint256 public immutable bondAmount;
+ /// @notice Forfeit destination — the DAO treasury (the timelock).
+ address public immutable treasury;
+
+ mapping(uint256 proposalId => Bond) private _bonds;
+
+ event BondLocked(uint256 indexed proposalId, address indexed proposer, uint256 amount);
+ event BondRefunded(uint256 indexed proposalId, address indexed proposer, uint256 amount);
+ event BondSlashed(uint256 indexed proposalId, uint256 amount, SlashReason reason);
+
+ error InvalidBondAmount(uint256 amount);
+ error ZeroTreasury();
+ error BondAlreadyLocked(uint256 proposalId);
+ error InsufficientBondReceived();
+ error NoBond(uint256 proposalId);
+ error BondAlreadySettled(uint256 proposalId);
+ error BondNotResolvable(uint256 proposalId, IGovernor.ProposalState state);
+
+ constructor(address governor_, IVotes token_, uint256 quorumNumerator_, uint256 bondAmount_, address treasury_)
+ RulesetCounting(governor_)
+ RulesetQuorumFraction(token_, quorumNumerator_)
+ {
+ if (bondAmount_ == 0) revert InvalidBondAmount(bondAmount_);
+ if (treasury_ == address(0)) revert ZeroTreasury();
+ bondAmount = bondAmount_;
+ treasury = treasury_;
+ }
+
+ /// @notice The bond locked for `proposalId` (zeroed if none). Every locked bond holds
+ /// exactly `bondAmount` — read that immutable for the amount.
+ function bondOf(uint256 proposalId) external view returns (address proposer, bool settled) {
+ Bond storage bond = _bonds[proposalId];
+ return (bond.proposer, bond.settled);
+ }
+
+ /// @notice Per-bucket tallies: Bravo triple plus the slash bucket.
+ function proposalVotes(uint256 proposalId)
+ external
+ view
+ returns (uint256 againstVotes, uint256 forVotes, uint256 abstainVotes, uint256 againstAndSlashVotes)
+ {
+ return (
+ tally(proposalId, uint8(VoteType.Against)),
+ tally(proposalId, uint8(VoteType.For)),
+ tally(proposalId, uint8(VoteType.Abstain)),
+ tally(proposalId, uint8(VoteType.AgainstAndSlash))
+ );
+ }
+
+ /// @dev The three Bravo options plus AgainstAndSlash.
+ function _isValidSupport(uint8 support) internal pure override returns (bool) {
+ return support <= uint8(VoteType.AgainstAndSlash);
+ }
+
+ /// @inheritdoc IRuleset
+ // solhint-disable-next-line func-name-mixedcase
+ function COUNTING_MODE() external pure returns (string memory) {
+ return "support=bravo,againstAndSlash&quorum=for,abstain";
+ }
+
+ /// @inheritdoc IERC165
+ function supportsInterface(bytes4 interfaceId) external pure returns (bool) {
+ return interfaceId == type(IRuleset).interfaceId || interfaceId == type(IProposalValidator).interfaceId
+ || interfaceId == type(IERC165).interfaceId;
+ }
+
+ /// @inheritdoc IRuleset
+ /// @dev For + Abstain only — AgainstAndSlash is an Against variant and, like Against,
+ /// never counts toward quorum. Non-monotonic under re-votes.
+ function quorumReached(uint256 proposalId) external view returns (bool) {
+ uint256 forVotes = tally(proposalId, uint8(VoteType.For));
+ uint256 abstainVotes = tally(proposalId, uint8(VoteType.Abstain));
+ uint256 snapshot = IBondGovernor(governor).proposalSnapshot(proposalId);
+ return forVotes + abstainVotes >= quorum(snapshot);
+ }
+
+ /// @inheritdoc IRuleset
+ /// @dev Rejections are the sum of both Against buckets — plain Against plus AgainstAndSlash.
+ /// Non-monotonic under re-votes.
+ function voteSucceeded(uint256 proposalId) external view returns (bool) {
+ uint256 rejections =
+ tally(proposalId, uint8(VoteType.Against)) + tally(proposalId, uint8(VoteType.AgainstAndSlash));
+ return tally(proposalId, uint8(VoteType.For)) > rejections;
+ }
+
+ /// @inheritdoc IProposalValidator
+ /// @dev Records the bond then pulls it (checks-effects-interactions); reverts if the token
+ /// delivers less than `bondAmount`, so a fee-on-transfer token can never under-collateralize.
+ function validateProposal(
+ uint256 proposalId,
+ address proposer,
+ address[] calldata,
+ uint256[] calldata,
+ bytes[] calldata
+ ) external onlyGovernor {
+ if (_bonds[proposalId].proposer != address(0)) revert BondAlreadyLocked(proposalId);
+
+ // Effect before interaction (CEI).
+ _bonds[proposalId] = Bond({proposer: proposer, settled: false});
+
+ IERC20 erc20 = IERC20(address(token));
+ uint256 balanceBefore = erc20.balanceOf(address(this));
+ // slither-disable-next-line arbitrary-send-erc20
+ erc20.safeTransferFrom(proposer, address(this), bondAmount);
+ if (erc20.balanceOf(address(this)) - balanceBefore < bondAmount) revert InsufficientBondReceived();
+
+ emit BondLocked(proposalId, proposer, bondAmount);
+ }
+
+ /// @notice Settles `proposalId`'s bond once its outcome is final. Permissionless and
+ /// one-shot: anyone may trigger settlement, nobody can trigger it twice.
+ /// @dev Refunds release from `Succeeded` onward — the bond is an anti-spam instrument
+ /// and surviving the vote fulfills its purpose; the timelock-veto forfeit reaches
+ /// only bonds still unsettled when the veto lands. Effects (settled flag) precede
+ /// the single transfer (CEI).
+ function resolveBond(uint256 proposalId) external {
+ Bond storage bond = _bonds[proposalId];
+ if (bond.proposer == address(0)) revert NoBond(proposalId);
+ if (bond.settled) revert BondAlreadySettled(proposalId);
+
+ _settle(proposalId, bond, _bondResolution(proposalId));
+ }
+
+ /// @dev Maps a resolvable proposal state to the bond's resolution. `None` refunds the
+ /// proposer; every other reason forfeits to the treasury — destination and event are
+ /// derived in `_settle`, so no contradictory (reason, destination) pair is
+ /// representable. Only `Pending`/`Active` revert: while the vote is live the
+ /// slash outcome is still undecided, so nothing may settle.
+ function _bondResolution(uint256 proposalId) private view returns (SlashReason) {
+ IGovernor.ProposalState state = IBondGovernor(governor).state(proposalId);
+ if (
+ state == IGovernor.ProposalState.Succeeded || state == IGovernor.ProposalState.Queued
+ || state == IGovernor.ProposalState.Executed
+ ) {
+ return SlashReason.None;
+ }
+ if (state == IGovernor.ProposalState.Defeated) {
+ return _slashVoted(proposalId) ? SlashReason.SlashVote : SlashReason.None;
+ }
+ if (state == IGovernor.ProposalState.Canceled) return _canceledBondResolution(proposalId);
+ revert BondNotResolvable(proposalId, state);
+ }
+
+ /// @dev Cancel partition on the recorded cancel timepoint: a self-cancel while still Pending
+ /// (`0 < canceledAt <= snapshot`) refunds; a council veto (no governor-path timepoint,
+ /// `canceledAt == 0`) or a self-cancel after voting opened forfeits.
+ function _canceledBondResolution(uint256 proposalId) private view returns (SlashReason) {
+ uint48 canceledAt = IBondGovernor(governor).proposalCanceledAt(proposalId);
+ if (canceledAt == 0) return SlashReason.TimelockVeto;
+ if (canceledAt <= IBondGovernor(governor).proposalSnapshot(proposalId)) return SlashReason.None;
+ return SlashReason.ActiveSelfCancel;
+ }
+
+ /// @dev Slash predicate — the rule the DAO ratified on Snapshot (EP 5.15), applied
+ /// verbatim on raw tallies: combined rejections strictly beat support AND
+ /// slash-weight strictly beats plain rejection. Either tie refunds. No per-address
+ /// scrubbing.
+ function _slashVoted(uint256 proposalId) private view returns (bool) {
+ uint256 forVotes = tally(proposalId, uint8(VoteType.For));
+ uint256 againstVotes = tally(proposalId, uint8(VoteType.Against));
+ uint256 slashVotes = tally(proposalId, uint8(VoteType.AgainstAndSlash));
+ return againstVotes + slashVotes > forVotes && slashVotes > againstVotes;
+ }
+
+ /// @dev One-shot settle: flag first, single transfer after (CEI). Destination and event
+ /// derive from the reason alone — `None` refunds the proposer, anything else
+ /// forfeits to the treasury.
+ function _settle(uint256 proposalId, Bond storage bond, SlashReason reason) private {
+ bond.settled = true;
+ bool slashed = reason != SlashReason.None;
+ IERC20(address(token)).safeTransfer(slashed ? treasury : bond.proposer, bondAmount);
+ if (slashed) emit BondSlashed(proposalId, bondAmount, reason);
+ else emit BondRefunded(proposalId, bond.proposer, bondAmount);
+ }
+}
diff --git a/src/rulesets/OptimisticRuleset.sol b/src/rulesets/OptimisticRuleset.sol
new file mode 100644
index 0000000..7046880
--- /dev/null
+++ b/src/rulesets/OptimisticRuleset.sol
@@ -0,0 +1,197 @@
+// SPDX-License-Identifier: MIT
+pragma solidity 0.8.30;
+
+import {IERC165} from "@openzeppelin/contracts/utils/introspection/IERC165.sol";
+
+import {IProposalValidator} from "../interfaces/IProposalValidator.sol";
+import {IRuleset} from "../interfaces/IRuleset.sol";
+import {RulesetCounting} from "../RulesetCounting.sol";
+
+/// @title OptimisticRuleset
+/// @notice Pass-by-default ruleset: no quorum, and a proposal succeeds unless the Against
+/// bucket holds `vetoThreshold` at the deadline — a proposal with zero votes cast
+/// executes. Safety moves to propose time (`validateProposal`): only allowlisted
+/// proposers, only allowlisted `(target, selector)` actions, no ETH value. Deploys
+/// with empty allowlists; the setters answer only to `admin`, the governance
+/// executor.
+/// @dev Counting mechanics (buckets, receipts, replace-on-re-vote) come from
+/// `RulesetCounting`, so a veto is withdrawable: a vetoer re-voting For/Abstain
+/// drains the Against bucket and `voteSucceeded` flips back — non-monotonic in both
+/// directions while voting is open. Threshold and validation logic are immutable;
+/// the allowlist entries are the one mutable surface.
+contract OptimisticRuleset is RulesetCounting, IProposalValidator {
+ /// @dev Bravo-style bucket ordering: 0=Against, 1=For, 2=Abstain. Only Against is
+ /// outcome-bearing; For/Abstain are accepted for signal and veto withdrawal.
+ enum VoteType {
+ Against,
+ For,
+ Abstain
+ }
+
+ /// @notice Governance executor that owns the allowlist setters. Must be the address
+ /// governance executions come from (the timelock), NOT the governor —
+ /// restricting to the governor would make the setters unreachable.
+ /// @dev Immutable, no successor path: if the DAO ever migrates executors, this ruleset's
+ /// allowlists freeze as-is — the migration is deploying a fresh ruleset bound to the
+ /// new executor and re-registering the type.
+ address public immutable admin;
+
+ /// @notice Absolute Against weight at which a proposal is defeated.
+ uint256 public immutable vetoThreshold;
+
+ /// @notice Accounts allowed to open proposals under this ruleset's type.
+ mapping(address proposer => bool) public allowedProposers;
+
+ /// @notice `(target, selector)` pairs a proposal under this ruleset's type may call.
+ mapping(address target => mapping(bytes4 selector => bool)) public allowedActions;
+
+ /// @notice A proposer allowlist entry was written.
+ event ProposerAllowedSet(address indexed proposer, bool allowed);
+ /// @notice An action allowlist entry was written.
+ event ActionAllowedSet(address indexed target, bytes4 indexed selector, bool allowed);
+
+ /// @notice `admin` is the zero address, which would freeze the allowlists empty forever.
+ error AdminZeroAddress();
+ /// @notice `vetoThreshold` is zero, which would defeat every proposal unconditionally.
+ error VetoThresholdZero();
+ /// @notice The proposal's `targets`/`values`/`calldatas` lengths disagree.
+ error LengthMismatch();
+ /// @notice `proposer` is not on the proposer allowlist.
+ error ProposerNotAllowed(address proposer);
+ /// @notice The action at `index` carries ETH value, which this ruleset forbids.
+ error ValueNotAllowed(uint256 index);
+ /// @notice The action at `index` has fewer than 4 bytes of calldata — no selector to check.
+ error SelectorMissing(uint256 index);
+ /// @notice `(target, selector)` is not on the action allowlist.
+ error ActionNotAllowed(address target, bytes4 selector);
+ /// @notice `target` is part of the governance core and can never be allowlisted.
+ error SelfTargetForbidden(address target);
+
+ modifier onlyAdmin() {
+ if (msg.sender != admin) revert Unauthorized(msg.sender);
+ _;
+ }
+
+ /// @param governor_ The GovernorNexus this ruleset is deployed for (counting and
+ /// validation caller).
+ /// @param admin_ Governance executor owning the allowlist setters; non-zero.
+ /// @param vetoThreshold_ Absolute Against weight that defeats a proposal; non-zero.
+ constructor(address governor_, address admin_, uint256 vetoThreshold_) RulesetCounting(governor_) {
+ if (admin_ == address(0)) revert AdminZeroAddress();
+ if (vetoThreshold_ == 0) revert VetoThresholdZero();
+ admin = admin_;
+ vetoThreshold = vetoThreshold_;
+ }
+
+ // ─────────────────────────── Propose-time validation ───────────────────────────
+
+ /// @inheritdoc IProposalValidator
+ /// @dev Validates exactly what it dereferences: the three arrays are indexed below, so
+ /// their lengths are checked first, with no assumption about what runs after it in
+ /// the governor. Empty proposals pass vacuously (nothing is indexed; the stock
+ /// `_propose` rejects them downstream). The governor-computed id is unused — this
+ /// validator keeps no per-proposal state. Restricted to the governor so third
+ /// parties cannot probe with spoofed arguments.
+ function validateProposal(
+ uint256,
+ address proposer,
+ address[] calldata targets,
+ uint256[] calldata values,
+ bytes[] calldata calldatas
+ ) external view onlyGovernor {
+ if (targets.length != values.length || values.length != calldatas.length) {
+ revert LengthMismatch();
+ }
+ if (!allowedProposers[proposer]) revert ProposerNotAllowed(proposer);
+
+ for (uint256 i = 0; i < targets.length; ++i) {
+ if (values[i] != 0) revert ValueNotAllowed(i);
+ if (calldatas[i].length < 4) revert SelectorMissing(i);
+ bytes4 selector = bytes4(calldatas[i]);
+ if (!allowedActions[targets[i]][selector]) revert ActionNotAllowed(targets[i], selector);
+ }
+ }
+
+ // ─────────────────────────── Allowlist setters ───────────────────────────
+
+ /// @notice Allow or disallow `proposer` to open proposals under this ruleset's type.
+ function setProposerAllowed(address proposer, bool allowed) external onlyAdmin {
+ allowedProposers[proposer] = allowed;
+ emit ProposerAllowedSet(proposer, allowed);
+ }
+
+ /// @notice Allow or disallow proposals under this ruleset's type to call
+ /// `selector` on `target`.
+ /// @dev Permanently refuses the governance core as a target — governor, timelock
+ /// (`admin`), and this ruleset — so a zero-vote proposal can never reconfigure
+ /// the system that created it. The refusal is unconditional on `allowed`: a
+ /// self-target entry can never exist, so there is nothing to disable.
+ function setActionAllowed(address target, bytes4 selector, bool allowed) external onlyAdmin {
+ if (target == governor || target == admin || target == address(this)) {
+ revert SelfTargetForbidden(target);
+ }
+ allowedActions[target][selector] = allowed;
+ emit ActionAllowedSet(target, selector, allowed);
+ }
+
+ // ─────────────────────────── Outcome rules ───────────────────────────
+
+ /// @inheritdoc IRuleset
+ /// @dev No participation requirement, so quorum is unconditionally met — including
+ /// for ids this ruleset never counted (the interface's no-revert contract).
+ function quorumReached(uint256) external pure returns (bool) {
+ return true;
+ }
+
+ /// @inheritdoc IRuleset
+ /// @dev Succeeds while Against holds strictly less than `vetoThreshold`; For/Abstain
+ /// never bear on the outcome. Non-monotonic in BOTH directions under re-votes —
+ /// a veto is withdrawable — so consumers needing finality must read at the
+ /// deadline.
+ function voteSucceeded(uint256 proposalId) external view returns (bool) {
+ return tally(proposalId, uint8(VoteType.Against)) < vetoThreshold;
+ }
+
+ /// @notice Against/For/Abstain tallies for `proposalId` — same name and return order
+ /// as OZ `GovernorCountingSimple`'s `proposalVotes`.
+ /// @dev An id this ruleset never counted returns all-zero, never reverts.
+ function proposalVotes(uint256 proposalId)
+ external
+ view
+ returns (uint256 againstVotes, uint256 forVotes, uint256 abstainVotes)
+ {
+ return (
+ tally(proposalId, uint8(VoteType.Against)),
+ tally(proposalId, uint8(VoteType.For)),
+ tally(proposalId, uint8(VoteType.Abstain))
+ );
+ }
+
+ /// @dev The three Bravo options — accepting For/Abstain is what makes the veto
+ /// withdrawable (a re-vote must have somewhere to move the weight).
+ function _isValidSupport(uint8 support) internal pure override returns (bool) {
+ return support <= uint8(VoteType.Abstain);
+ }
+
+ /// @inheritdoc IRuleset
+ /// @dev Tooling view only, never outcome logic: no participation is required, so zero.
+ function quorum(uint256) external pure returns (uint256) {
+ return 0;
+ }
+
+ /// @inheritdoc IRuleset
+ /// @dev All three buckets are tallied; only Against bears on the outcome (see
+ /// `voteSucceeded`).
+ // solhint-disable-next-line func-name-mixedcase
+ function COUNTING_MODE() external pure returns (string memory) {
+ return "support=bravo&quorum=against,for,abstain";
+ }
+
+ /// @inheritdoc IERC165
+ /// @dev Advertising `IProposalValidator` is what opts this ruleset into the governor's
+ /// propose-time validation gate (detected once, at type registration).
+ function supportsInterface(bytes4 interfaceId) external pure returns (bool) {
+ return interfaceId == type(IRuleset).interfaceId || interfaceId == type(IProposalValidator).interfaceId
+ || interfaceId == type(IERC165).interfaceId;
+ }
+}
diff --git a/src/rulesets/StandardRuleset.sol b/src/rulesets/StandardRuleset.sol
new file mode 100644
index 0000000..431398c
--- /dev/null
+++ b/src/rulesets/StandardRuleset.sol
@@ -0,0 +1,105 @@
+// SPDX-License-Identifier: MIT
+pragma solidity 0.8.30;
+
+import {IERC165} from "@openzeppelin/contracts/utils/introspection/IERC165.sol";
+import {IVotes} from "@openzeppelin/contracts/governance/utils/IVotes.sol";
+
+import {IRuleset} from "../interfaces/IRuleset.sol";
+import {RulesetCounting} from "../RulesetCounting.sol";
+import {RulesetQuorumFraction} from "../RulesetQuorumFraction.sol";
+
+/// @dev Minimal governor surface StandardRuleset consumes — only `proposalSnapshot`, so a
+/// registry or test can satisfy this with a trivial stand-in instead of a full governor.
+interface IRulesetGovernor {
+ function proposalSnapshot(uint256 proposalId) external view returns (uint256);
+}
+
+/// @title StandardRuleset
+/// @notice The ENS governor's counting rules (OZ `GovernorCountingSimple` +
+/// `GovernorVotesQuorumFraction`) as a standalone, governor-agnostic ruleset — with
+/// **mutable votes**: re-voting while the poll is open replaces the standing vote —
+/// a deliberate divergence from the live ENS governor, which reverts instead.
+/// @dev Counting mechanics (buckets, receipts, replace-on-re-vote) come from `RulesetCounting`;
+/// this contract owns only the rules layered on top. Note the base's non-monotonicity
+/// warning: `quorumReached` and `voteSucceeded` can flip in **both** directions while
+/// voting is open, so neither may be used to arm one-shot state.
+///
+/// Immutable by design — what the DAO audited is what runs forever: no setters,
+/// including for the quorum numerator. `countVote` is state-changing and therefore
+/// restricted to `governor`, so third parties cannot stuff vote tallies.
+contract StandardRuleset is RulesetCounting, RulesetQuorumFraction {
+ /// @dev Bravo-style bucket ordering: 0=Against, 1=For, 2=Abstain — the three options this
+ /// ruleset accepts (`_isValidSupport`).
+ enum VoteType {
+ Against,
+ For,
+ Abstain
+ }
+
+ /// @param governor_ The GovernorNexus this ruleset is deployed for; immutable and never
+ /// revisited, so it must be the address the governor will actually deploy to (see
+ /// the deploy script's CREATE-address precompute).
+ /// @param token_ Voting token backing `quorum`'s past-total-supply lookup.
+ /// @param quorumNumerator_ Numerator over the fixed 100 denominator; reverts
+ /// `InvalidQuorumFraction` at zero (would make `quorumReached` unconditionally
+ /// true) and above 100.
+ constructor(address governor_, IVotes token_, uint256 quorumNumerator_)
+ RulesetCounting(governor_)
+ RulesetQuorumFraction(token_, quorumNumerator_)
+ {}
+
+ /// @inheritdoc IRuleset
+ /// @dev A `proposalId` this ruleset never counted reads from empty-tally defaults, same
+ /// as `hasVoted`. That can make this return `true` for an uncounted id whenever
+ /// `quorum(0) == 0` (a token with no supply at timepoint 0; a zero numerator is
+ /// rejected at construction) — callers must gate on proposal existence; the
+ /// governor does this via `state()`.
+ ///
+ /// Non-monotonic under re-votes: a voter moving weight out of For/Abstain can
+ /// take a proposal back *below* quorum after it had been reached.
+ function quorumReached(uint256 proposalId) external view returns (bool) {
+ uint256 forVotes = tally(proposalId, uint8(VoteType.For));
+ uint256 abstainVotes = tally(proposalId, uint8(VoteType.Abstain));
+ uint256 snapshot = IRulesetGovernor(governor).proposalSnapshot(proposalId);
+ return forVotes + abstainVotes >= quorum(snapshot);
+ }
+
+ /// @inheritdoc IRuleset
+ /// @dev Non-monotonic under re-votes — see `quorumReached`.
+ function voteSucceeded(uint256 proposalId) external view returns (bool) {
+ return tally(proposalId, uint8(VoteType.For)) > tally(proposalId, uint8(VoteType.Against));
+ }
+
+ /// @notice Per-bucket tally for `proposalId`, mirroring OZ `GovernorCountingSimple`'s
+ /// `proposalVotes` (same name, same return order) so tooling pointed at the governor
+ /// via `governor.proposalRuleset(id)` and then this getter just works.
+ /// @dev The Bravo-shaped view of the base's generic buckets. An id this ruleset never counted
+ /// returns all-zero, never reverts. Non-monotonic under re-votes.
+ function proposalVotes(uint256 proposalId)
+ external
+ view
+ returns (uint256 againstVotes, uint256 forVotes, uint256 abstainVotes)
+ {
+ return (
+ tally(proposalId, uint8(VoteType.Against)),
+ tally(proposalId, uint8(VoteType.For)),
+ tally(proposalId, uint8(VoteType.Abstain))
+ );
+ }
+
+ /// @dev The three Bravo options — parity with the live ENS governor's counting surface.
+ function _isValidSupport(uint8 support) internal pure override returns (bool) {
+ return support <= uint8(VoteType.Abstain);
+ }
+
+ /// @inheritdoc IRuleset
+ // solhint-disable-next-line func-name-mixedcase
+ function COUNTING_MODE() external pure returns (string memory) {
+ return "support=bravo&quorum=for,abstain";
+ }
+
+ /// @inheritdoc IERC165
+ function supportsInterface(bytes4 interfaceId) external pure returns (bool) {
+ return interfaceId == type(IRuleset).interfaceId || interfaceId == type(IERC165).interfaceId;
+ }
+}
diff --git a/test/Deploy.t.sol b/test/Deploy.t.sol
index 3d4c8e7..e665837 100644
--- a/test/Deploy.t.sol
+++ b/test/Deploy.t.sol
@@ -5,18 +5,16 @@ import {Test} from "forge-std/Test.sol";
import {Deploy} from "../script/Deploy.s.sol";
import {GovernorNexus} from "../src/GovernorNexus.sol";
-import {StandardRuleset} from "../src/StandardRuleset.sol";
+import {StandardRuleset} from "../src/rulesets/StandardRuleset.sol";
import {ENSParams} from "../src/ENSParams.sol";
/// @dev Exercises `Deploy.run()` exactly as `forge script` would invoke it: no fork, no
-/// mocked token/timelock. The token-constructor investigation (see task report) found
-/// that neither `GovernorVotes` nor `GovernorTimelockControl`'s constructors make any
-/// external call on the addresses they're given — both only store them (see
-/// `lib/openzeppelin-contracts/contracts/governance/extensions/GovernorVotes.sol:18-20`
-/// and `.../GovernorTimelockControl.sol:36-38,153-156`) — so `ENSParams.TOKEN` and
-/// `ENSParams.TIMELOCK` can safely be no-code addresses here. The only constructor path
-/// that reaches out during deploy is `GovernorNexus`'s ERC165 `staticcall` on the
-/// ruleset, which is real, locally-deployed code. A fork is therefore unnecessary.
+/// mocked token/timelock. Neither `GovernorVotes` nor `GovernorTimelockControl`'s
+/// constructors make any external call on the addresses they're given — both only
+/// store them — so `ENSParams.TOKEN` and `ENSParams.TIMELOCK` can safely be no-code
+/// addresses here. The only constructor path that reaches out during deploy is
+/// `GovernorNexus`'s ERC165 `staticcall` on the ruleset, which is real,
+/// locally-deployed code. A fork is therefore unnecessary.
contract DeployTest is Test {
Deploy internal deployScript;
@@ -27,7 +25,7 @@ contract DeployTest is Test {
function test_run_wiresStandardRulesetAndGovernorNexus() public {
(StandardRuleset standardRuleset, GovernorNexus governor) = deployScript.run();
- // D11: name parity with the live governor's EIP-712 domain.
+ // Name parity with the live governor's EIP-712 domain.
assertEq(governor.name(), "ENS Governor");
// Ruleset <-> governor wiring (cycle broken via the precompute).
diff --git a/test/ENSGovernor.t.sol b/test/ENSGovernor.t.sol
deleted file mode 100644
index 000163b..0000000
--- a/test/ENSGovernor.t.sol
+++ /dev/null
@@ -1,169 +0,0 @@
-// SPDX-License-Identifier: MIT
-pragma solidity ^0.8.30;
-
-import {Test} from "forge-std/Test.sol";
-
-import {IGovernor} from "@openzeppelin/contracts/governance/IGovernor.sol";
-import {TimelockController} from "@openzeppelin/contracts/governance/TimelockController.sol";
-import {IVotes} from "@openzeppelin/contracts/governance/utils/IVotes.sol";
-
-import {ENSGovernor} from "../src/ENSGovernor.sol";
-import {ENSParams} from "../src/ENSParams.sol";
-import {Box} from "./mocks/Box.sol";
-import {MockENSToken} from "./mocks/MockENSToken.sol";
-
-/// @dev Unit suite for the stock scaffold, configured with the live ENS parameters.
-/// Exercises the full lifecycle against a mock token + fresh timelock; the fork
-/// suite (test/fork) repeats this against the real token/timelock and live governor.
-contract ENSGovernorTest is Test {
- uint256 internal constant TIMELOCK_DELAY = 2 days;
-
- MockENSToken internal token;
- TimelockController internal timelock;
- ENSGovernor internal governor;
- Box internal box;
-
- address internal alice = makeAddr("alice"); // above proposal threshold, clears quorum
- address internal bob = makeAddr("bob"); // small holder
-
- function setUp() public {
- vm.roll(1000);
- vm.warp(1_700_000_000);
-
- token = new MockENSToken();
- timelock = new TimelockController(TIMELOCK_DELAY, new address[](0), new address[](0), address(this));
- governor = new ENSGovernor(
- IVotes(address(token)),
- timelock,
- ENSParams.VOTING_DELAY,
- ENSParams.VOTING_PERIOD,
- ENSParams.PROPOSAL_THRESHOLD,
- ENSParams.QUORUM_NUMERATOR
- );
-
- timelock.grantRole(timelock.PROPOSER_ROLE(), address(governor));
- timelock.grantRole(timelock.CANCELLER_ROLE(), address(governor));
- timelock.grantRole(timelock.EXECUTOR_ROLE(), address(governor));
- timelock.renounceRole(timelock.DEFAULT_ADMIN_ROLE(), address(this));
-
- box = new Box(address(timelock));
-
- // 100M total supply mirrors ENS scale: alice alone clears the 1% quorum.
- _fund(alice, 2_000_000e18);
- _fund(bob, 98_000_000e18 - 2_000_000e18);
- vm.prank(bob);
- token.delegate(address(0)); // bob holds supply but delegates nothing
- _fund(address(0xdead), 2_000_000e18);
- vm.roll(block.number + 1);
- }
-
- function _fund(address account, uint256 amount) internal {
- token.mint(account, amount);
- vm.prank(account);
- token.delegate(account);
- }
-
- function _boxProposal(uint256 newValue, string memory description)
- internal
- view
- returns (address[] memory targets, uint256[] memory values, bytes[] memory calldatas, bytes32 descriptionHash)
- {
- targets = new address[](1);
- targets[0] = address(box);
- values = new uint256[](1);
- calldatas = new bytes[](1);
- calldatas[0] = abi.encodeCall(Box.setValue, (newValue));
- descriptionHash = keccak256(bytes(description));
- }
-
- // ─────────────────────────── Configuration ───────────────────────────
-
- function test_parametersMatchLiveENSGovernor() public view {
- assertEq(governor.name(), "ENS Governor");
- assertEq(governor.votingDelay(), 1);
- assertEq(governor.votingPeriod(), 45_818);
- assertEq(governor.proposalThreshold(), 100_000e18);
- assertEq(governor.COUNTING_MODE(), "support=bravo&quorum=for,abstain");
- assertEq(address(governor.token()), address(token));
- assertEq(governor.timelock(), address(timelock));
- }
-
- function test_quorumIsOnePercentOfPastSupply() public view {
- assertEq(governor.quorum(block.number - 1), token.getPastTotalSupply(block.number - 1) / 100);
- }
-
- // ─────────────────────────── Lifecycle ───────────────────────────
-
- function test_fullLifecycle_proposeVoteQueueExecute() public {
- (address[] memory targets, uint256[] memory values, bytes[] memory calldatas, bytes32 descriptionHash) =
- _boxProposal(42, "set 42");
-
- vm.prank(alice);
- uint256 proposalId = governor.propose(targets, values, calldatas, "set 42");
- assertEq(uint8(governor.state(proposalId)), uint8(IGovernor.ProposalState.Pending));
- assertEq(governor.proposalSnapshot(proposalId), block.number + ENSParams.VOTING_DELAY);
-
- vm.roll(governor.proposalSnapshot(proposalId) + 1);
- vm.prank(alice);
- governor.castVote(proposalId, 1);
-
- vm.roll(governor.proposalDeadline(proposalId) + 1);
- assertEq(uint8(governor.state(proposalId)), uint8(IGovernor.ProposalState.Succeeded));
-
- governor.queue(targets, values, calldatas, descriptionHash);
- assertEq(uint8(governor.state(proposalId)), uint8(IGovernor.ProposalState.Queued));
-
- vm.warp(block.timestamp + TIMELOCK_DELAY + 1);
- governor.execute(targets, values, calldatas, descriptionHash);
- assertEq(box.value(), 42);
- assertEq(uint8(governor.state(proposalId)), uint8(IGovernor.ProposalState.Executed));
- }
-
- function test_proposeBelowThreshold_reverts() public {
- (address[] memory targets, uint256[] memory values, bytes[] memory calldatas,) = _boxProposal(1, "no votes");
- vm.prank(bob); // delegated away, zero voting power
- vm.expectRevert(
- abi.encodeWithSelector(
- IGovernor.GovernorInsufficientProposerVotes.selector, bob, 0, ENSParams.PROPOSAL_THRESHOLD
- )
- );
- governor.propose(targets, values, calldatas, "no votes");
- }
-
- function test_defeated_whenQuorumNotReached() public {
- // drop alice below quorum: 500k < 1% of ~102M
- vm.prank(alice);
- token.delegate(alice); // no-op, keeps her power for proposing
- (address[] memory targets, uint256[] memory values, bytes[] memory calldatas,) = _boxProposal(1, "no quorum");
- vm.prank(alice);
- uint256 proposalId = governor.propose(targets, values, calldatas, "no quorum");
-
- vm.roll(governor.proposalSnapshot(proposalId) + 1);
- // nobody votes at all
- vm.roll(governor.proposalDeadline(proposalId) + 1);
- assertEq(uint8(governor.state(proposalId)), uint8(IGovernor.ProposalState.Defeated));
- }
-
- function test_cannotVoteBeforeSnapshot() public {
- (address[] memory targets, uint256[] memory values, bytes[] memory calldatas,) = _boxProposal(1, "early vote");
- vm.prank(alice);
- uint256 proposalId = governor.propose(targets, values, calldatas, "early vote");
-
- vm.prank(alice);
- vm.expectRevert();
- governor.castVote(proposalId, 1);
- }
-
- function test_cannotRevote_stockGovernorVotesAreImmutable() public {
- (address[] memory targets, uint256[] memory values, bytes[] memory calldatas,) = _boxProposal(1, "immutable");
- vm.prank(alice);
- uint256 proposalId = governor.propose(targets, values, calldatas, "immutable");
- vm.roll(governor.proposalSnapshot(proposalId) + 1);
-
- vm.prank(alice);
- governor.castVote(proposalId, 1);
- vm.prank(alice);
- vm.expectRevert(abi.encodeWithSelector(IGovernor.GovernorAlreadyCastVote.selector, alice));
- governor.castVote(proposalId, 0);
- }
-}
diff --git a/test/fork/Base.t.sol b/test/fork/Base.t.sol
index bb35117..aa8a940 100644
--- a/test/fork/Base.t.sol
+++ b/test/fork/Base.t.sol
@@ -7,7 +7,7 @@ import {TimelockController} from "@openzeppelin/contracts/governance/TimelockCon
import {IVotes} from "@openzeppelin/contracts/governance/utils/IVotes.sol";
import {GovernorNexus} from "../../src/GovernorNexus.sol";
-import {StandardRuleset} from "../../src/StandardRuleset.sol";
+import {StandardRuleset} from "../../src/rulesets/StandardRuleset.sol";
import {ENSParams} from "../../src/ENSParams.sol";
import {Box} from "../mocks/Box.sol";
import {IGov} from "./IGov.sol";
@@ -35,7 +35,7 @@ abstract contract BaseTest is Test {
// token nowadays); override with MAINNET_RPC_URL for a dedicated key.
vm.createSelectFork(vm.envOr("MAINNET_RPC_URL", string("https://eth.drpc.org")), FORK_BLOCK);
- // Wiring (spec §Wiring note): StandardRuleset.countVote is onlyGovernor and
+ // Wiring: StandardRuleset.countVote is onlyGovernor and
// quorumReached reads governor.proposalSnapshot, so the ruleset must be constructed
// with the governor's address — but the governor constructor needs the ruleset. Break
// the cycle by precomputing the governor's CREATE address (this deployer's next nonce
@@ -44,7 +44,7 @@ abstract contract BaseTest is Test {
standardRuleset = new StandardRuleset(predictedGovernor, IVotes(ENSParams.TOKEN), ENSParams.QUORUM_NUMERATOR);
// Name "ENS Governor" so `name()` and the EIP-712 vote-by-sig domain match the live
- // governor (D11). Type 0 = StandardRuleset with the live ENS params.
+ // governor. Type 0 = StandardRuleset with the live ENS params.
scaffold = new GovernorNexus(
"ENS Governor",
IVotes(ENSParams.TOKEN),
@@ -52,7 +52,10 @@ abstract contract BaseTest is Test {
standardRuleset,
ENSParams.VOTING_DELAY,
ENSParams.VOTING_PERIOD,
- ENSParams.PROPOSAL_THRESHOLD
+ ENSParams.PROPOSAL_THRESHOLD,
+ ENSParams.MAX_ACTIVE_PROPOSALS,
+ ENSParams.EXTENSION_WINDOW,
+ ENSParams.EXTENSION_DURATION
);
require(address(scaffold) == predictedGovernor, "scaffold governor address prediction failed");
scaffoldGov = IGov(address(scaffold));
diff --git a/test/fork/GasBench.t.sol b/test/fork/GasBench.t.sol
index 0b40c04..174684c 100644
--- a/test/fork/GasBench.t.sol
+++ b/test/fork/GasBench.t.sol
@@ -14,39 +14,9 @@ import {IGov} from "./IGov.sol";
/// Run: forge test --match-contract GasBench -vv
/// (override the RPC with MAINNET_RPC_URL if the default is rate-limited)
///
-/// Measured @ block 25445220, commit cc04973 — gas is the `gasleft()` delta around
-/// the single measured call (excludes setup/fixture cost):
-///
-/// | op | live gov | GovernorNexus | delta | attribution |
-/// |---------|---------:|--------------:|--------:|-------------------------------------|
-/// | propose | 115,052 | 102,838 | -12,214 | net cheaper despite the type-pin |
-/// | | | | | SSTORE + transient-context writes + |
-/// | | | | | extra `ProposalTypedCreated` event — |
-/// | | | | | OZ v5's packed `ProposalCore` beats |
-/// | | | | | the live governor's own storage |
-/// | | | | | layout by more than that adds |
-/// | castVote| 106,982 | 109,969 | +2,987 | one external CALL into the pinned |
-/// | | | | | ruleset's `countVote` (cold account |
-/// | | | | | access + its own tally SSTORE) — |
-/// | | | | | matches the ~+2.9k expectation |
-/// | queue | 102,244 | 117,983 | +15,739 | `queue()`'s state-bitmap check re- |
-/// | | | | | derives quorum/success by calling |
-/// | | | | | out to the ruleset, which itself |
-/// | | | | | calls back into the governor |
-/// | | | | | (`proposalSnapshot`) and out to the |
-/// | | | | | token (`getPastTotalSupply`) — a |
-/// | | | | | multi-hop CALL chain the live |
-/// | | | | | governor's local tally doesn't pay |
-/// | execute | 79,188 | 59,747 | -19,441 | net cheaper; `execute()`'s state |
-/// | | | | | check re-runs the same ruleset CALL |
-/// | | | | | chain as queue(), so this delta's |
-/// | | | | | sign flip is attributed to the live |
-/// | | | | | governor's own (opaque, bytecode- |
-/// | | | | | only) execute-path bookkeeping |
-/// | | | | | rather than anything ruleset-side |
-///
-/// None of these are "wildly off" (the one hard expectation, castVote, lands within
-/// noise of +2.9k) — see the Task-9 report for the full writeup.
+/// Gas is the `gasleft()` delta around the single measured call (excludes
+/// setup/fixture cost). Reference numbers and their attribution live in the
+/// README's "Gas benchmarks" section.
contract GasBenchTest is BaseTest {
// prepared in setUp (separate tx) so measured calls start from realistic cold state
uint256 internal liveVoteId;
diff --git a/test/fork/Parity.t.sol b/test/fork/Parity.t.sol
index ddf27ad..bef8ec3 100644
--- a/test/fork/Parity.t.sol
+++ b/test/fork/Parity.t.sol
@@ -4,7 +4,7 @@ pragma solidity ^0.8.30;
import {IGovernor} from "@openzeppelin/contracts/governance/IGovernor.sol";
import {ENSParams} from "../../src/ENSParams.sol";
-import {StandardRuleset} from "../../src/StandardRuleset.sol";
+import {StandardRuleset} from "../../src/rulesets/StandardRuleset.sol";
import {Box, BaseTest} from "./Base.t.sol";
import {IGov} from "./IGov.sol";
@@ -40,7 +40,6 @@ contract ParityTest is BaseTest {
}
function test_parity_quorum() public {
- // Divergence-pin update (was: "v5 checkpoints the quorum numerator at deployment").
// The type-0 StandardRuleset holds an IMMUTABLE numerator with NO checkpoint history,
// so quorum() answers any timepoint directly — like the live v4 governor. The roll to
// FORK_BLOCK + 1 is still required (not for checkpoints): quorum() reads
@@ -117,23 +116,6 @@ contract ParityTest is BaseTest {
assertEq(scaffoldGov.state(scaffoldId), liveGov.state(liveId));
}
- function test_parity_revoteRejectedOnBothSides() public {
- uint256 liveId = _propose(liveGov, liveBox, 9, "revote");
- uint256 scaffoldId = _propose(scaffoldGov, scaffoldBox, 9, "revote");
- vm.roll(liveGov.proposalSnapshot(liveId) + 1);
-
- vm.startPrank(WHALE);
- liveGov.castVote(liveId, 1);
- scaffoldGov.castVote(scaffoldId, 1);
-
- // Same behavior (revote rejected); error shape differs and is pinned in Divergences.
- vm.expectRevert();
- liveGov.castVote(liveId, 0);
- vm.expectRevert();
- scaffoldGov.castVote(scaffoldId, 0);
- vm.stopPrank();
- }
-
// ─────────────────────────── helpers ───────────────────────────
function _queueBoth(uint256 newValue, string memory desc) internal {
@@ -158,12 +140,12 @@ contract ParityTest is BaseTest {
contract ParityDivergencesTest is BaseTest {
/// Encoding-only divergence: v4 expresses 1% as 100/10000; the Nexus type-0 ruleset
/// (StandardRuleset) as 1/100. The effective quorum is identical (asserted in
- /// test_parity_quorum); only the raw numerator/denominator differ. Divergence-pin update:
- /// the fraction no longer lives on the governor — GovernorNexus dropped
- /// GovernorVotesQuorumFraction, so it has no quorumNumerator()/quorumDenominator(). The
- /// numerator moved to the immutable ruleset (public quorumNumerator()); the denominator is
- /// fixed at 100 inside StandardRuleset (private constant, never surfaced). Read the
- /// fixture's ruleset reference and keep the cross-encoding equality assert vs live.
+ /// test_parity_quorum); only the raw numerator/denominator differ. The fraction does not
+ /// live on the governor — GovernorNexus has no GovernorVotesQuorumFraction, so no
+ /// quorumNumerator()/quorumDenominator(). The numerator lives on the immutable ruleset
+ /// (public quorumNumerator()); the denominator is fixed at 100 inside StandardRuleset
+ /// (private constant, never surfaced). Read the fixture's ruleset reference and keep the
+ /// cross-encoding equality assert vs live.
function test_divergence_quorumFractionEncoding() public view {
uint256 scaffoldNumerator = standardRuleset.quorumNumerator();
uint256 scaffoldDenominator = 100; // StandardRuleset.QUORUM_DENOMINATOR (fixed, unexposed)
@@ -175,38 +157,81 @@ contract ParityDivergencesTest is BaseTest {
assertEq(liveGov.quorumNumerator() * scaffoldDenominator, scaffoldNumerator * liveGov.quorumDenominator());
}
- /// CONVERGENCE pin (was a v5 divergence). v5's GovernorVotesQuorumFraction checkpointed
- /// the numerator from the deploy block, so quorum() for pre-deploy timepoints resolved to
- /// 0 — diverging from the live v4 governor, which holds a plain numerator and answers any
- /// past timepoint. StandardRuleset's numerator is IMMUTABLE with no checkpoint history, so
- /// the scaffold now answers pre-deployment timepoints exactly like live v4. The v5
- /// divergence disappeared; this pins the convergence (both > 0 and equal) so a regression
- /// back to checkpoint behavior turns the suite red.
+ /// CONVERGENCE pin. StandardRuleset's numerator is IMMUTABLE with no checkpoint history,
+ /// so the scaffold answers pre-deployment timepoints exactly like the live v4 governor
+ /// (which holds a plain numerator and answers any past timepoint). A checkpointed
+ /// numerator — v5's GovernorVotesQuorumFraction checkpoints from the deploy block — would
+ /// resolve pre-deploy quorum() to 0; this pins the convergence (both > 0 and equal) so a
+ /// regression to checkpoint behavior turns the suite red.
function test_divergence_quorumBeforeDeploymentWindow() public {
vm.roll(FORK_BLOCK + 1);
assertEq(scaffoldGov.quorum(FORK_BLOCK - 1), liveGov.quorum(FORK_BLOCK - 1));
assertGt(scaffoldGov.quorum(FORK_BLOCK - 1), 0);
}
- /// v4 reverts with a require string; the Nexus scaffold reverts with the typed
- /// StandardRuleset.AlreadyVoted(voter), which bubbles unchanged through the governor's
- /// _countVote ruleset dispatch (not the stock GovernorAlreadyCastVote — that path is gone
- /// once counting moved to the ruleset). Behavior (revote rejected) is identical; only the
- /// revert data differs.
- function test_divergence_revoteErrorShape() public {
- uint256 liveId = _propose(liveGov, liveBox, 1, "err shape");
- uint256 scaffoldId = _propose(scaffoldGov, scaffoldBox, 1, "err shape");
+ /// BEHAVIORAL divergence, shipped on purpose: the live v4 governor rejects a second vote
+ /// ("vote already cast"); GovernorNexus *replaces* it, moving the voter's weight from the
+ /// old bucket to the new one. Parity's posture is "identical to live, minus the
+ /// mechanisms we ship on purpose" — each deliberate mechanism divergence gets its pin here.
+ ///
+ /// Integrator note: the re-vote emits a second `VoteCast` for the same (proposal,
+ /// voter); consumers must take the latest in log order as canonical, not sum them.
+ function test_divergence_revoteReplacesInsteadOfReverting() public {
+ uint256 liveId = _propose(liveGov, liveBox, 1, "revote");
+ uint256 scaffoldId = _propose(scaffoldGov, scaffoldBox, 1, "revote");
vm.roll(liveGov.proposalSnapshot(liveId) + 1);
vm.startPrank(WHALE);
liveGov.castVote(liveId, 1);
scaffoldGov.castVote(scaffoldId, 1);
+ // Live: the second vote is refused outright.
vm.expectRevert(bytes("GovernorVotingSimple: vote already cast"));
liveGov.castVote(liveId, 0);
- vm.expectRevert(abi.encodeWithSelector(StandardRuleset.AlreadyVoted.selector, WHALE));
+ // Nexus: the second vote replaces the first.
scaffoldGov.castVote(scaffoldId, 0);
vm.stopPrank();
+
+ uint256 weight = scaffoldGov.getVotes(WHALE, scaffoldGov.proposalSnapshot(scaffoldId));
+ (uint256 against, uint256 for_,) = standardRuleset.proposalVotes(scaffoldId);
+ assertEq(for_, 0, "the whale's weight left the For bucket");
+ assertEq(against, weight, "and is counted exactly once in Against");
+ assertTrue(scaffoldGov.hasVoted(scaffoldId, WHALE), "the whale still has a standing vote");
+ }
+
+ /// BEHAVIORAL divergence, shipped on purpose: a failing→passing
+ /// flip inside the final `extensionWindow` (24h) extends Nexus voting by
+ /// `extensionDuration` (48h) past the ORIGINAL deadline; the live governor closes on
+ /// schedule regardless of when the outcome flipped. Here the flip is the simplest kind:
+ /// the proposal sits failing (no votes → quorum unmet) until the WHALE flips it passing
+ /// inside the window.
+ function test_divergence_lateFlipExtendsNexusButNotLive() public {
+ uint256 liveId = _propose(liveGov, liveBox, 2, "late flip");
+ uint256 scaffoldId = _propose(scaffoldGov, scaffoldBox, 2, "late flip");
+ vm.roll(liveGov.proposalSnapshot(liveId) + 1);
+
+ uint256 liveDeadline = liveGov.proposalDeadline(liveId);
+ uint256 scaffoldDeadline = scaffoldGov.proposalDeadline(scaffoldId);
+ assertEq(scaffoldDeadline, liveDeadline, "identical periods before any flip");
+
+ // Flip failing→passing inside the final window, same block on both governors.
+ vm.roll(scaffoldDeadline - 100);
+ vm.startPrank(WHALE);
+ liveGov.castVote(liveId, 1);
+ scaffoldGov.castVote(scaffoldId, 1);
+ vm.stopPrank();
+
+ vm.roll(scaffoldDeadline + 1);
+ // Live: decided at the original deadline, snipe window and all.
+ assertEq(liveGov.proposalDeadline(liveId), liveDeadline, "live never extends");
+ assertEq(liveGov.state(liveId), 4, "live is already Succeeded"); // ProposalState.Succeeded
+ // Nexus: 48h of response time, anchored at the original deadline.
+ assertEq(
+ scaffoldGov.proposalDeadline(scaffoldId),
+ scaffoldDeadline + ENSParams.EXTENSION_DURATION,
+ "nexus extends by 48h from the original deadline"
+ );
+ assertEq(scaffoldGov.state(scaffoldId), 1, "nexus voting stays open"); // ProposalState.Active
}
}
diff --git a/test/GovernorNexus.adversarial.t.sol b/test/governor/GovernorNexus.adversarial.t.sol
similarity index 90%
rename from test/GovernorNexus.adversarial.t.sol
rename to test/governor/GovernorNexus.adversarial.t.sol
index 3ee4d49..ea8c492 100644
--- a/test/GovernorNexus.adversarial.t.sol
+++ b/test/governor/GovernorNexus.adversarial.t.sol
@@ -4,18 +4,18 @@ pragma solidity ^0.8.30;
import {IGovernor} from "@openzeppelin/contracts/governance/IGovernor.sol";
import {IERC165} from "@openzeppelin/contracts/utils/introspection/IERC165.sol";
-import {GovernorNexus} from "../src/GovernorNexus.sol";
-import {IRuleset} from "../src/IRuleset.sol";
-import {StandardRuleset} from "../src/StandardRuleset.sol";
+import {GovernorNexus} from "../../src/GovernorNexus.sol";
+import {IRuleset} from "../../src/interfaces/IRuleset.sol";
+import {StandardRuleset} from "../../src/rulesets/StandardRuleset.sol";
import {GovernorNexusTestBase} from "./GovernorNexusTestBase.sol";
-import {Box} from "./mocks/Box.sol";
+import {Box} from "../mocks/Box.sol";
import {
LyingRuleset,
ReentrantRuleset,
RevertingRuleset,
RevertingViewsRuleset,
WeightInflatingRuleset
-} from "./mocks/MaliciousRulesets.sol";
+} from "../mocks/MaliciousRulesets.sol";
/// @dev Supports ERC165 but NOT IRuleset — a ruleset that lies about its interface. Mirrors the
/// registry suite's `Mock165`; used here from the attack angle in the consolidated
@@ -27,11 +27,11 @@ contract FakeInterfaceRuleset is IERC165 {
}
/// @title GovernorNexus adversarial suite
-/// @notice Attack-first tests pinning the EXACT blast radius the spec (§8) promises: a
+/// @notice Attack-first tests pinning the EXACT blast radius the core guarantees: a
/// malicious/broken ruleset can break voting on ITS OWN proposals only. It must never
/// corrupt core lifecycle state, reach `onlyGovernance` surface, affect proposals
/// pinned to other types, let third parties stuff tallies, or let the registry accept
-/// junk. Rulesets are DAO-vote-gated code (trust boundary is procedural — spec D3), so
+/// junk. Rulesets are DAO-vote-gated code (the trust boundary is procedural), so
/// some outcomes (e.g. LyingRuleset succeeding with zero votes) are ACCEPTED risks this
/// suite documents rather than bugs the core prevents.
/// @dev Reuses `GovernorNexusTestBase` (alice funds the whole supply, so any standard-quorum
@@ -157,12 +157,12 @@ contract GovernorNexusAdversarialTest is GovernorNexusTestBase {
// ═══════════════════════ LyingRuleset ═══════════════════════
- /// @dev ACCEPTED RISK (spec D3): a ruleset whose outcome views always return true carries
+ /// @dev ACCEPTED RISK: a ruleset whose outcome views always return true carries
/// its proposal to Succeeded — and through queue/execute — with ZERO votes cast. This
/// is the trust model: rulesets are DAO-vote-gated code, so this is caught by process
/// (audit + the registration vote), not by the core. The test documents the blast
/// radius and pins that proposals on OTHER types are unaffected.
- function test_lyingRuleset_succeedsAndExecutesWithZeroVotes_acceptedRiskD3() public {
+ function test_lyingRuleset_succeedsAndExecutesWithZeroVotes_acceptedRisk() public {
LyingRuleset lyingRuleset = new LyingRuleset(address(governor));
uint8 badType = _registerType(lyingRuleset, 0, "register lying ruleset");
@@ -192,7 +192,10 @@ contract GovernorNexusAdversarialTest is GovernorNexusTestBase {
/// (Governor.state, OZ v5.6.1): so the proposal is queryable (Pending, then Active) up
/// to the deadline, and state() begins reverting only AFTER it. Queue/execute become
/// impossible for this proposal (both route through state()), while the governor's own
- /// bookkeeping views keep answering. Other types stay fully functional.
+ /// bookkeeping views keep answering. Other types stay fully functional. Note: this test
+ /// never casts a vote, so it doesn't exercise `GovernorPreventLateFlip`'s pre-count
+ /// hook — see `test_revertingViewsRuleset_blocksCastVoteInsideFinalWindow` below for
+ /// the earlier failure the late-flip mechanism introduces.
function test_revertingViewsRuleset_stateRevertsOnlyAfterDeadline() public {
RevertingViewsRuleset rv = new RevertingViewsRuleset(address(governor));
uint8 badType = _registerType(rv, 0, "register reverting-views ruleset");
@@ -233,6 +236,33 @@ contract GovernorNexusAdversarialTest is GovernorNexusTestBase {
assertEq(uint8(_stateOf(victimId)), uint8(IGovernor.ProposalState.Executed));
}
+ /// @dev `GovernorPreventLateFlip._observeLateFlip` reads the same poisoned views on every
+ /// cast inside the final `extensionWindow`, so `castVote` reverts there too — widening
+ /// the blast radius from "post-deadline queries only" to "the final window of voting,
+ /// plus everything after the deadline". Still contained to this one proposal type.
+ function test_revertingViewsRuleset_blocksCastVoteInsideFinalWindow() public {
+ RevertingViewsRuleset rv = new RevertingViewsRuleset(address(governor));
+ uint8 badType = _registerType(rv, 0, "register reverting-views ruleset (in-window)");
+
+ (uint256 id,,,,) = _proposeActiveBox(1, "poisoned views in-window vote", badType);
+ uint256 deadline = governor.proposalDeadline(id);
+
+ // Inside the final extensionWindow (20 blocks), still before the deadline.
+ vm.roll(deadline - 10);
+ vm.prank(alice);
+ vm.expectRevert(RevertingViewsRuleset.ViewPoisoned.selector);
+ governor.castVote(id, 1);
+
+ // Containment: a type-0 proposal votes and executes normally in the same window.
+ (uint256 victimId, address[] memory vt, uint256[] memory vv, bytes[] memory vc, bytes32 vh) =
+ _proposeActiveBox(66, "in-window victim proposal", 0);
+ _vote(victimId, alice, 1);
+ _rollPastDeadline(victimId);
+ _queueAndExecute(vt, vv, vc, vh);
+ assertEq(box.value(), 66);
+ assertEq(uint8(_stateOf(victimId)), uint8(IGovernor.ProposalState.Executed));
+ }
+
// ═══════════════════════ WeightInflatingRuleset ═══════════════════════
/// @dev countVote returns weight * 1000. Per OZ `_castVote` (v5.6.1) the returned weight
diff --git a/test/governor/GovernorNexus.batch.t.sol b/test/governor/GovernorNexus.batch.t.sol
new file mode 100644
index 0000000..7cc0d8d
--- /dev/null
+++ b/test/governor/GovernorNexus.batch.t.sol
@@ -0,0 +1,425 @@
+// SPDX-License-Identifier: MIT
+pragma solidity ^0.8.30;
+
+import {IGovernor} from "@openzeppelin/contracts/governance/IGovernor.sol";
+import {console2} from "forge-std/console2.sol";
+
+import {GovernorNexus} from "../../src/GovernorNexus.sol";
+import {Box} from "../mocks/Box.sol";
+import {GovernorNexusTestBase} from "./GovernorNexusTestBase.sol";
+import {RulesetCounting} from "../../src/RulesetCounting.sol";
+import {StandardRuleset} from "../../src/rulesets/StandardRuleset.sol";
+
+/// @dev Batch voting suite for `castVoteWithReasonAndParamsBatch`. Extends the shared base:
+/// alice (2_000_000e18) proposes; carol (30e18) is the batch voter, so most weight
+/// assertions read 30e18 — except test_castVoteWithReasonAndParamsBatch_weightsFollowEachProposalsSnapshot,
+/// which tops carol up mid-suite to prove per-item snapshot reads diverge. All-or-nothing
+/// semantics, one ballot-nonce spend per batch item on that item's proposal, duplicates
+/// are intra-tx re-votes.
+contract GovernorNexusBatchTest is GovernorNexusTestBase {
+ address internal carol = makeAddr("carol");
+ Box internal box;
+
+ function setUp() public override {
+ super.setUp();
+ box = new Box(address(timelock));
+ _fund(carol, 30e18);
+ vm.roll(block.number + 1);
+ }
+
+ /// @dev Batch scenarios keep up to 5 of alice's proposals live at once (gas benchmark),
+ /// so the fixture cap must sit above that.
+ function _maxActiveProposals() internal pure override returns (uint8) {
+ return 10;
+ }
+
+ // ─────────────────────────── Helpers ───────────────────────────
+
+ function _boxCall(uint256 newValue, string memory description)
+ internal
+ view
+ returns (address[] memory targets, uint256[] memory values, bytes[] memory calldatas, bytes32 descriptionHash)
+ {
+ targets = new address[](1);
+ targets[0] = address(box);
+ values = new uint256[](1);
+ calldatas = new bytes[](1);
+ calldatas[0] = abi.encodeCall(Box.setValue, (newValue));
+ descriptionHash = keccak256(bytes(description));
+ }
+
+ /// @dev Propose a box call as `typeId` and roll into the active window.
+ function _proposeActive(uint256 newValue, string memory description, uint8 typeId)
+ internal
+ returns (uint256 proposalId)
+ {
+ (address[] memory t, uint256[] memory v, bytes[] memory c,) = _boxCall(newValue, description);
+ vm.prank(alice);
+ proposalId = governor.proposeWithType(t, v, c, description, typeId);
+ vm.roll(governor.proposalSnapshot(proposalId) + 1);
+ }
+
+ function _ids(uint256 a, uint256 b) internal pure returns (uint256[] memory arr) {
+ arr = new uint256[](2);
+ arr[0] = a;
+ arr[1] = b;
+ }
+
+ function _supports(uint8 a, uint8 b) internal pure returns (uint8[] memory arr) {
+ arr = new uint8[](2);
+ arr[0] = a;
+ arr[1] = b;
+ }
+
+ function _reasons(string memory a, string memory b) internal pure returns (string[] memory arr) {
+ arr = new string[](2);
+ arr[0] = a;
+ arr[1] = b;
+ }
+
+ function _params(bytes memory a, bytes memory b) internal pure returns (bytes[] memory arr) {
+ arr = new bytes[](2);
+ arr[0] = a;
+ arr[1] = b;
+ }
+
+ // ─────────────────────────── 1. Happy path ───────────────────────────
+
+ function test_castVoteWithReasonAndParamsBatch_votesOnMultipleProposals() public {
+ uint256 p1 = _proposeActive(1, "batch 1", 0);
+ uint256 p2 = _proposeActive(2, "batch 2", 0);
+
+ vm.expectEmit(true, true, true, true, address(governor));
+ emit IGovernor.VoteCast(carol, p1, 1, 30e18, "yes");
+ vm.expectEmit(true, true, true, true, address(governor));
+ emit IGovernor.VoteCast(carol, p2, 0, 30e18, "");
+
+ vm.prank(carol);
+ uint256[] memory weights = governor.castVoteWithReasonAndParamsBatch(
+ _ids(p1, p2), _supports(1, 0), _reasons("yes", ""), _params("", "")
+ );
+
+ assertEq(weights.length, 2, "one weight per item");
+ assertEq(weights[0], 30e18, "p1 weight");
+ assertEq(weights[1], 30e18, "p2 weight");
+ assertTrue(governor.hasVoted(p1, carol));
+ assertTrue(governor.hasVoted(p2, carol));
+ assertEq(standardRuleset.tally(p1, 1), 30e18, "For tally on p1");
+ assertEq(standardRuleset.tally(p2, 0), 30e18, "Against tally on p2");
+ }
+
+ /// @dev The function returns an array because proposals have distinct snapshots →
+ /// potentially distinct weights. Prove it: carol's balance changes between the two proposals'
+ /// snapshots, so a single batch call must report two different weights, each read
+ /// at its own proposal's snapshot block.
+ function test_castVoteWithReasonAndParamsBatch_weightsFollowEachProposalsSnapshot() public {
+ uint256 p1 = _proposeActive(1, "early snap", 0); // rolls past p1's snapshot @ 30e18
+
+ _fund(carol, 20e18); // total 50e18, re-delegated
+ vm.roll(block.number + 1);
+
+ uint256 p2 = _proposeActive(2, "late snap", 0); // rolls past p2's snapshot @ 50e18
+
+ vm.prank(carol);
+ uint256[] memory weights =
+ governor.castVoteWithReasonAndParamsBatch(_ids(p1, p2), _supports(1, 1), _reasons("", ""), _params("", ""));
+
+ assertEq(weights[0], 30e18, "p1 weight: pre-top-up snapshot");
+ assertEq(weights[1], 50e18, "p2 weight: post-top-up snapshot");
+ assertEq(standardRuleset.tally(p1, 1), 30e18, "p1 tally matches its own snapshot");
+ assertEq(standardRuleset.tally(p2, 1), 50e18, "p2 tally matches its own snapshot");
+ }
+
+ // ─────────────────────────── 2. Guards ───────────────────────────
+
+ function test_castVoteWithReasonAndParamsBatch_emptyBatchReverts() public {
+ vm.expectRevert(GovernorNexus.EmptyBatch.selector);
+ vm.prank(carol);
+ governor.castVoteWithReasonAndParamsBatch(new uint256[](0), new uint8[](0), new string[](0), new bytes[](0));
+ }
+
+ function test_castVoteWithReasonAndParamsBatch_lengthMismatchReverts() public {
+ // supports shorter
+ vm.expectRevert(GovernorNexus.BatchLengthMismatch.selector);
+ vm.prank(carol);
+ governor.castVoteWithReasonAndParamsBatch(new uint256[](2), new uint8[](1), new string[](2), new bytes[](2));
+ // reasons shorter
+ vm.expectRevert(GovernorNexus.BatchLengthMismatch.selector);
+ vm.prank(carol);
+ governor.castVoteWithReasonAndParamsBatch(new uint256[](2), new uint8[](2), new string[](1), new bytes[](2));
+ // params shorter
+ vm.expectRevert(GovernorNexus.BatchLengthMismatch.selector);
+ vm.prank(carol);
+ governor.castVoteWithReasonAndParamsBatch(new uint256[](2), new uint8[](2), new string[](2), new bytes[](1));
+ }
+
+ // ─────────────────────────── 3. Nonce spend ───────────────────────────
+
+ /// @dev A batch is a direct cast: each item spends the (proposal, voter) ballot nonce,
+ /// so a batch invalidates the voter's outstanding signed ballots for exactly the
+ /// proposals it voted — a held ballot on an unbatched proposal survives.
+ function test_castVoteWithReasonAndParamsBatch_invalidatesOnlyBatchedProposalsBallots() public {
+ (address signer, uint256 signerKey) = makeAddrAndKey("signer");
+ _fund(signer, 30e18);
+ vm.roll(block.number + 1);
+
+ uint256 p1 = _proposeActive(1, "batched direct vote", 0);
+ uint256 p2 = _proposeActive(2, "held ballot", 0);
+
+ // Signer hands a relayer ballots on p1 and p2, then batch-votes on p1 only.
+ bytes memory pendingP1 = _signBallot(p1, 1, signer, signerKey, governor.voteNonce(p1, signer));
+ bytes memory pendingP2 = _signBallot(p2, 1, signer, signerKey, governor.voteNonce(p2, signer));
+
+ uint256[] memory ids = new uint256[](1);
+ ids[0] = p1;
+ uint8[] memory supportValues = new uint8[](1);
+ string[] memory reasons = new string[](1);
+ bytes[] memory params = new bytes[](1);
+ vm.prank(signer);
+ governor.castVoteWithReasonAndParamsBatch(ids, supportValues, reasons, params);
+
+ // The p1 ballot died with the batch item on p1…
+ vm.expectRevert(abi.encodeWithSelector(IGovernor.GovernorInvalidSignature.selector, signer));
+ governor.castVoteBySig(p1, 1, signer, pendingP1);
+
+ // …but the held p2 ballot survives: the batch never voted p2.
+ governor.castVoteBySig(p2, 1, signer, pendingP2);
+ }
+
+ function _signBallot(uint256 proposalId, uint8 support, address voter, uint256 key, uint256 nonce)
+ internal
+ view
+ returns (bytes memory)
+ {
+ bytes32 structHash = keccak256(abi.encode(governor.BALLOT_TYPEHASH(), proposalId, support, voter, nonce));
+ (, string memory name, string memory version, uint256 chainId, address verifyingContract,,) =
+ governor.eip712Domain();
+ bytes32 domainSeparator = keccak256(
+ abi.encode(
+ keccak256("EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"),
+ keccak256(bytes(name)),
+ keccak256(bytes(version)),
+ chainId,
+ verifyingContract
+ )
+ );
+ bytes32 digest = keccak256(abi.encodePacked("\x19\x01", domainSeparator, structHash));
+ (uint8 v, bytes32 r, bytes32 s) = vm.sign(key, digest);
+ return abi.encodePacked(r, s, v);
+ }
+
+ // ─────────────────────── 4. Duplicates & re-votes ───────────────────────
+
+ /// @dev Under mutable votes a duplicate id inside one batch is a valid same-tx
+ /// re-vote, last-wins. Both entries emit VoteCast and both report a weight;
+ /// conservation holds (the first vote's weight is debited before the second
+ /// credits).
+ function test_castVoteWithReasonAndParamsBatch_duplicateIdIsIntraTxRevote_lastWins() public {
+ uint256 p1 = _proposeActive(1, "dup", 0);
+
+ vm.expectEmit(true, true, true, true, address(governor));
+ emit IGovernor.VoteCast(carol, p1, 1, 30e18, "first");
+ vm.expectEmit(true, true, true, true, address(governor));
+ emit IGovernor.VoteCast(carol, p1, 0, 30e18, "changed my mind");
+
+ vm.prank(carol);
+ uint256[] memory weights = governor.castVoteWithReasonAndParamsBatch(
+ _ids(p1, p1), _supports(1, 0), _reasons("first", "changed my mind"), _params("", "")
+ );
+
+ assertEq(weights[0], 30e18);
+ assertEq(weights[1], 30e18);
+ assertEq(standardRuleset.tally(p1, 1), 0, "first vote debited (replace semantics)");
+ assertEq(standardRuleset.tally(p1, 0), 30e18, "last wins");
+ assertTrue(governor.hasVoted(p1, carol));
+ }
+
+ /// @dev A batch containing a proposal the voter already voted on singly is a re-vote
+ /// through the batch path — replace semantics hold end-to-end.
+ function test_castVoteWithReasonAndParamsBatch_revotesOverEarlierSingleVote() public {
+ uint256 p1 = _proposeActive(1, "revote via batch", 0);
+ uint256 p2 = _proposeActive(2, "fresh", 0);
+
+ vm.prank(carol);
+ governor.castVote(p1, 1); // single For, 30e18
+
+ vm.prank(carol);
+ governor.castVoteWithReasonAndParamsBatch(_ids(p1, p2), _supports(0, 1), _reasons("", ""), _params("", ""));
+
+ assertEq(standardRuleset.tally(p1, 1), 0, "single For debited by the batched re-vote");
+ assertEq(standardRuleset.tally(p1, 0), 30e18, "batched Against stands");
+ assertEq(standardRuleset.tally(p2, 1), 30e18, "fresh vote lands");
+ }
+
+ // ─────────────────────── 5. All-or-nothing ───────────────────────
+
+ /// @dev One dead id (canceled between signing and inclusion) reverts the other item
+ /// too — no partial state. Recovery is resending without the dead id (idempotent
+ /// under mutable votes).
+ function test_castVoteWithReasonAndParamsBatch_canceledItemRevertsWholeBatch() public {
+ uint256 p1 = _proposeActive(1, "survives", 0);
+
+ // p2 stays Pending so the proposer can still cancel it (stock OZ rule).
+ (address[] memory t, uint256[] memory v, bytes[] memory c, bytes32 h) = _boxCall(2, "canceled");
+ vm.prank(alice);
+ uint256 p2 = governor.propose(t, v, c, "canceled");
+ vm.roll(block.number + 1); // cancel is barred in the propose block; p2 still Pending
+ vm.prank(alice);
+ governor.cancel(t, v, c, h);
+ vm.roll(governor.proposalSnapshot(p2) + 1); // p1 and p2 share timing; p1 active
+
+ vm.prank(carol);
+ vm.expectRevert(
+ abi.encodeWithSelector(
+ IGovernor.GovernorUnexpectedProposalState.selector,
+ p2,
+ IGovernor.ProposalState.Canceled,
+ bytes32(uint256(1) << uint8(IGovernor.ProposalState.Active))
+ )
+ );
+ governor.castVoteWithReasonAndParamsBatch(_ids(p1, p2), _supports(1, 1), _reasons("", ""), _params("", ""));
+
+ assertEq(standardRuleset.tally(p1, 1), 0, "no partial state: p1 vote rolled back");
+ assertFalse(governor.hasVoted(p1, carol));
+ }
+
+ /// @dev Support validity is per-ruleset (_isValidSupport). A support value invalid for
+ /// one item's ruleset reverts the whole batch, including items whose support was
+ /// fine for THEIR ruleset.
+ function test_castVoteWithReasonAndParamsBatch_mixedRulesets_invalidSupportRevertsAll() public {
+ StandardRuleset rs1 = _newRuleset();
+ _executeSelfCall(
+ abi.encodeCall(GovernorNexus.registerType, (rs1, VOTING_DELAY, VOTING_PERIOD, PROPOSAL_THRESHOLD)),
+ "register type 1"
+ );
+
+ uint256 p0 = _proposeActive(1, "type 0", 0);
+ uint256 p1 = _proposeActive(2, "type 1", 1);
+
+ vm.prank(carol);
+ vm.expectRevert(RulesetCounting.InvalidVoteType.selector);
+ governor.castVoteWithReasonAndParamsBatch(_ids(p0, p1), _supports(1, 3), _reasons("", ""), _params("", ""));
+
+ assertEq(standardRuleset.tally(p0, 1), 0, "valid item rolled back with the batch");
+ assertEq(rs1.tally(p1, 1), 0);
+ }
+
+ // ─────────────────────── 6. Params passthrough ───────────────────────
+
+ /// @dev Empty params[i] → stock VoteCast; non-empty → VoteCastWithParams (OZ's own
+ /// dispatch in _castVote — no code of ours). StandardRuleset ignores params, so
+ /// counting is identical either way.
+ function test_castVoteWithReasonAndParamsBatch_paramsDispatchPerItem() public {
+ uint256 p1 = _proposeActive(1, "plain", 0);
+ uint256 p2 = _proposeActive(2, "with params", 0);
+
+ vm.expectEmit(true, true, true, true, address(governor));
+ emit IGovernor.VoteCast(carol, p1, 1, 30e18, "");
+ vm.expectEmit(true, true, true, true, address(governor));
+ emit IGovernor.VoteCastWithParams(carol, p2, 1, 30e18, "", hex"beef");
+
+ vm.prank(carol);
+ governor.castVoteWithReasonAndParamsBatch(
+ _ids(p1, p2), _supports(1, 1), _reasons("", ""), _params("", hex"beef")
+ );
+
+ assertEq(standardRuleset.tally(p1, 1), 30e18);
+ assertEq(standardRuleset.tally(p2, 1), 30e18, "params ignored by StandardRuleset counting");
+ }
+
+ // ─────────────────────── 7. Equivalence fuzz + gas ───────────────────────
+
+ /// @dev State equivalence: a batch lands exactly the tallies a sequence of single
+ /// casts lands (same voter, same order). Includes duplicate ids (re-votes) and
+ /// the full support range via bounding.
+ function testFuzz_castVoteWithReasonAndParamsBatch_equivalentToSingleCastSequence(
+ uint8 s0,
+ uint8 s1,
+ uint8 s2,
+ bool duplicate
+ ) public {
+ s0 = uint8(bound(s0, 0, 2));
+ s1 = uint8(bound(s1, 0, 2));
+ s2 = uint8(bound(s2, 0, 2));
+
+ uint256 p1 = _proposeActive(1, "fuzz A", 0);
+ uint256 p2 = _proposeActive(2, "fuzz B", 0);
+ uint256 p3 = _proposeActive(3, "fuzz C", 0);
+
+ uint256[] memory ids = new uint256[](3);
+ ids[0] = p1;
+ ids[1] = p2;
+ ids[2] = duplicate ? p1 : p3; // false: three genuinely distinct proposals
+ uint8[] memory supportValues = new uint8[](3);
+ supportValues[0] = s0;
+ supportValues[1] = s1;
+ supportValues[2] = s2;
+ string[] memory reasons = new string[](3);
+ bytes[] memory params = new bytes[](3);
+
+ uint256 snap = vm.snapshotState();
+
+ vm.prank(carol);
+ governor.castVoteWithReasonAndParamsBatch(ids, supportValues, reasons, params);
+ uint256[9] memory batchTallies = _tallies(p1, p2, p3);
+
+ vm.revertToState(snap);
+
+ for (uint256 i = 0; i < 3; ++i) {
+ vm.prank(carol);
+ governor.castVote(ids[i], supportValues[i]);
+ }
+ uint256[9] memory singleTallies = _tallies(p1, p2, p3);
+
+ for (uint256 i = 0; i < 9; ++i) {
+ assertEq(batchTallies[i], singleTallies[i], "batch != sequence of singles");
+ }
+ }
+
+ function _tallies(uint256 p1, uint256 p2, uint256 p3) internal view returns (uint256[9] memory t) {
+ for (uint8 s = 0; s <= 2; ++s) {
+ t[s] = standardRuleset.tally(p1, s);
+ t[3 + s] = standardRuleset.tally(p2, s);
+ t[6 + s] = standardRuleset.tally(p3, s);
+ }
+ }
+
+ /// @dev In-EVM gas comparison. Batch and singles now perform the same per-proposal
+ /// nonce spends, so in-EVM the batch only saves warm-vs-cold access differences and
+ /// can measure slightly negative (array ABI-decoding overhead). The real saving is
+ /// off-EVM: (N-1) avoided per-tx 21k intrinsic costs plus top-level calldata — the
+ /// assertion below is intrinsic-adjusted to credit that.
+ function test_castVoteWithReasonAndParamsBatch_gasComparedToSingles() public {
+ uint256[] memory ids = new uint256[](5);
+ uint8[] memory supportValues = new uint8[](5);
+ string[] memory reasons = new string[](5);
+ bytes[] memory params = new bytes[](5);
+ for (uint256 i = 0; i < 5; ++i) {
+ ids[i] = _proposeActive(i + 1, string(abi.encodePacked("gas ", bytes1(uint8(0x30 + i)))), 0);
+ supportValues[i] = 1;
+ }
+
+ uint256 snap = vm.snapshotState();
+ vm.prank(carol);
+ uint256 g0 = gasleft();
+ governor.castVoteWithReasonAndParamsBatch(ids, supportValues, reasons, params);
+ uint256 batchGas = g0 - gasleft();
+ vm.revertToState(snap);
+
+ uint256 singlesGas;
+ for (uint256 i = 0; i < 5; ++i) {
+ vm.prank(carol);
+ g0 = gasleft();
+ governor.castVote(ids[i], 1);
+ singlesGas += g0 - gasleft();
+ }
+
+ console2.log("batch(5) gas:", batchGas);
+ console2.log("5 singles gas:", singlesGas);
+ // In-EVM, a batch can cost slightly MORE than N singles (array ABI-decoding overhead,
+ // no in-EVM spend savings). The real saving is off-EVM: (N-1) avoided
+ // per-tx intrinsic costs (21k each) + top-level calldata. Assert the real-world win
+ // with the intrinsic adjustment; the logs above report the exact numbers.
+ assertLt(batchGas, singlesGas + 4 * 21_000, "batch must beat 5 singles once avoided intrinsic gas is counted");
+ }
+}
diff --git a/test/governor/GovernorNexus.bond.t.sol b/test/governor/GovernorNexus.bond.t.sol
new file mode 100644
index 0000000..5a9dc3e
--- /dev/null
+++ b/test/governor/GovernorNexus.bond.t.sol
@@ -0,0 +1,725 @@
+// SPDX-License-Identifier: MIT
+pragma solidity ^0.8.30;
+
+import {Test} from "forge-std/Test.sol";
+import {IGovernor} from "@openzeppelin/contracts/governance/IGovernor.sol";
+
+import {BondRuleset} from "../../src/rulesets/BondRuleset.sol";
+import {BondRulesetTestBase} from "../rulesets/BondRulesetTestBase.sol";
+
+/// @dev Integration suite for `resolveBond` against the real `GovernorNexus` + timelock —
+/// the ratified spam-slash predicate (EP 5.15 verbatim: combined rejections strictly
+/// beat For AND slash-weight strictly beats plain Against) and the Pending/Active-only
+/// guard, each exercised end to end through the actual propose → vote → queue/execute/
+/// cancel lifecycle rather than a mocked governor.
+contract GovernorNexusBondTest is BondRulesetTestBase {
+ function test_endToEnd_permissionlessPropose_zeroVP() public {
+ (uint256 id,,,,) = _proposeBonded("bonded");
+ assertEq(uint8(governor.state(id)), uint8(IGovernor.ProposalState.Pending));
+ (address proposer,) = bondRuleset.bondOf(id);
+ assertEq(proposer, bob);
+ assertEq(bondRuleset.bondAmount(), BOND_AMOUNT); // every bond holds exactly bondAmount
+ }
+
+ /// @dev The bond keys on the id the GOVERNOR computed (passed through
+ /// `IProposalValidator.validateProposal`), never a ruleset-side re-derivation —
+ /// pinned by matching the bond record against `hashProposal` for the same content.
+ function test_bondKeyedByGovernorCanonicalId() public {
+ (uint256 id, address[] memory t, uint256[] memory v, bytes[] memory c, bytes32 h) = _proposeBonded("canonical");
+ assertEq(id, governor.hashProposal(t, v, c, h));
+ (address proposer,) = bondRuleset.bondOf(governor.hashProposal(t, v, c, h));
+ assertEq(proposer, bob);
+ }
+
+ function test_resolve_executed_refunds() public {
+ // alice (2M ENS) already funded by base fixture
+ address[] memory t;
+ uint256[] memory v;
+ bytes[] memory c;
+ bytes32 h;
+ uint256 id;
+ (id, t, v, c, h) = _proposeBonded("passes");
+ vm.roll(governor.proposalSnapshot(id) + 1);
+ vm.prank(alice);
+ governor.castVote(id, uint8(BondRuleset.VoteType.For));
+ vm.roll(governor.proposalDeadline(id) + 1);
+ governor.queue(t, v, c, h);
+ vm.warp(block.timestamp + TIMELOCK_DELAY + 1);
+ governor.execute(t, v, c, h);
+
+ uint256 before = token.balanceOf(bob);
+ bondRuleset.resolveBond(id);
+ assertEq(token.balanceOf(bob), before + BOND_AMOUNT);
+ (, bool settled) = bondRuleset.bondOf(id);
+ assertTrue(settled);
+ }
+
+ function test_resolve_defeated_slashWins_forfeits() public {
+ address slasher = makeAddr("slasher");
+ _fund(slasher, 500_000e18);
+ vm.roll(block.number + 1);
+ (uint256 id,,,,) = _proposeBonded("slashed");
+ vm.roll(governor.proposalSnapshot(id) + 1);
+ vm.prank(alice);
+ governor.castVote(id, uint8(BondRuleset.VoteType.Abstain)); // quorum without approval
+ vm.prank(slasher);
+ governor.castVote(id, uint8(BondRuleset.VoteType.AgainstAndSlash));
+ vm.roll(governor.proposalDeadline(id) + 1);
+ assertEq(uint8(governor.state(id)), uint8(IGovernor.ProposalState.Defeated));
+
+ uint256 before = token.balanceOf(address(timelock));
+ vm.expectEmit(true, false, false, true);
+ emit BondRuleset.BondSlashed(id, BOND_AMOUNT, BondRuleset.SlashReason.SlashVote);
+ bondRuleset.resolveBond(id);
+ assertEq(token.balanceOf(address(timelock)), before + BOND_AMOUNT);
+ }
+
+ function test_resolve_defeated_plainNoMajority_refunds() public {
+ // Against 500k > Slash 100k → slash does not beat plain rejection → refund despite defeat
+ address noVoter = makeAddr("noVoter");
+ address slasher = makeAddr("slasher");
+ _fund(noVoter, 500_000e18);
+ _fund(slasher, 100_000e18);
+ vm.roll(block.number + 1);
+ (uint256 id,,,,) = _proposeBonded("defeated not slashed");
+ vm.roll(governor.proposalSnapshot(id) + 1);
+ vm.prank(alice);
+ governor.castVote(id, uint8(BondRuleset.VoteType.Abstain));
+ vm.prank(noVoter);
+ governor.castVote(id, uint8(BondRuleset.VoteType.Against));
+ vm.prank(slasher);
+ governor.castVote(id, uint8(BondRuleset.VoteType.AgainstAndSlash));
+ vm.roll(governor.proposalDeadline(id) + 1);
+
+ uint256 before = token.balanceOf(bob);
+ bondRuleset.resolveBond(id);
+ assertEq(token.balanceOf(bob), before + BOND_AMOUNT);
+ }
+
+ function test_resolve_defeated_quorumFailOnly_slashLeads_forfeits() public {
+ address slasher = makeAddr("slasher");
+ _fund(slasher, 100_000e18);
+ vm.roll(block.number + 1);
+ (uint256 id,,,,) = _proposeBonded("quorum fail");
+ vm.roll(governor.proposalSnapshot(id) + 1);
+ vm.prank(slasher);
+ governor.castVote(id, uint8(BondRuleset.VoteType.AgainstAndSlash));
+ vm.roll(governor.proposalDeadline(id) + 1);
+ assertEq(uint8(governor.state(id)), uint8(IGovernor.ProposalState.Defeated));
+
+ uint256 before = token.balanceOf(address(timelock));
+ vm.expectEmit(true, false, false, true);
+ emit BondRuleset.BondSlashed(id, BOND_AMOUNT, BondRuleset.SlashReason.SlashVote);
+ bondRuleset.resolveBond(id);
+ assertEq(token.balanceOf(address(timelock)), before + BOND_AMOUNT);
+ }
+
+ /// @dev The true quorum-fail carve-out: a tiny For vote below quorum defeats the
+ /// proposal, but the (empty) rejections don't beat For, so it refunds.
+ function test_resolve_defeated_quorumFail_forVotesLead_refunds() public {
+ address forVoter = makeAddr("forVoter");
+ _fund(forVoter, 1e18); // way below 1% quorum of ~2M supply
+ vm.roll(block.number + 1);
+ (uint256 id,,,,) = _proposeBonded("quorum fail, for leads");
+ vm.roll(governor.proposalSnapshot(id) + 1);
+ vm.prank(forVoter);
+ governor.castVote(id, uint8(BondRuleset.VoteType.For));
+ vm.roll(governor.proposalDeadline(id) + 1);
+ assertEq(uint8(governor.state(id)), uint8(IGovernor.ProposalState.Defeated)); // quorum missed
+ uint256 before = token.balanceOf(bob);
+ bondRuleset.resolveBond(id);
+ assertEq(token.balanceOf(bob), before + BOND_AMOUNT); // rejections 0 ≯ For → refund
+ }
+
+ /// @dev Legitimate proposal that missed quorum with real support: For outweighs a smaller
+ /// slash vote → refund.
+ function test_resolve_defeated_quorumFail_forOutweighsSlash_refunds() public {
+ address forVoter = makeAddr("forVoter");
+ address slasher = makeAddr("slasher");
+ _fund(forVoter, 2e18); // both way below 1% quorum
+ _fund(slasher, 1e18);
+ vm.roll(block.number + 1);
+ (uint256 id,,,,) = _proposeBonded("quorum fail, supported");
+ vm.roll(governor.proposalSnapshot(id) + 1);
+ vm.prank(forVoter);
+ governor.castVote(id, uint8(BondRuleset.VoteType.For));
+ vm.prank(slasher);
+ governor.castVote(id, uint8(BondRuleset.VoteType.AgainstAndSlash));
+ vm.roll(governor.proposalDeadline(id) + 1);
+ assertEq(uint8(governor.state(id)), uint8(IGovernor.ProposalState.Defeated));
+ uint256 before = token.balanceOf(bob);
+ bondRuleset.resolveBond(id);
+ assertEq(token.balanceOf(bob), before + BOND_AMOUNT);
+ }
+
+ // ─────────────── Ratified predicate (EP 5.15) boundary tests ───────────────
+
+ /// @dev A proposer defending with real voting weight via plain Against is legitimate
+ /// defense — the ratified rule reads raw buckets, and the per-address exclusion an
+ /// earlier revision layered on top was sybil-bypassable anyway: Against 300k >
+ /// Slash 200k kills the second clause → refund.
+ function test_resolve_proposerAgainstDefense_realWeight_refunds() public {
+ address slasher = makeAddr("slasher");
+ _fund(slasher, 200_000e18);
+ _fund(bob, 300_000e18); // bob now HAS voting power for this test
+ vm.roll(block.number + 1);
+ (uint256 id,,,,) = _proposeBonded("against defense");
+ vm.roll(governor.proposalSnapshot(id) + 1);
+ vm.prank(alice);
+ governor.castVote(id, uint8(BondRuleset.VoteType.Abstain));
+ vm.prank(slasher);
+ governor.castVote(id, uint8(BondRuleset.VoteType.AgainstAndSlash));
+ vm.prank(bob);
+ governor.castVote(id, uint8(BondRuleset.VoteType.Against));
+ vm.roll(governor.proposalDeadline(id) + 1);
+
+ uint256 before = token.balanceOf(bob);
+ bondRuleset.resolveBond(id);
+ assertEq(token.balanceOf(bob), before + BOND_AMOUNT); // slash is not the plurality
+ }
+
+ /// @dev Raw tallies, no per-address carve-out: a proposer voting AgainstAndSlash on
+ /// their own proposal counts like anyone else's slash weight → forfeit.
+ function test_resolve_proposerSelfSlash_rawTally_slashes() public {
+ _fund(bob, 300_000e18);
+ vm.roll(block.number + 1);
+ (uint256 id,,,,) = _proposeBonded("self slash");
+ vm.roll(governor.proposalSnapshot(id) + 1);
+ vm.prank(alice);
+ governor.castVote(id, uint8(BondRuleset.VoteType.Abstain));
+ vm.prank(bob);
+ governor.castVote(id, uint8(BondRuleset.VoteType.AgainstAndSlash));
+ vm.roll(governor.proposalDeadline(id) + 1);
+ uint256 before = token.balanceOf(address(timelock));
+ vm.expectEmit(true, false, false, true);
+ emit BondRuleset.BondSlashed(id, BOND_AMOUNT, BondRuleset.SlashReason.SlashVote);
+ bondRuleset.resolveBond(id);
+ assertEq(token.balanceOf(address(timelock)), before + BOND_AMOUNT);
+ }
+
+ /// @dev Mass plain rejection is not a confiscation mandate: Against 900k dwarfs a small
+ /// slash vote that nonetheless beats For → refund.
+ function test_resolve_massAgainst_smallSlashBeatsFor_refunds() public {
+ address noVoter = makeAddr("noVoter");
+ address slasher = makeAddr("slasher");
+ address forVoter = makeAddr("forVoter");
+ _fund(noVoter, 900_000e18);
+ _fund(slasher, 50_000e18);
+ _fund(forVoter, 10_000e18);
+ vm.roll(block.number + 1);
+ (uint256 id,,,,) = _proposeBonded("mass rejection");
+ vm.roll(governor.proposalSnapshot(id) + 1);
+ vm.prank(noVoter);
+ governor.castVote(id, uint8(BondRuleset.VoteType.Against));
+ vm.prank(slasher);
+ governor.castVote(id, uint8(BondRuleset.VoteType.AgainstAndSlash));
+ vm.prank(forVoter);
+ governor.castVote(id, uint8(BondRuleset.VoteType.For));
+ vm.roll(governor.proposalDeadline(id) + 1);
+ assertEq(uint8(governor.state(id)), uint8(IGovernor.ProposalState.Defeated));
+
+ uint256 before = token.balanceOf(bob);
+ bondRuleset.resolveBond(id);
+ assertEq(token.balanceOf(bob), before + BOND_AMOUNT);
+ }
+
+ /// @dev "Rejected" must be STRICT (EP 5.15: "rejections bigger than approvals"): with no
+ /// Against votes, slash tied with For means rejections tied with For → refund.
+ function test_resolve_tieSlashFor_refunds() public {
+ address forVoter = makeAddr("forVoter");
+ address slasher = makeAddr("slasher");
+ _fund(forVoter, 100_000e18);
+ _fund(slasher, 100_000e18);
+ vm.roll(block.number + 1);
+ (uint256 id,,,,) = _proposeBonded("tie slash-for");
+ vm.roll(governor.proposalSnapshot(id) + 1);
+ vm.prank(alice);
+ governor.castVote(id, uint8(BondRuleset.VoteType.Abstain));
+ vm.prank(forVoter);
+ governor.castVote(id, uint8(BondRuleset.VoteType.For));
+ vm.prank(slasher);
+ governor.castVote(id, uint8(BondRuleset.VoteType.AgainstAndSlash));
+ vm.roll(governor.proposalDeadline(id) + 1);
+ assertEq(uint8(governor.state(id)), uint8(IGovernor.ProposalState.Defeated)); // tie ≠ success
+ uint256 before = token.balanceOf(bob);
+ bondRuleset.resolveBond(id);
+ assertEq(token.balanceOf(bob), before + BOND_AMOUNT);
+ }
+
+ /// @dev The penalty clause must be STRICT: slash tied with Against refunds.
+ function test_resolve_tieSlashAgainst_refunds() public {
+ address noVoter = makeAddr("noVoter");
+ address slasher = makeAddr("slasher");
+ _fund(noVoter, 100_000e18);
+ _fund(slasher, 100_000e18);
+ vm.roll(block.number + 1);
+ (uint256 id,,,,) = _proposeBonded("tie slash-against");
+ vm.roll(governor.proposalSnapshot(id) + 1);
+ vm.prank(alice);
+ governor.castVote(id, uint8(BondRuleset.VoteType.Abstain));
+ vm.prank(noVoter);
+ governor.castVote(id, uint8(BondRuleset.VoteType.Against));
+ vm.prank(slasher);
+ governor.castVote(id, uint8(BondRuleset.VoteType.AgainstAndSlash));
+ vm.roll(governor.proposalDeadline(id) + 1);
+ uint256 before = token.balanceOf(bob);
+ bondRuleset.resolveBond(id);
+ assertEq(token.balanceOf(bob), before + BOND_AMOUNT);
+ }
+
+ /// @dev EP 5.15 counts REJECTIONS, not the slash bucket alone, against For: slash tied
+ /// with For still forfeits when plain Against pushes the combined rejections over.
+ /// (F=100k, A=50k, S=100k → rejections 150k > 100k ∧ slash 100k > 50k.)
+ function test_resolve_tieSlashFor_withAgainst_slashes() public {
+ address forVoter = makeAddr("forVoter");
+ address noVoter = makeAddr("noVoter");
+ address slasher = makeAddr("slasher");
+ _fund(forVoter, 100_000e18);
+ _fund(noVoter, 50_000e18);
+ _fund(slasher, 100_000e18);
+ vm.roll(block.number + 1);
+ (uint256 id,,,,) = _proposeBonded("tie slash-for, against present");
+ vm.roll(governor.proposalSnapshot(id) + 1);
+ vm.prank(alice);
+ governor.castVote(id, uint8(BondRuleset.VoteType.Abstain));
+ vm.prank(forVoter);
+ governor.castVote(id, uint8(BondRuleset.VoteType.For));
+ vm.prank(noVoter);
+ governor.castVote(id, uint8(BondRuleset.VoteType.Against));
+ vm.prank(slasher);
+ governor.castVote(id, uint8(BondRuleset.VoteType.AgainstAndSlash));
+ vm.roll(governor.proposalDeadline(id) + 1);
+ assertEq(uint8(governor.state(id)), uint8(IGovernor.ProposalState.Defeated));
+
+ uint256 before = token.balanceOf(address(timelock));
+ vm.expectEmit(true, false, false, true);
+ emit BondRuleset.BondSlashed(id, BOND_AMOUNT, BondRuleset.SlashReason.SlashVote);
+ bondRuleset.resolveBond(id);
+ assertEq(token.balanceOf(address(timelock)), before + BOND_AMOUNT);
+ }
+
+ /// @dev Rejections exactly tied with For never slash, even with slash leading Against:
+ /// the defeat clause is strict. (F=100k, A=30k, S=70k → rejections 100k ≯ 100k.)
+ function test_resolve_tieRejectionsFor_slashLeadsAgainst_refunds() public {
+ address forVoter = makeAddr("forVoter");
+ address noVoter = makeAddr("noVoter");
+ address slasher = makeAddr("slasher");
+ _fund(forVoter, 100_000e18);
+ _fund(noVoter, 30_000e18);
+ _fund(slasher, 70_000e18);
+ vm.roll(block.number + 1);
+ (uint256 id,,,,) = _proposeBonded("rejections tie for");
+ vm.roll(governor.proposalSnapshot(id) + 1);
+ vm.prank(alice);
+ governor.castVote(id, uint8(BondRuleset.VoteType.Abstain));
+ vm.prank(forVoter);
+ governor.castVote(id, uint8(BondRuleset.VoteType.For));
+ vm.prank(noVoter);
+ governor.castVote(id, uint8(BondRuleset.VoteType.Against));
+ vm.prank(slasher);
+ governor.castVote(id, uint8(BondRuleset.VoteType.AgainstAndSlash));
+ vm.roll(governor.proposalDeadline(id) + 1);
+ assertEq(uint8(governor.state(id)), uint8(IGovernor.ProposalState.Defeated)); // tie ≠ success
+
+ uint256 before = token.balanceOf(bob);
+ bondRuleset.resolveBond(id);
+ assertEq(token.balanceOf(bob), before + BOND_AMOUNT);
+ }
+
+ /// @dev The ratified rule forfeits when the proposal is rejected and slash leads plain
+ /// Against, even though slash alone does not beat For. (F=100k, A=60k, S=70k →
+ /// rejections 130k > 100k ∧ slash 70k > 60k.)
+ function test_resolve_rejectedSlashLeadsAgainst_slashBelowFor_slashes() public {
+ address forVoter = makeAddr("forVoter");
+ address noVoter = makeAddr("noVoter");
+ address slasher = makeAddr("slasher");
+ _fund(forVoter, 100_000e18);
+ _fund(noVoter, 60_000e18);
+ _fund(slasher, 70_000e18);
+ vm.roll(block.number + 1);
+ (uint256 id,,,,) = _proposeBonded("rejected, slash leads against");
+ vm.roll(governor.proposalSnapshot(id) + 1);
+ vm.prank(alice);
+ governor.castVote(id, uint8(BondRuleset.VoteType.Abstain));
+ vm.prank(forVoter);
+ governor.castVote(id, uint8(BondRuleset.VoteType.For));
+ vm.prank(noVoter);
+ governor.castVote(id, uint8(BondRuleset.VoteType.Against));
+ vm.prank(slasher);
+ governor.castVote(id, uint8(BondRuleset.VoteType.AgainstAndSlash));
+ vm.roll(governor.proposalDeadline(id) + 1);
+ assertEq(uint8(governor.state(id)), uint8(IGovernor.ProposalState.Defeated));
+
+ uint256 before = token.balanceOf(address(timelock));
+ vm.expectEmit(true, false, false, true);
+ emit BondRuleset.BondSlashed(id, BOND_AMOUNT, BondRuleset.SlashReason.SlashVote);
+ bondRuleset.resolveBond(id);
+ assertEq(token.balanceOf(address(timelock)), before + BOND_AMOUNT);
+ }
+
+ /// @dev Slash strictly above BOTH expressive buckets → forfeit.
+ function test_resolve_strictPluralityOverBoth_slashes() public {
+ address forVoter = makeAddr("forVoter");
+ address noVoter = makeAddr("noVoter");
+ address slasher = makeAddr("slasher");
+ _fund(forVoter, 100_000e18);
+ _fund(noVoter, 100_000e18);
+ _fund(slasher, 150_000e18);
+ vm.roll(block.number + 1);
+ (uint256 id,,,,) = _proposeBonded("strict plurality");
+ vm.roll(governor.proposalSnapshot(id) + 1);
+ vm.prank(alice);
+ governor.castVote(id, uint8(BondRuleset.VoteType.Abstain));
+ vm.prank(forVoter);
+ governor.castVote(id, uint8(BondRuleset.VoteType.For));
+ vm.prank(noVoter);
+ governor.castVote(id, uint8(BondRuleset.VoteType.Against));
+ vm.prank(slasher);
+ governor.castVote(id, uint8(BondRuleset.VoteType.AgainstAndSlash));
+ vm.roll(governor.proposalDeadline(id) + 1);
+
+ uint256 before = token.balanceOf(address(timelock));
+ vm.expectEmit(true, false, false, true);
+ emit BondRuleset.BondSlashed(id, BOND_AMOUNT, BondRuleset.SlashReason.SlashVote);
+ bondRuleset.resolveBond(id);
+ assertEq(token.balanceOf(address(timelock)), before + BOND_AMOUNT);
+ }
+
+ /// @dev ACCEPTED RESIDUAL (see README): at zero turnout a single wei of slash weight
+ /// satisfies both clauses and confiscates. The defense is attracting any single vote in
+ /// either expressive bucket; a participation floor was deliberately rejected so a
+ /// sybil spam wave can be slashed proposal-by-proposal without gathering quorum each time.
+ function test_resolve_zeroTurnout_oneWeiSlash_slashes_acceptedResidual() public {
+ address griefer = makeAddr("griefer");
+ _fund(griefer, 1);
+ vm.roll(block.number + 1);
+ (uint256 id,,,,) = _proposeBonded("zero turnout");
+ vm.roll(governor.proposalSnapshot(id) + 1);
+ vm.prank(griefer);
+ governor.castVote(id, uint8(BondRuleset.VoteType.AgainstAndSlash));
+ vm.roll(governor.proposalDeadline(id) + 1);
+ assertEq(uint8(governor.state(id)), uint8(IGovernor.ProposalState.Defeated));
+
+ uint256 before = token.balanceOf(address(timelock));
+ bondRuleset.resolveBond(id);
+ assertEq(token.balanceOf(address(timelock)), before + BOND_AMOUNT);
+ }
+
+ // ─────────────────────────────── Guard tests ───────────────────────────────
+
+ function test_resolve_revertsWhileLive() public {
+ (uint256 id,,,,) = _proposeBonded("live");
+ vm.expectRevert(
+ abi.encodeWithSelector(BondRuleset.BondNotResolvable.selector, id, IGovernor.ProposalState.Pending)
+ );
+ bondRuleset.resolveBond(id);
+ vm.roll(governor.proposalSnapshot(id) + 1);
+ vm.expectRevert(
+ abi.encodeWithSelector(BondRuleset.BondNotResolvable.selector, id, IGovernor.ProposalState.Active)
+ );
+ bondRuleset.resolveBond(id);
+ }
+
+ function test_resolve_refundsWhileQueued() public {
+ address[] memory t;
+ uint256[] memory v;
+ bytes[] memory c;
+ bytes32 h;
+ uint256 id;
+ (id, t, v, c, h) = _proposeBonded("queued");
+ vm.roll(governor.proposalSnapshot(id) + 1);
+ vm.prank(alice);
+ governor.castVote(id, uint8(BondRuleset.VoteType.For));
+ vm.roll(governor.proposalDeadline(id) + 1);
+ governor.queue(t, v, c, h);
+
+ uint256 before = token.balanceOf(bob);
+ vm.expectEmit(true, true, false, true);
+ emit BondRuleset.BondRefunded(id, bob, BOND_AMOUNT);
+ bondRuleset.resolveBond(id); // veto window still open — early refund is the accepted trade-off
+ assertEq(token.balanceOf(bob), before + BOND_AMOUNT);
+ (, bool settled) = bondRuleset.bondOf(id);
+ assertTrue(settled);
+ }
+
+ function test_resolve_refundsWhileSucceeded() public {
+ (uint256 id,,,,) = _proposeBonded("succeeded refund");
+ vm.roll(governor.proposalSnapshot(id) + 1);
+ vm.prank(alice);
+ governor.castVote(id, uint8(BondRuleset.VoteType.For));
+ vm.roll(governor.proposalDeadline(id) + 1);
+ assertEq(uint8(governor.state(id)), uint8(IGovernor.ProposalState.Succeeded));
+
+ uint256 before = token.balanceOf(bob);
+ vm.expectEmit(true, true, false, true);
+ emit BondRuleset.BondRefunded(id, bob, BOND_AMOUNT);
+ bondRuleset.resolveBond(id);
+ assertEq(token.balanceOf(bob), before + BOND_AMOUNT);
+ }
+
+ /// @dev Anyone may trigger the early refund; funds always go to the proposer.
+ function test_resolve_thirdPartyTriggersEarlyRefund_fundsGoToProposer() public {
+ (uint256 id,,,,) = _proposeBonded("stranger settles");
+ vm.roll(governor.proposalSnapshot(id) + 1);
+ vm.prank(alice);
+ governor.castVote(id, uint8(BondRuleset.VoteType.For));
+ vm.roll(governor.proposalDeadline(id) + 1);
+
+ uint256 strangerBefore = token.balanceOf(eoa);
+ uint256 proposerBefore = token.balanceOf(bob);
+ vm.prank(eoa);
+ bondRuleset.resolveBond(id);
+ assertEq(token.balanceOf(bob), proposerBefore + BOND_AMOUNT);
+ assertEq(token.balanceOf(eoa), strangerBefore);
+ }
+
+ /// @dev Early settle at Succeeded, then the proposal queues and executes normally —
+ /// resolution replay reverts, no double payout.
+ function test_resolve_earlySettleThenExecute_replayReverts() public {
+ address[] memory t;
+ uint256[] memory v;
+ bytes[] memory c;
+ bytes32 h;
+ uint256 id;
+ (id, t, v, c, h) = _proposeBonded("settle then execute");
+ vm.roll(governor.proposalSnapshot(id) + 1);
+ vm.prank(alice);
+ governor.castVote(id, uint8(BondRuleset.VoteType.For));
+ vm.roll(governor.proposalDeadline(id) + 1);
+
+ bondRuleset.resolveBond(id); // refund at Succeeded
+
+ governor.queue(t, v, c, h);
+ vm.warp(block.timestamp + TIMELOCK_DELAY + 1);
+ governor.execute(t, v, c, h); // lifecycle unaffected by the settled bond
+ assertEq(uint8(governor.state(id)), uint8(IGovernor.ProposalState.Executed));
+
+ vm.expectRevert(abi.encodeWithSelector(BondRuleset.BondAlreadySettled.selector, id));
+ bondRuleset.resolveBond(id);
+ }
+
+ /// @dev The accepted trade-off, pinned: bond settled while Queued, council vetoes
+ /// after — the forfeit is unreachable (replay reverts), the veto itself still lands.
+ function test_resolve_earlySettleThenVeto_noForfeit() public {
+ address[] memory t;
+ uint256[] memory v;
+ bytes[] memory c;
+ bytes32 h;
+ uint256 id;
+ (id, t, v, c, h) = _proposeBonded("settle then veto");
+ vm.roll(governor.proposalSnapshot(id) + 1);
+ vm.prank(alice);
+ governor.castVote(id, uint8(BondRuleset.VoteType.For));
+ vm.roll(governor.proposalDeadline(id) + 1);
+ governor.queue(t, v, c, h);
+
+ bondRuleset.resolveBond(id); // refund at Queued, before the veto
+
+ bytes32 salt = bytes20(address(governor)) ^ h;
+ bytes32 opId = timelock.hashOperationBatch(t, v, c, 0, salt);
+ vm.prank(council);
+ timelock.cancel(opId);
+ assertEq(uint8(governor.state(id)), uint8(IGovernor.ProposalState.Canceled));
+ assertEq(governor.proposalCanceledAt(id), 0);
+
+ uint256 treasuryBefore = token.balanceOf(address(timelock));
+ vm.expectRevert(abi.encodeWithSelector(BondRuleset.BondAlreadySettled.selector, id));
+ bondRuleset.resolveBond(id);
+ assertEq(token.balanceOf(address(timelock)), treasuryBefore); // forfeit never happens
+ }
+
+ function test_resolve_replayReverts() public {
+ address slasher = makeAddr("slasher");
+ _fund(slasher, 500_000e18);
+ vm.roll(block.number + 1);
+ (uint256 id,,,,) = _proposeBonded("replay");
+ vm.roll(governor.proposalSnapshot(id) + 1);
+ vm.prank(alice);
+ governor.castVote(id, uint8(BondRuleset.VoteType.Abstain));
+ vm.prank(slasher);
+ governor.castVote(id, uint8(BondRuleset.VoteType.AgainstAndSlash));
+ vm.roll(governor.proposalDeadline(id) + 1);
+
+ bondRuleset.resolveBond(id); // settles (forfeit)
+ vm.expectRevert(abi.encodeWithSelector(BondRuleset.BondAlreadySettled.selector, id));
+ bondRuleset.resolveBond(id);
+ }
+
+ function test_resolve_noBondReverts() public {
+ vm.expectRevert(abi.encodeWithSelector(BondRuleset.NoBond.selector, uint256(123)));
+ bondRuleset.resolveBond(123);
+ }
+
+ // ─────────────────────── Cancel-partition tests ───────────────────────
+
+ function test_cancel_pending_refunds() public {
+ address[] memory t;
+ uint256[] memory v;
+ bytes[] memory c;
+ bytes32 h;
+ uint256 id;
+ (id, t, v, c, h) = _proposeBonded("pending cancel");
+ vm.roll(block.number + 1); // clock == snapshot: still Pending, past the propose block
+ vm.prank(bob);
+ governor.cancel(t, v, c, h); // canceledAt == snapshot → the Pending-refund boundary
+ uint256 before = token.balanceOf(bob);
+ bondRuleset.resolveBond(id);
+ assertEq(token.balanceOf(bob), before + BOND_AMOUNT);
+ }
+
+ /// @dev Pins that the atomic propose→cancel(→resolve) round-trip — which would let a
+ /// flash-borrowed bond enter and leave custody inside one transaction — is denied at
+ /// the cancel step, so the bond provably survives the propose block in custody.
+ function test_cancel_sameBlockAsPropose_denied_bondStaysLocked() public {
+ address[] memory t;
+ uint256[] memory v;
+ bytes[] memory c;
+ bytes32 h;
+ uint256 id;
+ (id, t, v, c, h) = _proposeBonded("atomic round-trip");
+
+ vm.prank(bob);
+ vm.expectRevert(abi.encodeWithSelector(IGovernor.GovernorUnableToCancel.selector, id, bob));
+ governor.cancel(t, v, c, h);
+
+ (, bool settled) = bondRuleset.bondOf(id);
+ assertFalse(settled);
+ vm.expectRevert(
+ abi.encodeWithSelector(BondRuleset.BondNotResolvable.selector, id, IGovernor.ProposalState.Pending)
+ );
+ bondRuleset.resolveBond(id);
+ }
+
+ function test_cancel_active_forfeitsInFull() public {
+ address[] memory t;
+ uint256[] memory v;
+ bytes[] memory c;
+ bytes32 h;
+ uint256 id;
+ (id, t, v, c, h) = _proposeBonded("active cancel");
+ vm.roll(governor.proposalSnapshot(id) + 1); // Active
+ vm.prank(bob);
+ governor.cancel(t, v, c, h);
+ uint256 before = token.balanceOf(address(timelock));
+ vm.expectEmit(true, false, false, true);
+ emit BondRuleset.BondSlashed(id, BOND_AMOUNT, BondRuleset.SlashReason.ActiveSelfCancel);
+ bondRuleset.resolveBond(id);
+ assertEq(token.balanceOf(address(timelock)), before + BOND_AMOUNT);
+ }
+
+ function test_timelockVeto_forfeits_canceledAtZero() public {
+ address[] memory t;
+ uint256[] memory v;
+ bytes[] memory c;
+ bytes32 h;
+ uint256 id;
+ (id, t, v, c, h) = _proposeBonded("vetoed");
+ vm.roll(governor.proposalSnapshot(id) + 1);
+ vm.prank(alice);
+ governor.castVote(id, uint8(BondRuleset.VoteType.For));
+ vm.roll(governor.proposalDeadline(id) + 1);
+ governor.queue(t, v, c, h);
+
+ // Security-council veto: cancel directly on the timelock (GovernorTimelockControl salt).
+ bytes32 salt = bytes20(address(governor)) ^ h;
+ bytes32 opId = timelock.hashOperationBatch(t, v, c, 0, salt);
+ vm.prank(council);
+ timelock.cancel(opId);
+
+ assertEq(uint8(governor.state(id)), uint8(IGovernor.ProposalState.Canceled));
+ assertEq(governor.proposalCanceledAt(id), 0); // never canceled via the governor
+
+ uint256 before = token.balanceOf(address(timelock));
+ vm.expectEmit(true, false, false, true);
+ emit BondRuleset.BondSlashed(id, BOND_AMOUNT, BondRuleset.SlashReason.TimelockVeto);
+ bondRuleset.resolveBond(id);
+ assertEq(token.balanceOf(address(timelock)), before + BOND_AMOUNT);
+ }
+
+ function test_thirdPartyCancel_impossible_zeroThresholdLine() public {
+ // bond line has proposalThreshold = 0 → permissionless-cancel clause never fires.
+ address[] memory t;
+ uint256[] memory v;
+ bytes[] memory c;
+ bytes32 h;
+ uint256 id;
+ (id, t, v, c, h) = _proposeBonded("griefing target");
+ vm.roll(governor.proposalSnapshot(id) + 1);
+ vm.prank(eoa); // bob has zero VP — under a thresholded line ANYONE could cancel
+ vm.expectRevert(); // GovernorUnableToCancel
+ governor.cancel(t, v, c, h);
+ assertEq(uint8(governor.state(id)), uint8(IGovernor.ProposalState.Active));
+ }
+
+ // ─────────────────── Cross-mechanism interaction tests ───────────────────
+
+ /// @dev Batch voting against bond proposals: one call casts AgainstAndSlash on
+ /// one proposal and For on another — each bucket lands on its own proposal only.
+ function test_interaction_batchVote_supportThree() public {
+ address slasher = makeAddr("slasher");
+ _fund(slasher, 200_000e18);
+ vm.roll(block.number + 1);
+ (uint256 id1,,,,) = _proposeBonded("batch one");
+ (uint256 id2,,,,) = _proposeBonded("batch two");
+ vm.roll(governor.proposalSnapshot(id2) + 1);
+
+ uint256[] memory pids = new uint256[](2);
+ pids[0] = id1;
+ pids[1] = id2;
+ uint8[] memory supportValues = new uint8[](2);
+ supportValues[0] = uint8(BondRuleset.VoteType.AgainstAndSlash);
+ supportValues[1] = uint8(BondRuleset.VoteType.For);
+ string[] memory reasons = new string[](2);
+ bytes[] memory params = new bytes[](2);
+
+ vm.prank(slasher);
+ governor.castVoteWithReasonAndParamsBatch(pids, supportValues, reasons, params);
+
+ (,,, uint256 slash1) = bondRuleset.proposalVotes(id1);
+ (, uint256 for2,,) = bondRuleset.proposalVotes(id2);
+ assertEq(slash1, 200_000e18);
+ assertEq(for2, 200_000e18);
+ }
+
+ /// @dev Mutable re-vote (RulesetCounting semantics) against a bond proposal: a
+ /// voter that flips from AgainstAndSlash to For fully drains the slash bucket —
+ /// the old vote does not linger as residue.
+ function test_interaction_revote_drainsSlashBucket() public {
+ address swinger = makeAddr("swinger");
+ _fund(swinger, 200_000e18);
+ vm.roll(block.number + 1);
+ (uint256 id,,,,) = _proposeBonded("revote");
+ vm.roll(governor.proposalSnapshot(id) + 1);
+ vm.startPrank(swinger);
+ governor.castVote(id, uint8(BondRuleset.VoteType.AgainstAndSlash));
+ governor.castVote(id, uint8(BondRuleset.VoteType.For)); // replace — slash bucket back to 0
+ vm.stopPrank();
+ (,,, uint256 slash) = bondRuleset.proposalVotes(id);
+ assertEq(slash, 0);
+ }
+
+ /// @dev Late-flip anti-snipe extension against a bond proposal, proving the
+ /// mechanism is type-agnostic (the late-flip extension applies to every proposal type).
+ /// Mirrors the proven trigger from
+ /// `GovernorNexus.lateFlip.t.sol`: the pre-count observation inside the final
+ /// `extensionWindow` sees the still-failing tally (alice's earlier Against, not yet
+ /// overtaken by the flipper's own vote) and arms `FailingObserved`; the assertion
+ /// is read only after rolling past the original deadline, since the deadline view
+ /// promises nothing pre-deadline (`test_deadlineViewUnchangedBeforeOriginalDeadline`).
+ function test_interaction_lateFlip_extendsBondProposal() public {
+ // failing → passing inside the window must extend (the late-flip extension applies to every proposal type)
+ address flipper = makeAddr("flipper");
+ _fund(flipper, 2_500_000e18); // outweighs alice
+ vm.roll(block.number + 1);
+ (uint256 id,,,,) = _proposeBonded("late flip");
+ vm.roll(governor.proposalSnapshot(id) + 1);
+ vm.prank(alice);
+ governor.castVote(id, uint8(BondRuleset.VoteType.Against)); // failing
+ uint256 originalDeadline = governor.proposalDeadline(id);
+ vm.roll(originalDeadline - 5); // inside EXTENSION_WINDOW (20 blocks)
+ vm.prank(flipper);
+ governor.castVote(id, uint8(BondRuleset.VoteType.For)); // flip to passing
+ vm.roll(originalDeadline + 1); // past the original deadline: extension is decided by now
+ assertGt(governor.proposalDeadline(id), originalDeadline);
+ }
+}
diff --git a/test/governor/GovernorNexus.cancel.t.sol b/test/governor/GovernorNexus.cancel.t.sol
new file mode 100644
index 0000000..5f455c4
--- /dev/null
+++ b/test/governor/GovernorNexus.cancel.t.sol
@@ -0,0 +1,370 @@
+// SPDX-License-Identifier: MIT
+pragma solidity ^0.8.30;
+
+import {IGovernor} from "@openzeppelin/contracts/governance/IGovernor.sol";
+import {IVotes} from "@openzeppelin/contracts/governance/utils/IVotes.sol";
+
+import {GovernorNexus} from "../../src/GovernorNexus.sol";
+import {IRuleset} from "../../src/interfaces/IRuleset.sol";
+import {StandardRuleset} from "../../src/rulesets/StandardRuleset.sol";
+import {GovernorNexusTestBase} from "./GovernorNexusTestBase.sol";
+import {RevertingViewsRuleset} from "../mocks/MaliciousRulesets.sol";
+
+/// @dev Cancellation policy: cancel is possible only while the proposal is Pending|Active —
+/// by the proposer unconditionally, or by ANYONE when the proposer's prior-block votes
+/// fall below the pinned type's threshold. Once voting ends (Succeeded/Defeated/Queued
+/// and beyond) no one can cancel. `bob` is the proposer under test, `carol` the
+/// third-party canceller; `alice` stays on governance-loop duty.
+contract GovernorNexusCancelTest is GovernorNexusTestBase {
+ address internal bob = makeAddr("bob");
+ address internal carol = makeAddr("carol");
+
+ function setUp() public override {
+ super.setUp();
+ _fund(bob, 200_000e18);
+ vm.roll(block.number + 1);
+ }
+
+ // ─────────────────────────── Helpers ───────────────────────────
+
+ /// @dev Unique single-action proposal; the description carries the salt.
+ function _args(string memory description)
+ internal
+ pure
+ returns (address[] memory targets, uint256[] memory values, bytes[] memory calldatas, bytes32 descriptionHash)
+ {
+ targets = new address[](1);
+ targets[0] = address(0xBEEF);
+ values = new uint256[](1);
+ calldatas = new bytes[](1);
+ calldatas[0] = "";
+ descriptionHash = keccak256(bytes(description));
+ }
+
+ function _proposeAs(address proposer, string memory description) internal returns (uint256 proposalId) {
+ (address[] memory targets, uint256[] memory values, bytes[] memory calldatas,) = _args(description);
+ vm.prank(proposer);
+ proposalId = governor.propose(targets, values, calldatas, description);
+ }
+
+ function _cancelAs(address caller, string memory description) internal {
+ (address[] memory targets, uint256[] memory values, bytes[] memory calldatas, bytes32 descriptionHash) =
+ _args(description);
+ vm.prank(caller);
+ governor.cancel(targets, values, calldatas, descriptionHash);
+ }
+
+ /// @dev Drives `proposalId` from Pending into the target state. Assumes the standard
+ /// `_args` payload and that nobody but (optionally) alice votes.
+ function _reachState(uint256 proposalId, string memory description, IGovernor.ProposalState target) internal {
+ if (target == IGovernor.ProposalState.Pending) {
+ // leave the propose block so cancel attempts exercise the state/threshold
+ // clauses, not the propose-block bar (clock == snapshot is still Pending)
+ vm.roll(block.number + 1);
+ return;
+ }
+ vm.roll(governor.proposalSnapshot(proposalId) + 1);
+ if (target == IGovernor.ProposalState.Active) return;
+ if (target != IGovernor.ProposalState.Defeated) {
+ vm.prank(alice);
+ governor.castVote(proposalId, 1);
+ }
+ vm.roll(governor.proposalDeadline(proposalId) + 1);
+ if (target == IGovernor.ProposalState.Succeeded || target == IGovernor.ProposalState.Defeated) return;
+ (address[] memory targets, uint256[] memory values, bytes[] memory calldatas, bytes32 descriptionHash) =
+ _args(description);
+ governor.queue(targets, values, calldatas, descriptionHash);
+ if (target == IGovernor.ProposalState.Queued) return;
+ vm.warp(block.timestamp + TIMELOCK_DELAY + 1);
+ governor.execute(targets, values, calldatas, descriptionHash);
+ assertEq(uint8(governor.state(proposalId)), uint8(IGovernor.ProposalState.Executed));
+ }
+
+ /// @dev Drops `account` below any nonzero threshold: undelegate, then advance one block
+ /// so `getVotes(account, clock() - 1)` reads the zeroed checkpoint.
+ function _dipBelowThreshold(address account) internal {
+ vm.prank(account);
+ token.delegate(address(0));
+ vm.roll(block.number + 1);
+ }
+
+ function _expectUnableToCancel(uint256 proposalId, address caller) internal {
+ vm.expectRevert(abi.encodeWithSelector(IGovernor.GovernorUnableToCancel.selector, proposalId, caller));
+ }
+
+ /// @dev Timelock operation id as GovernorTimelockControl derives it.
+ function _timelockId(string memory description) internal view returns (bytes32) {
+ (address[] memory targets, uint256[] memory values, bytes[] memory calldatas, bytes32 descriptionHash) =
+ _args(description);
+ bytes32 salt = bytes32(bytes20(address(governor))) ^ descriptionHash;
+ return timelock.hashOperationBatch(targets, values, calldatas, 0, salt);
+ }
+
+ // ─────────────────────── Baseline: healthy proposals stay uncancellable ───────────────────────
+
+ function test_thirdParty_cannotCancelHealthyProposal_anyState() public {
+ IGovernor.ProposalState[4] memory states = [
+ IGovernor.ProposalState.Pending,
+ IGovernor.ProposalState.Active,
+ IGovernor.ProposalState.Succeeded,
+ IGovernor.ProposalState.Queued
+ ];
+ for (uint256 i = 0; i < states.length; ++i) {
+ // fresh proposer per iteration: Pending|Active proposals occupy spam-limit slots
+ address proposer = makeAddr(string.concat("healthy-proposer", vm.toString(i)));
+ _fund(proposer, 200_000e18);
+ vm.roll(block.number + 1);
+ string memory description = string.concat("healthy", vm.toString(i));
+ uint256 id = _proposeAs(proposer, description);
+ _reachState(id, description, states[i]);
+ _expectUnableToCancel(id, carol);
+ _cancelAs(carol, description);
+ }
+ }
+
+ function test_cancelNonexistentProposal_reverts() public {
+ (address[] memory targets, uint256[] memory values, bytes[] memory calldatas, bytes32 descriptionHash) =
+ _args("never proposed");
+ uint256 id = governor.getProposalId(targets, values, calldatas, descriptionHash);
+ vm.expectRevert(abi.encodeWithSelector(IGovernor.GovernorNonexistentProposal.selector, id));
+ vm.prank(carol);
+ governor.cancel(targets, values, calldatas, descriptionHash);
+ }
+
+ // ─────────────────────── Self-cancel: Pending|Active only ───────────────────────
+
+ function test_selfCancel_pending() public {
+ uint256 id = _proposeAs(bob, "p");
+ vm.roll(block.number + 1); // clock == snapshot: still Pending, past the propose block
+ vm.expectEmit(address(governor));
+ emit IGovernor.ProposalCanceled(id);
+ _cancelAs(bob, "p");
+ assertEq(uint8(governor.state(id)), uint8(IGovernor.ProposalState.Canceled));
+ }
+
+ function test_selfCancel_active() public {
+ uint256 id = _proposeAs(bob, "p");
+ _reachState(id, "p", IGovernor.ProposalState.Active);
+ _cancelAs(bob, "p");
+ assertEq(uint8(governor.state(id)), uint8(IGovernor.ProposalState.Canceled));
+ }
+
+ function test_selfCancel_afterVotingEnds_reverts_whileAboveThreshold() public {
+ uint256 id = _proposeAs(bob, "p");
+ _reachState(id, "p", IGovernor.ProposalState.Succeeded);
+ _expectUnableToCancel(id, bob);
+ _cancelAs(bob, "p");
+
+ uint256 idQ = _proposeAs(bob, "q");
+ _reachState(idQ, "q", IGovernor.ProposalState.Queued);
+ _expectUnableToCancel(idQ, bob);
+ _cancelAs(bob, "q");
+ }
+
+ // ─────────────────── Continuous threshold: permissionless cancel ───────────────────
+
+ function test_belowThreshold_anyoneCancels_pending() public {
+ uint256 id = _proposeAs(bob, "p");
+ _dipBelowThreshold(bob);
+ _cancelAs(carol, "p");
+ assertEq(uint8(governor.state(id)), uint8(IGovernor.ProposalState.Canceled));
+ }
+
+ function test_belowThreshold_anyoneCancels_active() public {
+ uint256 id = _proposeAs(bob, "p");
+ _reachState(id, "p", IGovernor.ProposalState.Active);
+ _dipBelowThreshold(bob);
+ _cancelAs(carol, "p");
+ assertEq(uint8(governor.state(id)), uint8(IGovernor.ProposalState.Canceled));
+ }
+
+ function test_belowThreshold_votingEnded_uncancellable() public {
+ // once voting ends the proposal is settled for cancellation purposes: below-threshold
+ // proposers no longer expose it, in any post-vote state.
+ IGovernor.ProposalState[3] memory states =
+ [IGovernor.ProposalState.Succeeded, IGovernor.ProposalState.Defeated, IGovernor.ProposalState.Queued];
+ for (uint256 i = 0; i < states.length; ++i) {
+ address proposer = makeAddr(string.concat("ended-proposer", vm.toString(i)));
+ _fund(proposer, 200_000e18);
+ vm.roll(block.number + 1);
+ string memory description = string.concat("ended", vm.toString(i));
+ uint256 id = _proposeAs(proposer, description);
+ _reachState(id, description, states[i]);
+ _dipBelowThreshold(proposer);
+ _expectUnableToCancel(id, carol);
+ _cancelAs(carol, description);
+ }
+ }
+
+ function test_belowThreshold_queued_staysScheduled() public {
+ uint256 id = _proposeAs(bob, "p");
+ _reachState(id, "p", IGovernor.ProposalState.Queued);
+ _dipBelowThreshold(bob);
+
+ _expectUnableToCancel(id, carol);
+ _cancelAs(carol, "p");
+
+ assertEq(uint8(governor.state(id)), uint8(IGovernor.ProposalState.Queued));
+ assertTrue(timelock.isOperation(_timelockId("p")));
+ }
+
+ function test_belowThreshold_proposerCannotCancel_postVote() public {
+ uint256 id = _proposeAs(bob, "p");
+ _reachState(id, "p", IGovernor.ProposalState.Succeeded);
+ _dipBelowThreshold(bob);
+ _expectUnableToCancel(id, bob);
+ _cancelAs(bob, "p");
+ }
+
+ function test_belowThreshold_executedProposal_uncancellable() public {
+ uint256 id = _proposeAs(bob, "p");
+ _reachState(id, "p", IGovernor.ProposalState.Executed);
+ _dipBelowThreshold(bob);
+ _expectUnableToCancel(id, carol);
+ _cancelAs(carol, "p");
+ assertEq(uint8(governor.state(id)), uint8(IGovernor.ProposalState.Executed));
+ }
+
+ function test_exactlyAtThreshold_notCancellable() public {
+ address eve = makeAddr("eve");
+ _fund(eve, PROPOSAL_THRESHOLD); // exactly at threshold: `<` must not fire
+ vm.roll(block.number + 1);
+ uint256 id = _proposeAs(eve, "p");
+ vm.roll(block.number + 1);
+ _expectUnableToCancel(id, carol);
+ _cancelAs(carol, "p");
+ }
+
+ // ─────────────────────── Prior-block read, churn window ───────────────────────
+
+ function test_dipAtPriorBlock_cancellableEvenIfRestoredNow() public {
+ uint256 id = _proposeAs(bob, "p");
+
+ vm.prank(bob);
+ token.delegate(address(0)); // checkpoint N: 0 votes
+ vm.roll(block.number + 1);
+ vm.prank(bob);
+ token.delegate(bob); // checkpoint N+1: restored — but clock()-1 reads N
+
+ _cancelAs(carol, "p");
+ assertEq(uint8(governor.state(id)), uint8(IGovernor.ProposalState.Canceled));
+ }
+
+ // ─────────────────────── Pin discipline: per-type threshold ───────────────────────
+
+ function test_pinnedTypeThreshold_drivesTheCheck_notDefaultType() public {
+ // register a 300k-threshold type; bob (200k) proposes under type 0 (100k) fine,
+ // and is NOT cancellable — the pinned line, not the highest or newest, applies.
+ StandardRuleset rs = _newRuleset();
+ _executeSelfCall(
+ abi.encodeCall(GovernorNexus.registerType, (rs, VOTING_DELAY, VOTING_PERIOD, 300_000e18)),
+ "register 300k type"
+ );
+
+ uint256 id = _proposeAs(bob, "under type 0");
+ vm.roll(block.number + 1);
+ _expectUnableToCancel(id, carol);
+ _cancelAs(carol, "under type 0");
+
+ // and a proposal pinned to the 300k line IS cancellable once its proposer dips below
+ // 300k — even though they stay above type 0's 100k.
+ address whale = makeAddr("whale");
+ _fund(whale, 400_000e18);
+ vm.roll(block.number + 1);
+ (address[] memory targets, uint256[] memory values, bytes[] memory calldatas,) = _args("under type 1");
+ vm.prank(whale);
+ uint256 idTyped = governor.proposeWithType(targets, values, calldatas, "under type 1", 1);
+
+ vm.prank(whale);
+ assertTrue(token.transfer(bob, 250_000e18)); // whale: 150k — above 100k, below pinned 300k
+ vm.roll(block.number + 1);
+
+ _cancelAs(carol, "under type 1");
+ assertEq(uint8(governor.state(idTyped)), uint8(IGovernor.ProposalState.Canceled));
+ }
+
+ // ─────────────────── Threshold-based types only (threshold == 0) ───────────────────
+
+ function test_zeroThresholdType_neverPermissionlesslyCancellable() public {
+ StandardRuleset rs = _newRuleset();
+ _executeSelfCall(
+ abi.encodeCall(GovernorNexus.registerType, (rs, VOTING_DELAY, VOTING_PERIOD, uint256(0))),
+ "register zero-threshold type"
+ );
+
+ // dave holds zero votes — proposes under the zero-threshold type
+ address dave = makeAddr("dave");
+ (address[] memory targets, uint256[] memory values, bytes[] memory calldatas, bytes32 descriptionHash) =
+ _args("bondlike");
+ vm.prank(dave);
+ uint256 id = governor.proposeWithType(targets, values, calldatas, "bondlike", 1);
+ vm.roll(block.number + 1); // past the propose block; still Pending
+
+ _expectUnableToCancel(id, carol);
+ _cancelAs(carol, "bondlike");
+
+ // self-cancel still works for the zero-threshold type's proposer
+ vm.prank(dave);
+ governor.cancel(targets, values, calldatas, descriptionHash);
+ assertEq(uint8(governor.state(id)), uint8(IGovernor.ProposalState.Canceled));
+ }
+
+ // ─────────────────── Interactions & containment ───────────────────
+
+ function test_permissionlessCancel_freesSpamLimitSlot() public {
+ _proposeAs(bob, "p1");
+ _proposeAs(bob, "p2"); // bob at the cap (2)
+ _dipBelowThreshold(bob);
+ _cancelAs(carol, "p1");
+ assertEq(governor.activeProposalCount(bob), 1);
+ }
+
+ function test_poisonedRulesetType_selfCancelWorks_withinDeadline() public {
+ // a ruleset with reverting views must not block cancel while state() still
+ // resolves from core storage (pre-deadline) — the core's containment boundary.
+ RevertingViewsRuleset poisoned = new RevertingViewsRuleset(address(governor));
+ _executeSelfCall(
+ abi.encodeCall(
+ GovernorNexus.registerType, (IRuleset(address(poisoned)), VOTING_DELAY, VOTING_PERIOD, uint256(0))
+ ),
+ "register poisoned type"
+ );
+
+ (address[] memory targets, uint256[] memory values, bytes[] memory calldatas, bytes32 descriptionHash) =
+ _args("poisoned");
+ vm.prank(bob);
+ uint256 id = governor.proposeWithType(targets, values, calldatas, "poisoned", 1);
+ vm.roll(block.number + 1); // past the propose block; still Pending
+
+ vm.prank(bob);
+ governor.cancel(targets, values, calldatas, descriptionHash);
+ assertEq(uint8(governor.state(id)), uint8(IGovernor.ProposalState.Canceled));
+ }
+
+ // ─────────────────────── Propose-block bar ───────────────────────
+
+ function test_cancelInProposeBlock_denied_thenNextBlockSucceeds() public {
+ uint256 id = _proposeAs(bob, "same-block");
+ _expectUnableToCancel(id, bob);
+ _cancelAs(bob, "same-block"); // atomic propose→cancel round-trip is unrepresentable
+
+ vm.roll(block.number + 1); // one block later the ordinary Pending self-cancel works
+ _cancelAs(bob, "same-block");
+ assertEq(uint8(governor.state(id)), uint8(IGovernor.ProposalState.Canceled));
+ }
+
+ // ─────────────────────── proposalCanceledAt ───────────────────────
+
+ function test_proposalCanceledAt_zeroBeforeCancel() public {
+ uint256 id = _proposeAs(bob, "canceled-at zero");
+ assertEq(governor.proposalCanceledAt(id), 0);
+ }
+
+ function test_proposalCanceledAt_recordsClockOnSelfCancel() public {
+ uint256 id = _proposeAs(bob, "canceled-at self");
+ vm.roll(block.number + 1); // still Pending
+ uint48 expected = uint48(block.number);
+ _cancelAs(bob, "canceled-at self");
+ assertEq(governor.proposalCanceledAt(id), expected);
+ }
+}
diff --git a/test/governor/GovernorNexus.lateFlip.t.sol b/test/governor/GovernorNexus.lateFlip.t.sol
new file mode 100644
index 0000000..f7243dc
--- /dev/null
+++ b/test/governor/GovernorNexus.lateFlip.t.sol
@@ -0,0 +1,427 @@
+// SPDX-License-Identifier: MIT
+pragma solidity ^0.8.30;
+
+import {IGovernor} from "@openzeppelin/contracts/governance/IGovernor.sol";
+import {IVotes} from "@openzeppelin/contracts/governance/utils/IVotes.sol";
+import {Vm} from "forge-std/Vm.sol";
+
+import {GovernorNexus} from "../../src/GovernorNexus.sol";
+import {GovernorPreventLateFlip} from "../../src/GovernorPreventLateFlip.sol";
+import {StandardRuleset} from "../../src/rulesets/StandardRuleset.sol";
+import {GovernorNexusTestBase} from "./GovernorNexusTestBase.sol";
+
+/// @dev Anti-snipe late-vote extension. The mechanism's public surface is deliberately
+/// minimal: the `proposalDeadline` view, the `ProposalExtended` event, and the two
+/// immutable params — every test here asserts through those only.
+///
+/// Trigger semantics under test ("window low-water mark"): the extension fires iff the
+/// proposal was observed failing at any point inside the final `extensionWindow` AND
+/// would pass at the original deadline — with no state armed on tally crossings, so
+/// mutable-vote oscillation cannot burn it. Anchor: original deadline +
+/// `extensionDuration`, regardless of flip timing.
+contract GovernorNexusLateFlipTest is GovernorNexusTestBase {
+ /// @dev OZ `GovernorPreventLateQuorum` event ABI, adopted verbatim.
+ event ProposalExtended(uint256 indexed proposalId, uint64 extendedDeadline);
+
+ address internal bob = makeAddr("bob"); // can out-vote alice alone
+ address internal carol = makeAddr("carol"); // dust weight: materializes, never flips
+ address internal dave = makeAddr("dave"); // can out-vote alice + bob together
+
+ function setUp() public virtual override {
+ super.setUp();
+ _fund(bob, 3_000_000e18);
+ _fund(carol, 100e18);
+ _fund(dave, 6_000_000e18);
+ vm.roll(block.number + 1);
+ }
+
+ // ─────────────────────────── helpers ───────────────────────────
+
+ /// @dev Propose through the default (standard) type and roll into Active.
+ /// Returns the id and the ORIGINAL deadline T (read before any extension can exist).
+ function _proposeActive(string memory description) internal returns (uint256 id, uint256 t) {
+ address[] memory targets = new address[](1);
+ targets[0] = address(governor);
+ uint256[] memory values = new uint256[](1);
+ bytes[] memory calldatas = new bytes[](1);
+ calldatas[0] = "";
+
+ vm.prank(alice);
+ id = governor.propose(targets, values, calldatas, description);
+ vm.roll(governor.proposalSnapshot(id) + 1);
+ t = governor.proposalDeadline(id);
+ }
+
+ function _vote(address voter, uint256 id, uint8 support) internal {
+ vm.prank(voter);
+ governor.castVote(id, support);
+ }
+
+ /// @dev "Would pass right now" exactly as the core evaluates it.
+ function _wouldPass(uint256 id) internal view returns (bool) {
+ return standardRuleset.quorumReached(id) && standardRuleset.voteSucceeded(id);
+ }
+
+ // ─────────────────────────── constructor surface ───────────────────────────
+
+ function test_constructor_extensionParamsExposed() public view {
+ assertEq(governor.extensionWindow(), EXTENSION_WINDOW);
+ assertEq(governor.extensionDuration(), EXTENSION_DURATION);
+ }
+
+ function test_constructor_revertsWhenVotingPeriodNotBeyondExtensionWindow() public {
+ StandardRuleset rs = _rulesetForNextGovernor();
+ vm.expectRevert(
+ abi.encodeWithSelector(GovernorNexus.VotingPeriodTooShort.selector, EXTENSION_WINDOW, EXTENSION_WINDOW)
+ );
+ new GovernorNexus(
+ "GovernorNexus",
+ IVotes(address(token)),
+ timelock,
+ rs,
+ VOTING_DELAY,
+ // votingPeriod == window: the "final 24h" would be the whole vote. The cast is
+ // safe: EXTENSION_WINDOW is 20.
+ // forge-lint: disable-next-line(unsafe-typecast)
+ uint32(EXTENSION_WINDOW),
+ PROPOSAL_THRESHOLD,
+ 2,
+ EXTENSION_WINDOW,
+ EXTENSION_DURATION
+ );
+ }
+
+ function test_constructor_revertsOnZeroExtensionParams() public {
+ vm.expectRevert(GovernorPreventLateFlip.InvalidExtensionConfig.selector);
+ new GovernorNexus(
+ "GovernorNexus",
+ IVotes(address(token)),
+ timelock,
+ standardRuleset,
+ VOTING_DELAY,
+ VOTING_PERIOD,
+ PROPOSAL_THRESHOLD,
+ 2,
+ 0,
+ EXTENSION_DURATION
+ );
+
+ vm.expectRevert(GovernorPreventLateFlip.InvalidExtensionConfig.selector);
+ new GovernorNexus(
+ "GovernorNexus",
+ IVotes(address(token)),
+ timelock,
+ standardRuleset,
+ VOTING_DELAY,
+ VOTING_PERIOD,
+ PROPOSAL_THRESHOLD,
+ 2,
+ EXTENSION_WINDOW,
+ 0
+ );
+ }
+
+ /// @dev The `registerType` guard shares `_registerType` with the constructor (single
+ /// registration path), so the constructor case above exercises the same check the
+ /// governance door hits.
+
+ // ─────────────────────────── trigger matrix ───────────────────────────
+
+ /// @dev The headline case: failing at window entry, flipped passing inside the
+ /// window → extended by exactly `extensionDuration` past the ORIGINAL deadline.
+ function test_flipInsideWindow_extendsDeadlineByExtensionDuration() public {
+ (uint256 id, uint256 t) = _proposeActive("flip inside window");
+
+ _vote(alice, id, 0); // failing: Against 2M, For 0
+ vm.roll(t - 10); // inside the final window
+ _vote(bob, id, 1); // flip: For 3M > Against 2M, quorum met
+
+ vm.roll(t + 1);
+ assertEq(governor.proposalDeadline(id), t + EXTENSION_DURATION, "extended by duration from original deadline");
+ assertEq(uint8(governor.state(id)), uint8(IGovernor.ProposalState.Active), "voting stays open");
+
+ vm.roll(t + EXTENSION_DURATION);
+ assertEq(uint8(governor.state(id)), uint8(IGovernor.ProposalState.Active), "open through the last block");
+
+ vm.roll(t + EXTENSION_DURATION + 1);
+ assertEq(uint8(governor.state(id)), uint8(IGovernor.ProposalState.Succeeded), "final tally decides at T+E");
+ }
+
+ /// @dev Before the original deadline the view promises nothing: a mid-window flip can
+ /// still revert, so the extension is undecidable until T.
+ function test_deadlineViewUnchangedBeforeOriginalDeadline() public {
+ (uint256 id, uint256 t) = _proposeActive("undecidable before T");
+
+ _vote(alice, id, 0);
+ vm.roll(t - 10);
+ _vote(bob, id, 1); // flip observed in window
+
+ assertEq(governor.proposalDeadline(id), t, "no tentative extension mid-window");
+ vm.roll(t);
+ assertEq(governor.proposalDeadline(id), t, "still the original deadline at T itself");
+ assertEq(uint8(governor.state(id)), uint8(IGovernor.ProposalState.Active), "T is a voting block either way");
+ }
+
+ /// @dev Normal proposals are not delayed when outcome direction is stable —
+ /// passing through the whole window (with in-window activity) never extends.
+ function test_stablePassingThroughWindow_noExtension() public {
+ (uint256 id, uint256 t) = _proposeActive("stable passing");
+
+ _vote(bob, id, 1); // passing well before the window
+ vm.roll(t - 10);
+ _vote(carol, id, 1); // in-window vote observes passing → no low-water mark
+
+ vm.roll(t + 1);
+ assertEq(governor.proposalDeadline(id), t, "no extension");
+ assertEq(uint8(governor.state(id)), uint8(IGovernor.ProposalState.Succeeded), "decided at T");
+ }
+
+ function test_stableFailing_noExtension_defeatedAtOriginalDeadline() public {
+ (uint256 id, uint256 t) = _proposeActive("stable failing");
+
+ _vote(alice, id, 0);
+
+ vm.roll(t + 1);
+ assertEq(governor.proposalDeadline(id), t, "no extension");
+ assertEq(uint8(governor.state(id)), uint8(IGovernor.ProposalState.Defeated), "defeated at T");
+ }
+
+ /// @dev The dip-snipe — the scenario a two-point boundary comparison misses. Passing at
+ /// window entry AND at T, but failing in between: the low-water mark catches the
+ /// mid-window failing state, so the late re-flip still extends.
+ function test_dipAndRecover_passingAtBothBoundaries_stillExtends() public {
+ (uint256 id, uint256 t) = _proposeActive("dip and recover");
+
+ _vote(bob, id, 1); // passing before the window opens
+ vm.roll(t - 15);
+ _vote(bob, id, 0); // re-vote creates a failing state inside the window (the dip)
+ vm.roll(t - 1);
+ _vote(bob, id, 1); // late re-flip back to passing
+
+ vm.roll(t + 1);
+ assertEq(governor.proposalDeadline(id), t + EXTENSION_DURATION, "dip inside the window forces the extension");
+ assertEq(uint8(governor.state(id)), uint8(IGovernor.ProposalState.Active), "response window open");
+ }
+
+ /// @dev The oscillation that burns OZ-style one-shot slots. Crossing early, re-voting
+ /// down, and sniping late must CAUSE the extension, not consume it.
+ function test_oscillation_cannotBurnExtension() public {
+ (uint256 id, uint256 t) = _proposeActive("threshold oscillation");
+
+ _vote(alice, id, 0); // failing baseline
+ vm.roll(t - 18);
+ _vote(bob, id, 1); // cross early inside the window
+ vm.roll(t - 15);
+ _vote(bob, id, 0); // re-vote down — this is where OZ's slot would already be burned
+ vm.roll(t - 1);
+ _vote(bob, id, 1); // the snipe
+
+ vm.roll(t + 1);
+ assertEq(governor.proposalDeadline(id), t + EXTENSION_DURATION, "extension not burnable by oscillation");
+ }
+
+ /// @dev One-directional trigger: a late flip TO failing gets no extension — the proposal
+ /// simply dies at T. A failing observation alone is not enough; it must pass at T.
+ function test_lateFlipToFailing_noExtension() public {
+ (uint256 id, uint256 t) = _proposeActive("late flip to failing");
+
+ _vote(bob, id, 1); // passing before the window
+ vm.roll(t - 5);
+ _vote(bob, id, 0); // late re-vote: failing at T
+
+ vm.roll(t + 1);
+ assertEq(governor.proposalDeadline(id), t, "no extension for a failing outcome");
+ assertEq(uint8(governor.state(id)), uint8(IGovernor.ProposalState.Defeated), "dies at T");
+ }
+
+ // ─────────────────────── lazy materialization & event ───────────────────────
+
+ /// @dev The first cast after T materializes the (already-determined) extension and emits
+ /// the OZ-shaped event — exactly once, anchored at T + duration.
+ function test_firstPostDeadlineCast_materializesAndEmitsOnce() public {
+ (uint256 id, uint256 t) = _proposeActive("materialization");
+
+ _vote(alice, id, 0);
+ vm.roll(t - 10);
+ _vote(bob, id, 1); // flip in window
+
+ vm.roll(t + 5);
+ vm.expectEmit(true, false, false, true);
+ emit ProposalExtended(id, uint64(t + EXTENSION_DURATION));
+ _vote(carol, id, 1); // dust vote: materializes, cannot flip anything
+
+ // A second cast during the extension must not re-emit.
+ vm.recordLogs();
+ _vote(carol, id, 2);
+ Vm.Log[] memory logs = vm.getRecordedLogs();
+ bytes32 topic = keccak256("ProposalExtended(uint256,uint64)");
+ for (uint256 i = 0; i < logs.length; i++) {
+ assertTrue(logs[i].topics[0] != topic, "ProposalExtended emitted more than once");
+ }
+ }
+
+ /// @dev Degenerate case: nobody votes during the extension — the event never fires, but
+ /// the views stay correct forever off the tally frozen since T.
+ function test_noVotesDuringExtension_viewsConsistent_noEvent() public {
+ (uint256 id, uint256 t) = _proposeActive("silent extension");
+
+ _vote(alice, id, 0);
+ vm.roll(t - 10);
+ _vote(bob, id, 1);
+
+ vm.roll(t + EXTENSION_DURATION + 1);
+ assertEq(governor.proposalDeadline(id), t + EXTENSION_DURATION, "extension visible without materialization");
+ assertEq(uint8(governor.state(id)), uint8(IGovernor.ProposalState.Succeeded), "outcome = tally at T, unchanged");
+ }
+
+ /// @dev A post-T cast on a NON-extended proposal reverts wholesale — no partial state,
+ /// no extension residue.
+ function test_postDeadlineCastOnNonExtendedProposal_revertsWholesale() public {
+ (uint256 id, uint256 t) = _proposeActive("no zombie votes");
+
+ _vote(alice, id, 0); // stable failing → no extension
+
+ vm.roll(t + 1);
+ vm.prank(bob);
+ vm.expectPartialRevert(IGovernor.GovernorUnexpectedProposalState.selector);
+ governor.castVote(id, 1);
+
+ assertEq(governor.proposalDeadline(id), t, "no residue from the reverted cast");
+ assertEq(uint8(governor.state(id)), uint8(IGovernor.ProposalState.Defeated), "still defeated");
+ }
+
+ // ─────────────────────── free voting during the extension ───────────────────────
+
+ /// @dev Votes stay free in both directions during the extension; the tally at T+E decides.
+ /// Here the community uses the response window to defeat the sniped proposal.
+ function test_votingFreeDuringExtension_finalTallyDecides() public {
+ (uint256 id, uint256 t) = _proposeActive("extension defends");
+
+ _vote(alice, id, 0);
+ vm.roll(t - 10);
+ _vote(bob, id, 1); // snipe: For 3M vs Against 2M
+
+ vm.roll(t + 10);
+ _vote(dave, id, 0); // the response the window exists for: Against 8M
+
+ assertEq(governor.proposalDeadline(id), t + EXTENSION_DURATION, "deadline stable during the extension");
+ vm.roll(t + EXTENSION_DURATION + 1);
+ assertEq(uint8(governor.state(id)), uint8(IGovernor.ProposalState.Defeated), "snipe defeated in the extension");
+ }
+
+ /// @dev One extension only: a flip inside the extension never re-extends — T + E is a
+ /// hard ceiling.
+ function test_noSecondExtension_flipInsideExtensionDoesNotReExtend() public {
+ (uint256 id, uint256 t) = _proposeActive("no re-extension");
+
+ _vote(alice, id, 0);
+ vm.roll(t - 10);
+ _vote(bob, id, 1); // extended
+
+ vm.roll(t + 10);
+ _vote(dave, id, 0); // failing inside the extension
+ vm.roll(t + EXTENSION_DURATION - 2);
+ _vote(dave, id, 1); // flips back passing right before T+E — no second extension
+
+ assertEq(governor.proposalDeadline(id), t + EXTENSION_DURATION, "ceiling holds");
+ vm.roll(t + EXTENSION_DURATION + 1);
+ assertEq(uint8(governor.state(id)), uint8(IGovernor.ProposalState.Succeeded), "decided at the ceiling");
+ }
+
+ // ─────────────────────── cast-path coverage: bySig ───────────────────────
+
+ /// @dev The hooks live on the internal `_castVote`, so the sig paths (the per-proposal
+ /// nonce spend in `_castVote`) are covered too: a bySig flip inside the window
+ /// extends.
+ function test_castVoteBySig_insideWindow_triggersExtension() public {
+ (address signer, uint256 signerKey) = makeAddrAndKey("signer");
+ _fund(signer, 5_000_000e18);
+ vm.roll(block.number + 1);
+
+ (uint256 id, uint256 t) = _proposeActive("bySig flip");
+ _vote(alice, id, 0); // failing
+
+ vm.roll(t - 10);
+ bytes memory ballot = _signBallot(id, 1, signer, signerKey, governor.voteNonce(id, signer));
+ governor.castVoteBySig(id, 1, signer, ballot); // flip through the sig path
+
+ vm.roll(t + 1);
+ assertEq(governor.proposalDeadline(id), t + EXTENSION_DURATION, "sig-path flip extends");
+ }
+
+ // ─────────────────────── property fuzz ───────────────────────
+
+ /// @dev The core invariant, model-checked: for arbitrary bounded cast sequences,
+ /// the effective deadline is T+E iff (some in-window evaluation — pre- or post-cast —
+ /// observed a failing state) AND (the outcome at T is passing); otherwise T. The
+ /// model mirrors the implementation's observation points exactly, which is sound
+ /// because tallies only change inside casts.
+ function testFuzz_extensionMatchesLowWaterPredicate(uint8[4] memory sups, uint8[4] memory offsets) public {
+ (uint256 id, uint256 t) = _proposeActive("fuzz low-water");
+ uint256 snapshot = governor.proposalSnapshot(id);
+
+ // Normalize: supports into {0,1,2}, offsets into (snapshot, T] ascending.
+ uint256[4] memory blocks_;
+ for (uint256 i = 0; i < 4; i++) {
+ sups[i] = sups[i] % 3;
+ blocks_[i] = snapshot + 1 + (uint256(offsets[i]) % VOTING_PERIOD); // (snapshot, T]
+ }
+ // insertion sort, ascending
+ for (uint256 i = 1; i < 4; i++) {
+ for (uint256 j = i; j > 0 && blocks_[j - 1] > blocks_[j]; j--) {
+ (blocks_[j - 1], blocks_[j]) = (blocks_[j], blocks_[j - 1]);
+ (sups[j - 1], sups[j]) = (sups[j], sups[j - 1]);
+ }
+ }
+
+ address[2] memory voters = [bob, dave];
+ bool sawFailing = false;
+ for (uint256 i = 0; i < 4; i++) {
+ vm.roll(blocks_[i]);
+ bool inWindow = blocks_[i] >= t - EXTENSION_WINDOW; // ≤ T by construction
+ if (inWindow && !_wouldPass(id)) sawFailing = true;
+ _vote(voters[i % 2], id, sups[i]);
+ if (inWindow && !_wouldPass(id)) sawFailing = true;
+ }
+
+ vm.roll(t);
+ bool passesAtT = _wouldPass(id);
+ uint256 expected = (sawFailing && passesAtT) ? t + EXTENSION_DURATION : t;
+
+ vm.roll(t + 1);
+ assertEq(governor.proposalDeadline(id), expected, "deadline matches the low-water predicate");
+ if (!(sawFailing && passesAtT)) {
+ assertEq(
+ uint8(governor.state(id)),
+ uint8(passesAtT ? IGovernor.ProposalState.Succeeded : IGovernor.ProposalState.Defeated),
+ "non-extended outcome decided at T"
+ );
+ } else {
+ assertEq(uint8(governor.state(id)), uint8(IGovernor.ProposalState.Active), "extension keeps voting open");
+ }
+ }
+
+ // ─────────────────────────── helpers (sig path) ───────────────────────────
+
+ function _signBallot(uint256 proposalId, uint8 support, address voter, uint256 key, uint256 nonce)
+ internal
+ view
+ returns (bytes memory)
+ {
+ bytes32 structHash = keccak256(abi.encode(governor.BALLOT_TYPEHASH(), proposalId, support, voter, nonce));
+ (, string memory name, string memory version, uint256 chainId, address verifyingContract,,) =
+ governor.eip712Domain();
+ bytes32 domainSeparator = keccak256(
+ abi.encode(
+ keccak256("EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"),
+ keccak256(bytes(name)),
+ keccak256(bytes(version)),
+ chainId,
+ verifyingContract
+ )
+ );
+ bytes32 digest = keccak256(abi.encodePacked("\x19\x01", domainSeparator, structHash));
+ (uint8 v, bytes32 r, bytes32 s) = vm.sign(key, digest);
+ return abi.encodePacked(r, s, v);
+ }
+}
diff --git a/test/GovernorNexus.lifecycle.t.sol b/test/governor/GovernorNexus.lifecycle.t.sol
similarity index 69%
rename from test/GovernorNexus.lifecycle.t.sol
rename to test/governor/GovernorNexus.lifecycle.t.sol
index da1b6ee..a53bcfb 100644
--- a/test/GovernorNexus.lifecycle.t.sol
+++ b/test/governor/GovernorNexus.lifecycle.t.sol
@@ -7,11 +7,12 @@ import {IGovernor} from "@openzeppelin/contracts/governance/IGovernor.sol";
import {TimelockController} from "@openzeppelin/contracts/governance/TimelockController.sol";
import {IVotes} from "@openzeppelin/contracts/governance/utils/IVotes.sol";
-import {GovernorNexus} from "../src/GovernorNexus.sol";
-import {IRuleset} from "../src/IRuleset.sol";
-import {StandardRuleset} from "../src/StandardRuleset.sol";
-import {Box} from "./mocks/Box.sol";
-import {MockENSToken} from "./mocks/MockENSToken.sol";
+import {GovernorNexus} from "../../src/GovernorNexus.sol";
+import {IRuleset} from "../../src/interfaces/IRuleset.sol";
+import {RulesetCounting} from "../../src/RulesetCounting.sol";
+import {StandardRuleset} from "../../src/rulesets/StandardRuleset.sol";
+import {Box} from "../mocks/Box.sol";
+import {MockENSToken} from "../mocks/MockENSToken.sol";
/// @dev Full-lifecycle suite for GovernorNexus with real ruleset dispatch. Unlike the
/// registry/propose suites (which use a trivial harness fixture), this deploys plain
@@ -27,6 +28,8 @@ contract GovernorNexusLifecycleTest is Test {
uint48 internal constant VOTING_DELAY = 1;
uint32 internal constant VOTING_PERIOD = 50;
uint256 internal constant PROPOSAL_THRESHOLD = 1e18;
+ uint48 internal constant EXTENSION_WINDOW = 20;
+ uint48 internal constant EXTENSION_DURATION = 40;
uint256 internal constant Q0_NUMERATOR = 20; // default type: quorum = 20e18
uint256 internal constant Q1_NUMERATOR = 60; // second type: quorum = 60e18
@@ -52,7 +55,7 @@ contract GovernorNexusLifecycleTest is Test {
token = new MockENSToken();
timelock = new TimelockController(TIMELOCK_DELAY, new address[](0), new address[](0), address(this));
- // Wiring (spec §Wiring note): StandardRuleset.countVote is onlyGovernor and
+ // Wiring: StandardRuleset.countVote is onlyGovernor and
// quorumReached reads governor.proposalSnapshot, so the ruleset must be constructed
// with the governor's address. The governor's constructor in turn needs the ruleset,
// so we precompute the governor's CREATE address (next nonce + 1) and hand it to the
@@ -66,7 +69,10 @@ contract GovernorNexusLifecycleTest is Test {
standardRuleset,
VOTING_DELAY,
VOTING_PERIOD,
- PROPOSAL_THRESHOLD
+ PROPOSAL_THRESHOLD,
+ 2,
+ EXTENSION_WINDOW,
+ EXTENSION_DURATION
);
require(address(governor) == predictedGovernor, "governor address prediction failed");
@@ -241,15 +247,126 @@ contract GovernorNexusLifecycleTest is Test {
assertEq(uint8(_state(id)), uint8(IGovernor.ProposalState.Succeeded));
}
- // ─────────────────────── 3. Revote rejected ───────────────────────
+ // ─────────────────────── 3. Revote replaces ───────────────────────
- function test_revote_revertsAlreadyVoted() public {
- (uint256 id,,,,) = _proposeActive(1, "revote", 0);
+ /// @dev End-to-end proof that the outcome follows the *standing* votes: alice (50e18) carries
+ /// the proposal, then re-votes Against — at the deadline the proposal is Defeated, the
+ /// For bucket holding only bob's weight.
+ function test_revote_outcomeFollowsTheLatestVote() public {
+ (uint256 id,,,,) = _proposeActive(1, "revote decides", 0);
+ _vote(id, alice, 1); // For 50e18
+ _vote(id, bob, 1); // For 10e18 → For 60e18, quorum (20e18) reached, succeeding
+ assertTrue(standardRuleset.voteSucceeded(id));
+
+ _vote(id, alice, 0); // alice re-votes Against 50e18 → For 10e18, Against 50e18
+
+ (uint256 against, uint256 for_,) = standardRuleset.proposalVotes(id);
+ assertEq(for_, 10e18, "alice's weight left the For bucket");
+ assertEq(against, 50e18, "and landed in Against: counted once, not twice");
+ assertTrue(governor.hasVoted(id, alice), "hasVoted means 'has a standing vote'");
+
+ vm.roll(governor.proposalDeadline(id) + 1);
+ assertEq(uint8(_state(id)), uint8(IGovernor.ProposalState.Defeated));
+ }
+
+ /// @dev No new event — the core re-emits stock `VoteCast` on every cast, so an indexer's
+ /// rule is "latest VoteCast per (proposal, voter), in log order, is canonical".
+ function test_revote_emitsVoteCastAgain() public {
+ (uint256 id,,,,) = _proposeActive(1, "revote emits", 0);
+ _vote(id, alice, 1);
+
+ vm.expectEmit(true, true, true, true, address(governor));
+ emit IGovernor.VoteCast(alice, id, 0, 50e18, "");
+ vm.prank(alice);
+ governor.castVote(id, 0);
+ }
+
+ /// @dev The ruleset never reads the clock — the core's Active-state gate is what closes
+ /// the re-vote window, exactly as it closes the first-vote window.
+ function test_revote_afterDeadline_revertsInTheCore() public {
+ (uint256 id,,,,) = _proposeActive(1, "revote too late", 0);
_vote(id, alice, 1);
+ vm.roll(governor.proposalDeadline(id) + 1);
vm.prank(alice);
- vm.expectRevert(abi.encodeWithSelector(StandardRuleset.AlreadyVoted.selector, alice));
+ vm.expectRevert(
+ abi.encodeWithSelector(
+ IGovernor.GovernorUnexpectedProposalState.selector,
+ id,
+ IGovernor.ProposalState.Succeeded, // alice's 50e18 For cleared the 20e18 quorum
+ bytes32(1 << uint8(IGovernor.ProposalState.Active))
+ )
+ );
+ governor.castVote(id, 0);
+ }
+
+ /// @dev An already-submitted `castVoteBySig` ballot cannot be replayed: applying the vote
+ /// spends the (proposal, voter) ballot nonce, so the second submission of the same
+ /// signature validates against a bumped nonce and reverts.
+ function test_usedSignatureCannotBeReplayed() public {
+ (address signer, uint256 signerKey) = makeAddrAndKey("signer");
+ _fund(signer, 30e18);
+ vm.roll(block.number + 1);
+
+ (uint256 id,,,,) = _proposeActive(1, "sig replay", 0);
+
+ bytes memory ballotFor = _signBallot(id, 1, signer, signerKey, governor.voteNonce(id, signer));
+ governor.castVoteBySig(id, 1, signer, ballotFor);
+
+ vm.expectRevert(abi.encodeWithSelector(IGovernor.GovernorInvalidSignature.selector, signer));
+ governor.castVoteBySig(id, 1, signer, ballotFor); // same signature, nonce already spent
+ }
+
+ /// @dev The stale-pre-signed-ballot override. A voter signs a gasless
+ /// ballot and hands it to a relayer, but then changes their mind and votes directly. Under
+ /// mutable votes the last-applied cast wins, so without a defense the relayer could submit
+ /// the outstanding signature AFTERWARD to override the voter's direct vote. GovernorNexus
+ /// closes it by spending the (proposal, voter) ballot nonce on every applied cast: a direct
+ /// vote invalidates any outstanding signed ballot for that proposal, so the relayer's stale
+ /// ballot reverts.
+ function test_directVote_invalidatesOutstandingSignedBallot() public {
+ (address signer, uint256 signerKey) = makeAddrAndKey("signer");
+ _fund(signer, 30e18);
+ vm.roll(block.number + 1);
+
+ (uint256 id,,,,) = _proposeActive(1, "stale sig override", 0);
+
+ // Voter signs a For ballot for the relayer but does NOT submit it.
+ bytes memory pendingFor = _signBallot(id, 1, signer, signerKey, governor.voteNonce(id, signer));
+
+ // Voter changes their mind and votes Against directly.
+ vm.prank(signer);
governor.castVote(id, 0);
+
+ // The outstanding signature can no longer override the direct vote.
+ vm.expectRevert(abi.encodeWithSelector(IGovernor.GovernorInvalidSignature.selector, signer));
+ governor.castVoteBySig(id, 1, signer, pendingFor);
+
+ (uint256 against, uint256 for_,) = standardRuleset.proposalVotes(id);
+ assertEq(for_, 0, "the pending For ballot cannot override the direct vote");
+ assertEq(against, 30e18, "the direct Against vote stands");
+ }
+
+ function _signBallot(uint256 proposalId, uint8 support, address voter, uint256 key, uint256 nonce)
+ internal
+ view
+ returns (bytes memory)
+ {
+ bytes32 structHash = keccak256(abi.encode(governor.BALLOT_TYPEHASH(), proposalId, support, voter, nonce));
+ (, string memory name, string memory version, uint256 chainId, address verifyingContract,,) =
+ governor.eip712Domain();
+ bytes32 domainSeparator = keccak256(
+ abi.encode(
+ keccak256("EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"),
+ keccak256(bytes(name)),
+ keccak256(bytes(version)),
+ chainId,
+ verifyingContract
+ )
+ );
+ (uint8 v, bytes32 r, bytes32 s) =
+ vm.sign(key, keccak256(abi.encodePacked("\x19\x01", domainSeparator, structHash)));
+ return abi.encodePacked(r, s, v);
}
// ─────────────────────── 4. Invalid support value ───────────────────────
@@ -258,7 +375,7 @@ contract GovernorNexusLifecycleTest is Test {
(uint256 id,,,,) = _proposeActive(1, "bad support", 0);
vm.prank(alice);
- vm.expectRevert(StandardRuleset.InvalidVoteType.selector);
+ vm.expectRevert(RulesetCounting.InvalidVoteType.selector);
governor.castVote(id, 3);
}
@@ -317,7 +434,7 @@ contract GovernorNexusLifecycleTest is Test {
assertEq(standardRuleset.governor(), address(governor));
vm.prank(eoa);
- vm.expectRevert(abi.encodeWithSelector(StandardRuleset.Unauthorized.selector, eoa));
+ vm.expectRevert(abi.encodeWithSelector(RulesetCounting.Unauthorized.selector, eoa));
standardRuleset.countVote(id, eoa, 1, 1_000e18, "");
}
}
diff --git a/test/governor/GovernorNexus.optimistic.t.sol b/test/governor/GovernorNexus.optimistic.t.sol
new file mode 100644
index 0000000..3c591c1
--- /dev/null
+++ b/test/governor/GovernorNexus.optimistic.t.sol
@@ -0,0 +1,241 @@
+// SPDX-License-Identifier: MIT
+pragma solidity ^0.8.30;
+
+import {IGovernor} from "@openzeppelin/contracts/governance/IGovernor.sol";
+
+import {GovernorNexus} from "../../src/GovernorNexus.sol";
+import {OptimisticRuleset} from "../../src/rulesets/OptimisticRuleset.sol";
+import {GovernorNexusTestBase} from "./GovernorNexusTestBase.sol";
+import {Box} from "../mocks/Box.sol";
+
+/// @dev Integration suite for the optimistic type on a live GovernorNexus: the ruleset's
+/// validation rules propagating through the propose-time gate, allowlist entries
+/// landing through the full governance loop, the end-to-end lifecycle (zero-vote
+/// success, veto defeat), and the veto-withdrawal interaction with the anti-snipe
+/// extension. The gate mechanism itself is covered in
+/// `GovernorNexus.proposalValidation.t.sol`.
+contract GovernorNexusOptimisticTest is GovernorNexusTestBase {
+ /// @dev OZ `GovernorPreventLateQuorum` event ABI, adopted verbatim by the extension.
+ event ProposalExtended(uint256 indexed proposalId, uint64 extendedDeadline);
+
+ uint256 internal constant VETO_THRESHOLD = 500_000e18;
+ uint8 internal constant OPTIMISTIC_TYPE = 1;
+
+ OptimisticRuleset internal optimistic;
+ Box internal box;
+
+ address internal bob = makeAddr("bob"); // vetoer, funded above the threshold
+
+ function setUp() public virtual override {
+ super.setUp();
+ _fund(bob, 600_000e18);
+ vm.roll(block.number + 1);
+
+ box = new Box(address(timelock));
+ optimistic = new OptimisticRuleset(address(governor), address(timelock), VETO_THRESHOLD);
+
+ // Proposer threshold 0: under this ruleset the proposer gate is the allowlist, not
+ // voting power (registration choice, mirroring the intended production line).
+ _executeSelfCall(
+ abi.encodeCall(GovernorNexus.registerType, (optimistic, VOTING_DELAY, VOTING_PERIOD, uint256(0))),
+ "register optimistic type"
+ );
+ assertEq(governor.typeCount(), 2);
+ }
+
+ // ─────────────────────────── helpers ───────────────────────────
+
+ function _allowAlice() internal {
+ vm.startPrank(address(timelock));
+ optimistic.setProposerAllowed(alice, true);
+ optimistic.setActionAllowed(address(box), Box.setValue.selector, true);
+ vm.stopPrank();
+ }
+
+ function _boxProposal(uint256 newValue)
+ internal
+ view
+ returns (address[] memory targets, uint256[] memory values, bytes[] memory calldatas)
+ {
+ targets = new address[](1);
+ targets[0] = address(box);
+ values = new uint256[](1);
+ calldatas = new bytes[](1);
+ calldatas[0] = abi.encodeCall(Box.setValue, (newValue));
+ }
+
+ /// @dev Propose `box.setValue(newValue)` through the optimistic type and roll into
+ /// Active. Returns the id and the ORIGINAL deadline.
+ function _proposeOptimistic(uint256 newValue, string memory description)
+ internal
+ returns (uint256 id, uint256 originalDeadline)
+ {
+ (address[] memory targets, uint256[] memory values, bytes[] memory calldatas) = _boxProposal(newValue);
+ vm.prank(alice);
+ id = governor.proposeWithType(targets, values, calldatas, description, OPTIMISTIC_TYPE);
+ vm.roll(governor.proposalSnapshot(id) + 1);
+ originalDeadline = governor.proposalDeadline(id);
+ }
+
+ function _vote(address voter, uint256 id, uint8 support) internal {
+ vm.prank(voter);
+ governor.castVote(id, support);
+ }
+
+ // ─────────────────────────── validation rules through the gate ───────────────────────────
+
+ function test_proposeWithType_revertsForNonAllowlistedProposer() public {
+ (address[] memory targets, uint256[] memory values, bytes[] memory calldatas) = _boxProposal(1);
+ vm.prank(alice);
+ vm.expectRevert(abi.encodeWithSelector(OptimisticRuleset.ProposerNotAllowed.selector, alice));
+ governor.proposeWithType(targets, values, calldatas, "not allowlisted", OPTIMISTIC_TYPE);
+ }
+
+ function test_proposeWithType_revertsForOffListAction() public {
+ vm.prank(address(timelock));
+ optimistic.setProposerAllowed(alice, true);
+
+ (address[] memory targets, uint256[] memory values, bytes[] memory calldatas) = _boxProposal(1);
+ vm.prank(alice);
+ vm.expectRevert(
+ abi.encodeWithSelector(OptimisticRuleset.ActionNotAllowed.selector, address(box), Box.setValue.selector)
+ );
+ governor.proposeWithType(targets, values, calldatas, "action off-list", OPTIMISTIC_TYPE);
+ }
+
+ function test_proposeWithType_revertsForNonZeroValue() public {
+ _allowAlice();
+ (address[] memory targets, uint256[] memory values, bytes[] memory calldatas) = _boxProposal(1);
+ values[0] = 1 ether;
+
+ vm.prank(alice);
+ vm.expectRevert(abi.encodeWithSelector(OptimisticRuleset.ValueNotAllowed.selector, 0));
+ governor.proposeWithType(targets, values, calldatas, "value forbidden", OPTIMISTIC_TYPE);
+ }
+
+ // ─────────────────────────── allowlists governed by the timelock ───────────────────────────
+
+ function test_allowlistEntryLandsThroughFullGovernanceLoop() public {
+ // The production path for "the DAO votes entries in": a standard proposal whose
+ // action targets the ruleset's setter, executed by the timelock.
+ address[] memory targets = new address[](1);
+ targets[0] = address(optimistic);
+ uint256[] memory values = new uint256[](1);
+ bytes[] memory calldatas = new bytes[](1);
+ calldatas[0] = abi.encodeCall(OptimisticRuleset.setProposerAllowed, (alice, true));
+ string memory description = "allowlist alice as optimistic proposer";
+
+ vm.prank(alice);
+ uint256 id = governor.propose(targets, values, calldatas, description);
+ vm.roll(governor.proposalSnapshot(id) + 1);
+ _vote(alice, id, 1);
+ vm.roll(governor.proposalDeadline(id) + 1);
+ governor.queue(targets, values, calldatas, keccak256(bytes(description)));
+ vm.warp(block.timestamp + TIMELOCK_DELAY + 1);
+ governor.execute(targets, values, calldatas, keccak256(bytes(description)));
+
+ assertTrue(optimistic.allowedProposers(alice));
+ }
+
+ // ─────────────────────────── optimistic lifecycle e2e ───────────────────────────
+
+ function test_e2e_zeroVoteProposalSucceedsAndExecutes() public {
+ _allowAlice();
+ (uint256 id,) = _proposeOptimistic(42, "zero-vote optimistic");
+
+ vm.roll(governor.proposalDeadline(id) + 1);
+ assertEq(
+ uint8(governor.state(id)),
+ uint8(IGovernor.ProposalState.Succeeded),
+ "pass-by-default: zero votes cast, proposal succeeds"
+ );
+
+ (address[] memory targets, uint256[] memory values, bytes[] memory calldatas) = _boxProposal(42);
+ bytes32 descriptionHash = keccak256(bytes("zero-vote optimistic"));
+ governor.queue(targets, values, calldatas, descriptionHash);
+ vm.warp(block.timestamp + TIMELOCK_DELAY + 1);
+ governor.execute(targets, values, calldatas, descriptionHash);
+
+ assertEq(box.value(), 42, "optimistic path must actually execute");
+ }
+
+ function test_e2e_vetoAtThresholdDefeats() public {
+ _allowAlice();
+ (uint256 id,) = _proposeOptimistic(7, "vetoed optimistic");
+
+ _vote(bob, id, 0); // 600k Against >= 500k threshold
+
+ vm.roll(governor.proposalDeadline(id) + 1);
+ assertEq(uint8(governor.state(id)), uint8(IGovernor.ProposalState.Defeated));
+ }
+
+ function test_e2e_forVotesDoNotSaveVetoedProposal() public {
+ _allowAlice();
+ (uint256 id,) = _proposeOptimistic(7, "for votes irrelevant");
+
+ _vote(alice, id, 1); // 2M For
+ _vote(bob, id, 0); // 600k Against — veto wins regardless
+
+ vm.roll(governor.proposalDeadline(id) + 1);
+ assertEq(uint8(governor.state(id)), uint8(IGovernor.ProposalState.Defeated));
+ }
+
+ // ─────────────────────── veto withdrawal × anti-snipe extension ───────────────────────
+
+ function test_earlyVetoWithdrawal_noExtension() public {
+ _allowAlice();
+ (uint256 id, uint256 originalDeadline) = _proposeOptimistic(1, "early veto in and out");
+
+ // Veto placed and withdrawn BEFORE the final window: no failing state is observed
+ // in-window, so no extension arms.
+ vm.roll(originalDeadline - EXTENSION_WINDOW - 5);
+ _vote(bob, id, 0);
+ _vote(bob, id, 1);
+
+ vm.roll(originalDeadline + 1);
+ assertEq(governor.proposalDeadline(id), originalDeadline, "no in-window failing witness, no extension");
+ assertEq(uint8(governor.state(id)), uint8(IGovernor.ProposalState.Succeeded));
+ }
+
+ function test_lateVetoWithdrawal_extendsAndSucceedsIfNotReVetoed() public {
+ _allowAlice();
+ (uint256 id, uint256 originalDeadline) = _proposeOptimistic(1, "late veto withdrawal");
+
+ // Veto lands inside the final window (proposal observed failing), then the vetoer
+ // withdraws — the snipe shape the extension exists for.
+ vm.roll(originalDeadline - 5);
+ _vote(bob, id, 0);
+ _vote(bob, id, 1);
+
+ vm.roll(originalDeadline + 1);
+ assertEq(
+ governor.proposalDeadline(id),
+ originalDeadline + EXTENSION_DURATION,
+ "failing->passing flip inside the window must extend voting"
+ );
+ assertEq(uint8(governor.state(id)), uint8(IGovernor.ProposalState.Active), "extension keeps voting open");
+
+ vm.roll(originalDeadline + EXTENSION_DURATION + 1);
+ assertEq(uint8(governor.state(id)), uint8(IGovernor.ProposalState.Succeeded));
+ }
+
+ function test_lateVetoWithdrawal_reVetoDuringExtensionDefeats() public {
+ _allowAlice();
+ (uint256 id, uint256 originalDeadline) = _proposeOptimistic(1, "re-veto during extension");
+
+ vm.roll(originalDeadline - 5);
+ _vote(bob, id, 0);
+ _vote(bob, id, 1);
+
+ // First cast past the original deadline materializes the extension (event emitted),
+ // and the re-assembled veto inside the extension defeats the proposal.
+ vm.roll(originalDeadline + 1);
+ vm.expectEmit(true, false, false, true, address(governor));
+ // forge-lint: disable-next-line(unsafe-typecast)
+ emit ProposalExtended(id, uint64(originalDeadline + EXTENSION_DURATION));
+ _vote(bob, id, 0);
+
+ vm.roll(originalDeadline + EXTENSION_DURATION + 1);
+ assertEq(uint8(governor.state(id)), uint8(IGovernor.ProposalState.Defeated));
+ }
+}
diff --git a/test/governor/GovernorNexus.proposalValidation.t.sol b/test/governor/GovernorNexus.proposalValidation.t.sol
new file mode 100644
index 0000000..5465a14
--- /dev/null
+++ b/test/governor/GovernorNexus.proposalValidation.t.sol
@@ -0,0 +1,147 @@
+// SPDX-License-Identifier: MIT
+pragma solidity ^0.8.30;
+
+import {IGovernor} from "@openzeppelin/contracts/governance/IGovernor.sol";
+
+import {GovernorNexus} from "../../src/GovernorNexus.sol";
+import {IRuleset} from "../../src/interfaces/IRuleset.sol";
+import {GovernorNexusTestBase} from "./GovernorNexusTestBase.sol";
+import {
+ AcceptingValidatorRuleset,
+ GasBurnValidatorRuleset,
+ PoisonedValidatorRuleset,
+ ToggleableValidatorRuleset
+} from "../mocks/ValidatorRulesets.sol";
+
+/// @dev Integration suite for the propose-time validation gate, using only mock validators —
+/// the gate is a core feature independent of any production ruleset. Pins:
+/// `hasProposalValidation` detection at registration and its immutability, validator
+/// revert propagation (a rejected proposal is never created), byte-identical behavior
+/// for validator-less types, and misbehaving-validator blast-radius containment.
+/// The optimistic ruleset's use of the gate is covered in `GovernorNexus.optimistic.t.sol`.
+contract GovernorNexusProposalValidationTest is GovernorNexusTestBase {
+ uint8 internal constant ACCEPTING_TYPE = 1;
+
+ AcceptingValidatorRuleset internal accepting;
+
+ function setUp() public virtual override {
+ super.setUp();
+ accepting = new AcceptingValidatorRuleset(address(governor));
+ _executeSelfCall(
+ abi.encodeCall(GovernorNexus.registerType, (accepting, VOTING_DELAY, VOTING_PERIOD, uint256(0))),
+ "register accepting validator type"
+ );
+ assertEq(governor.typeCount(), 2);
+ }
+
+ // ─────────────────────────── helpers ───────────────────────────
+
+ /// @dev A well-formed single-action proposal; content is irrelevant to every mock here.
+ function _dummyProposal()
+ internal
+ returns (address[] memory targets, uint256[] memory values, bytes[] memory calldatas)
+ {
+ targets = new address[](1);
+ targets[0] = makeAddr("target");
+ values = new uint256[](1);
+ calldatas = new bytes[](1);
+ calldatas[0] = hex"12345678";
+ }
+
+ /// @dev Registers `ruleset` as the next type through the governance loop.
+ function _registerRuleset(IRuleset ruleset, string memory description) internal returns (uint8 id) {
+ id = governor.typeCount();
+ _executeSelfCall(
+ abi.encodeCall(GovernorNexus.registerType, (ruleset, VOTING_DELAY, VOTING_PERIOD, uint256(0))), description
+ );
+ }
+
+ // ─────────────────────────── detection at registration ───────────────────────────
+
+ function test_registerType_pinsHasProposalValidationTrueForValidatorRuleset() public view {
+ assertTrue(governor.getTypeConfig(ACCEPTING_TYPE).hasProposalValidation);
+ }
+
+ function test_registerType_pinsHasProposalValidationFalseForStandardRuleset() public view {
+ assertFalse(
+ governor.getTypeConfig(0).hasProposalValidation, "bootstrap standard type must not have a validator"
+ );
+ }
+
+ function test_hasProposalValidationIsPinnedAtRegistration_neverRequeried() public {
+ ToggleableValidatorRuleset toggleable = new ToggleableValidatorRuleset(address(governor));
+ // Registered while NOT advertising the validator interface -> pinned false.
+ uint8 typeId = _registerRuleset(toggleable, "register toggleable");
+ assertFalse(governor.getTypeConfig(typeId).hasProposalValidation);
+
+ // Flipping the advertisement afterwards must change nothing: the pinned line rules.
+ toggleable.setAdvertiseValidator(true);
+ (address[] memory targets, uint256[] memory values, bytes[] memory calldatas) = _dummyProposal();
+ vm.prank(alice);
+ uint256 id = governor.proposeWithType(targets, values, calldatas, "post-flip propose", typeId);
+ assertEq(uint8(governor.state(id)), uint8(IGovernor.ProposalState.Pending));
+ }
+
+ // ─────────────────────────── revert propagation ───────────────────────────
+
+ function test_validatorRevertLeavesProposalUncreated() public {
+ PoisonedValidatorRuleset poisoned = new PoisonedValidatorRuleset(address(governor));
+ uint8 poisonedType = _registerRuleset(poisoned, "register poisoned validator");
+
+ (address[] memory targets, uint256[] memory values, bytes[] memory calldatas) = _dummyProposal();
+ string memory description = "rejected by validator";
+ uint256 wouldBeId = governor.hashProposal(targets, values, calldatas, keccak256(bytes(description)));
+
+ vm.prank(alice);
+ vm.expectRevert(PoisonedValidatorRuleset.ValidatorPoisoned.selector);
+ governor.proposeWithType(targets, values, calldatas, description, poisonedType);
+
+ assertEq(governor.proposalSnapshot(wouldBeId), 0, "rejected proposal must not exist");
+ }
+
+ function test_validatorLessDefaultPathNeverTouchesValidator() public {
+ // Default (standard) type while validator types exist: must pass — the gate
+ // belongs to the types whose rulesets opted in.
+ (address[] memory targets, uint256[] memory values, bytes[] memory calldatas) = _dummyProposal();
+ vm.prank(alice);
+ uint256 id = governor.propose(targets, values, calldatas, "standard path untouched");
+ assertEq(uint8(governor.state(id)), uint8(IGovernor.ProposalState.Pending));
+ }
+
+ // ─────────────────────────── misbehaving-validator containment ───────────────────────────
+
+ function test_poisonedValidator_bricksOnlyItsOwnType() public {
+ PoisonedValidatorRuleset poisoned = new PoisonedValidatorRuleset(address(governor));
+ uint8 poisonedType = _registerRuleset(poisoned, "register poisoned validator");
+ assertTrue(governor.getTypeConfig(poisonedType).hasProposalValidation);
+
+ (address[] memory targets, uint256[] memory values, bytes[] memory calldatas) = _dummyProposal();
+
+ // Its own type: propose bricked (revert IS the gate's behavior, blast radius = itself).
+ vm.prank(alice);
+ vm.expectRevert(PoisonedValidatorRuleset.ValidatorPoisoned.selector);
+ governor.proposeWithType(targets, values, calldatas, "poisoned type", poisonedType);
+
+ // Default type and the healthy validated type: unaffected.
+ vm.prank(alice);
+ governor.propose(targets, values, calldatas, "default path alive");
+
+ (address[] memory t2, uint256[] memory v2, bytes[] memory c2) = _dummyProposal();
+ vm.prank(alice);
+ governor.proposeWithType(t2, v2, c2, "healthy validated type alive", ACCEPTING_TYPE);
+ }
+
+ function test_gasBurnValidator_bricksOnlyItsOwnType() public {
+ GasBurnValidatorRuleset gasBurner = new GasBurnValidatorRuleset(address(governor));
+ uint8 burnType = _registerRuleset(gasBurner, "register gas burner");
+
+ (address[] memory targets, uint256[] memory values, bytes[] memory calldatas) = _dummyProposal();
+
+ vm.prank(alice);
+ vm.expectRevert();
+ governor.proposeWithType{gas: 2_000_000}(targets, values, calldatas, "gas burn type", burnType);
+
+ vm.prank(alice);
+ governor.propose(targets, values, calldatas, "default path alive after burn");
+ }
+}
diff --git a/test/GovernorNexus.propose.t.sol b/test/governor/GovernorNexus.propose.t.sol
similarity index 97%
rename from test/GovernorNexus.propose.t.sol
rename to test/governor/GovernorNexus.propose.t.sol
index 6b0245c..15fa834 100644
--- a/test/GovernorNexus.propose.t.sol
+++ b/test/governor/GovernorNexus.propose.t.sol
@@ -4,9 +4,9 @@ pragma solidity ^0.8.30;
import {IGovernor} from "@openzeppelin/contracts/governance/IGovernor.sol";
import {Strings} from "@openzeppelin/contracts/utils/Strings.sol";
-import {GovernorNexus} from "../src/GovernorNexus.sol";
-import {IRuleset} from "../src/IRuleset.sol";
-import {StandardRuleset} from "../src/StandardRuleset.sol";
+import {GovernorNexus} from "../../src/GovernorNexus.sol";
+import {IRuleset} from "../../src/interfaces/IRuleset.sol";
+import {StandardRuleset} from "../../src/rulesets/StandardRuleset.sol";
import {GovernorNexusTestBase} from "./GovernorNexusTestBase.sol";
contract GovernorNexusProposeTest is GovernorNexusTestBase {
@@ -199,7 +199,7 @@ contract GovernorNexusProposeTest is GovernorNexusTestBase {
assertEq(address(governor.proposalRuleset(id1)), address(rs1));
}
- // ─────────────── 8. Duplicate payload reverts across types (D2) ───────────────
+ // ─────────────── 8. Duplicate payload reverts across types ───────────────
function test_duplicatePayload_revertsAcrossTypes() public {
_registerType1();
@@ -220,7 +220,7 @@ contract GovernorNexusProposeTest is GovernorNexusTestBase {
}
// ──────────── 9+10. Pin invariant on both doors + transient context cleared ────────────
- // D10: `_propose` cannot be sealed (it is the sole ProposalCore writer, reached via
+ // `_propose` cannot be sealed (it is the sole ProposalCore writer, reached via
// `super`), so the invariant it protected is asserted instead: every proposal created
// through either public door carries a pin (also asserted in tests 1 and 7), and the
// transient type context never leaks into a later propose in the same transaction.
diff --git a/test/GovernorNexus.registry.t.sol b/test/governor/GovernorNexus.registry.t.sol
similarity index 77%
rename from test/GovernorNexus.registry.t.sol
rename to test/governor/GovernorNexus.registry.t.sol
index 801c257..9681920 100644
--- a/test/GovernorNexus.registry.t.sol
+++ b/test/governor/GovernorNexus.registry.t.sol
@@ -5,9 +5,9 @@ import {IGovernor} from "@openzeppelin/contracts/governance/IGovernor.sol";
import {IVotes} from "@openzeppelin/contracts/governance/utils/IVotes.sol";
import {IERC165} from "@openzeppelin/contracts/utils/introspection/IERC165.sol";
-import {GovernorNexus} from "../src/GovernorNexus.sol";
-import {IRuleset} from "../src/IRuleset.sol";
-import {StandardRuleset} from "../src/StandardRuleset.sol";
+import {GovernorNexus} from "../../src/GovernorNexus.sol";
+import {IRuleset} from "../../src/interfaces/IRuleset.sol";
+import {StandardRuleset} from "../../src/rulesets/StandardRuleset.sol";
import {GovernorNexusTestBase} from "./GovernorNexusTestBase.sol";
/// @dev Supports ERC165 but NOT IRuleset — exercises the "165 but wrong interface" guardrail.
@@ -53,16 +53,40 @@ contract GovernorNexusRegistryTest is GovernorNexusTestBase {
}
function test_constructor_emitsTypeRegistered() public {
+ StandardRuleset rs = _rulesetForNextGovernor();
vm.expectEmit(true, true, false, true);
- emit TypeRegistered(0, standardRuleset, VOTING_DELAY, VOTING_PERIOD, PROPOSAL_THRESHOLD);
+ emit TypeRegistered(0, rs, VOTING_DELAY, VOTING_PERIOD, PROPOSAL_THRESHOLD);
new GovernorNexus(
"GovernorNexus",
IVotes(address(token)),
timelock,
- standardRuleset,
+ rs,
+ VOTING_DELAY,
+ VOTING_PERIOD,
+ PROPOSAL_THRESHOLD,
+ 2,
+ EXTENSION_WINDOW,
+ EXTENSION_DURATION
+ );
+ }
+
+ /// @dev Genesis default is announced like any later change — event-sourcing indexers
+ /// reconstruct the default-type pointer with no deployment special case.
+ function test_constructor_emitsGenesisDefaultTypeSet() public {
+ StandardRuleset rs = _rulesetForNextGovernor();
+ vm.expectEmit(true, false, false, true);
+ emit DefaultTypeSet(0);
+ new GovernorNexus(
+ "GovernorNexus",
+ IVotes(address(token)),
+ timelock,
+ rs,
VOTING_DELAY,
VOTING_PERIOD,
- PROPOSAL_THRESHOLD
+ PROPOSAL_THRESHOLD,
+ 2,
+ EXTENSION_WINDOW,
+ EXTENSION_DURATION
);
}
@@ -75,14 +99,44 @@ contract GovernorNexusRegistryTest is GovernorNexusTestBase {
IRuleset(address(0)),
VOTING_DELAY,
VOTING_PERIOD,
- PROPOSAL_THRESHOLD
+ PROPOSAL_THRESHOLD,
+ 2,
+ EXTENSION_WINDOW,
+ EXTENSION_DURATION
);
}
function test_constructor_revertsOnZeroVotingPeriod() public {
+ StandardRuleset rs = _rulesetForNextGovernor();
vm.expectRevert(GovernorNexus.InvalidVotingPeriod.selector);
new GovernorNexus(
- "GovernorNexus", IVotes(address(token)), timelock, standardRuleset, VOTING_DELAY, 0, PROPOSAL_THRESHOLD
+ "GovernorNexus",
+ IVotes(address(token)),
+ timelock,
+ rs,
+ VOTING_DELAY,
+ 0,
+ PROPOSAL_THRESHOLD,
+ 2,
+ EXTENSION_WINDOW,
+ EXTENSION_DURATION
+ );
+ }
+
+ function test_constructor_revertsOnZeroVotingDelay() public {
+ StandardRuleset rs = _rulesetForNextGovernor();
+ vm.expectRevert(GovernorNexus.InvalidVotingDelay.selector);
+ new GovernorNexus(
+ "GovernorNexus",
+ IVotes(address(token)),
+ timelock,
+ rs,
+ 0,
+ VOTING_PERIOD,
+ PROPOSAL_THRESHOLD,
+ 2,
+ EXTENSION_WINDOW,
+ EXTENSION_DURATION
);
}
@@ -96,7 +150,32 @@ contract GovernorNexusRegistryTest is GovernorNexusTestBase {
IRuleset(address(notRuleset)),
VOTING_DELAY,
VOTING_PERIOD,
- PROPOSAL_THRESHOLD
+ PROPOSAL_THRESHOLD,
+ 2,
+ EXTENSION_WINDOW,
+ EXTENSION_DURATION
+ );
+ }
+
+ function test_constructor_revertsOnRulesetBoundToAnotherGovernor() public {
+ // The fixture ruleset is bound to the fixture governor — a second governor deploy
+ // reusing it must be refused at registration of row 0.
+ vm.expectRevert(
+ abi.encodeWithSelector(
+ GovernorNexus.RulesetGovernorMismatch.selector, address(standardRuleset), address(governor)
+ )
+ );
+ new GovernorNexus(
+ "GovernorNexus",
+ IVotes(address(token)),
+ timelock,
+ standardRuleset,
+ VOTING_DELAY,
+ VOTING_PERIOD,
+ PROPOSAL_THRESHOLD,
+ 2,
+ EXTENSION_WINDOW,
+ EXTENSION_DURATION
);
}
@@ -125,7 +204,7 @@ contract GovernorNexusRegistryTest is GovernorNexusTestBase {
// A second registration takes id 2.
StandardRuleset rs2 = _newRuleset();
_executeSelfCall(
- abi.encodeCall(GovernorNexus.registerType, (rs2, uint48(2), uint32(9), uint256(1))), "register type 2"
+ abi.encodeCall(GovernorNexus.registerType, (rs2, uint48(2), uint32(29), uint256(1))), "register type 2"
);
assertEq(governor.typeCount(), 3);
assertEq(address(governor.getTypeConfig(2).ruleset), address(rs2));
@@ -149,6 +228,15 @@ contract GovernorNexusRegistryTest is GovernorNexusTestBase {
governor.execute(t, v, c, h);
}
+ function test_registerType_revertsOnZeroVotingDelay() public {
+ StandardRuleset rs = _newRuleset();
+ (address[] memory t, uint256[] memory v, bytes[] memory c, bytes32 h) = _prepareSelfCall(
+ abi.encodeCall(GovernorNexus.registerType, (rs, uint48(0), VOTING_PERIOD, uint256(0))), "zero delay"
+ );
+ vm.expectRevert(GovernorNexus.InvalidVotingDelay.selector);
+ governor.execute(t, v, c, h);
+ }
+
function test_registerType_revertsOnEOA() public {
(address[] memory t, uint256[] memory v, bytes[] memory c, bytes32 h) = _prepareSelfCall(
abi.encodeCall(GovernorNexus.registerType, (IRuleset(eoa), VOTING_DELAY, VOTING_PERIOD, uint256(0))),
@@ -182,6 +270,19 @@ contract GovernorNexusRegistryTest is GovernorNexusTestBase {
governor.execute(t, v, c, h);
}
+ function test_registerType_revertsOnRulesetBoundToAnotherGovernor() public {
+ address otherGovernor = makeAddr("otherGovernor");
+ StandardRuleset foreign = new StandardRuleset(otherGovernor, IVotes(address(token)), 1);
+ (address[] memory t, uint256[] memory v, bytes[] memory c, bytes32 h) = _prepareSelfCall(
+ abi.encodeCall(GovernorNexus.registerType, (foreign, VOTING_DELAY, VOTING_PERIOD, uint256(0))),
+ "foreign-bound ruleset"
+ );
+ vm.expectRevert(
+ abi.encodeWithSelector(GovernorNexus.RulesetGovernorMismatch.selector, address(foreign), otherGovernor)
+ );
+ governor.execute(t, v, c, h);
+ }
+
function test_registerType_revertsForUnauthorizedCaller() public {
StandardRuleset rs = _newRuleset();
vm.prank(eoa);
@@ -197,7 +298,7 @@ contract GovernorNexusRegistryTest is GovernorNexusTestBase {
// Register a new type and toggle/point at it — none of which may touch row 0 content.
StandardRuleset rs = _newRuleset();
- _executeSelfCall(abi.encodeCall(GovernorNexus.registerType, (rs, uint48(9), uint32(9), uint256(9))), "reg");
+ _executeSelfCall(abi.encodeCall(GovernorNexus.registerType, (rs, uint48(9), uint32(29), uint256(9))), "reg");
_executeSelfCall(abi.encodeCall(GovernorNexus.setDefaultType, (uint8(1))), "default to 1");
_executeSelfCall(abi.encodeCall(GovernorNexus.setTypeActive, (uint8(0), false)), "deactivate 0");
@@ -257,7 +358,7 @@ contract GovernorNexusRegistryTest is GovernorNexusTestBase {
function test_setDefaultType_movesPointerAndEmits() public {
StandardRuleset rs = _newRuleset();
- _executeSelfCall(abi.encodeCall(GovernorNexus.registerType, (rs, uint48(3), uint32(11), uint256(5))), "reg");
+ _executeSelfCall(abi.encodeCall(GovernorNexus.registerType, (rs, uint48(3), uint32(31), uint256(5))), "reg");
(address[] memory t, uint256[] memory v, bytes[] memory c, bytes32 h) =
_prepareSelfCall(abi.encodeCall(GovernorNexus.setDefaultType, (uint8(1))), "default to 1");
@@ -268,7 +369,7 @@ contract GovernorNexusRegistryTest is GovernorNexusTestBase {
assertEq(governor.defaultTypeId(), 1);
// Default-type views now read row 1.
assertEq(governor.votingDelay(), 3);
- assertEq(governor.votingPeriod(), 11);
+ assertEq(governor.votingPeriod(), 31);
assertEq(governor.proposalThreshold(), 5);
}
diff --git a/test/governor/GovernorNexus.spamlimit.t.sol b/test/governor/GovernorNexus.spamlimit.t.sol
new file mode 100644
index 0000000..c08e600
--- /dev/null
+++ b/test/governor/GovernorNexus.spamlimit.t.sol
@@ -0,0 +1,362 @@
+// SPDX-License-Identifier: MIT
+pragma solidity ^0.8.30;
+
+import {IGovernor} from "@openzeppelin/contracts/governance/IGovernor.sol";
+import {IVotes} from "@openzeppelin/contracts/governance/utils/IVotes.sol";
+
+import {GovernorNexus} from "../../src/GovernorNexus.sol";
+import {IRuleset} from "../../src/interfaces/IRuleset.sol";
+import {StandardRuleset} from "../../src/rulesets/StandardRuleset.sol";
+import {GovernorNexusTestBase} from "./GovernorNexusTestBase.sol";
+import {RevertingViewsRuleset, StatefulPoisonRuleset} from "../mocks/MaliciousRulesets.sol";
+
+/// @dev Per-proposer cap on concurrently live (Pending|Active) proposals, lazily pruned
+/// at propose time. `bob`/`carol` are the spam subjects so `alice` stays free for
+/// the governance loop the setters need.
+contract GovernorNexusSpamLimitTest is GovernorNexusTestBase {
+ address internal bob = makeAddr("bob");
+ address internal carol = makeAddr("carol");
+
+ function setUp() public override {
+ super.setUp();
+ _fund(bob, 200_000e18);
+ _fund(carol, 200_000e18);
+ vm.roll(block.number + 1);
+ }
+
+ // ─────────────────────────── Helpers ───────────────────────────
+
+ /// @dev Unique single-action proposal; the description carries the salt.
+ function _args(string memory description)
+ internal
+ pure
+ returns (address[] memory targets, uint256[] memory values, bytes[] memory calldatas, bytes32 descriptionHash)
+ {
+ targets = new address[](1);
+ targets[0] = address(0xBEEF);
+ values = new uint256[](1);
+ calldatas = new bytes[](1);
+ calldatas[0] = "";
+ descriptionHash = keccak256(bytes(description));
+ }
+
+ function _proposeAs(address proposer, string memory description) internal returns (uint256 proposalId) {
+ (address[] memory targets, uint256[] memory values, bytes[] memory calldatas,) = _args(description);
+ vm.prank(proposer);
+ proposalId = governor.propose(targets, values, calldatas, description);
+ }
+
+ function _cancelAs(address proposer, string memory description) internal {
+ (address[] memory targets, uint256[] memory values, bytes[] memory calldatas, bytes32 descriptionHash) =
+ _args(description);
+ vm.prank(proposer);
+ governor.cancel(targets, values, calldatas, descriptionHash);
+ }
+
+ function _expectLimitRevert(address proposer) internal {
+ vm.expectRevert(
+ abi.encodeWithSelector(
+ GovernorNexus.ProposerActiveLimitReached.selector, proposer, governor.maxActiveProposals()
+ )
+ );
+ }
+
+ // ─────────────────────────── Cap behavior ───────────────────────────
+
+ function test_thirdLiveProposal_reverts() public {
+ _proposeAs(bob, "p1");
+ _proposeAs(bob, "p2");
+ _expectLimitRevert(bob);
+ vm.prank(bob);
+ (address[] memory targets, uint256[] memory values, bytes[] memory calldatas,) = _args("p3");
+ governor.propose(targets, values, calldatas, "p3");
+ }
+
+ function test_bothDoors_enforceAndRecord() public {
+ // one proposal through each door, then both doors reject the third
+ _proposeAs(bob, "door1");
+ (address[] memory targets, uint256[] memory values, bytes[] memory calldatas,) = _args("door2");
+ vm.prank(bob);
+ governor.proposeWithType(targets, values, calldatas, "door2", 0);
+
+ assertEq(governor.activeProposalCount(bob), 2);
+
+ (targets, values, calldatas,) = _args("door3");
+ _expectLimitRevert(bob);
+ vm.prank(bob);
+ governor.propose(targets, values, calldatas, "door3");
+
+ _expectLimitRevert(bob);
+ vm.prank(bob);
+ governor.proposeWithType(targets, values, calldatas, "door3", 0);
+ }
+
+ // ─────────────────────── Prune per exit state ───────────────────────
+
+ function test_canceledProposal_freesSlot_sameBlock() public {
+ _proposeAs(bob, "p1");
+ _proposeAs(bob, "p2");
+ vm.roll(block.number + 1); // cancel is barred in the propose block itself
+ // concurrency cap, not a rate limit: cancel-then-repropose succeeds in the same block
+ _cancelAs(bob, "p1");
+ uint256 id3 = _proposeAs(bob, "p3");
+ assertEq(uint8(governor.state(id3)), uint8(IGovernor.ProposalState.Pending));
+ }
+
+ function test_defeatedProposal_freesSlot() public {
+ uint256 id1 = _proposeAs(bob, "p1");
+ _proposeAs(bob, "p2");
+ vm.roll(governor.proposalDeadline(id1) + 1); // nobody voted: quorum unmet → Defeated
+ assertEq(uint8(governor.state(id1)), uint8(IGovernor.ProposalState.Defeated));
+ _proposeAs(bob, "p3");
+ // p2 shared p1's deadline (same creation block) so both are Defeated; only p3 occupies
+ assertEq(governor.activeProposalCount(bob), 1);
+ }
+
+ function test_succeededProposal_freesSlot() public {
+ uint256 id1 = _proposeAs(bob, "p1");
+ _proposeAs(bob, "p2");
+ vm.roll(governor.proposalSnapshot(id1) + 1);
+ vm.prank(alice);
+ governor.castVote(id1, 1);
+ vm.roll(governor.proposalDeadline(id1) + 1);
+ assertEq(uint8(governor.state(id1)), uint8(IGovernor.ProposalState.Succeeded));
+ _proposeAs(bob, "p3"); // p2 is Defeated by now as well; only p3 occupies
+ assertEq(governor.activeProposalCount(bob), 1);
+ }
+
+ function test_queuedProposal_doesNotOccupySlot() public {
+ // Queued survived the vote — it is no longer contestable attention-spam
+ (address[] memory targets, uint256[] memory values, bytes[] memory calldatas, bytes32 descriptionHash) =
+ _args("queued");
+ vm.prank(bob);
+ uint256 id1 = governor.propose(targets, values, calldatas, "queued");
+ _proposeAs(bob, "p2");
+
+ vm.roll(governor.proposalSnapshot(id1) + 1);
+ vm.prank(alice);
+ governor.castVote(id1, 1);
+ vm.roll(governor.proposalDeadline(id1) + 1);
+ governor.queue(targets, values, calldatas, descriptionHash);
+ assertEq(uint8(governor.state(id1)), uint8(IGovernor.ProposalState.Queued));
+
+ // p2 hit its deadline unvoted (Defeated); only the new proposal occupies afterwards
+ _proposeAs(bob, "p3");
+ assertEq(governor.activeProposalCount(bob), 1);
+ }
+
+ function test_executedProposal_freesSlot() public {
+ (address[] memory targets, uint256[] memory values, bytes[] memory calldatas, bytes32 descriptionHash) =
+ _args("executed");
+ vm.prank(bob);
+ uint256 id1 = governor.propose(targets, values, calldatas, "executed");
+ vm.roll(governor.proposalSnapshot(id1) + 1);
+ vm.prank(alice);
+ governor.castVote(id1, 1);
+ vm.roll(governor.proposalDeadline(id1) + 1);
+ governor.queue(targets, values, calldatas, descriptionHash);
+ vm.warp(block.timestamp + TIMELOCK_DELAY + 1);
+ governor.execute(targets, values, calldatas, descriptionHash);
+ assertEq(uint8(governor.state(id1)), uint8(IGovernor.ProposalState.Executed));
+
+ _proposeAs(bob, "p2");
+ _proposeAs(bob, "p3");
+ assertEq(governor.activeProposalCount(bob), 2);
+ }
+
+ // ─────────────────────────── Setter guards ───────────────────────────
+
+ function test_constructor_rejectsZeroAndAboveCeiling() public {
+ // A reverting CREATE still consumes the deployer's nonce, so each attempt needs its
+ // own next-address-bound ruleset — deployed before expectRevert arms.
+ StandardRuleset rs0 = _rulesetForNextGovernor();
+ vm.expectRevert(abi.encodeWithSelector(GovernorNexus.InvalidMaxActiveProposals.selector, 0));
+ new GovernorNexus(
+ "t",
+ IVotes(address(token)),
+ timelock,
+ rs0,
+ VOTING_DELAY,
+ VOTING_PERIOD,
+ PROPOSAL_THRESHOLD,
+ 0,
+ EXTENSION_WINDOW,
+ EXTENSION_DURATION
+ );
+
+ StandardRuleset rs11 = _rulesetForNextGovernor();
+ vm.expectRevert(abi.encodeWithSelector(GovernorNexus.InvalidMaxActiveProposals.selector, 11));
+ new GovernorNexus(
+ "t",
+ IVotes(address(token)),
+ timelock,
+ rs11,
+ VOTING_DELAY,
+ VOTING_PERIOD,
+ PROPOSAL_THRESHOLD,
+ 11,
+ EXTENSION_WINDOW,
+ EXTENSION_DURATION
+ );
+ }
+
+ function test_constructor_acceptsBounds() public {
+ GovernorNexus g1 = new GovernorNexus(
+ "t",
+ IVotes(address(token)),
+ timelock,
+ _rulesetForNextGovernor(),
+ VOTING_DELAY,
+ VOTING_PERIOD,
+ PROPOSAL_THRESHOLD,
+ 1,
+ EXTENSION_WINDOW,
+ EXTENSION_DURATION
+ );
+ assertEq(g1.maxActiveProposals(), 1);
+ GovernorNexus g10 = new GovernorNexus(
+ "t",
+ IVotes(address(token)),
+ timelock,
+ _rulesetForNextGovernor(),
+ VOTING_DELAY,
+ VOTING_PERIOD,
+ PROPOSAL_THRESHOLD,
+ 10,
+ EXTENSION_WINDOW,
+ EXTENSION_DURATION
+ );
+ assertEq(g10.maxActiveProposals(), 10);
+ }
+
+ function test_setter_onlyGovernance() public {
+ vm.expectRevert();
+ vm.prank(eoa);
+ governor.setMaxActiveProposals(3);
+ }
+
+ function test_setter_viaGovernance_updatesAndEmits() public {
+ bytes memory call = abi.encodeWithSelector(GovernorNexus.setMaxActiveProposals.selector, uint8(3));
+ (address[] memory targets, uint256[] memory values, bytes[] memory calldatas, bytes32 descriptionHash) =
+ _prepareSelfCall(call, "set max 3");
+ vm.expectEmit(address(governor));
+ emit GovernorNexus.MaxActiveProposalsSet(3);
+ governor.execute(targets, values, calldatas, descriptionHash);
+ assertEq(governor.maxActiveProposals(), 3);
+ }
+
+ // ─────────────────── Cap lowered below live count ───────────────────
+
+ function test_capLoweredBelowLiveCount_blocksUntilBelowNewCap() public {
+ _proposeAs(bob, "p1");
+ uint256 id2 = _proposeAs(bob, "p2");
+ // deadline of bob's proposals must outlive the governance loop; re-propose late instead:
+ // run the loop first, then check bob. Governance sets cap 2 → 1 while bob has 2 live.
+ _executeSelfCall(abi.encodeWithSelector(GovernorNexus.setMaxActiveProposals.selector, uint8(1)), "set max 1");
+
+ // bob's p1/p2 have long passed deadline (Defeated) during the loop → re-arm 2 live now
+ assertEq(governor.activeProposalCount(bob), 0);
+ _proposeAs(bob, "p3");
+ _expectLimitRevert(bob); // cap is now 1
+ vm.prank(bob);
+ (address[] memory targets, uint256[] memory values, bytes[] memory calldatas,) = _args("p4");
+ governor.propose(targets, values, calldatas, "p4");
+
+ vm.roll(block.number + 1); // cancel is barred in the propose block itself
+ _cancelAs(bob, "p3");
+ uint256 id5 = _proposeAs(bob, "p5");
+ assertEq(uint8(governor.state(id5)), uint8(IGovernor.ProposalState.Pending));
+ assertEq(governor.activeProposalCount(bob), 1);
+ // silence unused warnings meaningfully: id2 really is dead
+ assertEq(uint8(governor.state(id2)), uint8(IGovernor.ProposalState.Defeated));
+ }
+
+ // ─────────────────────────── Views + independence ───────────────────────────
+
+ function test_activeProposalCount_neverCountsDeadUnprunedIds() public {
+ assertEq(governor.activeProposalCount(bob), 0);
+ _proposeAs(bob, "p1");
+ _proposeAs(bob, "p2");
+ assertEq(governor.activeProposalCount(bob), 2);
+ vm.roll(block.number + 1); // cancel is barred in the propose block itself
+ // cancel without any propose (no prune runs): the view must filter the dead id
+ _cancelAs(bob, "p2");
+ assertEq(governor.activeProposalCount(bob), 1);
+ }
+
+ // ─────────────────── Containment: poisoned ruleset cannot brick propose ───────────────────
+
+ function test_poisonedRulesetProposal_doesNotBrickProposersNextPropose() public {
+ // register a ruleset whose outcome views revert (the adversarial mock)
+ RevertingViewsRuleset rv = new RevertingViewsRuleset(address(governor));
+ uint8 badType = uint8(governor.typeCount());
+ _executeSelfCall(
+ abi.encodeCall(GovernorNexus.registerType, (IRuleset(rv), VOTING_DELAY, VOTING_PERIOD, 0)),
+ "register reverting-views ruleset"
+ );
+
+ // bob proposes under the poisoned type and the proposal passes its deadline:
+ // state(id) now reverts ViewPoisoned — but the prune must settle liveness on the
+ // deadline alone and never reach the ruleset.
+ (address[] memory targets, uint256[] memory values, bytes[] memory calldatas,) = _args("poisoned");
+ vm.prank(bob);
+ uint256 id = governor.proposeWithType(targets, values, calldatas, "poisoned", badType);
+ vm.roll(governor.proposalDeadline(id) + 1);
+ vm.expectRevert(RevertingViewsRuleset.ViewPoisoned.selector);
+ governor.state(id);
+
+ assertEq(governor.activeProposalCount(bob), 0); // the view is poison-proof too
+ _proposeAs(bob, "after poison 1");
+ _proposeAs(bob, "after poison 2"); // full cap available again
+ assertEq(governor.activeProposalCount(bob), 2);
+ }
+
+ /// @dev Containment through the late-flip path the unconditional mock cannot reach: a ruleset
+ /// that behaves while voting is open (so a final-window cast arms `FailingObserved`) and
+ /// only reverts after the deadline. Pre-fix, `_isLive` read the OVERRIDDEN
+ /// `proposalDeadline`, whose `FailingObserved` branch calls `_wouldPass` → the poisoned
+ /// ruleset → revert, bricking the proposer's prune (and every future propose). The probe
+ /// must instead settle liveness on the original deadline + late-flip stage alone.
+ function test_statefulPoisonedRuleset_afterFailingObserved_doesNotBrickPropose() public {
+ StatefulPoisonRuleset poison = new StatefulPoisonRuleset(address(governor));
+ uint8 badType = uint8(governor.typeCount());
+ _executeSelfCall(
+ abi.encodeCall(GovernorNexus.registerType, (IRuleset(poison), VOTING_DELAY, VOTING_PERIOD, 0)),
+ "register stateful-poison ruleset"
+ );
+
+ (address[] memory targets, uint256[] memory values, bytes[] memory calldatas,) = _args("stateful poison");
+ vm.prank(bob);
+ uint256 id = governor.proposeWithType(targets, values, calldatas, "stateful poison", badType);
+
+ // Cast inside the final window while the ruleset still behaves (views return failing):
+ // this arms the late-flip `FailingObserved` stage — the state the unconditional mock
+ // can never produce.
+ vm.roll(governor.proposalDeadline(id) - 1);
+ vm.prank(alice);
+ governor.castVote(id, 1);
+
+ // Past the conservative window (originalDeadline + extensionDuration), then poison it.
+ vm.roll(governor.proposalDeadline(id) + EXTENSION_DURATION + 1);
+ poison.poison();
+
+ // Containment boundary: state() legitimately reaches the ruleset post-deadline, so it
+ // still reverts — but the liveness probe must not, so propose stays available.
+ vm.expectRevert(StatefulPoisonRuleset.ViewPoisoned.selector);
+ governor.state(id);
+
+ assertEq(governor.activeProposalCount(bob), 0); // probe is ruleset-free: dead id, no revert
+ _proposeAs(bob, "after stateful poison 1");
+ _proposeAs(bob, "after stateful poison 2"); // full cap available again
+ assertEq(governor.activeProposalCount(bob), 2);
+ }
+
+ function test_capIsPerProposer() public {
+ _proposeAs(bob, "b1");
+ _proposeAs(bob, "b2");
+ // bob at cap; carol unaffected
+ uint256 c1 = _proposeAs(carol, "c1");
+ assertEq(uint8(governor.state(c1)), uint8(IGovernor.ProposalState.Pending));
+ assertEq(governor.activeProposalCount(carol), 1);
+ }
+}
diff --git a/test/governor/GovernorNexus.voteNonce.t.sol b/test/governor/GovernorNexus.voteNonce.t.sol
new file mode 100644
index 0000000..d5fee34
--- /dev/null
+++ b/test/governor/GovernorNexus.voteNonce.t.sol
@@ -0,0 +1,222 @@
+// SPDX-License-Identifier: MIT
+pragma solidity ^0.8.30;
+
+import {IGovernor} from "@openzeppelin/contracts/governance/IGovernor.sol";
+
+import {Box} from "../mocks/Box.sol";
+import {GovernorNexusTestBase} from "./GovernorNexusTestBase.sol";
+
+/// @dev Per-proposal ballot nonce suite. `voteNonce(proposalId, account)` scopes signed-ballot
+/// invalidation to the proposal that was cast on; the inherited account-global
+/// `nonces(address)` is orphaned at 0 and never spent.
+contract GovernorNexusVoteNonceTest is GovernorNexusTestBase {
+ address internal signer;
+ uint256 internal signerKey;
+ Box internal box;
+
+ function setUp() public override {
+ super.setUp();
+ box = new Box(address(timelock));
+ (signer, signerKey) = makeAddrAndKey("signer");
+ _fund(signer, 30e18);
+ vm.roll(block.number + 1);
+ }
+
+ // ─────────────────────────── Helpers ───────────────────────────
+
+ /// @dev Propose a distinct box call as type 0 and roll into the active window.
+ function _proposeActive(uint256 newValue, string memory description) internal returns (uint256 proposalId) {
+ address[] memory targets = new address[](1);
+ targets[0] = address(box);
+ uint256[] memory values = new uint256[](1);
+ bytes[] memory calldatas = new bytes[](1);
+ calldatas[0] = abi.encodeCall(Box.setValue, (newValue));
+ vm.prank(alice);
+ proposalId = governor.proposeWithType(targets, values, calldatas, description, 0);
+ vm.roll(governor.proposalSnapshot(proposalId) + 1);
+ }
+
+ function _domainSeparator() internal view returns (bytes32) {
+ (, string memory name, string memory version, uint256 chainId, address verifyingContract,,) =
+ governor.eip712Domain();
+ return keccak256(
+ abi.encode(
+ keccak256("EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"),
+ keccak256(bytes(name)),
+ keccak256(bytes(version)),
+ chainId,
+ verifyingContract
+ )
+ );
+ }
+
+ function _signBallot(uint256 proposalId, uint8 support, address voter, uint256 key, uint256 nonce)
+ internal
+ view
+ returns (bytes memory)
+ {
+ bytes32 structHash = keccak256(abi.encode(governor.BALLOT_TYPEHASH(), proposalId, support, voter, nonce));
+ bytes32 digest = keccak256(abi.encodePacked("\x19\x01", _domainSeparator(), structHash));
+ (uint8 v, bytes32 r, bytes32 s) = vm.sign(key, digest);
+ return abi.encodePacked(r, s, v);
+ }
+
+ function _signExtendedBallot(
+ uint256 proposalId,
+ uint8 support,
+ address voter,
+ uint256 key,
+ uint256 nonce,
+ string memory reason,
+ bytes memory params
+ ) internal view returns (bytes memory) {
+ bytes32 structHash = keccak256(
+ abi.encode(
+ governor.EXTENDED_BALLOT_TYPEHASH(),
+ proposalId,
+ support,
+ voter,
+ nonce,
+ keccak256(bytes(reason)),
+ keccak256(params)
+ )
+ );
+ bytes32 digest = keccak256(abi.encodePacked("\x19\x01", _domainSeparator(), structHash));
+ (uint8 v, bytes32 r, bytes32 s) = vm.sign(key, digest);
+ return abi.encodePacked(r, s, v);
+ }
+
+ // ─────────────────────────── Cross-proposal independence (the target case) ───────────────────────────
+
+ /// @dev A direct vote on proposal A must NOT invalidate the voter's outstanding signed
+ /// ballot for proposal B — the nonce is scoped per proposal.
+ function test_directVote_doesNotInvalidateSignaturesOnOtherProposals() public {
+ uint256 idA = _proposeActive(1, "proposal A");
+ uint256 idB = _proposeActive(2, "proposal B");
+
+ bytes memory pendingB = _signBallot(idB, 1, signer, signerKey, governor.voteNonce(idB, signer));
+
+ vm.prank(signer);
+ governor.castVote(idA, 1);
+
+ governor.castVoteBySig(idB, 1, signer, pendingB);
+
+ (, uint256 forB,) = standardRuleset.proposalVotes(idB);
+ assertEq(forB, 30e18, "ballot for B must survive a direct vote on A");
+ }
+
+ /// @dev Same-proposal protection is preserved: a direct vote invalidates the voter's
+ /// outstanding ballot for THAT proposal.
+ function test_directVote_invalidatesOutstandingBallotSameProposal() public {
+ uint256 id = _proposeActive(1, "same proposal");
+
+ bytes memory pending = _signBallot(id, 1, signer, signerKey, governor.voteNonce(id, signer));
+
+ vm.prank(signer);
+ governor.castVote(id, 0);
+
+ vm.expectRevert(abi.encodeWithSelector(IGovernor.GovernorInvalidSignature.selector, signer));
+ governor.castVoteBySig(id, 1, signer, pending);
+ }
+
+ // ─────────────────────────── Nonce accounting ───────────────────────────
+
+ /// @dev Core invariant: voteNonce == number of applied casts, across every cast path.
+ function test_voteNonce_incrementsOnEveryCastPath() public {
+ uint256 idA = _proposeActive(1, "count A");
+ uint256 idB = _proposeActive(2, "count B");
+
+ assertEq(governor.voteNonce(idA, signer), 0, "fresh proposal starts at 0");
+
+ vm.prank(signer);
+ governor.castVote(idA, 1);
+ assertEq(governor.voteNonce(idA, signer), 1, "direct cast bumps");
+ assertEq(governor.voteNonce(idB, signer), 0, "other proposal untouched");
+
+ bytes memory sig = _signBallot(idA, 0, signer, signerKey, governor.voteNonce(idA, signer));
+ governor.castVoteBySig(idA, 0, signer, sig);
+ assertEq(governor.voteNonce(idA, signer), 2, "bySig cast bumps");
+
+ uint256[] memory ids = new uint256[](2);
+ ids[0] = idA;
+ ids[1] = idB;
+ uint8[] memory supportValues = new uint8[](2);
+ supportValues[0] = 1;
+ supportValues[1] = 1;
+ vm.prank(signer);
+ governor.castVoteWithReasonAndParamsBatch(ids, supportValues, new string[](2), new bytes[](2));
+ assertEq(governor.voteNonce(idA, signer), 3, "batch item bumps its own proposal");
+ assertEq(governor.voteNonce(idB, signer), 1, "each batch item spends on its proposal");
+ }
+
+ /// @dev Duplicate ids inside one batch are intra-tx re-votes: each application bumps.
+ function test_batch_duplicateIds_bumpNoncePerItem() public {
+ uint256 idA = _proposeActive(1, "dup A");
+ uint256 idB = _proposeActive(2, "dup B");
+
+ uint256[] memory ids = new uint256[](3);
+ ids[0] = idA;
+ ids[1] = idB;
+ ids[2] = idA;
+ uint8[] memory supportValues = new uint8[](3);
+ supportValues[0] = 1;
+ supportValues[1] = 1;
+ supportValues[2] = 0;
+ vm.prank(signer);
+ governor.castVoteWithReasonAndParamsBatch(ids, supportValues, new string[](3), new bytes[](3));
+
+ assertEq(governor.voteNonce(idA, signer), 2, "duplicate id bumps once per item");
+ assertEq(governor.voteNonce(idB, signer), 1, "single item bumps once");
+ }
+
+ /// @dev The inherited account-global Nonces is orphaned: nothing spends it anymore.
+ function test_accountGlobalNonces_stayZero() public {
+ uint256 id = _proposeActive(1, "orphaned nonces");
+
+ vm.prank(signer);
+ governor.castVote(id, 1);
+
+ bytes memory sig = _signBallot(id, 0, signer, signerKey, governor.voteNonce(id, signer));
+ governor.castVoteBySig(id, 0, signer, sig);
+
+ assertEq(governor.nonces(signer), 0, "account-global nonce is never spent");
+ }
+
+ // ─────────────────────────── Re-signing and extended path ───────────────────────────
+
+ /// @dev After a direct vote, a ballot signed against the FRESH per-proposal nonce is
+ /// valid — mutable votes, last-applied wins.
+ function test_freshSignatureAfterDirectVote_succeeds() public {
+ uint256 id = _proposeActive(1, "fresh re-sign");
+
+ vm.prank(signer);
+ governor.castVote(id, 0);
+
+ bytes memory fresh = _signBallot(id, 1, signer, signerKey, governor.voteNonce(id, signer));
+ governor.castVoteBySig(id, 1, signer, fresh);
+
+ (uint256 against, uint256 for_,) = standardRuleset.proposalVotes(id);
+ assertEq(for_, 30e18, "fresh bySig re-vote lands");
+ assertEq(against, 0, "re-vote replaces the direct vote");
+ }
+
+ /// @dev The extended (reason+params) signature path binds to the same per-proposal nonce.
+ function test_extendedBallot_usesPerProposalNonce() public {
+ uint256 idA = _proposeActive(1, "extended A");
+ uint256 idB = _proposeActive(2, "extended B");
+
+ bytes memory pendingB =
+ _signExtendedBallot(idB, 1, signer, signerKey, governor.voteNonce(idB, signer), "gm", "");
+
+ vm.prank(signer);
+ governor.castVote(idA, 1);
+
+ governor.castVoteWithReasonAndParamsBySig(idB, 1, signer, "gm", "", pendingB);
+ assertEq(governor.voteNonce(idB, signer), 1, "extended bySig applied and bumped");
+
+ // A second cast on B invalidates a stale extended ballot for B.
+ bytes memory stale = _signExtendedBallot(idB, 0, signer, signerKey, 0, "stale", "");
+ vm.expectRevert(abi.encodeWithSelector(IGovernor.GovernorInvalidSignature.selector, signer));
+ governor.castVoteWithReasonAndParamsBySig(idB, 0, signer, "stale", "", stale);
+ }
+}
diff --git a/test/GovernorNexusTestBase.sol b/test/governor/GovernorNexusTestBase.sol
similarity index 73%
rename from test/GovernorNexusTestBase.sol
rename to test/governor/GovernorNexusTestBase.sol
index c351eb7..41a140d 100644
--- a/test/GovernorNexusTestBase.sol
+++ b/test/governor/GovernorNexusTestBase.sol
@@ -6,16 +6,16 @@ import {Test} from "forge-std/Test.sol";
import {TimelockController} from "@openzeppelin/contracts/governance/TimelockController.sol";
import {IVotes} from "@openzeppelin/contracts/governance/utils/IVotes.sol";
-import {GovernorNexus} from "../src/GovernorNexus.sol";
-import {StandardRuleset} from "../src/StandardRuleset.sol";
-import {MockENSToken} from "./mocks/MockENSToken.sol";
+import {GovernorNexus} from "../../src/GovernorNexus.sol";
+import {StandardRuleset} from "../../src/rulesets/StandardRuleset.sol";
+import {MockENSToken} from "../mocks/MockENSToken.sol";
/// @dev Shared fixture for GovernorNexus unit suites: deploys token + timelock + plain
/// `GovernorNexus` + bootstrap ruleset, funds a majority voter, and provides the
/// governance loop that is the only path to the `onlyGovernance` setters.
///
-/// With real ruleset counting in place (Task 4) the suites run against production
-/// `GovernorNexus` directly — no counting mixin, no subclass. The bootstrap ruleset's
+/// The suites run against production `GovernorNexus` directly — no counting mixin,
+/// no subclass. The bootstrap ruleset's
/// 1% quorum is trivially cleared by alice's 2_000_000e18 (the only funded holder here,
/// so total supply == her balance), keeping the governance loop passing.
abstract contract GovernorNexusTestBase is Test {
@@ -24,6 +24,10 @@ abstract contract GovernorNexusTestBase is Test {
uint48 internal constant VOTING_DELAY = 1;
uint32 internal constant VOTING_PERIOD = 50;
uint256 internal constant PROPOSAL_THRESHOLD = 100_000e18;
+ // Late-flip extension params scaled to the 50-block test period — production values
+ // are ENSParams.EXTENSION_WINDOW/EXTENSION_DURATION (24h/48h).
+ uint48 internal constant EXTENSION_WINDOW = 20;
+ uint48 internal constant EXTENSION_DURATION = 40;
MockENSToken internal token;
TimelockController internal timelock;
@@ -40,7 +44,7 @@ abstract contract GovernorNexusTestBase is Test {
token = new MockENSToken();
timelock = new TimelockController(TIMELOCK_DELAY, new address[](0), new address[](0), address(this));
- // Wiring (spec §Wiring note): StandardRuleset.countVote is onlyGovernor and
+ // Wiring: StandardRuleset.countVote is onlyGovernor and
// quorumReached reads governor.proposalSnapshot, so the bootstrap ruleset must know
// the governor address — but the governor constructor needs the ruleset. Break the
// cycle by precomputing the governor's CREATE address (this deployer's next nonce
@@ -55,7 +59,10 @@ abstract contract GovernorNexusTestBase is Test {
standardRuleset,
VOTING_DELAY,
VOTING_PERIOD,
- PROPOSAL_THRESHOLD
+ PROPOSAL_THRESHOLD,
+ _maxActiveProposals(),
+ EXTENSION_WINDOW,
+ EXTENSION_DURATION
);
require(address(governor) == predictedGovernor, "governor address prediction failed");
@@ -68,6 +75,13 @@ abstract contract GovernorNexusTestBase is Test {
vm.roll(block.number + 1);
}
+ /// @dev Per-proposer live-proposal cap the fixture governor is deployed with. Suites
+ /// whose scenarios need more simultaneous live proposals from one proposer than
+ /// the default override this.
+ function _maxActiveProposals() internal pure virtual returns (uint8) {
+ return 2;
+ }
+
function _fund(address account, uint256 amount) internal {
token.mint(account, amount);
vm.prank(account);
@@ -79,6 +93,16 @@ abstract contract GovernorNexusTestBase is Test {
return new StandardRuleset(address(governor), IVotes(address(token)), 1);
}
+ /// @dev StandardRuleset bound to the address the NEXT `new GovernorNexus(...)` from this
+ /// test contract will deploy to — registration checks the binding, so tests that
+ /// deploy a second governor need a ruleset wired to it, not to the fixture governor.
+ /// Exactly one deploy (the ruleset itself) must sit between this call and that
+ /// governor deploy.
+ function _rulesetForNextGovernor() internal returns (StandardRuleset) {
+ address predicted = vm.computeCreateAddress(address(this), vm.getNonce(address(this)) + 1);
+ return new StandardRuleset(predicted, IVotes(address(token)), 1);
+ }
+
// ───────────────── Governance loop (the only path to the setters) ─────────────────
/// @dev Propose (self-call) → vote → queue → warp past timelock; leaves the proposal
diff --git a/test/mocks/FeeOnTransferToken.sol b/test/mocks/FeeOnTransferToken.sol
new file mode 100644
index 0000000..e18bd18
--- /dev/null
+++ b/test/mocks/FeeOnTransferToken.sol
@@ -0,0 +1,21 @@
+// SPDX-License-Identifier: MIT
+pragma solidity ^0.8.30;
+
+import {MockENSToken} from "./MockENSToken.sol";
+
+/// @dev ERC20Votes mock that burns 1% on every transfer — exercises the under-delivery guard
+/// (a token that delivers less than requested makes a bond lock revert). Never a real
+/// deployment concern (ENS is plain); the guard must not depend on that assumption.
+contract FeeOnTransferToken is MockENSToken {
+ function transfer(address to, uint256 value) public override returns (bool) {
+ uint256 fee = value / 100;
+ super.transfer(address(0xdead), fee);
+ return super.transfer(to, value - fee);
+ }
+
+ function transferFrom(address from, address to, uint256 value) public override returns (bool) {
+ uint256 fee = value / 100;
+ super.transferFrom(from, address(0xdead), fee);
+ return super.transferFrom(from, to, value - fee);
+ }
+}
diff --git a/test/mocks/MaliciousRulesets.sol b/test/mocks/MaliciousRulesets.sol
index 86cb93d..050c3e8 100644
--- a/test/mocks/MaliciousRulesets.sol
+++ b/test/mocks/MaliciousRulesets.sol
@@ -4,14 +4,14 @@ pragma solidity ^0.8.30;
import {IERC165} from "@openzeppelin/contracts/utils/introspection/IERC165.sol";
import {GovernorNexus} from "../../src/GovernorNexus.sol";
-import {IRuleset} from "../../src/IRuleset.sol";
+import {IRuleset} from "../../src/interfaces/IRuleset.sol";
/// @title Malicious / broken ruleset mocks for the adversarial suite
/// @notice Each concrete ruleset below embodies exactly ONE attack or failure mode against a
/// GovernorNexus core, so `GovernorNexus.adversarial.t.sol` can pin the blast radius
-/// the spec (§8) promises: a bad ruleset breaks voting on ITS OWN proposals only.
+/// the core guarantees: a bad ruleset breaks voting on ITS OWN proposals only.
/// @dev All variants advertise `IRuleset` via ERC165 so they pass registration — the trust
-/// boundary is procedural (spec D3: rulesets are DAO-vote-gated code), not a runtime
+/// boundary is procedural (rulesets are DAO-vote-gated code), not a runtime
/// interface check, so a malicious ruleset that implements the interface WILL register.
/// @dev Shared plumbing: ERC165 advertisement + the inert view surface (`quorum`,
@@ -117,8 +117,8 @@ contract RevertingRuleset is AdversarialRulesetBase {
/// @notice Attack: outcome views always return `true`, recording nothing.
/// @dev Makes its proposal Succeed after the deadline with ZERO votes cast. This is the
-/// accepted-risk consequence of D3 (rulesets are trusted DAO-approved code); the suite
-/// documents the blast radius, it is not a core bug.
+/// accepted-risk consequence of the trust model (rulesets are trusted DAO-approved
+/// code); the suite documents the blast radius, it is not a core bug.
contract LyingRuleset is AdversarialRulesetBase {
constructor(address governor_) AdversarialRulesetBase(governor_) {}
@@ -144,9 +144,11 @@ contract LyingRuleset is AdversarialRulesetBase {
}
/// @notice Attack: the outcome views (`quorumReached`/`voteSucceeded`) revert.
-/// @dev `state()` calls these only in the deadline-passed branch, so the poison surfaces only
-/// AFTER the voting deadline — the proposal is queryable (Pending/Active) up to then, then
-/// `state()` reverts, which in turn makes queue/execute impossible for that proposal only.
+/// @dev `state()` calls these only in the deadline-passed branch, so voting stays open and
+/// queryable up to the deadline, then `state()` reverts, making queue/execute impossible
+/// for that proposal only. `GovernorPreventLateFlip` also reads these views on every cast
+/// inside the final `extensionWindow`, so `castVote` itself reverts for that slice of the
+/// voting period too — the poison surfaces earlier than the deadline, not only after it.
contract RevertingViewsRuleset is AdversarialRulesetBase {
error ViewPoisoned();
@@ -173,6 +175,56 @@ contract RevertingViewsRuleset is AdversarialRulesetBase {
}
}
+/// @notice Attack: outcome views behave (report a failing tally) while voting is open, then
+/// revert once `poison()` is flipped — the stateful cousin of {RevertingViewsRuleset}.
+/// @dev Purpose-built for the `_isLive` containment gap. A well-behaved final-window cast can
+/// arm the late-flip `FailingObserved` stage (the views return `false`, not revert), and
+/// only AFTER the deadline does the ruleset turn poisonous. That exact sequence is what
+/// routes a post-deadline liveness probe through `proposalDeadline → _wouldPass → ruleset`.
+/// {RevertingViewsRuleset} cannot reach it: reverting unconditionally, its final-window
+/// casts revert before any stage is armed, so the id stays at stage `None`.
+contract StatefulPoisonRuleset is AdversarialRulesetBase {
+ error ViewPoisoned();
+
+ bool public poisoned;
+
+ mapping(uint256 => mapping(address => bool)) internal _voted;
+
+ constructor(address governor_) AdversarialRulesetBase(governor_) {}
+
+ /// @dev Flip the ruleset poisonous; the test calls this only after the deadline.
+ function poison() external {
+ poisoned = true;
+ }
+
+ /// @inheritdoc IRuleset
+ function countVote(uint256 proposalId, address voter, uint8, uint256 weight, bytes calldata)
+ external
+ returns (uint256)
+ {
+ require(msg.sender == governor, "not governor");
+ _voted[proposalId][voter] = true;
+ return weight; // accepts silently; the views, not the tally, drive the scenario
+ }
+
+ /// @inheritdoc IRuleset
+ function quorumReached(uint256) external view returns (bool) {
+ if (poisoned) revert ViewPoisoned();
+ return false; // failing while open: lets a final-window cast arm FailingObserved
+ }
+
+ /// @inheritdoc IRuleset
+ function voteSucceeded(uint256) external view returns (bool) {
+ if (poisoned) revert ViewPoisoned();
+ return false;
+ }
+
+ /// @inheritdoc IRuleset
+ function hasVoted(uint256 proposalId, address voter) external view returns (bool) {
+ return _voted[proposalId][voter];
+ }
+}
+
/// @notice Attack: `countVote` returns weight * 1000 (more than it was passed / tallied).
/// @dev The honest tally still stores the REAL weight; only the RETURN value is inflated. That
/// return feeds nothing but the `VoteCast` event's weight field and `castVote`'s return —
diff --git a/test/mocks/ValidatorRulesets.sol b/test/mocks/ValidatorRulesets.sol
new file mode 100644
index 0000000..6ac24ed
--- /dev/null
+++ b/test/mocks/ValidatorRulesets.sol
@@ -0,0 +1,129 @@
+// SPDX-License-Identifier: MIT
+pragma solidity ^0.8.30;
+
+import {IERC165} from "@openzeppelin/contracts/utils/introspection/IERC165.sol";
+
+import {IProposalValidator} from "../../src/interfaces/IProposalValidator.sol";
+import {IRuleset} from "../../src/interfaces/IRuleset.sol";
+
+/// @title Validator ruleset mocks for the propose-time validation gate suite
+/// @notice Each concrete ruleset below differs from a plain inert ruleset by exactly one
+/// validator behavior, so `GovernorNexus.proposalValidation.t.sol` can pin the
+/// gate's properties — detection/pinning at registration, revert propagation, and
+/// blast-radius containment — independent of any production ruleset.
+
+/// @dev Minimal well-formed ruleset base: honest inert counting surface, so each concrete
+/// mock is its one validator behavior and nothing else.
+abstract contract ValidatorMockBase is IRuleset {
+ /// @inheritdoc IRuleset
+ address public immutable governor;
+
+ constructor(address governor_) {
+ governor = governor_;
+ }
+
+ function countVote(uint256, address, uint8, uint256 weight, bytes calldata) external pure returns (uint256) {
+ return weight;
+ }
+
+ function quorumReached(uint256) external pure returns (bool) {
+ return false;
+ }
+
+ function voteSucceeded(uint256) external pure returns (bool) {
+ return false;
+ }
+
+ function hasVoted(uint256, address) external pure returns (bool) {
+ return false;
+ }
+
+ function quorum(uint256) external pure returns (uint256) {
+ return 0;
+ }
+
+ // solhint-disable-next-line func-name-mixedcase
+ function COUNTING_MODE() external pure returns (string memory) {
+ return "support=bravo&quorum=for";
+ }
+}
+
+/// @dev Well-behaved validator: accepts every proposal. The healthy control a containment
+/// test proposes through while a sibling type's validator is misbehaving.
+contract AcceptingValidatorRuleset is ValidatorMockBase, IProposalValidator {
+ constructor(address governor_) ValidatorMockBase(governor_) {}
+
+ function validateProposal(uint256, address, address[] calldata, uint256[] calldata, bytes[] calldata)
+ external
+ pure {}
+
+ function supportsInterface(bytes4 interfaceId) external pure returns (bool) {
+ return interfaceId == type(IRuleset).interfaceId || interfaceId == type(IProposalValidator).interfaceId
+ || interfaceId == type(IERC165).interfaceId;
+ }
+}
+
+/// @dev Attack: `validateProposal` always reverts — a poisoned gate. Containment expected:
+/// only proposes of ITS OWN type brick; every other type is unaffected.
+contract PoisonedValidatorRuleset is ValidatorMockBase, IProposalValidator {
+ error ValidatorPoisoned();
+
+ constructor(address governor_) ValidatorMockBase(governor_) {}
+
+ function validateProposal(uint256, address, address[] calldata, uint256[] calldata, bytes[] calldata)
+ external
+ pure
+ {
+ revert ValidatorPoisoned();
+ }
+
+ function supportsInterface(bytes4 interfaceId) external pure returns (bool) {
+ return interfaceId == type(IRuleset).interfaceId || interfaceId == type(IProposalValidator).interfaceId
+ || interfaceId == type(IERC165).interfaceId;
+ }
+}
+
+/// @dev Attack: `validateProposal` burns all forwarded gas. Same containment expectation.
+contract GasBurnValidatorRuleset is ValidatorMockBase, IProposalValidator {
+ constructor(address governor_) ValidatorMockBase(governor_) {}
+
+ function validateProposal(uint256, address, address[] calldata, uint256[] calldata, bytes[] calldata)
+ external
+ pure
+ {
+ for (uint256 i = 0;; ++i) {}
+ }
+
+ function supportsInterface(bytes4 interfaceId) external pure returns (bool) {
+ return interfaceId == type(IRuleset).interfaceId || interfaceId == type(IProposalValidator).interfaceId
+ || interfaceId == type(IERC165).interfaceId;
+ }
+}
+
+/// @dev A ruleset whose ERC165 answer for `IProposalValidator` is MUTABLE — impossible for
+/// the immutable rulesets the DAO actually registers, built here to pin that detection
+/// happens once, at registration, and is never re-queried.
+contract ToggleableValidatorRuleset is ValidatorMockBase, IProposalValidator {
+ error ShouldNeverRun();
+
+ bool public advertiseValidator;
+
+ constructor(address governor_) ValidatorMockBase(governor_) {}
+
+ function setAdvertiseValidator(bool advertise) external {
+ advertiseValidator = advertise;
+ }
+
+ /// @dev Would brick every propose if the gate ever became live for this type.
+ function validateProposal(uint256, address, address[] calldata, uint256[] calldata, bytes[] calldata)
+ external
+ pure
+ {
+ revert ShouldNeverRun();
+ }
+
+ function supportsInterface(bytes4 interfaceId) external view returns (bool) {
+ if (interfaceId == type(IProposalValidator).interfaceId) return advertiseValidator;
+ return interfaceId == type(IRuleset).interfaceId || interfaceId == type(IERC165).interfaceId;
+ }
+}
diff --git a/test/rulesets/BondRuleset.invariant.t.sol b/test/rulesets/BondRuleset.invariant.t.sol
new file mode 100644
index 0000000..225d4b5
--- /dev/null
+++ b/test/rulesets/BondRuleset.invariant.t.sol
@@ -0,0 +1,153 @@
+// SPDX-License-Identifier: MIT
+pragma solidity ^0.8.30;
+
+import {IGovernor} from "@openzeppelin/contracts/governance/IGovernor.sol";
+import {Test} from "forge-std/Test.sol";
+
+import {BondRuleset} from "../../src/rulesets/BondRuleset.sol";
+import {GovernorNexus} from "../../src/GovernorNexus.sol";
+import {MockENSToken} from "../mocks/MockENSToken.sol";
+import {BondRulesetTestBase} from "./BondRulesetTestBase.sol";
+
+/// @dev Drives randomized propose/vote/roll/resolve/queueExecute/cancelGov sequences against
+/// the real governor + BondRuleset and checks conservation: the ruleset's balance always
+/// covers every unsettled bond, and no bond ever pays out twice.
+contract BondHandler is Test {
+ // Mirrors GovernorNexusTestBase.TIMELOCK_DELAY — the fixture's timelock min-delay.
+ uint256 internal constant TIMELOCK_DELAY = 2 days;
+
+ GovernorNexus public governor;
+ BondRuleset public ruleset;
+ MockENSToken public token;
+ uint8 public bondTypeId;
+ address public proposerPool; // single bonded proposer keeps VP bookkeeping simple
+ address public voter;
+
+ uint256[] public ids;
+ mapping(uint256 => bool) public resolvedOnce;
+ uint256 public doubleSettles; // must stay 0
+
+ /// @dev Full proposal args per id — needed to drive queue/execute/cancel, which take the
+ /// (targets, values, calldatas, descriptionHash) tuple rather than the id itself.
+ struct Prop {
+ address[] targets;
+ uint256[] values;
+ bytes[] calldatas;
+ bytes32 descriptionHash;
+ }
+
+ mapping(uint256 => Prop) internal props;
+
+ uint256 internal nonce;
+
+ constructor(GovernorNexus g, BondRuleset r, MockENSToken t, uint8 typeId, address proposer_, address voter_) {
+ governor = g;
+ ruleset = r;
+ token = t;
+ bondTypeId = typeId;
+ proposerPool = proposer_;
+ voter = voter_;
+ }
+
+ function propose() external {
+ ++nonce;
+ string memory description = string(abi.encodePacked("bond#", vm.toString(nonce)));
+ address[] memory t = new address[](1);
+ t[0] = address(0xBEEF);
+ uint256[] memory v = new uint256[](1);
+ bytes[] memory c = new bytes[](1);
+ c[0] = abi.encodePacked(nonce); // unique calldata → unique id
+ bytes32 descriptionHash = keccak256(bytes(description));
+ vm.startPrank(proposerPool);
+ token.approve(address(ruleset), ruleset.bondAmount());
+ try governor.proposeWithType(t, v, c, description, bondTypeId) returns (uint256 id) {
+ ids.push(id);
+ props[id] = Prop({targets: t, values: v, calldatas: c, descriptionHash: descriptionHash});
+ } catch {} // spam-limit cap etc. — fine
+ vm.stopPrank();
+ }
+
+ function vote(uint256 idSeed, uint8 support) external {
+ if (ids.length == 0) return;
+ uint256 id = ids[idSeed % ids.length];
+ support = support % 4;
+ vm.prank(voter);
+ try governor.castVote(id, support) {} catch {}
+ }
+
+ function roll(uint16 blocks) external {
+ vm.roll(block.number + (uint256(blocks) % 100) + 1);
+ }
+
+ function resolve(uint256 idSeed) external {
+ if (ids.length == 0) return;
+ uint256 id = ids[idSeed % ids.length];
+ try ruleset.resolveBond(id) {
+ if (resolvedOnce[id]) ++doubleSettles;
+ resolvedOnce[id] = true;
+ } catch {}
+ }
+
+ /// @dev Queue then execute a seed-selected id. Most ids won't be in a queue-able
+ /// (Succeeded) or execute-able (Queued, past the timelock delay) state — those
+ /// reverts are expected legal-sequence rejections and are swallowed. This is the
+ /// only path that drives a bond to `Executed` so `resolveBond`'s Executed branch
+ /// gets fuzzed.
+ function queueExecute(uint256 idSeed) external {
+ if (ids.length == 0) return;
+ Prop storage p = props[ids[idSeed % ids.length]];
+ try governor.queue(p.targets, p.values, p.calldatas, p.descriptionHash) {} catch {}
+ vm.warp(block.timestamp + TIMELOCK_DELAY + 1);
+ try governor.execute(p.targets, p.values, p.calldatas, p.descriptionHash) {} catch {}
+ }
+
+ /// @dev The bonded proposer self-cancels via the governor. Legal only while
+ /// Pending/Active (`_validateCancel`); reaches `Canceled` with `proposalCanceledAt`
+ /// set, so `resolveBond` routes to the Pending-refund or ActiveSelfCancel-forfeit
+ /// sub-case depending on when cancellation lands relative to the snapshot.
+ function cancelGov(uint256 idSeed) external {
+ if (ids.length == 0) return;
+ Prop storage p = props[ids[idSeed % ids.length]];
+ vm.prank(proposerPool);
+ try governor.cancel(p.targets, p.values, p.calldatas, p.descriptionHash) {} catch {}
+ }
+
+ function idsLength() external view returns (uint256) {
+ return ids.length;
+ }
+
+ function idAt(uint256 i) external view returns (uint256) {
+ return ids[i];
+ }
+}
+
+contract BondRulesetInvariantTest is BondRulesetTestBase {
+ BondHandler internal handler;
+
+ function setUp() public override {
+ super.setUp();
+ token.mint(bob, 1_000_000e18); // deep pool for many proposals
+ handler = new BondHandler(governor, bondRuleset, token, bondTypeId, bob, alice);
+ targetContract(address(handler));
+ }
+
+ function _maxActiveProposals() internal pure override returns (uint8) {
+ return 10;
+ }
+
+ /// Conservation: ruleset balance covers every unsettled bond.
+ function invariant_bondConservation() public view {
+ uint256 owed;
+ uint256 n = handler.idsLength();
+ for (uint256 i = 0; i < n; ++i) {
+ (address bondProposer, bool settled) = bondRuleset.bondOf(handler.idAt(i));
+ if (bondProposer != address(0) && !settled) owed += bondRuleset.bondAmount();
+ }
+ assertGe(token.balanceOf(address(bondRuleset)), owed);
+ }
+
+ /// One-shot settlement: resolveBond never succeeds twice for the same id.
+ function invariant_noDoubleSettle() public view {
+ assertEq(handler.doubleSettles(), 0);
+ }
+}
diff --git a/test/rulesets/BondRuleset.t.sol b/test/rulesets/BondRuleset.t.sol
new file mode 100644
index 0000000..0c70b52
--- /dev/null
+++ b/test/rulesets/BondRuleset.t.sol
@@ -0,0 +1,229 @@
+// SPDX-License-Identifier: MIT
+pragma solidity ^0.8.30;
+
+import {Test} from "forge-std/Test.sol";
+import {IERC165} from "@openzeppelin/contracts/utils/introspection/IERC165.sol";
+import {IVotes} from "@openzeppelin/contracts/governance/utils/IVotes.sol";
+
+import {BondRuleset} from "../../src/rulesets/BondRuleset.sol";
+import {IRuleset} from "../../src/interfaces/IRuleset.sol";
+import {IProposalValidator} from "../../src/interfaces/IProposalValidator.sol";
+import {RulesetCounting} from "../../src/RulesetCounting.sol";
+import {RulesetQuorumFraction} from "../../src/RulesetQuorumFraction.sol";
+import {MockENSToken} from "../mocks/MockENSToken.sol";
+import {FeeOnTransferToken} from "../mocks/FeeOnTransferToken.sol";
+
+contract MockSnapshotGovernor {
+ uint256 public snapshot;
+
+ function setSnapshot(uint256 s) external {
+ snapshot = s;
+ }
+
+ function proposalSnapshot(uint256) external view returns (uint256) {
+ return snapshot;
+ }
+}
+
+contract BondRulesetTest is Test {
+ MockENSToken internal token;
+ MockSnapshotGovernor internal govStub;
+ address internal governorMock; // == address(govStub); pranked for countVote/validateProposal
+ address internal treasury = makeAddr("treasury");
+ uint256 internal constant BOND = 1_000e18;
+
+ BondRuleset internal ruleset;
+
+ function setUp() public {
+ token = new MockENSToken();
+ govStub = new MockSnapshotGovernor();
+ governorMock = address(govStub);
+ ruleset = new BondRuleset(governorMock, IVotes(address(token)), 1, BOND, treasury);
+ }
+
+ function test_constructor_pinsImmutables() public view {
+ assertEq(address(ruleset.token()), address(token));
+ assertEq(ruleset.quorumNumerator(), 1);
+ assertEq(ruleset.bondAmount(), BOND);
+ assertEq(ruleset.treasury(), treasury);
+ }
+
+ function test_constructor_revertsOnZeroBond() public {
+ vm.expectRevert(abi.encodeWithSelector(BondRuleset.InvalidBondAmount.selector, 0));
+ new BondRuleset(governorMock, IVotes(address(token)), 1, 0, treasury);
+ }
+
+ function test_constructor_revertsOnZeroTreasury() public {
+ vm.expectRevert(BondRuleset.ZeroTreasury.selector);
+ new BondRuleset(governorMock, IVotes(address(token)), 1, BOND, address(0));
+ }
+
+ function test_constructor_revertsOnQuorumAbove100() public {
+ vm.expectRevert(abi.encodeWithSelector(RulesetQuorumFraction.InvalidQuorumFraction.selector, 101, 100));
+ new BondRuleset(governorMock, IVotes(address(token)), 101, BOND, treasury);
+ }
+
+ function test_constructor_revertsOnZeroQuorumNumerator() public {
+ // Zero would make `quorumReached` unconditionally true — rejected at construction.
+ vm.expectRevert(abi.encodeWithSelector(RulesetQuorumFraction.InvalidQuorumFraction.selector, 0, 100));
+ new BondRuleset(governorMock, IVotes(address(token)), 0, BOND, treasury);
+ }
+
+ function test_supportsInterface() public view {
+ assertTrue(ruleset.supportsInterface(type(IRuleset).interfaceId));
+ assertTrue(ruleset.supportsInterface(type(IProposalValidator).interfaceId));
+ assertTrue(ruleset.supportsInterface(type(IERC165).interfaceId));
+ assertFalse(ruleset.supportsInterface(0xdeadbeef));
+ }
+
+ function test_countingMode() public view {
+ assertEq(ruleset.COUNTING_MODE(), "support=bravo,againstAndSlash&quorum=for,abstain");
+ }
+
+ function test_supportValues_acceptsFourRejectsFifth() public {
+ vm.startPrank(governorMock);
+ ruleset.countVote(1, address(1), 0, 1, "");
+ ruleset.countVote(1, address(2), 1, 1, "");
+ ruleset.countVote(1, address(3), 2, 1, "");
+ ruleset.countVote(1, address(4), 3, 1, "");
+ vm.expectRevert(RulesetCounting.InvalidVoteType.selector); // inherited error
+ ruleset.countVote(1, address(5), 4, 1, "");
+ vm.stopPrank();
+ }
+
+ function test_proposalVotes_fourBuckets() public {
+ vm.startPrank(governorMock);
+ ruleset.countVote(1, address(1), 0, 10, "");
+ ruleset.countVote(1, address(2), 1, 20, "");
+ ruleset.countVote(1, address(3), 2, 30, "");
+ ruleset.countVote(1, address(4), 3, 40, "");
+ vm.stopPrank();
+ (uint256 against, uint256 forV, uint256 abstain, uint256 slash) = ruleset.proposalVotes(1);
+ assertEq(against, 10);
+ assertEq(forV, 20);
+ assertEq(abstain, 30);
+ assertEq(slash, 40);
+ }
+
+ function test_voteSucceeded_slashCountsAsOpposition() public {
+ // For 50 vs Against 30 + Slash 30 → rejections 60 > 50 → not succeeded
+ vm.startPrank(governorMock);
+ ruleset.countVote(1, address(1), uint8(BondRuleset.VoteType.For), 50, "");
+ ruleset.countVote(1, address(2), uint8(BondRuleset.VoteType.Against), 30, "");
+ ruleset.countVote(1, address(3), uint8(BondRuleset.VoteType.AgainstAndSlash), 30, "");
+ vm.stopPrank();
+ assertFalse(ruleset.voteSucceeded(1));
+ }
+
+ function test_voteSucceeded_tieIsNotSuccess() public {
+ vm.startPrank(governorMock);
+ ruleset.countVote(1, address(1), uint8(BondRuleset.VoteType.For), 60, "");
+ ruleset.countVote(1, address(2), uint8(BondRuleset.VoteType.AgainstAndSlash), 60, "");
+ vm.stopPrank();
+ assertFalse(ruleset.voteSucceeded(1));
+ }
+
+ function test_quorumReached_ignoresAgainstAndSlash() public {
+ // Give the token real past supply: 1000e18 at the snapshot → quorum (1%) = 10e18.
+ token.mint(makeAddr("holder"), 1000e18);
+ vm.roll(block.number + 1);
+ govStub.setSnapshot(block.number - 1);
+
+ // Slash-only weight 100e18 must NOT satisfy quorum...
+ vm.prank(governorMock);
+ ruleset.countVote(1, address(1), uint8(BondRuleset.VoteType.AgainstAndSlash), 100e18, "");
+ assertFalse(ruleset.quorumReached(1));
+ // ...but 10e18 of Abstain does.
+ vm.prank(governorMock);
+ ruleset.countVote(1, address(2), uint8(BondRuleset.VoteType.Abstain), 10e18, "");
+ assertTrue(ruleset.quorumReached(1));
+ }
+
+ function test_revote_movesWeightAcrossSlashBucket() public {
+ vm.startPrank(governorMock);
+ ruleset.countVote(1, address(1), uint8(BondRuleset.VoteType.AgainstAndSlash), 40, "");
+ ruleset.countVote(1, address(1), uint8(BondRuleset.VoteType.For), 40, ""); // replace
+ vm.stopPrank();
+ (uint256 against,,, uint256 slash) = ruleset.proposalVotes(1);
+ assertEq(slash, 0);
+ assertEq(against, 0);
+ assertTrue(ruleset.voteSucceeded(1));
+ }
+
+ function _lockArgs() internal pure returns (address[] memory t, uint256[] memory v, bytes[] memory c, bytes32 h) {
+ t = new address[](1);
+ t[0] = address(0xBEEF);
+ v = new uint256[](1);
+ c = new bytes[](1);
+ c[0] = "";
+ h = keccak256(bytes("bond proposal"));
+ }
+
+ function _canonicalId(address[] memory t, uint256[] memory v, bytes[] memory c, bytes32 h)
+ internal
+ pure
+ returns (uint256)
+ {
+ return uint256(keccak256(abi.encode(t, v, c, h)));
+ }
+
+ function test_validateProposal_locksBond_recordsDelta() public {
+ (address[] memory t, uint256[] memory v, bytes[] memory c, bytes32 h) = _lockArgs();
+ address bob = makeAddr("bob");
+ token.mint(bob, BOND);
+ vm.prank(bob);
+ token.approve(address(ruleset), BOND);
+
+ vm.expectEmit(true, true, false, true);
+ emit BondRuleset.BondLocked(_canonicalId(t, v, c, h), bob, BOND);
+ vm.prank(governorMock);
+ ruleset.validateProposal(_canonicalId(t, v, c, h), bob, t, v, c);
+
+ (address proposer, bool settled) = ruleset.bondOf(_canonicalId(t, v, c, h));
+ assertEq(proposer, bob);
+ assertEq(ruleset.bondAmount(), BOND); // every bond holds exactly bondAmount
+ assertFalse(settled);
+ assertEq(token.balanceOf(address(ruleset)), BOND);
+ }
+
+ function test_validateProposal_onlyGovernor() public {
+ (address[] memory t, uint256[] memory v, bytes[] memory c, bytes32 h) = _lockArgs();
+ vm.expectRevert(abi.encodeWithSelector(RulesetCounting.Unauthorized.selector, address(this)));
+ ruleset.validateProposal(_canonicalId(t, v, c, h), makeAddr("bob"), t, v, c);
+ }
+
+ function test_validateProposal_revertsWithoutApproval() public {
+ (address[] memory t, uint256[] memory v, bytes[] memory c, bytes32 h) = _lockArgs();
+ address bob = makeAddr("bob");
+ token.mint(bob, BOND); // funded but no approve
+ vm.prank(governorMock);
+ vm.expectRevert(); // SafeERC20 insufficient-allowance revert
+ ruleset.validateProposal(_canonicalId(t, v, c, h), bob, t, v, c);
+ }
+
+ function test_validateProposal_duplicateLockReverts() public {
+ (address[] memory t, uint256[] memory v, bytes[] memory c, bytes32 h) = _lockArgs();
+ address bob = makeAddr("bob");
+ token.mint(bob, 2 * BOND);
+ vm.prank(bob);
+ token.approve(address(ruleset), 2 * BOND);
+ vm.startPrank(governorMock);
+ ruleset.validateProposal(_canonicalId(t, v, c, h), bob, t, v, c);
+ vm.expectRevert(abi.encodeWithSelector(BondRuleset.BondAlreadyLocked.selector, _canonicalId(t, v, c, h)));
+ ruleset.validateProposal(_canonicalId(t, v, c, h), bob, t, v, c);
+ vm.stopPrank();
+ }
+
+ function test_validateProposal_feeOnTransfer_reverts() public {
+ FeeOnTransferToken feeToken = new FeeOnTransferToken();
+ BondRuleset feeRuleset = new BondRuleset(governorMock, IVotes(address(feeToken)), 1, BOND, treasury);
+ (address[] memory t, uint256[] memory v, bytes[] memory c, bytes32 h) = _lockArgs();
+ address bob = makeAddr("bob");
+ feeToken.mint(bob, BOND);
+ vm.prank(bob);
+ feeToken.approve(address(feeRuleset), BOND);
+ vm.prank(governorMock);
+ vm.expectRevert(BondRuleset.InsufficientBondReceived.selector);
+ feeRuleset.validateProposal(_canonicalId(t, v, c, h), bob, t, v, c);
+ }
+}
diff --git a/test/rulesets/BondRulesetTestBase.sol b/test/rulesets/BondRulesetTestBase.sol
new file mode 100644
index 0000000..b25e2dc
--- /dev/null
+++ b/test/rulesets/BondRulesetTestBase.sol
@@ -0,0 +1,65 @@
+// SPDX-License-Identifier: MIT
+pragma solidity ^0.8.30;
+
+import {IVotes} from "@openzeppelin/contracts/governance/utils/IVotes.sol";
+
+import {GovernorNexus} from "../../src/GovernorNexus.sol";
+import {BondRuleset} from "../../src/rulesets/BondRuleset.sol";
+import {IRuleset} from "../../src/interfaces/IRuleset.sol";
+import {GovernorNexusTestBase} from "../governor/GovernorNexusTestBase.sol";
+
+/// @dev Extends the shared fixture with a registered bond type (proposalThreshold = 0, making
+/// proposing permissionless), a fund-but-no-VP proposer, and a council address holding the
+/// timelock's CANCELLER_ROLE to simulate the security-council veto.
+abstract contract BondRulesetTestBase is GovernorNexusTestBase {
+ uint256 internal constant BOND_AMOUNT = 1_000e18;
+
+ BondRuleset internal bondRuleset;
+ uint8 internal bondTypeId;
+ address internal bob = makeAddr("bob"); // bond proposer: tokens, no delegation → 0 VP
+ address internal council = makeAddr("council");
+
+ function setUp() public virtual override {
+ super.setUp();
+
+ bondRuleset = new BondRuleset(address(governor), IVotes(address(token)), 1, BOND_AMOUNT, address(timelock));
+ _executeSelfCall(
+ abi.encodeCall(
+ GovernorNexus.registerType, (IRuleset(address(bondRuleset)), VOTING_DELAY, VOTING_PERIOD, 0)
+ ),
+ "register bond type"
+ );
+ bondTypeId = governor.typeCount() - 1;
+
+ token.mint(bob, 10 * BOND_AMOUNT); // deliberately NOT delegated — zero voting power
+
+ bytes32 cancellerRole = timelock.CANCELLER_ROLE();
+ vm.prank(address(timelock));
+ timelock.grantRole(cancellerRole, council);
+
+ vm.roll(block.number + 1);
+ }
+
+ function _proposeBonded(string memory description)
+ internal
+ returns (
+ uint256 proposalId,
+ address[] memory targets,
+ uint256[] memory values,
+ bytes[] memory calldatas,
+ bytes32 descriptionHash
+ )
+ {
+ targets = new address[](1);
+ targets[0] = address(0xBEEF);
+ values = new uint256[](1);
+ calldatas = new bytes[](1);
+ calldatas[0] = "";
+ descriptionHash = keccak256(bytes(description));
+
+ vm.startPrank(bob);
+ token.approve(address(bondRuleset), BOND_AMOUNT);
+ proposalId = governor.proposeWithType(targets, values, calldatas, description, bondTypeId);
+ vm.stopPrank();
+ }
+}
diff --git a/test/rulesets/OptimisticRuleset.t.sol b/test/rulesets/OptimisticRuleset.t.sol
new file mode 100644
index 0000000..f159fe6
--- /dev/null
+++ b/test/rulesets/OptimisticRuleset.t.sol
@@ -0,0 +1,506 @@
+// SPDX-License-Identifier: MIT
+pragma solidity ^0.8.30;
+
+import {Test} from "forge-std/Test.sol";
+
+import {IERC165} from "@openzeppelin/contracts/utils/introspection/IERC165.sol";
+
+import {IProposalValidator} from "../../src/interfaces/IProposalValidator.sol";
+import {IRuleset} from "../../src/interfaces/IRuleset.sol";
+import {OptimisticRuleset} from "../../src/rulesets/OptimisticRuleset.sol";
+import {RulesetCounting} from "../../src/RulesetCounting.sol";
+
+/// @dev Isolated unit suite. The ruleset reads nothing from its governor (no quorum, no
+/// snapshot, no token), so a plain address suffices as the `onlyGovernor` caller —
+/// pranking as it exercises the real authorization path. `admin` stands in for the
+/// timelock the production deploy passes.
+contract OptimisticRulesetTest is Test {
+ uint256 internal constant VETO_THRESHOLD = 500_000e18;
+ uint256 internal constant PROPOSAL_ID = 1;
+
+ OptimisticRuleset internal ruleset;
+
+ address internal governor = makeAddr("governor");
+ address internal admin = makeAddr("admin");
+ address internal alice = makeAddr("alice");
+ address internal bob = makeAddr("bob");
+ address internal stranger = makeAddr("stranger");
+
+ address internal target = makeAddr("target");
+ bytes4 internal constant SELECTOR = bytes4(keccak256("store(uint256)"));
+
+ function setUp() public {
+ ruleset = new OptimisticRuleset(governor, admin, VETO_THRESHOLD);
+ }
+
+ function _countVote(address voter, uint8 support, uint256 weight) internal returns (uint256) {
+ vm.prank(governor);
+ return ruleset.countVote(PROPOSAL_ID, voter, support, weight, "");
+ }
+
+ function _allowProposer(address proposer) internal {
+ vm.prank(admin);
+ ruleset.setProposerAllowed(proposer, true);
+ }
+
+ function _allowAction(address target_, bytes4 selector) internal {
+ vm.prank(admin);
+ ruleset.setActionAllowed(target_, selector, true);
+ }
+
+ /// @dev A single-action proposal that clears every validation rule once `alice` and
+ /// `(target, SELECTOR)` are allowlisted.
+ function _validArrays()
+ internal
+ view
+ returns (address[] memory targets, uint256[] memory values, bytes[] memory calldatas)
+ {
+ targets = new address[](1);
+ targets[0] = target;
+ values = new uint256[](1);
+ calldatas = new bytes[](1);
+ calldatas[0] = abi.encodeWithSelector(SELECTOR, 42);
+ }
+
+ function _validate(address proposer, address[] memory targets, uint256[] memory values, bytes[] memory calldatas)
+ internal
+ {
+ vm.prank(governor);
+ ruleset.validateProposal(0, proposer, targets, values, calldatas);
+ }
+
+ // ─────────────────────────── Constructor ───────────────────────────
+
+ function test_constructor_exposesImmutables() public view {
+ assertEq(ruleset.governor(), governor);
+ assertEq(ruleset.admin(), admin);
+ assertEq(ruleset.vetoThreshold(), VETO_THRESHOLD);
+ }
+
+ function test_constructor_revertsOnZeroVetoThreshold() public {
+ vm.expectRevert(OptimisticRuleset.VetoThresholdZero.selector);
+ new OptimisticRuleset(governor, admin, 0);
+ }
+
+ function test_constructor_revertsOnZeroAdmin() public {
+ vm.expectRevert(OptimisticRuleset.AdminZeroAddress.selector);
+ new OptimisticRuleset(governor, address(0), VETO_THRESHOLD);
+ }
+
+ // ─────────────────────────── ERC165 ───────────────────────────
+
+ function test_supportsInterface_ruleset() public view {
+ assertTrue(ruleset.supportsInterface(type(IRuleset).interfaceId));
+ }
+
+ function test_supportsInterface_proposalValidator() public view {
+ assertTrue(ruleset.supportsInterface(type(IProposalValidator).interfaceId));
+ }
+
+ function test_supportsInterface_erc165() public view {
+ assertTrue(ruleset.supportsInterface(type(IERC165).interfaceId));
+ }
+
+ function test_supportsInterface_rejectsUnknown() public view {
+ assertFalse(ruleset.supportsInterface(bytes4(0xdeadbeef)));
+ }
+
+ // ─────────────────────────── Outcome: quorum ───────────────────────────
+
+ function test_quorumReached_trueWithNoVotes() public view {
+ assertTrue(ruleset.quorumReached(PROPOSAL_ID));
+ }
+
+ function test_quorumReached_trueForUnknownId() public view {
+ assertTrue(ruleset.quorumReached(0xdead), "no-revert contract: unknown ids answer from defaults");
+ }
+
+ function test_quorum_alwaysZero() public view {
+ assertEq(ruleset.quorum(0), 0);
+ assertEq(ruleset.quorum(block.number), 0);
+ }
+
+ // ─────────────────────────── Outcome: veto rule ───────────────────────────
+
+ function test_voteSucceeded_trueWithNoVotes() public view {
+ assertTrue(ruleset.voteSucceeded(PROPOSAL_ID), "pass-by-default: zero votes is a passing state");
+ }
+
+ function test_voteSucceeded_trueForUnknownId() public view {
+ assertTrue(ruleset.voteSucceeded(0xbeef));
+ }
+
+ function test_voteSucceeded_trueJustBelowThreshold() public {
+ _countVote(alice, 0, VETO_THRESHOLD - 1);
+ assertTrue(ruleset.voteSucceeded(PROPOSAL_ID));
+ }
+
+ function test_voteSucceeded_falseAtExactThreshold() public {
+ _countVote(alice, 0, VETO_THRESHOLD);
+ assertFalse(ruleset.voteSucceeded(PROPOSAL_ID), "Against == threshold must defeat (>= semantics)");
+ }
+
+ function test_voteSucceeded_falseAboveThreshold() public {
+ _countVote(alice, 0, VETO_THRESHOLD + 1);
+ assertFalse(ruleset.voteSucceeded(PROPOSAL_ID));
+ }
+
+ function test_voteSucceeded_ignoresForAndAbstain() public {
+ // For/Abstain weight is tallied but never outcome-bearing, in either direction.
+ _countVote(alice, 1, 100 * VETO_THRESHOLD);
+ _countVote(bob, 2, 100 * VETO_THRESHOLD);
+ assertTrue(ruleset.voteSucceeded(PROPOSAL_ID));
+
+ _countVote(stranger, 0, VETO_THRESHOLD);
+ assertFalse(ruleset.voteSucceeded(PROPOSAL_ID), "massive For support cannot save a vetoed proposal");
+ }
+
+ function test_vetoAccumulatesAcrossVoters() public {
+ _countVote(alice, 0, VETO_THRESHOLD / 2);
+ assertTrue(ruleset.voteSucceeded(PROPOSAL_ID));
+ _countVote(bob, 0, VETO_THRESHOLD - VETO_THRESHOLD / 2);
+ assertFalse(ruleset.voteSucceeded(PROPOSAL_ID));
+ }
+
+ // ─────────────────────────── Withdrawable veto (re-votes) ───────────────────────────
+
+ function test_voteSucceeded_vetoWithdrawalFlipsBackToPassing() public {
+ _countVote(alice, 0, VETO_THRESHOLD);
+ assertFalse(ruleset.voteSucceeded(PROPOSAL_ID));
+
+ _countVote(alice, 1, VETO_THRESHOLD); // withdraw the veto by re-voting For
+ assertTrue(ruleset.voteSucceeded(PROPOSAL_ID), "veto is withdrawable: re-vote drains the Against bucket");
+ }
+
+ function test_voteSucceeded_revoteIntoVetoFlipsToFailing() public {
+ _countVote(alice, 1, VETO_THRESHOLD);
+ assertTrue(ruleset.voteSucceeded(PROPOSAL_ID));
+
+ _countVote(alice, 0, VETO_THRESHOLD);
+ assertFalse(ruleset.voteSucceeded(PROPOSAL_ID), "non-monotonic in both directions");
+ }
+
+ // ─────────────────────────── Counting surface ───────────────────────────
+
+ function test_countVote_acceptsAllThreeBravoOptions() public {
+ _countVote(alice, 0, 1e18);
+ _countVote(bob, 1, 2e18);
+ _countVote(stranger, 2, 3e18);
+
+ (uint256 against, uint256 forVotes, uint256 abstain) = ruleset.proposalVotes(PROPOSAL_ID);
+ assertEq(against, 1e18);
+ assertEq(forVotes, 2e18);
+ assertEq(abstain, 3e18);
+ }
+
+ function test_countVote_revertsOnSupportAboveAbstain() public {
+ vm.prank(governor);
+ vm.expectRevert(RulesetCounting.InvalidVoteType.selector);
+ ruleset.countVote(PROPOSAL_ID, alice, 3, 1e18, "");
+ }
+
+ function test_countVote_revertsWhenCallerIsNotGovernor() public {
+ vm.prank(stranger);
+ vm.expectRevert(abi.encodeWithSelector(RulesetCounting.Unauthorized.selector, stranger));
+ ruleset.countVote(PROPOSAL_ID, alice, 0, 1e18, "");
+ }
+
+ function test_hasVoted_reflectsState() public {
+ assertFalse(ruleset.hasVoted(PROPOSAL_ID, alice));
+ _countVote(alice, 0, 1e18);
+ assertTrue(ruleset.hasVoted(PROPOSAL_ID, alice));
+ }
+
+ function test_proposalVotes_zeroForUnknownId() public view {
+ (uint256 against, uint256 forVotes, uint256 abstain) = ruleset.proposalVotes(0xdead);
+ assertEq(against, 0);
+ assertEq(forVotes, 0);
+ assertEq(abstain, 0);
+ }
+
+ function test_countingMode() public view {
+ assertEq(ruleset.COUNTING_MODE(), "support=bravo&quorum=against,for,abstain");
+ }
+
+ // ─────────────────────────── validateProposal: authorization ───────────────────────────
+
+ function test_validateProposal_revertsWhenCallerIsNotGovernor() public {
+ (address[] memory targets, uint256[] memory values, bytes[] memory calldatas) = _validArrays();
+ vm.prank(stranger);
+ vm.expectRevert(abi.encodeWithSelector(RulesetCounting.Unauthorized.selector, stranger));
+ ruleset.validateProposal(0, alice, targets, values, calldatas);
+ }
+
+ // ─────────────────────────── validateProposal: length check ───────────────────────────
+
+ function test_validateProposal_revertsOnShorterValues() public {
+ _allowProposer(alice);
+ (address[] memory targets,, bytes[] memory calldatas) = _validArrays();
+ uint256[] memory shortValues = new uint256[](0);
+
+ vm.prank(governor);
+ vm.expectRevert(OptimisticRuleset.LengthMismatch.selector);
+ ruleset.validateProposal(0, alice, targets, shortValues, calldatas);
+ }
+
+ function test_validateProposal_revertsOnShorterCalldatas() public {
+ _allowProposer(alice);
+ (address[] memory targets, uint256[] memory values,) = _validArrays();
+ bytes[] memory shortCalldatas = new bytes[](0);
+
+ vm.prank(governor);
+ vm.expectRevert(OptimisticRuleset.LengthMismatch.selector);
+ ruleset.validateProposal(0, alice, targets, values, shortCalldatas);
+ }
+
+ function test_validateProposal_revertsOnShorterTargets() public {
+ _allowProposer(alice);
+ (, uint256[] memory values, bytes[] memory calldatas) = _validArrays();
+ address[] memory shortTargets = new address[](0);
+
+ vm.prank(governor);
+ vm.expectRevert(OptimisticRuleset.LengthMismatch.selector);
+ ruleset.validateProposal(0, alice, shortTargets, values, calldatas);
+ }
+
+ function test_validateProposal_lengthCheckRunsBeforeProposerCheck() public {
+ // Non-allowlisted proposer AND mismatched lengths: the length diagnosis must win —
+ // the validator relies on nothing having been indexed before this check.
+ (address[] memory targets,, bytes[] memory calldatas) = _validArrays();
+ uint256[] memory shortValues = new uint256[](0);
+
+ vm.prank(governor);
+ vm.expectRevert(OptimisticRuleset.LengthMismatch.selector);
+ ruleset.validateProposal(0, stranger, targets, shortValues, calldatas);
+ }
+
+ /// @dev Any asymmetric length triple reverts `LengthMismatch` — never an out-of-bounds
+ /// panic, pinning that no array is indexed before the three-way check.
+ function testFuzz_validateProposal_anyLengthMismatchRevertsCleanly(
+ uint256 targetsLength,
+ uint256 valuesLength,
+ uint256 calldatasLength
+ ) public {
+ targetsLength = bound(targetsLength, 0, 6);
+ valuesLength = bound(valuesLength, 0, 6);
+ calldatasLength = bound(calldatasLength, 0, 6);
+ vm.assume(!(targetsLength == valuesLength && valuesLength == calldatasLength));
+
+ vm.prank(governor);
+ vm.expectRevert(OptimisticRuleset.LengthMismatch.selector);
+ ruleset.validateProposal(
+ 0, alice, new address[](targetsLength), new uint256[](valuesLength), new bytes[](calldatasLength)
+ );
+ }
+
+ // ─────────────────────────── validateProposal: proposer allowlist ───────────────────────────
+
+ function test_validateProposal_revertsOnNonAllowlistedProposer() public {
+ _allowAction(target, SELECTOR);
+ (address[] memory targets, uint256[] memory values, bytes[] memory calldatas) = _validArrays();
+
+ vm.prank(governor);
+ vm.expectRevert(abi.encodeWithSelector(OptimisticRuleset.ProposerNotAllowed.selector, alice));
+ ruleset.validateProposal(0, alice, targets, values, calldatas);
+ }
+
+ function test_validateProposal_revertsAfterProposerDisallowed() public {
+ _allowProposer(alice);
+ _allowAction(target, SELECTOR);
+ (address[] memory targets, uint256[] memory values, bytes[] memory calldatas) = _validArrays();
+ _validate(alice, targets, values, calldatas); // passes while allowlisted
+
+ vm.prank(admin);
+ ruleset.setProposerAllowed(alice, false);
+
+ vm.prank(governor);
+ vm.expectRevert(abi.encodeWithSelector(OptimisticRuleset.ProposerNotAllowed.selector, alice));
+ ruleset.validateProposal(0, alice, targets, values, calldatas);
+ }
+
+ // ─────────────────────────── validateProposal: per-action rules ───────────────────────────
+
+ function test_validateProposal_revertsOnNonZeroValue() public {
+ _allowProposer(alice);
+ _allowAction(target, SELECTOR);
+ (address[] memory targets, uint256[] memory values, bytes[] memory calldatas) = _validArrays();
+ values[0] = 1 wei;
+
+ vm.prank(governor);
+ vm.expectRevert(abi.encodeWithSelector(OptimisticRuleset.ValueNotAllowed.selector, 0));
+ ruleset.validateProposal(0, alice, targets, values, calldatas);
+ }
+
+ function test_validateProposal_revertsOnEmptyCalldata() public {
+ _allowProposer(alice);
+ (address[] memory targets, uint256[] memory values, bytes[] memory calldatas) = _validArrays();
+ calldatas[0] = "";
+
+ vm.prank(governor);
+ vm.expectRevert(abi.encodeWithSelector(OptimisticRuleset.SelectorMissing.selector, 0));
+ ruleset.validateProposal(0, alice, targets, values, calldatas);
+ }
+
+ function test_validateProposal_revertsOnCalldataShorterThanSelector() public {
+ _allowProposer(alice);
+ (address[] memory targets, uint256[] memory values, bytes[] memory calldatas) = _validArrays();
+ calldatas[0] = hex"aabbcc"; // 3 bytes: no selector to check
+
+ vm.prank(governor);
+ vm.expectRevert(abi.encodeWithSelector(OptimisticRuleset.SelectorMissing.selector, 0));
+ ruleset.validateProposal(0, alice, targets, values, calldatas);
+ }
+
+ function test_validateProposal_revertsOnNonAllowlistedAction() public {
+ _allowProposer(alice);
+ (address[] memory targets, uint256[] memory values, bytes[] memory calldatas) = _validArrays();
+
+ vm.prank(governor);
+ vm.expectRevert(abi.encodeWithSelector(OptimisticRuleset.ActionNotAllowed.selector, target, SELECTOR));
+ ruleset.validateProposal(0, alice, targets, values, calldatas);
+ }
+
+ function test_validateProposal_revertsOnAllowlistedSelectorAtDifferentTarget() public {
+ // The allowlist key is the (target, selector) PAIR — the same selector at another
+ // address is a different action.
+ _allowProposer(alice);
+ _allowAction(target, SELECTOR);
+ (address[] memory targets, uint256[] memory values, bytes[] memory calldatas) = _validArrays();
+ address otherTarget = makeAddr("otherTarget");
+ targets[0] = otherTarget;
+
+ vm.prank(governor);
+ vm.expectRevert(abi.encodeWithSelector(OptimisticRuleset.ActionNotAllowed.selector, otherTarget, SELECTOR));
+ ruleset.validateProposal(0, alice, targets, values, calldatas);
+ }
+
+ function test_validateProposal_reportsFailingIndexInMultiActionProposal() public {
+ _allowProposer(alice);
+ _allowAction(target, SELECTOR);
+
+ address[] memory targets = new address[](2);
+ targets[0] = target;
+ targets[1] = target;
+ uint256[] memory values = new uint256[](2);
+ values[1] = 1 ether; // only the second action violates
+ bytes[] memory calldatas = new bytes[](2);
+ calldatas[0] = abi.encodeWithSelector(SELECTOR, 1);
+ calldatas[1] = abi.encodeWithSelector(SELECTOR, 2);
+
+ vm.prank(governor);
+ vm.expectRevert(abi.encodeWithSelector(OptimisticRuleset.ValueNotAllowed.selector, 1));
+ ruleset.validateProposal(0, alice, targets, values, calldatas);
+ }
+
+ // ─────────────────────────── validateProposal: happy paths ───────────────────────────
+
+ function test_validateProposal_passesWithAllRulesSatisfied() public {
+ _allowProposer(alice);
+ _allowAction(target, SELECTOR);
+ (address[] memory targets, uint256[] memory values, bytes[] memory calldatas) = _validArrays();
+ _validate(alice, targets, values, calldatas);
+ }
+
+ function test_validateProposal_emptyProposalPassesVacuously() public {
+ // No actions, nothing to index — OZ's `_propose` rejects empty proposals downstream,
+ // and nothing here depends on that ordering.
+ _allowProposer(alice);
+ _validate(alice, new address[](0), new uint256[](0), new bytes[](0));
+ }
+
+ function test_validateProposal_multiActionAllAllowlistedPasses() public {
+ _allowProposer(alice);
+ _allowAction(target, SELECTOR);
+ bytes4 otherSelector = bytes4(keccak256("retrieve()"));
+ _allowAction(target, otherSelector);
+
+ address[] memory targets = new address[](2);
+ targets[0] = target;
+ targets[1] = target;
+ uint256[] memory values = new uint256[](2);
+ bytes[] memory calldatas = new bytes[](2);
+ calldatas[0] = abi.encodeWithSelector(SELECTOR, 7);
+ calldatas[1] = abi.encodeWithSelector(otherSelector);
+
+ _validate(alice, targets, values, calldatas);
+ }
+
+ // ─────────────────────────── Setters: authorization ───────────────────────────
+
+ function test_setProposerAllowed_revertsForNonAdmin() public {
+ vm.prank(stranger);
+ vm.expectRevert(abi.encodeWithSelector(RulesetCounting.Unauthorized.selector, stranger));
+ ruleset.setProposerAllowed(alice, true);
+ }
+
+ function test_setProposerAllowed_revertsForGovernor() public {
+ // The governor is NOT the admin: governance executions come from the timelock.
+ vm.prank(governor);
+ vm.expectRevert(abi.encodeWithSelector(RulesetCounting.Unauthorized.selector, governor));
+ ruleset.setProposerAllowed(alice, true);
+ }
+
+ function test_setActionAllowed_revertsForNonAdmin() public {
+ vm.prank(stranger);
+ vm.expectRevert(abi.encodeWithSelector(RulesetCounting.Unauthorized.selector, stranger));
+ ruleset.setActionAllowed(target, SELECTOR, true);
+ }
+
+ // ─────────────────────────── Setters: writes + events ───────────────────────────
+
+ function test_setProposerAllowed_writesAndEmits() public {
+ vm.expectEmit(true, false, false, true, address(ruleset));
+ emit OptimisticRuleset.ProposerAllowedSet(alice, true);
+ vm.prank(admin);
+ ruleset.setProposerAllowed(alice, true);
+ assertTrue(ruleset.allowedProposers(alice));
+
+ vm.expectEmit(true, false, false, true, address(ruleset));
+ emit OptimisticRuleset.ProposerAllowedSet(alice, false);
+ vm.prank(admin);
+ ruleset.setProposerAllowed(alice, false);
+ assertFalse(ruleset.allowedProposers(alice));
+ }
+
+ function test_setActionAllowed_writesAndEmits() public {
+ vm.expectEmit(true, true, false, true, address(ruleset));
+ emit OptimisticRuleset.ActionAllowedSet(target, SELECTOR, true);
+ vm.prank(admin);
+ ruleset.setActionAllowed(target, SELECTOR, true);
+ assertTrue(ruleset.allowedActions(target, SELECTOR));
+
+ vm.expectEmit(true, true, false, true, address(ruleset));
+ emit OptimisticRuleset.ActionAllowedSet(target, SELECTOR, false);
+ vm.prank(admin);
+ ruleset.setActionAllowed(target, SELECTOR, false);
+ assertFalse(ruleset.allowedActions(target, SELECTOR));
+ }
+
+ // ─────────────────────────── Setters: self-target refusal ───────────────────────────
+
+ function test_setActionAllowed_refusesGovernorAsTarget() public {
+ vm.prank(admin);
+ vm.expectRevert(abi.encodeWithSelector(OptimisticRuleset.SelfTargetForbidden.selector, governor));
+ ruleset.setActionAllowed(governor, SELECTOR, true);
+ }
+
+ function test_setActionAllowed_refusesAdminAsTarget() public {
+ vm.prank(admin);
+ vm.expectRevert(abi.encodeWithSelector(OptimisticRuleset.SelfTargetForbidden.selector, admin));
+ ruleset.setActionAllowed(admin, SELECTOR, true);
+ }
+
+ function test_setActionAllowed_refusesSelfAsTarget() public {
+ vm.prank(admin);
+ vm.expectRevert(abi.encodeWithSelector(OptimisticRuleset.SelfTargetForbidden.selector, address(ruleset)));
+ ruleset.setActionAllowed(address(ruleset), SELECTOR, true);
+ }
+
+ function test_setActionAllowed_refusalIsUnconditionalOnAllowedFlag() public {
+ // Even a disable write is refused: a self-target entry can never exist, so there is
+ // nothing to disable and the refusal keeps the invariant unconditional.
+ vm.prank(admin);
+ vm.expectRevert(abi.encodeWithSelector(OptimisticRuleset.SelfTargetForbidden.selector, governor));
+ ruleset.setActionAllowed(governor, SELECTOR, false);
+ }
+}
diff --git a/test/rulesets/RulesetCounting.t.sol b/test/rulesets/RulesetCounting.t.sol
new file mode 100644
index 0000000..0f4b2dc
--- /dev/null
+++ b/test/rulesets/RulesetCounting.t.sol
@@ -0,0 +1,417 @@
+// SPDX-License-Identifier: MIT
+pragma solidity ^0.8.30;
+
+import {Test} from "forge-std/Test.sol";
+
+import {RulesetCounting} from "../../src/RulesetCounting.sol";
+
+/// @dev Concrete stand-in for the abstract base: the mutable-vote counting mechanics live
+/// entirely in `RulesetCounting`, so a ruleset whose *rules* are stubs is enough to
+/// exercise them in isolation. Real rulesets (StandardRuleset) layer quorum/success on top.
+contract CountingHarness is RulesetCounting {
+ constructor(address governor_) RulesetCounting(governor_) {}
+
+ /// @dev The three Bravo options, as StandardRuleset defines them.
+ function _isValidSupport(uint8 support) internal pure override returns (bool) {
+ return support <= 2;
+ }
+
+ /// @dev All three buckets at once, so tests can assert conservation in one read.
+ function tallies(uint256 proposalId) external view returns (uint256, uint256, uint256) {
+ return (tally(proposalId, 0), tally(proposalId, 1), tally(proposalId, 2));
+ }
+
+ // Rule stubs — not under test here; the rules live in the concrete rulesets.
+
+ function quorumReached(uint256) external pure returns (bool) {
+ return false;
+ }
+
+ function voteSucceeded(uint256) external pure returns (bool) {
+ return false;
+ }
+
+ function quorum(uint256) external pure returns (uint256) {
+ return 0;
+ }
+
+ // solhint-disable-next-line func-name-mixedcase
+ function COUNTING_MODE() external pure returns (string memory) {
+ return "support=bravo&quorum=for,abstain";
+ }
+
+ function supportsInterface(bytes4) external pure returns (bool) {
+ return false;
+ }
+}
+
+/// @dev A ruleset with a FOURTH option, standing in for the Bond ruleset (AgainstAndSlash).
+/// The base must count it without a storage-layout change — otherwise "the counting layer
+/// every ruleset shares" is only true for the three-bucket rulesets.
+contract FourOptionHarness is RulesetCounting {
+ uint8 internal constant NO_AND_SLASH = 3;
+
+ constructor(address governor_) RulesetCounting(governor_) {}
+
+ function _isValidSupport(uint8 support) internal pure override returns (bool) {
+ return support <= NO_AND_SLASH;
+ }
+
+ function quorumReached(uint256) external pure returns (bool) {
+ return false;
+ }
+
+ function voteSucceeded(uint256) external pure returns (bool) {
+ return false;
+ }
+
+ function quorum(uint256) external pure returns (uint256) {
+ return 0;
+ }
+
+ // solhint-disable-next-line func-name-mixedcase
+ function COUNTING_MODE() external pure returns (string memory) {
+ return "support=bravo,slash&quorum=for,abstain";
+ }
+
+ function supportsInterface(bytes4) external pure returns (bool) {
+ return false;
+ }
+}
+
+/// @dev Unit suite for the shared mutable-vote counting base.
+/// The governor is a plain address pranked as the caller — the base's only external
+/// dependency is `onlyGovernor`, so no governor implementation is needed here.
+contract RulesetCountingTest is Test {
+ uint8 internal constant AGAINST = 0;
+ uint8 internal constant FOR = 1;
+ uint8 internal constant ABSTAIN = 2;
+
+ uint256 internal constant PROPOSAL_ID = 1;
+ uint256 internal constant OTHER_PROPOSAL_ID = 2;
+
+ /// @dev The receipt packs weight into `uint240`; this is the first value that does not fit.
+ uint256 internal constant WEIGHT_LIMIT = 1 << 240;
+
+ CountingHarness internal counting;
+
+ address internal governor = makeAddr("governor");
+ address internal alice = makeAddr("alice");
+ address internal bob = makeAddr("bob");
+ address internal stranger = makeAddr("stranger");
+
+ function setUp() public {
+ counting = new CountingHarness(governor);
+ }
+
+ function _countVote(uint256 proposalId, address voter, uint8 support, uint256 weight) internal returns (uint256) {
+ vm.prank(governor);
+ return counting.countVote(proposalId, voter, support, weight, "");
+ }
+
+ function _countVote(address voter, uint8 support, uint256 weight) internal returns (uint256) {
+ return _countVote(PROPOSAL_ID, voter, support, weight);
+ }
+
+ function _bucketOf(uint256 proposalId, uint8 support) internal view returns (uint256) {
+ (uint256 against, uint256 for_, uint256 abstain) = counting.tallies(proposalId);
+ if (support == AGAINST) return against;
+ if (support == FOR) return for_;
+ return abstain;
+ }
+
+ // ─────────────────────────── First vote (baseline) ───────────────────────────
+
+ function test_countVote_firstVote_creditsBucketAndRecordsReceipt() public {
+ uint256 counted = _countVote(alice, FOR, 600e18);
+
+ assertEq(counted, 600e18);
+ assertEq(_bucketOf(PROPOSAL_ID, FOR), 600e18);
+ assertTrue(counting.hasVoted(PROPOSAL_ID, alice));
+
+ (bool hasVoted, uint8 support, uint256 weight) = counting.voteReceipt(PROPOSAL_ID, alice);
+ assertTrue(hasVoted);
+ assertEq(support, FOR);
+ assertEq(weight, 600e18);
+ }
+
+ function test_countVote_revertsOnInvalidSupport() public {
+ vm.prank(governor);
+ vm.expectRevert(RulesetCounting.InvalidVoteType.selector);
+ counting.countVote(PROPOSAL_ID, alice, 3, 600e18, "");
+ }
+
+ function test_countVote_revertsWhenCallerIsNotGovernor() public {
+ vm.prank(stranger);
+ vm.expectRevert(abi.encodeWithSelector(RulesetCounting.Unauthorized.selector, stranger));
+ counting.countVote(PROPOSAL_ID, alice, FOR, 600e18, "");
+ }
+
+ // ─────────────────────────── Re-vote: the 9 transitions ───────────────────────────
+
+ /// @dev Every (from, to) support pair: the old bucket must be debited by the recorded
+ /// weight and the new bucket credited, leaving exactly one standing vote. The three
+ /// same-support pairs are the degenerate case — tallies unchanged, still one vote.
+ function test_countVote_revote_movesWeightAcrossEverySupportPair() public {
+ for (uint8 from = 0; from < 3; ++from) {
+ for (uint8 to = 0; to < 3; ++to) {
+ uint256 proposalId = 100 + uint256(from) * 3 + uint256(to);
+
+ _countVote(proposalId, alice, from, 600e18);
+ _countVote(proposalId, alice, to, 600e18);
+
+ (uint256 against, uint256 for_, uint256 abstain) = counting.tallies(proposalId);
+ uint256 total = against + for_ + abstain;
+
+ assertEq(_bucketOf(proposalId, to), 600e18, "new bucket must hold the standing weight");
+ assertEq(total, 600e18, "no double count: exactly one standing vote");
+ assertTrue(counting.hasVoted(proposalId, alice), "hasVoted stays true after a re-vote");
+
+ (, uint8 support,) = counting.voteReceipt(proposalId, alice);
+ assertEq(support, to, "receipt must record the latest support");
+ }
+ }
+ }
+
+ function test_countVote_revote_returnsTheNewStandingWeight() public {
+ _countVote(alice, FOR, 600e18);
+ uint256 counted = _countVote(alice, AGAINST, 600e18);
+
+ assertEq(counted, 600e18, "countVote reports the standing vote, not a delta");
+ }
+
+ /// @dev The debit side reads the *recorded* weight, the credit side the *passed* weight.
+ /// Under snapshot voting both are equal, but the accounting must not assume it.
+ function test_countVote_revote_withDifferentWeight_debitsRecordedCreditsPassed() public {
+ _countVote(alice, FOR, 600e18);
+ _countVote(alice, AGAINST, 250e18);
+
+ assertEq(_bucketOf(PROPOSAL_ID, FOR), 0, "old bucket debited by the recorded weight");
+ assertEq(_bucketOf(PROPOSAL_ID, AGAINST), 250e18, "new bucket credited with the passed weight");
+
+ (,, uint256 weight) = counting.voteReceipt(PROPOSAL_ID, alice);
+ assertEq(weight, 250e18, "receipt tracks the new weight");
+ }
+
+ function test_countVote_repeatedRevotes_leaveExactlyOneStandingVote() public {
+ for (uint256 i = 0; i < 10; ++i) {
+ _countVote(alice, uint8(i % 3), 600e18);
+ }
+
+ (uint256 against, uint256 for_, uint256 abstain) = counting.tallies(PROPOSAL_ID);
+ assertEq(against + for_ + abstain, 600e18);
+ assertEq(_bucketOf(PROPOSAL_ID, uint8(9 % 3)), 600e18);
+ }
+
+ function test_countVote_revote_doesNotTouchOtherVoters() public {
+ _countVote(alice, FOR, 600e18);
+ _countVote(bob, FOR, 350e18);
+
+ _countVote(alice, AGAINST, 600e18);
+
+ assertEq(_bucketOf(PROPOSAL_ID, FOR), 350e18, "bob's vote survives alice's re-vote");
+ assertEq(_bucketOf(PROPOSAL_ID, AGAINST), 600e18);
+ }
+
+ function test_countVote_revote_doesNotTouchOtherProposals() public {
+ _countVote(PROPOSAL_ID, alice, FOR, 600e18);
+ _countVote(OTHER_PROPOSAL_ID, alice, FOR, 600e18);
+
+ _countVote(PROPOSAL_ID, alice, AGAINST, 600e18);
+
+ assertEq(_bucketOf(OTHER_PROPOSAL_ID, FOR), 600e18, "per-proposal tallies are independent");
+ assertEq(_bucketOf(OTHER_PROPOSAL_ID, AGAINST), 0);
+ }
+
+ function test_countVote_revote_canEmptyABucketBackToZero() public {
+ _countVote(alice, FOR, 600e18);
+ _countVote(alice, ABSTAIN, 600e18);
+
+ assertEq(_bucketOf(PROPOSAL_ID, FOR), 0, "sole voter re-voting away zeroes the bucket");
+ }
+
+ function test_countVote_revote_fromZeroWeightVote() public {
+ _countVote(alice, FOR, 0);
+ _countVote(alice, AGAINST, 600e18);
+
+ assertEq(_bucketOf(PROPOSAL_ID, FOR), 0);
+ assertEq(_bucketOf(PROPOSAL_ID, AGAINST), 600e18);
+ }
+
+ // ─────────────────────────── Receipt width guard ───────────────────────────
+
+ function test_countVote_acceptsMaxUint240Weight() public {
+ uint256 counted = _countVote(alice, FOR, WEIGHT_LIMIT - 1);
+
+ assertEq(counted, WEIGHT_LIMIT - 1);
+ assertEq(_bucketOf(PROPOSAL_ID, FOR), WEIGHT_LIMIT - 1);
+
+ (,, uint256 weight) = counting.voteReceipt(PROPOSAL_ID, alice);
+ assertEq(weight, WEIGHT_LIMIT - 1, "receipt must round-trip the boundary weight");
+ }
+
+ /// @dev The receipt is narrower than the tally (uint240 vs uint256). Silent truncation
+ /// would break conservation — the guard makes it a loud revert instead.
+ function test_countVote_revertsOnWeightExceedingReceiptWidth() public {
+ vm.prank(governor);
+ vm.expectRevert(abi.encodeWithSelector(RulesetCounting.WeightOverflow.selector, WEIGHT_LIMIT));
+ counting.countVote(PROPOSAL_ID, alice, FOR, WEIGHT_LIMIT, "");
+ }
+
+ // ─────────────────────────── Per-support tally (frozen vector surface) ───────────────────────────
+
+ /// @dev `tally(id, support)` is the accessor the frozen differential-vector ABI requires
+ /// (`IStandardRulesetVector`). It reads the same buckets as `proposalVotes`,
+ /// one at a time, which is what the tally-conservation vectors iterate over.
+ function test_tally_readsTheSameBucketsAsProposalVotes() public {
+ _countVote(alice, AGAINST, 600e18);
+ _countVote(bob, FOR, 350e18);
+
+ assertEq(counting.tally(PROPOSAL_ID, AGAINST), 600e18);
+ assertEq(counting.tally(PROPOSAL_ID, FOR), 350e18);
+ assertEq(counting.tally(PROPOSAL_ID, ABSTAIN), 0);
+
+ (uint256 against, uint256 for_, uint256 abstain) = counting.tallies(PROPOSAL_ID);
+ assertEq(counting.tally(PROPOSAL_ID, AGAINST), against);
+ assertEq(counting.tally(PROPOSAL_ID, FOR), for_);
+ assertEq(counting.tally(PROPOSAL_ID, ABSTAIN), abstain);
+ }
+
+ function test_tally_revertsOnInvalidSupport() public {
+ vm.expectRevert(RulesetCounting.InvalidVoteType.selector);
+ counting.tally(PROPOSAL_ID, 3);
+ }
+
+ // ─────────────────────────── Unknown-id contract ───────────────────────────
+
+ function test_views_unknownProposalId_neverRevert() public view {
+ uint256 unknown = 999;
+
+ assertFalse(counting.hasVoted(unknown, alice));
+
+ (uint256 against, uint256 for_, uint256 abstain) = counting.tallies(unknown);
+ assertEq(against, 0);
+ assertEq(for_, 0);
+ assertEq(abstain, 0);
+
+ (bool hasVoted, uint8 support, uint256 weight) = counting.voteReceipt(unknown, alice);
+ assertFalse(hasVoted);
+ assertEq(support, 0);
+ assertEq(weight, 0);
+ }
+
+ function test_voteReceipt_unknownVoter_returnsEmpty() public {
+ _countVote(alice, FOR, 600e18);
+
+ (bool hasVoted, uint8 support, uint256 weight) = counting.voteReceipt(PROPOSAL_ID, bob);
+ assertFalse(hasVoted);
+ assertEq(support, 0);
+ assertEq(weight, 0);
+ }
+
+ // ─────────────────────────── Extra support options (Bond) ───────────────────────────
+
+ /// @dev The base must carry a ruleset that defines more than the three Bravo options: Bond
+ /// adds AgainstAndSlash as support=3. A re-vote *into* the extra bucket
+ /// must conserve the tally exactly as the three-option case does.
+ function test_extraSupportOption_countsAndConservesOnRevote() public {
+ FourOptionHarness bond = new FourOptionHarness(governor);
+ uint8 againstAndSlash = 3;
+
+ vm.prank(governor);
+ bond.countVote(PROPOSAL_ID, alice, FOR, 600e18, "");
+ vm.prank(governor);
+ bond.countVote(PROPOSAL_ID, alice, againstAndSlash, 600e18, ""); // re-vote into the 4th bucket
+
+ assertEq(bond.tally(PROPOSAL_ID, FOR), 0, "the For bucket was debited");
+ assertEq(bond.tally(PROPOSAL_ID, againstAndSlash), 600e18, "the extra bucket holds the standing vote");
+
+ (, uint8 support,) = bond.voteReceipt(PROPOSAL_ID, alice);
+ assertEq(support, againstAndSlash);
+ }
+
+ /// @dev Each ruleset still owns which options it accepts: the three-option harness must
+ /// reject the support value the Bond-like one accepts.
+ function test_extraSupportOption_isPerRulesetNotGlobal() public {
+ vm.prank(governor);
+ vm.expectRevert(RulesetCounting.InvalidVoteType.selector);
+ counting.countVote(PROPOSAL_ID, alice, 3, 600e18, "");
+ }
+
+ // ─────────────────────────── Tally conservation (fuzz) ───────────────────────────
+
+ /// @dev The headline conservation property: after an arbitrary re-vote sequence, each
+ /// bucket equals the sum of the weights of the voters whose *latest* vote points at it,
+ /// and the buckets together equal the total standing weight — never more (double count),
+ /// never less (lost debit).
+ function testFuzz_tallyConservation_underArbitraryRevoteSequences(
+ uint8[16] calldata supportPicks,
+ uint8[16] calldata voterPicks,
+ uint96[16] calldata weights
+ ) public {
+ address[3] memory voters = [alice, bob, stranger];
+
+ // Independent oracle: reconstruct the expected tallies from the INPUT sequence, not from
+ // the contract's own receipts — so a bug that mis-stored support *consistently* with a
+ // mis-credited bucket cannot make the two agree. Each voter's standing = their latest cast.
+ uint8[3] memory latestSupport;
+ uint256[3] memory latestWeight;
+ bool[3] memory voted;
+ for (uint256 i = 0; i < 16; ++i) {
+ uint256 v = voterPicks[i] % 3;
+ uint8 support = supportPicks[i] % 3;
+ _countVote(voters[v], support, weights[i]);
+ latestSupport[v] = support;
+ latestWeight[v] = weights[i];
+ voted[v] = true;
+ }
+
+ uint256[3] memory expected;
+ for (uint256 v = 0; v < 3; ++v) {
+ if (voted[v]) expected[latestSupport[v]] += latestWeight[v];
+ }
+
+ (uint256 against, uint256 for_, uint256 abstain) = counting.tallies(PROPOSAL_ID);
+ assertEq(against, expected[AGAINST], "against bucket == sum of standing against weights");
+ assertEq(for_, expected[FOR], "for bucket == sum of standing for weights");
+ assertEq(abstain, expected[ABSTAIN], "abstain bucket == sum of standing abstain weights");
+ }
+
+ /// @dev A re-vote that reverts (invalid support / weight overflow) must leave the standing vote
+ /// untouched. Both guards run before any state write, so the EVM rolls back — this pins
+ /// that no partial debit/credit escapes ahead of the revert.
+ function test_countVote_rejectedRevote_leavesStandingVoteIntact() public {
+ _countVote(alice, FOR, 600e18);
+
+ vm.prank(governor);
+ vm.expectRevert(RulesetCounting.InvalidVoteType.selector);
+ counting.countVote(PROPOSAL_ID, alice, 3, 600e18, "");
+
+ vm.prank(governor);
+ vm.expectRevert(abi.encodeWithSelector(RulesetCounting.WeightOverflow.selector, WEIGHT_LIMIT));
+ counting.countVote(PROPOSAL_ID, alice, AGAINST, WEIGHT_LIMIT, "");
+
+ assertEq(_bucketOf(PROPOSAL_ID, FOR), 600e18, "the standing For vote survives both rejected re-votes");
+ (bool voted, uint8 support, uint256 weight) = counting.voteReceipt(PROPOSAL_ID, alice);
+ assertTrue(voted);
+ assertEq(support, FOR);
+ assertEq(weight, 600e18);
+ }
+
+ /// @dev The threshold-oscillation attack shape: a tally that crosses a threshold, is re-voted back below
+ /// it, and crosses again must be exactly reconstructible at every step — the tally layer
+ /// stays coherent even though the *crossing* is not a monotonic event.
+ function test_tally_oscillatesAcrossAThresholdWithoutDrift() public {
+ _countVote(alice, FOR, 600e18);
+ assertEq(_bucketOf(PROPOSAL_ID, FOR), 600e18, "crossed");
+
+ _countVote(alice, AGAINST, 600e18);
+ assertEq(_bucketOf(PROPOSAL_ID, FOR), 0, "back below");
+
+ _countVote(alice, FOR, 600e18);
+ assertEq(_bucketOf(PROPOSAL_ID, FOR), 600e18, "crossed again, no drift");
+
+ (uint256 against, uint256 for_, uint256 abstain) = counting.tallies(PROPOSAL_ID);
+ assertEq(against + for_ + abstain, 600e18, "conservation holds across the oscillation");
+ }
+}
diff --git a/test/StandardRuleset.t.sol b/test/rulesets/StandardRuleset.t.sol
similarity index 77%
rename from test/StandardRuleset.t.sol
rename to test/rulesets/StandardRuleset.t.sol
index e71f047..ab1b90b 100644
--- a/test/StandardRuleset.t.sol
+++ b/test/rulesets/StandardRuleset.t.sol
@@ -6,10 +6,12 @@ import {Test} from "forge-std/Test.sol";
import {IERC165} from "@openzeppelin/contracts/utils/introspection/IERC165.sol";
import {IVotes} from "@openzeppelin/contracts/governance/utils/IVotes.sol";
-import {IRuleset} from "../src/IRuleset.sol";
-import {StandardRuleset} from "../src/StandardRuleset.sol";
-import {MockENSToken} from "./mocks/MockENSToken.sol";
-import {MockGovernor} from "./mocks/MockGovernor.sol";
+import {IRuleset} from "../../src/interfaces/IRuleset.sol";
+import {RulesetCounting} from "../../src/RulesetCounting.sol";
+import {RulesetQuorumFraction} from "../../src/RulesetQuorumFraction.sol";
+import {StandardRuleset} from "../../src/rulesets/StandardRuleset.sol";
+import {MockENSToken} from "../mocks/MockENSToken.sol";
+import {MockGovernor} from "../mocks/MockGovernor.sol";
/// @dev Isolated unit suite: no governor implementation exists yet, so `MockGovernor`
/// supplies the one method StandardRuleset consumes (`proposalSnapshot`) and doubles
@@ -57,10 +59,16 @@ contract StandardRulesetTest is Test {
}
function test_constructor_revertsWithQuorumNumeratorAboveDenominator() public {
- vm.expectRevert(abi.encodeWithSelector(StandardRuleset.InvalidQuorumFraction.selector, 101, 100));
+ vm.expectRevert(abi.encodeWithSelector(RulesetQuorumFraction.InvalidQuorumFraction.selector, 101, 100));
new StandardRuleset(address(governor), IVotes(address(token)), 101);
}
+ function test_constructor_revertsWithZeroQuorumNumerator() public {
+ // Zero would make `quorumReached` unconditionally true — rejected at construction.
+ vm.expectRevert(abi.encodeWithSelector(RulesetQuorumFraction.InvalidQuorumFraction.selector, 0, 100));
+ new StandardRuleset(address(governor), IVotes(address(token)), 0);
+ }
+
function _countVote(address voter, uint8 support, uint256 weight) internal returns (uint256) {
vm.prank(address(governor));
return ruleset.countVote(PROPOSAL_ID, voter, support, weight, "");
@@ -84,7 +92,7 @@ contract StandardRulesetTest is Test {
function test_countVote_revertsWhenCallerIsNotGovernor() public {
vm.prank(stranger);
- vm.expectRevert(abi.encodeWithSelector(StandardRuleset.Unauthorized.selector, stranger));
+ vm.expectRevert(abi.encodeWithSelector(RulesetCounting.Unauthorized.selector, stranger));
ruleset.countVote(PROPOSAL_ID, alice, 1, 600e18, "");
}
@@ -121,17 +129,41 @@ contract StandardRulesetTest is Test {
function test_countVote_revertsOnSupportGreaterThanTwo() public {
vm.prank(address(governor));
- vm.expectRevert(StandardRuleset.InvalidVoteType.selector);
+ vm.expectRevert(RulesetCounting.InvalidVoteType.selector);
ruleset.countVote(PROPOSAL_ID, alice, 3, 600e18, "");
}
// ─────────────────────────── Revote ───────────────────────────
- function test_countVote_revertsOnDoubleVote() public {
+ /// @dev The one semantic delta vs the live ENS governor (which reverts):
+ /// re-voting replaces the standing vote. Mechanics are covered in `RulesetCounting.t.sol`;
+ /// here we pin that StandardRuleset inherits them and that its *rules* follow the tally.
+ function test_countVote_revoteReplacesPreviousVote() public {
+ _countVote(alice, 1, 600e18); // for
+ _countVote(alice, 0, 600e18); // against — replaces
+
+ (uint256 against, uint256 for_,) = ruleset.proposalVotes(PROPOSAL_ID);
+ assertEq(for_, 0);
+ assertEq(against, 600e18);
+ }
+
+ function test_voteSucceeded_flipsBackToFalseOnRevoteAway() public {
_countVote(alice, 1, 600e18);
- vm.prank(address(governor));
- vm.expectRevert(abi.encodeWithSelector(StandardRuleset.AlreadyVoted.selector, alice));
- ruleset.countVote(PROPOSAL_ID, alice, 0, 600e18, "");
+ assertTrue(ruleset.voteSucceeded(PROPOSAL_ID));
+
+ _countVote(alice, 0, 600e18);
+ assertFalse(ruleset.voteSucceeded(PROPOSAL_ID), "success is non-monotonic under re-votes");
+ }
+
+ function test_quorumReached_flipsBackToFalseOnRevoteToZeroWeightBucket() public {
+ // carol alone cannot reach quorum; bob can. Bob votes, then re-votes with the weight the
+ // governor would pass after... nothing changes — quorum counts for+abstain, so a re-vote
+ // from For to Against drops the quorum-eligible tally back below the bar.
+ _countVote(bob, 1, 350e18); // for -> quorum (100e18) reached
+ assertTrue(ruleset.quorumReached(PROPOSAL_ID));
+
+ _countVote(bob, 0, 350e18); // against does not count toward quorum
+ assertFalse(ruleset.quorumReached(PROPOSAL_ID), "quorum is non-monotonic under re-votes");
}
function test_hasVoted_reflectsState() public {
@@ -140,6 +172,12 @@ contract StandardRulesetTest is Test {
assertTrue(ruleset.hasVoted(PROPOSAL_ID, alice));
}
+ function test_hasVoted_staysTrueAfterRevote() public {
+ _countVote(alice, 1, 600e18);
+ _countVote(alice, 0, 600e18);
+ assertTrue(ruleset.hasVoted(PROPOSAL_ID, alice), "hasVoted means 'has a standing vote'");
+ }
+
// ─────────────────────────── Zero weight ───────────────────────────
function test_countVote_zeroWeight_recordsVoteWithoutChangingTallies() public {