Skip to content

fix(sequencer): stop signalling already-executed governance payloads#24764

Open
spalladino wants to merge 2 commits into
merge-train/spartan-v5from
spl/a-1422-sequencer-keeps-signalling-a-governance-payload
Open

fix(sequencer): stop signalling already-executed governance payloads#24764
spalladino wants to merge 2 commits into
merge-train/spartan-v5from
spl/a-1422-sequencer-keeps-signalling-a-governance-payload

Conversation

@spalladino

@spalladino spalladino commented Jul 17, 2026

Copy link
Copy Markdown
Contributor

Context

A mainnet sequencer kept casting governance signals for a payload whose proposal had already been executed: the outer Multicall3 tx succeeds but the inner signalWithSig reverts (swallowed by allowFailure), wasting ~100k gas per slot. Two client-side bugs allowed this: executed proposals were treated like any other terminal state, so the payload was re-signalled every round; and the TS ProposalState enum was missing Droppable (present in IGovernance.sol since July 2025), so decoding an Expired proposal threw, and the publisher failed open and signalled anyway. Additionally, since the executed payload upgraded the canonical rollup, proposers of the old rollup kept signalling on a GovernanceProposer now keyed to a different instance, where their signals always revert.

Approach

  • Fix the ProposalState enum to match IGovernance.sol (9 states including Droppable) and drive the sweep from an explicit LIVE_PROPOSAL_STATES set.
  • Replace the boolean hasActiveProposalWithPayload sweep with getPayloadProposalStatus returning 'live' | 'executed' | 'none'. The publisher stops signalling a payload whose proposal was executed, unless the new GOVERNANCE_PROPOSER_FORCE_PAYLOAD_VOTE config is set (L1 permits re-proposing an executed payload, and some payloads are designed to be re-executed). Live proposals take precedence over executed ones, executed verdicts are memoized in-process, and the sweep remains a bounded lookback capped by the protocol-wide proposal lifetime.
  • Add a canonicality guard to enqueueCastSignalHelper: resolve the canonical rollup via getRollupAddress() and skip the signal when the configured rollup is not canonical. Only the canonical rollup's current proposer can signal, so a non-canonical node would otherwise waste gas on a reverting signal. Because the guard returns early on a mismatch, round accounting and the EIP-712 digest use the configured (verified-canonical) rollup address.
  • The proposal-status guard fails open on RPC errors — at worst a duplicate signal the contract simply counts alongside others in the round — so a flaky L1 endpoint cannot silence governance participation. A failure to resolve the canonical rollup instead skips the signal for that slot and is retried on the next one.

API changes

ReadOnlyGovernanceContract.hasActiveProposalWithPayload and the GovernanceProposerContract wrapper are replaced by getPayloadProposalStatus(payload). The redundant GovernanceProposerContract.getInstance() wrapper is removed in favour of the existing getRollupAddress(). New optional sequencer config governanceProposerForcePayloadVote (env GOVERNANCE_PROPOSER_FORCE_PAYLOAD_VOTE, default false).

To be forward-ported to merge-train/spartan after landing on the v5 line.

Fixes A-1422

…s executed

A mainnet sequencer kept signalling a governance payload whose proposal had
already been executed, wasting ~100k gas per slot on a signal the canonical
rollup rejected. Three fixes:

- Fix the `ProposalState` enum, which was missing `Droppable` (Solidity
  `IGovernance` has 9 states). The mismatch made Solidity `Expired` decode as
  out-of-range and throw in `asProposalState`, so the publisher failed open and
  signalled anyway. Add an explicit `LIVE_PROPOSAL_STATES` set for the sweep.

- Replace `hasActiveProposalWithPayload` (boolean) with `getPayloadProposalStatus`
  returning `'live' | 'executed' | 'none'`, matching a proposal by its stored
  payload directly or via its GSEPayload wrapper. The publisher now stops
  signalling an executed payload (memoized in-process, live takes precedence over
  executed) unless `GOVERNANCE_PROPOSER_FORCE_PAYLOAD_VOTE` is set, for payloads
  designed to be re-executed.

- Add a canonicality guard: resolve the canonical rollup instance once and skip
  the signal when the configured rollup is not canonical, reusing that instance
  for round accounting and the EIP-712 digest so the read cannot race a
  canonical-rollup change.
…ser API

Resolve the canonical rollup via getRollupAddress() instead of the getInstance()
wrapper, dropping the now-unused instance parameter threaded through
createSignalRequestWithSignature. Since the guard returns early when the configured
rollup is not canonical, round accounting and the EIP-712 digest can just use the
configured rollup address. Also drop the redundant sawExecuted flag from
getPayloadProposalStatus (the executed verdict is already memoized in the set).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant