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 }} 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 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. | diff --git a/src/ENSParams.sol b/src/ENSParams.sol index 1d3c93c..21eb591 100644 --- a/src/ENSParams.sol +++ b/src/ENSParams.sol @@ -20,6 +20,10 @@ library ENSParams { // 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 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