From 3aa3ebfd1b3da1776d0ee5de8a5b65bead1394c0 Mon Sep 17 00:00:00 2001 From: Leonardo Vieira Date: Wed, 19 Aug 2026 14:17:02 -0300 Subject: [PATCH 1/7] docs(readme): fix stale claims ahead of the implementation report MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - regenerate the gas table from GasBench at the pinned block (propose 138,778 / castVote 135,842; queue and execute unchanged) - validateProposal signature: the hook receives the governor-computed proposalId first; the layout row carried descriptionHash from the pre-proposalId interface - self-cancel: 'always' was false — cancel is barred in the propose block; document the bar and why it exists - layout table: add RulesetQuorumFraction, the batch and voteNonce suites, and the FeeOnTransferToken mock; retitle the page Co-Authored-By: Claude Fable 5 --- README.md | 28 +++++++++++++++++----------- 1 file changed, 17 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index 18bf4bb..5361b41 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# 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)). @@ -209,8 +209,8 @@ target — the governor, the timelock, and the ruleset itself — so a zero-vote 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(proposer, targets, values, -calldatas)` called before the proposal is created, and a revert blocks creation. +`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 @@ -237,8 +237,11 @@ replaces that (via the `_validateCancel` hook — no fork): **cancellation is po 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 always cancel their own proposal, recovering from - mistakes without burning a full voting cycle. +- **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 @@ -362,17 +365,20 @@ Accepted residuals: | `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 `descriptionHash`), ERC165-detected at registration; drives the optimistic gate and `BondRuleset`'s bond lock | +| `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 (single source of truth) | +| `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/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 | @@ -386,7 +392,7 @@ Accepted residuals: | `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`, `ValidatorRulesets`, `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 @@ -407,7 +413,7 @@ The live ENS governor is a 2021, OZ-v4, Bravo-style deployment with everything f 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://anticapture.com/ens) +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**. @@ -434,7 +440,7 @@ setup/fixture cost. Reference numbers at block 25,445,220 (regenerate with | op | live gov | GovernorNexus | delta | attribution | |---|---:|---:|---:|---| -| propose | 115,052 | 139,441 | +24,389 | 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,831 | +28,849 | 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. | +| 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. | From 0364ad06e75f350a3bb0327e6fc942511ffc3e10 Mon Sep 17 00:00:00 2001 From: Leonardo Vieira Date: Wed, 19 Aug 2026 14:17:02 -0300 Subject: [PATCH 2/7] chore: add MIT license and security policy Source files already carried MIT SPDX headers with no license text at the root. SECURITY.md gives researchers a private disclosure path (shared@blockful.io) ahead of the external audit. Co-Authored-By: Claude Fable 5 --- LICENSE | 21 +++++++++++++++++++++ SECURITY.md | 21 +++++++++++++++++++++ 2 files changed, 42 insertions(+) create mode 100644 LICENSE create mode 100644 SECURITY.md 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/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..36b38c1 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,21 @@ +# Security Policy + +Governor Nexus is governance infrastructure for ENS DAO. It is not deployed yet, and an +external audit is being scheduled. If you believe you have found a vulnerability, please +report it privately. Do not open a public issue. + +## Reporting a vulnerability + +Email **shared@blockful.io** with: + +- a description of the issue and the affected contract or file +- steps to reproduce, or a proof-of-concept test if you have one +- your assessment of the impact + +We will acknowledge your report within 72 hours and keep you updated as we work on it. + +## Scope + +All contracts under `src/`. Findings reported here before the external audit will be +incorporated into the audit scope, and reporters will be credited unless they prefer +otherwise. From 66a4b9b24b6ba4fc92933b0fb28d22cfbc1c964a Mon Sep 17 00:00:00 2001 From: Leonardo Vieira Date: Wed, 19 Aug 2026 14:17:02 -0300 Subject: [PATCH 3/7] chore: align comments with the shipped AgainstAndSlash naming No+Slash predates the option's final name; also reword one bond-suite pin comment that referenced an internal review id. Co-Authored-By: Claude Fable 5 --- src/RulesetCounting.sol | 6 +++--- test/governor/GovernorNexus.bond.t.sol | 2 +- test/rulesets/RulesetCounting.t.sol | 12 ++++++------ 3 files changed, 10 insertions(+), 10 deletions(-) diff --git a/src/RulesetCounting.sol b/src/RulesetCounting.sol index 858cf23..3cbf6cc 100644 --- a/src/RulesetCounting.sol +++ b/src/RulesetCounting.sol @@ -10,7 +10,7 @@ import {IRuleset} from "./interfaces/IRuleset.sol"; /// @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 No+Slash — +/// 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`. /// @@ -133,13 +133,13 @@ abstract contract RulesetCounting is IRuleset { } /// @dev The support values this ruleset accepts. Standard/Optimistic use the three Bravo - /// options; Bond adds No+Slash. Declared `pure` so an override physically cannot read + /// 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 No+Slash is the live example.) + /// 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/test/governor/GovernorNexus.bond.t.sol b/test/governor/GovernorNexus.bond.t.sol index 57837ac..5a9dc3e 100644 --- a/test/governor/GovernorNexus.bond.t.sol +++ b/test/governor/GovernorNexus.bond.t.sol @@ -567,7 +567,7 @@ contract GovernorNexusBondTest is BondRulesetTestBase { assertEq(token.balanceOf(bob), before + BOND_AMOUNT); } - /// @dev LEAD-10 pin: the atomic propose→cancel(→resolve) round-trip — which would let a + /// @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 { diff --git a/test/rulesets/RulesetCounting.t.sol b/test/rulesets/RulesetCounting.t.sol index 3034ea5..0f4b2dc 100644 --- a/test/rulesets/RulesetCounting.t.sol +++ b/test/rulesets/RulesetCounting.t.sol @@ -45,7 +45,7 @@ contract CountingHarness is RulesetCounting { } } -/// @dev A ruleset with a FOURTH option, standing in for the Bond ruleset (No+Slash). +/// @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 { @@ -312,22 +312,22 @@ contract RulesetCountingTest is Test { // ─────────────────────────── Extra support options (Bond) ─────────────────────────── /// @dev The base must carry a ruleset that defines more than the three Bravo options: Bond - /// adds No+Slash as support=3. A re-vote *into* the extra bucket + /// 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 noAndSlash = 3; + uint8 againstAndSlash = 3; vm.prank(governor); bond.countVote(PROPOSAL_ID, alice, FOR, 600e18, ""); vm.prank(governor); - bond.countVote(PROPOSAL_ID, alice, noAndSlash, 600e18, ""); // re-vote into the 4th bucket + 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, noAndSlash), 600e18, "the extra bucket holds the standing vote"); + assertEq(bond.tally(PROPOSAL_ID, againstAndSlash), 600e18, "the extra bucket holds the standing vote"); (, uint8 support,) = bond.voteReceipt(PROPOSAL_ID, alice); - assertEq(support, noAndSlash); + assertEq(support, againstAndSlash); } /// @dev Each ruleset still owns which options it accepts: the three-option harness must From cc407ef3b4a5c0a6c591d6af9ae173d9473350bd Mon Sep 17 00:00:00 2001 From: Leonardo Vieira Date: Wed, 19 Aug 2026 14:17:02 -0300 Subject: [PATCH 4/7] feat(params): pin the intended bond and veto values in ENSParams BOND_AMOUNT (1,000 ENS, EP 5.15) and VETO_THRESHOLD (500k ENS) were quoted in the README but existed nowhere in src; ENSParams is the single source of truth, so they live here. Co-Authored-By: Claude Fable 5 --- src/ENSParams.sol | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/ENSParams.sol b/src/ENSParams.sol index 1d3c93c..c45b737 100644 --- a/src/ENSParams.sol +++ b/src/ENSParams.sol @@ -20,6 +20,12 @@ library ENSParams { // so numerator 1 encodes the same 1%. uint256 internal constant QUORUM_NUMERATOR = 1; + // Intended ENS registration values for the additional rulesets — not read from the + // live governor (it has neither mechanism): the DAO-ratified proposal bond (EP 5.15) + // and the optimistic ruleset's absolute veto threshold. + 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 From c3de64a419edc7395c478e2524c68af510066c2d Mon Sep 17 00:00:00 2001 From: Leonardo Vieira Date: Wed, 19 Aug 2026 14:17:02 -0300 Subject: [PATCH 5/7] ci: remove the task-sync workflow Internal-process wiring, and it triggered on a branch that is being retired. Co-Authored-By: Claude Fable 5 --- .github/workflows/clickup.yaml | 26 -------------------------- 1 file changed, 26 deletions(-) delete mode 100644 .github/workflows/clickup.yaml diff --git a/.github/workflows/clickup.yaml b/.github/workflows/clickup.yaml deleted file mode 100644 index d198825..0000000 --- a/.github/workflows/clickup.yaml +++ /dev/null @@ -1,26 +0,0 @@ -name: ClickUp sync - -on: - create: - pull_request: - types: [opened, ready_for_review, synchronize, closed] - pull_request_review: - types: [submitted] - push: - branches: [main] - -permissions: - contents: read - pull-requests: read - -jobs: - pr-sync: - if: github.event_name != 'push' - uses: blockful/.github/.github/workflows/clickup-pr-sync.yaml@main - secrets: - clickup_token: ${{ secrets.CLICKUP_API_TOKEN }} - release-sync: - if: github.event_name == 'push' - uses: blockful/.github/.github/workflows/clickup-release-sync.yaml@main - secrets: - clickup_token: ${{ secrets.CLICKUP_API_TOKEN }} From db81e3be3831fead6cbf350185cb354bc18f265d Mon Sep 17 00:00:00 2001 From: Leonardo Vieira <69486932+LeonardoVieira1630@users.noreply.github.com> Date: Wed, 19 Aug 2026 14:31:13 -0300 Subject: [PATCH 6/7] Update ENSParams.sol --- src/ENSParams.sol | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/ENSParams.sol b/src/ENSParams.sol index c45b737..21eb591 100644 --- a/src/ENSParams.sol +++ b/src/ENSParams.sol @@ -20,9 +20,7 @@ library ENSParams { // so numerator 1 encodes the same 1%. uint256 internal constant QUORUM_NUMERATOR = 1; - // Intended ENS registration values for the additional rulesets — not read from the - // live governor (it has neither mechanism): the DAO-ratified proposal bond (EP 5.15) - // and the optimistic ruleset's absolute veto threshold. + // 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 From e99b68c7282ac62ed59a67b2347657d0844115c2 Mon Sep 17 00:00:00 2001 From: Leonardo Vieira <69486932+LeonardoVieira1630@users.noreply.github.com> Date: Wed, 19 Aug 2026 14:34:55 -0300 Subject: [PATCH 7/7] Delete SECURITY.md --- SECURITY.md | 21 --------------------- 1 file changed, 21 deletions(-) delete mode 100644 SECURITY.md diff --git a/SECURITY.md b/SECURITY.md deleted file mode 100644 index 36b38c1..0000000 --- a/SECURITY.md +++ /dev/null @@ -1,21 +0,0 @@ -# Security Policy - -Governor Nexus is governance infrastructure for ENS DAO. It is not deployed yet, and an -external audit is being scheduled. If you believe you have found a vulnerability, please -report it privately. Do not open a public issue. - -## Reporting a vulnerability - -Email **shared@blockful.io** with: - -- a description of the issue and the affected contract or file -- steps to reproduce, or a proof-of-concept test if you have one -- your assessment of the impact - -We will acknowledge your report within 72 hours and keep you updated as we work on it. - -## Scope - -All contracts under `src/`. Findings reported here before the external audit will be -incorporated into the audit scope, and reporters will be credited unless they prefer -otherwise.