Skip to content

Commit 229c981

Browse files
authored
fix(sequencer): bounded sweep instead of event scan for governance proposal check (#22989) (#23001)
Backport of #22989 to v4
2 parents f8c89cf + e44142a commit 229c981

6 files changed

Lines changed: 629 additions & 112 deletions

File tree

yarn-project/ethereum/src/contracts/empire_base.ts

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,8 +22,6 @@ export interface IEmpireBase {
2222
signerAddress: Hex,
2323
signer: (msg: TypedDataDefinition) => Promise<Hex>,
2424
): Promise<L1TxRequest>;
25-
/** Checks if a payload was ever submitted to governance via submitRoundWinner. */
26-
hasPayloadBeenProposed(payload: Hex, fromBlock: bigint): Promise<boolean>;
2725
}
2826

2927
export function encodeSignal(payload: Hex): Hex {

yarn-project/ethereum/src/contracts/governance.test.ts

Lines changed: 291 additions & 32 deletions
Large diffs are not rendered by default.

yarn-project/ethereum/src/contracts/governance.ts

Lines changed: 266 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,21 @@ import type { L1ContractAddresses } from '../l1_contract_addresses.js';
1717
import { createL1TxUtils } from '../l1_tx_utils/index.js';
1818
import { type ExtendedViemWalletClient, type ViemClient, isExtendedClient } from '../types.js';
1919

20+
// Minimal ABI for IProposerPayload (`l1-contracts/src/governance/interfaces/IProposerPayload.sol`).
21+
// The GovernanceProposer wraps every original payload in a GSEPayload before submitting it to
22+
// Governance, so `Proposal.payload` on the Governance contract is the wrapper address rather than
23+
// the original. We only need `getOriginalPayload` to recover the underlying payload, so we inline
24+
// the ABI here instead of generating a full artifact.
25+
const ProposerPayloadAbi = [
26+
{
27+
type: 'function',
28+
name: 'getOriginalPayload',
29+
inputs: [],
30+
outputs: [{ type: 'address' }],
31+
stateMutability: 'view',
32+
},
33+
] as const;
34+
2035
export type L1GovernanceContractAddresses = Pick<
2136
L1ContractAddresses,
2237
'governanceAddress' | 'rollupAddress' | 'registryAddress' | 'governanceProposerAddress'
@@ -34,6 +49,98 @@ export enum ProposalState {
3449
Expired,
3550
}
3651

52+
/** Vote tallies on a single proposal. Both fields are mutated by `Governance.vote`. */
53+
export interface Ballot {
54+
yea: bigint;
55+
nay: bigint;
56+
}
57+
58+
/**
59+
* Snapshot of the timing/quorum parameters that govern a single proposal's lifecycle. Each proposal
60+
* stores its own copy at creation time (see `_propose` in Governance.sol), so this snapshot is
61+
* immutable for the lifetime of the proposal even if the global `Configuration` changes later.
62+
*/
63+
export interface ProposalConfiguration {
64+
votingDelay: bigint;
65+
votingDuration: bigint;
66+
executionDelay: bigint;
67+
gracePeriod: bigint;
68+
quorum: bigint;
69+
requiredYeaMargin: bigint;
70+
minimumVotes: bigint;
71+
}
72+
73+
/** Parameters for `Governance.proposeWithLock`. Stored only in the global Configuration, never on a proposal. */
74+
export interface ProposeWithLockConfiguration {
75+
lockDelay: bigint;
76+
lockAmount: bigint;
77+
}
78+
79+
/**
80+
* Live, mutable governance configuration. `proposeConfig` is the lock configuration used by
81+
* `proposeWithLock`; the remaining fields are the same shape as `ProposalConfiguration` and are
82+
* snapshotted onto each new proposal at creation time.
83+
*/
84+
export interface GovernanceConfiguration extends ProposalConfiguration {
85+
proposeConfig: ProposeWithLockConfiguration;
86+
}
87+
88+
/**
89+
* A governance proposal augmented with its live (computed) state.
90+
*
91+
* Mutability:
92+
* - `config`, `payload`, `proposer`, `creation` are immutable for the lifetime of the proposal.
93+
* - `cachedState` is the raw value stored on-chain. It is only written when a proposal is explicitly
94+
* executed or dropped, so for time-derived terminal states (Rejected, Expired) it remains at the
95+
* value that was current when the proposal was created.
96+
* - `state` is the computed state returned by `Governance.getProposalState`. This is the value
97+
* callers almost always want -- it reflects time-derived transitions that `cachedState` does not.
98+
* - `summedBallot` is mutated by every `Governance.vote` call while the proposal is Active.
99+
*
100+
* Once `state` is in a terminal phase (`Executed`/`Rejected`/`Dropped`/`Expired`) the entire struct
101+
* is provably immutable on-chain (no further votes can be cast and no state-mutating call to
102+
* `execute`/`dropProposal` can succeed), and the wrapper memoizes it.
103+
*/
104+
export interface Proposal {
105+
config: ProposalConfiguration;
106+
cachedState: ProposalState;
107+
state: ProposalState;
108+
payload: EthAddress;
109+
proposer: EthAddress;
110+
creation: bigint;
111+
summedBallot: Ballot;
112+
}
113+
114+
/** Set of `ProposalState` values for which a proposal is fully immutable on-chain. */
115+
const TERMINAL_PROPOSAL_STATES: ReadonlySet<ProposalState> = new Set([
116+
ProposalState.Executed,
117+
ProposalState.Rejected,
118+
ProposalState.Dropped,
119+
ProposalState.Expired,
120+
]);
121+
122+
// Hard upper bound on the wall-clock lifetime of any Governance proposal, in seconds.
123+
// Each proposal stores its own snapshot of `ProposalConfiguration` at creation time and progresses
124+
// through Pending -> Active -> Queued -> Executable using those frozen durations
125+
// (see `ProposalLib.{pendingThrough,activeThrough,queuedThrough,executableThrough}`). Each of those
126+
// four durations is bounded by `ConfigurationLib.TIME_UPPER = 90 days` (validated in
127+
// `ConfigurationLib.assertValid`), so no proposal can be live for more than 4 * 90 days regardless
128+
// of what config it was created under. Once past this point, the proposal is guaranteed to be in a
129+
// terminal state (Executed / Rejected / Dropped / Expired).
130+
export const MAX_PROPOSAL_LIFETIME_SECONDS = 4n * 90n * 24n * 3600n;
131+
132+
/**
133+
* Validates a number returned by an on-chain `ProposalState` enum field and narrows it to the
134+
* `ProposalState` enum. Throws if the value is out of range.
135+
*/
136+
function asProposalState(raw: number): ProposalState {
137+
// eslint-disable-next-line @typescript-eslint/no-unsafe-enum-comparison
138+
if (raw < 0 || raw > ProposalState.Expired) {
139+
throw new Error(`Invalid proposal state: ${raw}`);
140+
}
141+
return raw as ProposalState;
142+
}
143+
37144
export function extractProposalIdFromLogs(logs: Log[]): bigint {
38145
const parsedLogs = parseEventLogs({
39146
abi: GovernanceAbi,
@@ -50,6 +157,22 @@ export function extractProposalIdFromLogs(logs: Log[]): bigint {
50157
export class ReadOnlyGovernanceContract {
51158
protected readonly governanceContract: GetContractReturnType<typeof GovernanceAbi, ViemClient>;
52159

160+
/**
161+
* Cache of fully-resolved proposals keyed by id. We populate this lazily and only retain entries
162+
* whose state is provably terminal -- once `cachedState` is `Executed` or `Dropped` the on-chain
163+
* proposal struct is frozen and safe to memoize indefinitely. Other state transitions (e.g.
164+
* Pending -> Active, or accumulating votes) leave the cache untouched and force a fresh fetch.
165+
*/
166+
private readonly proposalCache: Map<bigint, Proposal> = new Map();
167+
168+
/**
169+
* Cache of `IProposerPayload.getOriginalPayload()` results keyed by wrapper address. The wrapper
170+
* contract's bytecode is immutable, so this mapping never changes -- a value of `undefined`
171+
* encodes a proposal whose payload doesn't implement `getOriginalPayload` (e.g. proposeWithLock
172+
* proposals) and should be treated as "no original".
173+
*/
174+
private readonly originalPayloadCache: Map<Hex, Hex | undefined> = new Map();
175+
53176
constructor(
54177
address: Hex,
55178
public readonly client: ViemClient,
@@ -65,21 +188,155 @@ export class ReadOnlyGovernanceContract {
65188
return EthAddress.fromString(await this.governanceContract.read.governanceProposer());
66189
}
67190

68-
public getConfiguration() {
69-
return this.governanceContract.read.getConfiguration();
191+
public async getConfiguration(): Promise<GovernanceConfiguration> {
192+
const raw = await this.governanceContract.read.getConfiguration();
193+
return {
194+
proposeConfig: {
195+
lockDelay: raw.proposeConfig.lockDelay,
196+
lockAmount: raw.proposeConfig.lockAmount,
197+
},
198+
votingDelay: raw.votingDelay,
199+
votingDuration: raw.votingDuration,
200+
executionDelay: raw.executionDelay,
201+
gracePeriod: raw.gracePeriod,
202+
quorum: raw.quorum,
203+
requiredYeaMargin: raw.requiredYeaMargin,
204+
minimumVotes: raw.minimumVotes,
205+
};
70206
}
71207

72-
public getProposal(proposalId: bigint) {
73-
return this.governanceContract.read.getProposal([proposalId]);
208+
/**
209+
* Fetches a proposal by id together with its live state, returning the mapped {@link Proposal}
210+
* type. Issues `getProposal` and `getProposalState` in parallel so the result carries both the
211+
* raw stored `cachedState` and the time-derived `state` -- callers can use whichever they need
212+
* without an extra round-trip.
213+
*
214+
* Backed by an in-memory cache that retains entries only when `state` is in one of the four
215+
* terminal phases (`Executed` / `Rejected` / `Dropped` / `Expired`). At that point the entire
216+
* proposal struct is provably immutable on-chain (no further votes can be cast and no
217+
* state-mutating call can succeed), so caching is safe forever. Non-terminal states force a
218+
* fresh fetch on every call so callers always see fresh `state` and `summedBallot` values.
219+
*/
220+
public async getProposal(proposalId: bigint): Promise<Proposal> {
221+
const cached = this.proposalCache.get(proposalId);
222+
if (cached !== undefined) {
223+
return cached;
224+
}
225+
const [raw, rawState] = await Promise.all([
226+
this.governanceContract.read.getProposal([proposalId]),
227+
this.governanceContract.read.getProposalState([proposalId]),
228+
]);
229+
const proposal: Proposal = {
230+
config: {
231+
votingDelay: raw.config.votingDelay,
232+
votingDuration: raw.config.votingDuration,
233+
executionDelay: raw.config.executionDelay,
234+
gracePeriod: raw.config.gracePeriod,
235+
quorum: raw.config.quorum,
236+
requiredYeaMargin: raw.config.requiredYeaMargin,
237+
minimumVotes: raw.config.minimumVotes,
238+
},
239+
cachedState: asProposalState(raw.cachedState),
240+
state: asProposalState(rawState),
241+
payload: EthAddress.fromString(raw.payload),
242+
proposer: EthAddress.fromString(raw.proposer),
243+
creation: raw.creation,
244+
summedBallot: { yea: raw.summedBallot.yea, nay: raw.summedBallot.nay },
245+
};
246+
if (TERMINAL_PROPOSAL_STATES.has(proposal.state)) {
247+
this.proposalCache.set(proposalId, proposal);
248+
}
249+
return proposal;
74250
}
75251

252+
/**
253+
* Returns the live state of a proposal as computed by `Governance.getProposalState`. Prefer
254+
* {@link getProposal} when you also need any other proposal data -- it returns this same value
255+
* via {@link Proposal.state} alongside the rest of the struct in a single round-trip.
256+
*/
76257
public async getProposalState(proposalId: bigint): Promise<ProposalState> {
77-
const state = await this.governanceContract.read.getProposalState([proposalId]);
78-
// eslint-disable-next-line @typescript-eslint/no-unsafe-enum-comparison
79-
if (state < 0 || state > ProposalState.Expired) {
80-
throw new Error(`Invalid proposal state: ${state}`);
258+
return asProposalState(await this.governanceContract.read.getProposalState([proposalId]));
259+
}
260+
261+
public getProposalCount(): Promise<bigint> {
262+
return this.governanceContract.read.proposalCount();
263+
}
264+
265+
/**
266+
* Checks whether the given original payload is currently the subject of a live (non-terminal)
267+
* Governance proposal. Returns true only if a proposal references this payload and is still in
268+
* Pending, Active, Queued, or Executable state. Terminal proposals (Executed, Rejected, Dropped,
269+
* Expired) are ignored, because once a proposal reaches a terminal state the same original
270+
* payload may legitimately be re-signaled and re-submitted via the GovernanceProposer (each round
271+
* is independent and there is no payload-level uniqueness check on-chain).
272+
*
273+
* Implemented as a bounded view-call sweep over `Governance.proposals` rather than an event scan,
274+
* because `eth_getLogs` over the full deployment history of a long-lived rollup exceeds typical
275+
* RPC block-range caps. The number of proposals (`proposalCount`) is small in practice, and we
276+
* walk newest -> oldest with a hard early-stop on the protocol-wide proposal lifetime cap.
277+
*/
278+
public async hasActiveProposalWithPayload(payload: Hex): Promise<boolean> {
279+
const proposalCount = await this.getProposalCount();
280+
if (proposalCount === 0n) {
281+
return false;
282+
}
283+
284+
// Anything created before this cutoff is guaranteed terminal regardless of its frozen config.
285+
const block = await this.client.getBlock();
286+
const hardCutoff = block.timestamp - MAX_PROPOSAL_LIFETIME_SECONDS;
287+
288+
const target = payload.toLowerCase() as Hex;
289+
290+
// Proposals are append-only with monotonically non-decreasing creation timestamps, so iterating
291+
// from newest -> oldest lets us early-stop as soon as we cross the lifetime cutoff.
292+
for (let id = proposalCount - 1n; id >= 0n; id--) {
293+
const proposal = await this.getProposal(id);
294+
295+
// Hard early-stop: every older proposal is also older than the cutoff and therefore terminal.
296+
if (proposal.creation < hardCutoff) {
297+
return false;
298+
}
299+
300+
const original = await this.getOriginalPayload(proposal.payload);
301+
if (original === undefined || original.toLowerCase() !== target) {
302+
continue;
303+
}
304+
305+
// The wrapper unwraps to our payload. Only treat this as "already proposed" if the proposal
306+
// is still live -- terminal states allow re-proposing the same payload in a later round.
307+
if (TERMINAL_PROPOSAL_STATES.has(proposal.state)) {
308+
continue;
309+
}
310+
311+
return true;
312+
}
313+
314+
return false;
315+
}
316+
317+
/**
318+
* Resolves the original payload behind a `GSEPayload` wrapper. Returns `undefined` if the
319+
* wrapper does not implement `IProposerPayload.getOriginalPayload` (e.g. proposals created via
320+
* `Governance.proposeWithLock`, which bypass GSEPayload entirely and store the raw `IPayload`
321+
* directly). Results are memoized indefinitely because deployed wrapper bytecode is immutable.
322+
*/
323+
private async getOriginalPayload(wrapper: EthAddress): Promise<Hex | undefined> {
324+
const key = wrapper.toString();
325+
if (this.originalPayloadCache.has(key)) {
326+
return this.originalPayloadCache.get(key);
327+
}
328+
let original: Hex | undefined;
329+
try {
330+
original = await this.client.readContract({
331+
address: key,
332+
abi: ProposerPayloadAbi,
333+
functionName: 'getOriginalPayload',
334+
});
335+
} catch {
336+
original = undefined;
81337
}
82-
return state as ProposalState;
338+
this.originalPayloadCache.set(key, original);
339+
return original;
83340
}
84341

85342
public async awaitProposalActive({ proposalId, logger }: { proposalId: bigint; logger: Logger }) {

yarn-project/ethereum/src/contracts/governance_proposer.ts

Lines changed: 22 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ import {
1515
import type { L1TxRequest, L1TxUtils } from '../l1_tx_utils/index.js';
1616
import type { ViemClient } from '../types.js';
1717
import { type IEmpireBase, encodeSignal, encodeSignalWithSignature, signSignalWithSig } from './empire_base.js';
18-
import { extractProposalIdFromLogs } from './governance.js';
18+
import { ReadOnlyGovernanceContract, extractProposalIdFromLogs } from './governance.js';
1919

2020
export class GovernanceProposerContract implements IEmpireBase {
2121
private readonly proposer: GetContractReturnType<typeof GovernanceProposerAbi, ViemClient>;
@@ -110,10 +110,27 @@ export class GovernanceProposerContract implements IEmpireBase {
110110
};
111111
}
112112

113-
/** Checks if a payload was ever submitted to governance via submitRoundWinner. */
114-
public async hasPayloadBeenProposed(payload: Hex, fromBlock: bigint): Promise<boolean> {
115-
const events = await this.proposer.getEvents.PayloadSubmitted({ payload }, { fromBlock, strict: true });
116-
return events.length > 0;
113+
/**
114+
* Resolves the Governance contract this proposer submits winners to. Lazily reads
115+
* `GovernanceProposer.getGovernance()` (which itself looks the address up via the registry) and
116+
* memoizes the resulting wrapper.
117+
*/
118+
@memoize
119+
public async getGovernance(): Promise<ReadOnlyGovernanceContract> {
120+
const address = await this.proposer.read.getGovernance();
121+
return new ReadOnlyGovernanceContract(address, this.client);
122+
}
123+
124+
/**
125+
* Returns true iff the given original payload is currently the subject of a live (non-terminal)
126+
* Governance proposal. Delegates to `ReadOnlyGovernanceContract.hasActiveProposalWithPayload`, which
127+
* implements the actual sweep against the Governance contract -- this method exists only as a
128+
* convenience wrapper so callers that already hold a GovernanceProposer reference don't have to
129+
* resolve the Governance address themselves.
130+
*/
131+
public async hasActiveProposalWithPayload(payload: Hex): Promise<boolean> {
132+
const governance = await this.getGovernance();
133+
return governance.hasActiveProposalWithPayload(payload);
117134
}
118135

119136
public async submitRoundWinner(

0 commit comments

Comments
 (0)