Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 0 additions & 26 deletions .github/workflows/clickup.yaml

This file was deleted.

21 changes: 21 additions & 0 deletions LICENSE
Original file line number Diff line number Diff line change
@@ -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.
28 changes: 17 additions & 11 deletions README.md
Original file line number Diff line number Diff line change
@@ -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)).
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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 |
Expand All @@ -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
Expand All @@ -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**.

Expand All @@ -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. |
4 changes: 4 additions & 0 deletions src/ENSParams.sol
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 3 additions & 3 deletions src/RulesetCounting.sol
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
///
Expand Down Expand Up @@ -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);
}
2 changes: 1 addition & 1 deletion test/governor/GovernorNexus.bond.t.sol
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
12 changes: 6 additions & 6 deletions test/rulesets/RulesetCounting.t.sol
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand Down
Loading