@@ -17,6 +17,21 @@ import type { L1ContractAddresses } from '../l1_contract_addresses.js';
1717import { createL1TxUtils } from '../l1_tx_utils/index.js' ;
1818import { 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+
2035export 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+
37144export function extractProposalIdFromLogs ( logs : Log [ ] ) : bigint {
38145 const parsedLogs = parseEventLogs ( {
39146 abi : GovernanceAbi ,
@@ -50,6 +157,22 @@ export function extractProposalIdFromLogs(logs: Log[]): bigint {
50157export 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 } ) {
0 commit comments