Production implementation of Governor Nexus — blockful's modular security upgrade for ENS governance (RFC).
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.
Governor Nexus uses a modular router architecture:
flowchart TD
U(("Users")) -->|"propose · castVote"| CORE["<b>Governor Nexus Core</b><br/>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<br/>type 0 · live-ENS parity"] ~~~ O["Optimistic<br/>pass-unless-vetoed"] ~~~ B["Bond<br/>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
RulesetCountingbase (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
IRulesetplus its own voting delay, voting period, and proposal threshold. Once registered, a type's ruleset and parameters never change — only itsactiveflag 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, andhasVoted— so the DAO swaps counting per type without ever touching the governor. StandardRulesetis 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.
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
VoteCastfor 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-globalnonces(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 fromnonces(address)reverts withGovernorInvalidSignature— relayers must readvoteNonce.
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:
proposalDeadlineis 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)(OZGovernorPreventLateQuorumABI) is emitted by the first cast after the original deadline. If nobody votes during the extension the event never fires — the views (or replayingVoteCasttallies against the immutable params) remain the source of truth.
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.
GovernorNexus caps how many proposals a single proposer can hold concurrently live:
- Live means
PendingorActive, nothing else: a proposal that already survived its vote (Queued) does not occupy a slot, and one that'sCanceled/Defeated/Executedfrees 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) within1..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 consultsstate()(which resolves purely fromPending/Activethere), and past the original deadline it decides from the late-flip stage alone — aNonestage 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 untiloriginalDeadline + extensionDuration. It never calls_wouldPass(the only ruleset-dependent path). The cost is a deliberate over-approximation: aFailingObservedid that ends up failing holds its slot up toextensionDurationlonger than strictly necessary, because its true deadline can only be known by asking the ruleset the probe must not call.
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(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
approveis 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.
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 always cancel their own proposal, recovering from mistakes without burning a full voting cycle.
- 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.
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
AgainstAndSlashon 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
AgainstAndSlashweight satisfies both comparisons and confiscates an honest proposer's bond. The defense is attracting any single vote in either expressive bucket (FororAgainst), 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 —
Againstweight matching the slash bucket, orForweight 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
BondRulesettransfers 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.
| Path | What |
|---|---|
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/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/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) |
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.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, ValidatorRulesets, Box test target |
test/fork/ |
Mainnet-fork suites: behavioral parity (live governor vs GovernorNexus) + A/B gas benchmark |
forge build
forge test --no-match-path "test/fork/*" # unit suite, no network
forge test --match-path "test/fork/*" -vv # mainnet-fork parity + gas bench
forge coverage --no-match-path "test/fork/*" --report summaryFork 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).
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 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 |
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 | 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. |
| 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. |