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 {