@aztec/ethereum 0.0.1-commit.42ee6df9b → 0.0.1-commit.431c48d

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (144) hide show
  1. package/dest/client.d.ts +19 -3
  2. package/dest/client.d.ts.map +1 -1
  3. package/dest/client.js +52 -3
  4. package/dest/config.d.ts +15 -6
  5. package/dest/config.d.ts.map +1 -1
  6. package/dest/config.js +29 -9
  7. package/dest/contracts/chain_state_override.d.ts +78 -0
  8. package/dest/contracts/chain_state_override.d.ts.map +1 -0
  9. package/dest/contracts/chain_state_override.js +137 -0
  10. package/dest/contracts/empire_base.d.ts +1 -3
  11. package/dest/contracts/empire_base.d.ts.map +1 -1
  12. package/dest/contracts/fee_asset_price_oracle.d.ts +6 -2
  13. package/dest/contracts/fee_asset_price_oracle.d.ts.map +1 -1
  14. package/dest/contracts/fee_asset_price_oracle.js +11 -6
  15. package/dest/contracts/governance.d.ts +135 -35
  16. package/dest/contracts/governance.d.ts.map +1 -1
  17. package/dest/contracts/governance.js +226 -15
  18. package/dest/contracts/governance_proposer.d.ts +33 -4
  19. package/dest/contracts/governance_proposer.d.ts.map +1 -1
  20. package/dest/contracts/governance_proposer.js +52 -13
  21. package/dest/contracts/gse.d.ts +4 -2
  22. package/dest/contracts/gse.d.ts.map +1 -1
  23. package/dest/contracts/gse.js +2 -2
  24. package/dest/contracts/inbox.d.ts +3 -3
  25. package/dest/contracts/inbox.d.ts.map +1 -1
  26. package/dest/contracts/inbox.js +9 -3
  27. package/dest/contracts/index.d.ts +3 -3
  28. package/dest/contracts/index.d.ts.map +1 -1
  29. package/dest/contracts/index.js +2 -2
  30. package/dest/contracts/multicall.d.ts +79 -13
  31. package/dest/contracts/multicall.d.ts.map +1 -1
  32. package/dest/contracts/multicall.js +117 -95
  33. package/dest/contracts/outbox.d.ts +27 -4
  34. package/dest/contracts/outbox.d.ts.map +1 -1
  35. package/dest/contracts/outbox.js +34 -7
  36. package/dest/contracts/rollup.d.ts +5401 -34
  37. package/dest/contracts/rollup.d.ts.map +1 -1
  38. package/dest/contracts/rollup.js +271 -119
  39. package/dest/contracts/slashing_proposer.d.ts +171 -0
  40. package/dest/contracts/slashing_proposer.d.ts.map +1 -0
  41. package/dest/contracts/slashing_proposer.js +753 -0
  42. package/dest/contracts/watch_event.d.ts +36 -0
  43. package/dest/contracts/watch_event.d.ts.map +1 -0
  44. package/dest/contracts/watch_event.js +66 -0
  45. package/dest/deploy_aztec_l1_contracts.d.ts +4 -7
  46. package/dest/deploy_aztec_l1_contracts.d.ts.map +1 -1
  47. package/dest/deploy_aztec_l1_contracts.js +15 -8
  48. package/dest/foundry_binary.d.ts +13 -0
  49. package/dest/foundry_binary.d.ts.map +1 -0
  50. package/dest/foundry_binary.js +52 -0
  51. package/dest/generated/l1-contracts-defaults.d.ts +2 -2
  52. package/dest/generated/l1-contracts-defaults.js +2 -2
  53. package/dest/l1_artifacts.d.ts +14941 -16273
  54. package/dest/l1_artifacts.d.ts.map +1 -1
  55. package/dest/l1_artifacts.js +9 -24
  56. package/dest/l1_contract_addresses.d.ts +61 -65
  57. package/dest/l1_contract_addresses.d.ts.map +1 -1
  58. package/dest/l1_contract_addresses.js +101 -82
  59. package/dest/l1_reader.d.ts +3 -5
  60. package/dest/l1_reader.d.ts.map +1 -1
  61. package/dest/l1_reader.js +3 -6
  62. package/dest/l1_tx_utils/config.js +3 -3
  63. package/dest/l1_tx_utils/l1_tx_utils.d.ts +7 -1
  64. package/dest/l1_tx_utils/l1_tx_utils.d.ts.map +1 -1
  65. package/dest/l1_tx_utils/l1_tx_utils.js +82 -29
  66. package/dest/l1_tx_utils/readonly_l1_tx_utils.d.ts +1 -1
  67. package/dest/l1_tx_utils/readonly_l1_tx_utils.d.ts.map +1 -1
  68. package/dest/l1_tx_utils/readonly_l1_tx_utils.js +49 -19
  69. package/dest/l1_tx_utils/tx_delayer.d.ts +5 -3
  70. package/dest/l1_tx_utils/tx_delayer.d.ts.map +1 -1
  71. package/dest/l1_tx_utils/tx_delayer.js +7 -5
  72. package/dest/publisher_manager.d.ts +18 -6
  73. package/dest/publisher_manager.d.ts.map +1 -1
  74. package/dest/publisher_manager.js +44 -7
  75. package/dest/queries.d.ts +13 -2
  76. package/dest/queries.d.ts.map +1 -1
  77. package/dest/queries.js +62 -3
  78. package/dest/test/blob_kzg_warmup.d.ts +19 -0
  79. package/dest/test/blob_kzg_warmup.d.ts.map +1 -0
  80. package/dest/test/blob_kzg_warmup.js +64 -0
  81. package/dest/test/chain_monitor.d.ts +34 -2
  82. package/dest/test/chain_monitor.d.ts.map +1 -1
  83. package/dest/test/chain_monitor.js +76 -6
  84. package/dest/test/eth_cheat_codes.d.ts +26 -3
  85. package/dest/test/eth_cheat_codes.d.ts.map +1 -1
  86. package/dest/test/eth_cheat_codes.js +61 -40
  87. package/dest/test/index.d.ts +2 -1
  88. package/dest/test/index.d.ts.map +1 -1
  89. package/dest/test/index.js +1 -0
  90. package/dest/test/recording_rpc_server.d.ts +18 -0
  91. package/dest/test/recording_rpc_server.d.ts.map +1 -0
  92. package/dest/test/recording_rpc_server.js +68 -0
  93. package/dest/test/rollup_cheat_codes.d.ts +60 -3
  94. package/dest/test/rollup_cheat_codes.d.ts.map +1 -1
  95. package/dest/test/rollup_cheat_codes.js +86 -7
  96. package/dest/test/start_anvil.d.ts +1 -1
  97. package/dest/test/start_anvil.d.ts.map +1 -1
  98. package/dest/test/start_anvil.js +41 -5
  99. package/dest/utils.d.ts +9 -2
  100. package/dest/utils.d.ts.map +1 -1
  101. package/dest/utils.js +45 -14
  102. package/package.json +6 -6
  103. package/src/client.ts +63 -1
  104. package/src/config.ts +44 -13
  105. package/src/contracts/chain_state_override.ts +200 -0
  106. package/src/contracts/empire_base.ts +0 -2
  107. package/src/contracts/fee_asset_price_oracle.ts +10 -5
  108. package/src/contracts/governance.ts +310 -10
  109. package/src/contracts/governance_proposer.ts +56 -13
  110. package/src/contracts/gse.ts +7 -2
  111. package/src/contracts/inbox.ts +12 -4
  112. package/src/contracts/index.ts +2 -2
  113. package/src/contracts/multicall.ts +179 -105
  114. package/src/contracts/outbox.ts +42 -8
  115. package/src/contracts/rollup.ts +302 -119
  116. package/src/contracts/{tally_slashing_proposer.ts → slashing_proposer.ts} +119 -45
  117. package/src/contracts/watch_event.ts +110 -0
  118. package/src/deploy_aztec_l1_contracts.ts +19 -10
  119. package/src/foundry_binary.ts +57 -0
  120. package/src/generated/l1-contracts-defaults.ts +2 -2
  121. package/src/l1_artifacts.ts +12 -35
  122. package/src/l1_contract_addresses.ts +120 -94
  123. package/src/l1_reader.ts +4 -9
  124. package/src/l1_tx_utils/config.ts +3 -3
  125. package/src/l1_tx_utils/l1_tx_utils.ts +83 -36
  126. package/src/l1_tx_utils/readonly_l1_tx_utils.ts +49 -13
  127. package/src/l1_tx_utils/tx_delayer.ts +16 -7
  128. package/src/publisher_manager.ts +54 -18
  129. package/src/queries.ts +70 -4
  130. package/src/test/blob_kzg_warmup.ts +65 -0
  131. package/src/test/chain_monitor.ts +93 -5
  132. package/src/test/eth_cheat_codes.ts +57 -43
  133. package/src/test/index.ts +1 -0
  134. package/src/test/recording_rpc_server.ts +69 -0
  135. package/src/test/rollup_cheat_codes.ts +115 -6
  136. package/src/test/start_anvil.ts +38 -6
  137. package/src/utils.ts +41 -17
  138. package/dest/contracts/empire_slashing_proposer.d.ts +0 -69
  139. package/dest/contracts/empire_slashing_proposer.d.ts.map +0 -1
  140. package/dest/contracts/empire_slashing_proposer.js +0 -216
  141. package/dest/contracts/tally_slashing_proposer.d.ts +0 -140
  142. package/dest/contracts/tally_slashing_proposer.d.ts.map +0 -1
  143. package/dest/contracts/tally_slashing_proposer.js +0 -320
  144. package/src/contracts/empire_slashing_proposer.ts +0 -265
@@ -0,0 +1,200 @@
1
+ import { toHex as toPaddedHex } from '@aztec/foundation/bigint-buffer';
2
+ import type { CheckpointNumber, SlotNumber } from '@aztec/foundation/branded-types';
3
+ import type { Buffer32 } from '@aztec/foundation/buffer';
4
+ import { merge } from '@aztec/foundation/collection';
5
+ import type { Fr } from '@aztec/foundation/curves/bn254';
6
+
7
+ import type { StateOverride } from 'viem';
8
+
9
+ import { type FeeHeader, RollupContract } from './rollup.js';
10
+
11
+ /**
12
+ * Override values for the pending checkpoint that the simulation should treat as already applied.
13
+ * Every field is optional at plan-building time so callers can populate them incrementally; whatever
14
+ * is present at translation time is forwarded to the partial `tempCheckpointLogs` helper so the
15
+ * load-bearing `slotNumber` can land even if other fields could not be derived locally.
16
+ */
17
+ export type PendingCheckpointOverrideState = {
18
+ archive?: Fr;
19
+ feeHeader?: FeeHeader;
20
+ headerHash?: Fr;
21
+ outHash?: Fr;
22
+ payloadDigest?: Buffer32;
23
+ slotNumber?: SlotNumber;
24
+ };
25
+
26
+ export type ChainTipsOverride = {
27
+ pending?: CheckpointNumber;
28
+ proven?: CheckpointNumber;
29
+ };
30
+
31
+ /** Describes the simulated L1 rollup state that downstream calls should observe. */
32
+ export type SimulationOverridesPlan = {
33
+ chainTipsOverride?: ChainTipsOverride;
34
+ pendingCheckpointState?: PendingCheckpointOverrideState;
35
+ disableBlobCheck?: boolean;
36
+ };
37
+
38
+ /** Builds a single-checkpoint simulation plan before it is translated into a viem state override. */
39
+ export class SimulationOverridesBuilder {
40
+ private chainTipsOverride?: ChainTipsOverride;
41
+ private pendingCheckpointState?: PendingCheckpointOverrideState;
42
+ private disableBlobCheck = false;
43
+
44
+ /** Starts from an existing plan so callers can extend or specialize it. */
45
+ public static from(plan: SimulationOverridesPlan | undefined): SimulationOverridesBuilder {
46
+ return new SimulationOverridesBuilder().merge(plan);
47
+ }
48
+
49
+ /**
50
+ * Merges another plan into this builder. Later values win on a per-half basis for chain tips,
51
+ * but explicit `undefined` fields in the incoming plan are ignored so they cannot erase a
52
+ * previously-set value.
53
+ */
54
+ public merge(plan: SimulationOverridesPlan | undefined): this {
55
+ if (!plan) {
56
+ return this;
57
+ }
58
+
59
+ if (plan.chainTipsOverride) {
60
+ this.chainTipsOverride = merge(this.chainTipsOverride ?? {}, plan.chainTipsOverride);
61
+ }
62
+ if (plan.pendingCheckpointState) {
63
+ this.pendingCheckpointState = merge(this.pendingCheckpointState ?? {}, plan.pendingCheckpointState);
64
+ }
65
+ this.disableBlobCheck = this.disableBlobCheck || (plan.disableBlobCheck ?? false);
66
+
67
+ return this;
68
+ }
69
+
70
+ /**
71
+ * Sets the pending and/or proven checkpoint number overrides. Subsequent calls merge into the existing
72
+ * override on a per-half basis, so callers can set pending in one call and proven in another without
73
+ * clobbering each other.
74
+ */
75
+ public withChainTips(override: ChainTipsOverride): this {
76
+ this.chainTipsOverride = { ...(this.chainTipsOverride ?? {}), ...override };
77
+ return this;
78
+ }
79
+
80
+ /** Overrides the archive root for the configured pending checkpoint. */
81
+ public withPendingArchive(archive: Fr): this {
82
+ this.assertPendingCheckpointNumber();
83
+ this.pendingCheckpointState = { ...(this.pendingCheckpointState ?? {}), archive };
84
+ return this;
85
+ }
86
+
87
+ /** Overrides the fee header for the configured pending checkpoint. */
88
+ public withPendingFeeHeader(feeHeader: FeeHeader): this {
89
+ this.assertPendingCheckpointNumber();
90
+ this.pendingCheckpointState = { ...(this.pendingCheckpointState ?? {}), feeHeader };
91
+ return this;
92
+ }
93
+
94
+ /**
95
+ * Overrides one or more `tempCheckpointLogs` cell fields for the configured pending checkpoint.
96
+ * Fields are independent: any subset can be provided. The translator (`makeTempCheckpointLogOverride`)
97
+ * emits a stateDiff entry per field actually set, so unspecified fields stay at their on-chain
98
+ * values.
99
+ *
100
+ * `slotNumber` is load-bearing for `STFLib.canPruneAtTime`: when the simulation overrides `pending`
101
+ * to a checkpoint that has no on-chain `tempCheckpointLogs` entry yet, the missing slotNumber falls
102
+ * back to 0 and the contract treats the pending tip as belonging to epoch 0, triggering a phantom
103
+ * prune that silently undoes the `pending` override.
104
+ */
105
+ public withPendingTempCheckpointLogFields(fields: {
106
+ headerHash?: Fr;
107
+ outHash?: Fr;
108
+ payloadDigest?: Buffer32;
109
+ slotNumber?: SlotNumber;
110
+ }): this {
111
+ this.assertPendingCheckpointNumber();
112
+ this.pendingCheckpointState = { ...(this.pendingCheckpointState ?? {}), ...fields };
113
+ return this;
114
+ }
115
+
116
+ /** Disables blob checking for simulations that cannot provide DA inputs. */
117
+ public withoutBlobCheck(): this {
118
+ this.disableBlobCheck = true;
119
+ return this;
120
+ }
121
+
122
+ /** Builds the final plan, or `undefined` when no overrides were configured. */
123
+ public build(): SimulationOverridesPlan | undefined {
124
+ if (!this.pendingCheckpointState && !this.chainTipsOverride && !this.disableBlobCheck) {
125
+ return undefined;
126
+ }
127
+
128
+ return {
129
+ chainTipsOverride: this.chainTipsOverride,
130
+ pendingCheckpointState: this.pendingCheckpointState,
131
+ disableBlobCheck: this.disableBlobCheck || undefined,
132
+ };
133
+ }
134
+
135
+ private assertPendingCheckpointNumber(): void {
136
+ if (this.chainTipsOverride?.pending === undefined) {
137
+ throw new Error('withChainTips({ pending }) must be called before attaching archive or fee header overrides');
138
+ }
139
+ }
140
+ }
141
+
142
+ /** Translates a simulation plan into the viem state override shape expected by rollup calls. */
143
+ export async function buildSimulationOverridesStateOverride(
144
+ rollup: RollupContract,
145
+ plan: SimulationOverridesPlan | undefined,
146
+ ): Promise<StateOverride> {
147
+ if (!plan) {
148
+ return [];
149
+ }
150
+
151
+ const rollupStateDiff: NonNullable<StateOverride[number]['stateDiff']> = [];
152
+
153
+ if (plan.chainTipsOverride) {
154
+ rollupStateDiff.push(...extractRollupStateDiff(await rollup.makeChainTipsOverride(plan.chainTipsOverride)));
155
+ }
156
+
157
+ if (plan.pendingCheckpointState && plan.chainTipsOverride?.pending === undefined) {
158
+ throw new Error('pendingCheckpointState requires chainTipsOverride.pending to be set');
159
+ }
160
+
161
+ if (plan.pendingCheckpointState?.archive) {
162
+ rollupStateDiff.push(
163
+ ...extractRollupStateDiff(
164
+ rollup.makeArchiveOverride(plan.chainTipsOverride!.pending!, plan.pendingCheckpointState.archive),
165
+ ),
166
+ );
167
+ }
168
+
169
+ if (plan.pendingCheckpointState) {
170
+ rollupStateDiff.push(
171
+ ...extractRollupStateDiff(
172
+ await rollup.makeTempCheckpointLogOverride(plan.chainTipsOverride!.pending!, {
173
+ headerHash: plan.pendingCheckpointState.headerHash,
174
+ outHash: plan.pendingCheckpointState.outHash,
175
+ payloadDigest: plan.pendingCheckpointState.payloadDigest,
176
+ slotNumber: plan.pendingCheckpointState.slotNumber,
177
+ feeHeader: plan.pendingCheckpointState.feeHeader,
178
+ }),
179
+ ),
180
+ );
181
+ }
182
+
183
+ if (plan.disableBlobCheck) {
184
+ rollupStateDiff.push({
185
+ slot: toPaddedHex(RollupContract.checkBlobStorageSlot, true),
186
+ value: toPaddedHex(0n, true),
187
+ });
188
+ }
189
+
190
+ if (rollupStateDiff.length === 0) {
191
+ return [];
192
+ }
193
+
194
+ return [{ address: rollup.address, stateDiff: rollupStateDiff }];
195
+ }
196
+
197
+ function extractRollupStateDiff(override: StateOverride | StateOverride[number] | undefined) {
198
+ const entries = Array.isArray(override) ? override : override ? [override] : [];
199
+ return entries.flatMap(entry => entry.stateDiff ?? []);
200
+ }
@@ -22,8 +22,6 @@ export interface IEmpireBase {
22
22
  signerAddress: Hex,
23
23
  signer: (msg: TypedDataDefinition) => Promise<Hex>,
24
24
  ): Promise<L1TxRequest>;
25
- /** Checks if a payload was ever submitted to governance via submitRoundWinner. */
26
- hasPayloadBeenProposed(payload: Hex, fromBlock: bigint): Promise<boolean>;
27
25
  }
28
26
 
29
27
  export function encodeSignal(payload: Hex): Hex {
@@ -63,28 +63,33 @@ export class FeeAssetPriceOracle {
63
63
  *
64
64
  * Returns 0 if not on mainnet or if the oracle query fails.
65
65
  *
66
+ * @param currentPriceE12 - Optional override for the parent checkpoint's eth-per-fee-asset
67
+ * (E12 scale). When omitted, the latest published value is read from L1. Pipelined
68
+ * proposers should supply the predicted parent value so the modifier they emit aligns
69
+ * with the parent fee header L1 will see when the previous pipelined checkpoint lands.
66
70
  * @returns The price modifier in basis points (positive to increase price, negative to decrease)
67
71
  */
68
- async computePriceModifier(): Promise<bigint> {
72
+ async computePriceModifier(currentPriceE12?: bigint): Promise<bigint> {
69
73
  const uniswapOracle = await this.getUniswapOracle();
70
74
  if (!uniswapOracle) {
71
75
  return 0n;
72
76
  }
73
77
 
74
78
  try {
75
- // Get current on-chain price (ETH per fee asset, E12)
76
- const currentPriceE12 = await this.rollupContract.getEthPerFeeAsset();
79
+ // Get current on-chain price (ETH per fee asset, E12), preferring the caller-supplied value.
80
+ const resolvedCurrentPriceE12 = currentPriceE12 ?? (await this.rollupContract.getEthPerFeeAsset());
77
81
 
78
82
  // Get oracle price (median of last N blocks, ETH per fee asset, E12)
79
83
  const oraclePriceE12 = await uniswapOracle.getMeanEthPerFeeAssetE12();
80
84
 
81
85
  // Compute modifier in basis points
82
- const modifier = this.computePriceModifierBps(currentPriceE12, oraclePriceE12);
86
+ const modifier = this.computePriceModifierBps(resolvedCurrentPriceE12, oraclePriceE12);
83
87
 
84
88
  this.log.debug('Computed price modifier', {
85
- currentPriceE12: currentPriceE12.toString(),
89
+ currentPriceE12: resolvedCurrentPriceE12.toString(),
86
90
  oraclePriceE12: oraclePriceE12.toString(),
87
91
  modifierBps: modifier.toString(),
92
+ currentPriceFromCaller: currentPriceE12 !== undefined,
88
93
  });
89
94
 
90
95
  return modifier;
@@ -17,12 +17,27 @@ import type { L1ContractAddresses } from '../l1_contract_addresses.js';
17
17
  import { createL1TxUtils } from '../l1_tx_utils/index.js';
18
18
  import { type ExtendedViemWalletClient, type ViemClient, isExtendedClient } from '../types.js';
19
19
 
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
+
20
35
  export type L1GovernanceContractAddresses = Pick<
21
36
  L1ContractAddresses,
22
37
  'governanceAddress' | 'rollupAddress' | 'registryAddress' | 'governanceProposerAddress'
23
38
  >;
24
39
 
25
- // NOTE: Must be kept in sync with DataStructures.ProposalState in l1-contracts
40
+ // NOTE: Must be kept in sync with IGovernance.ProposalState in l1-contracts
26
41
  export enum ProposalState {
27
42
  Pending,
28
43
  Active,
@@ -30,10 +45,124 @@ export enum ProposalState {
30
45
  Executable,
31
46
  Rejected,
32
47
  Executed,
48
+ Droppable,
33
49
  Dropped,
34
50
  Expired,
35
51
  }
36
52
 
53
+ /**
54
+ * Outcome of {@link ReadOnlyGovernanceContract.getPayloadProposalStatus} for a queried payload.
55
+ * - `'live'`: a proposal referencing the payload is still progressing (Pending/Active/Queued/
56
+ * Executable) or is `Droppable`, so signalling for it again is redundant or premature.
57
+ * - `'executed'`: no live proposal references the payload, but one was already executed within the
58
+ * bounded lookback (or a prior sweep observed the execution and memoized it).
59
+ * - `'none'`: no live or executed proposal references the payload within the bounded lookback.
60
+ */
61
+ export type PayloadProposalStatus = 'live' | 'executed' | 'none';
62
+
63
+ /** Vote tallies on a single proposal. Both fields are mutated by `Governance.vote`. */
64
+ export interface Ballot {
65
+ yea: bigint;
66
+ nay: bigint;
67
+ }
68
+
69
+ /**
70
+ * Snapshot of the timing/quorum parameters that govern a single proposal's lifecycle. Each proposal
71
+ * stores its own copy at creation time (see `_propose` in Governance.sol), so this snapshot is
72
+ * immutable for the lifetime of the proposal even if the global `Configuration` changes later.
73
+ */
74
+ export interface ProposalConfiguration {
75
+ votingDelay: bigint;
76
+ votingDuration: bigint;
77
+ executionDelay: bigint;
78
+ gracePeriod: bigint;
79
+ quorum: bigint;
80
+ requiredYeaMargin: bigint;
81
+ minimumVotes: bigint;
82
+ }
83
+
84
+ /** Parameters for `Governance.proposeWithLock`. Stored only in the global Configuration, never on a proposal. */
85
+ export interface ProposeWithLockConfiguration {
86
+ lockDelay: bigint;
87
+ lockAmount: bigint;
88
+ }
89
+
90
+ /**
91
+ * Live, mutable governance configuration. `proposeConfig` is the lock configuration used by
92
+ * `proposeWithLock`; the remaining fields are the same shape as `ProposalConfiguration` and are
93
+ * snapshotted onto each new proposal at creation time.
94
+ */
95
+ export interface GovernanceConfiguration extends ProposalConfiguration {
96
+ proposeConfig: ProposeWithLockConfiguration;
97
+ }
98
+
99
+ /**
100
+ * A governance proposal augmented with its live (computed) state.
101
+ *
102
+ * Mutability:
103
+ * - `config`, `payload`, `proposer`, `creation` are immutable for the lifetime of the proposal.
104
+ * - `cachedState` is the raw value stored on-chain. It is only written when a proposal is explicitly
105
+ * executed or dropped, so for time-derived terminal states (Rejected, Expired) it remains at the
106
+ * value that was current when the proposal was created.
107
+ * - `state` is the computed state returned by `Governance.getProposalState`. This is the value
108
+ * callers almost always want -- it reflects time-derived transitions that `cachedState` does not.
109
+ * - `summedBallot` is mutated by every `Governance.vote` call while the proposal is Active.
110
+ *
111
+ * Once `state` is in a terminal phase (`Executed`/`Rejected`/`Dropped`/`Expired`) the entire struct
112
+ * is provably immutable on-chain (no further votes can be cast and no state-mutating call to
113
+ * `execute`/`dropProposal` can succeed), and the wrapper memoizes it.
114
+ */
115
+ export interface Proposal {
116
+ config: ProposalConfiguration;
117
+ cachedState: ProposalState;
118
+ state: ProposalState;
119
+ payload: EthAddress;
120
+ proposer: EthAddress;
121
+ creation: bigint;
122
+ summedBallot: Ballot;
123
+ }
124
+
125
+ /** Set of `ProposalState` values for which a proposal is fully immutable on-chain. */
126
+ const TERMINAL_PROPOSAL_STATES: ReadonlySet<ProposalState> = new Set([
127
+ ProposalState.Executed,
128
+ ProposalState.Rejected,
129
+ ProposalState.Dropped,
130
+ ProposalState.Expired,
131
+ ]);
132
+
133
+ // Set of `ProposalState` values in which a proposal is still progressing towards execution.
134
+ // `Droppable` is deliberately excluded: it is neither live (it cannot progress on its own) nor
135
+ // terminal (it can resume its lifecycle if the governanceProposer is restored), so it is handled
136
+ // separately and is never memoized as immutable.
137
+ const LIVE_PROPOSAL_STATES: ReadonlySet<ProposalState> = new Set([
138
+ ProposalState.Pending,
139
+ ProposalState.Active,
140
+ ProposalState.Queued,
141
+ ProposalState.Executable,
142
+ ]);
143
+
144
+ // Hard upper bound on the wall-clock lifetime of any Governance proposal, in seconds.
145
+ // Each proposal stores its own snapshot of `ProposalConfiguration` at creation time and progresses
146
+ // through Pending -> Active -> Queued -> Executable using those frozen durations
147
+ // (see `ProposalLib.{pendingThrough,activeThrough,queuedThrough,executableThrough}`). Each of those
148
+ // four durations is bounded by `ConfigurationLib.TIME_UPPER = 90 days` (validated in
149
+ // `ConfigurationLib.assertValid`), so no proposal can be live for more than 4 * 90 days regardless
150
+ // of what config it was created under. Once past this point, the proposal is guaranteed to be in a
151
+ // terminal state (Executed / Rejected / Dropped / Expired).
152
+ export const MAX_PROPOSAL_LIFETIME_SECONDS = 4n * 90n * 24n * 3600n;
153
+
154
+ /**
155
+ * Validates a number returned by an on-chain `ProposalState` enum field and narrows it to the
156
+ * `ProposalState` enum. Throws if the value is out of range.
157
+ */
158
+ function asProposalState(raw: number): ProposalState {
159
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-enum-comparison
160
+ if (raw < 0 || raw > ProposalState.Expired) {
161
+ throw new Error(`Invalid proposal state: ${raw}`);
162
+ }
163
+ return raw as ProposalState;
164
+ }
165
+
37
166
  export function extractProposalIdFromLogs(logs: Log[]): bigint {
38
167
  const parsedLogs = parseEventLogs({
39
168
  abi: GovernanceAbi,
@@ -50,6 +179,30 @@ export function extractProposalIdFromLogs(logs: Log[]): bigint {
50
179
  export class ReadOnlyGovernanceContract {
51
180
  protected readonly governanceContract: GetContractReturnType<typeof GovernanceAbi, ViemClient>;
52
181
 
182
+ /**
183
+ * Cache of fully-resolved proposals keyed by id. We populate this lazily and only retain entries
184
+ * whose state is provably terminal -- once `cachedState` is `Executed` or `Dropped` the on-chain
185
+ * proposal struct is frozen and safe to memoize indefinitely. Other state transitions (e.g.
186
+ * Pending -> Active, or accumulating votes) leave the cache untouched and force a fresh fetch.
187
+ */
188
+ private readonly proposalCache: Map<bigint, Proposal> = new Map();
189
+
190
+ /**
191
+ * Cache of `IProposerPayload.getOriginalPayload()` results keyed by wrapper address. The wrapper
192
+ * contract's bytecode is immutable, so this mapping never changes -- a value of `undefined`
193
+ * encodes a proposal whose payload doesn't implement `getOriginalPayload` (e.g. proposeWithLock
194
+ * proposals) and should be treated as "no original".
195
+ */
196
+ private readonly originalPayloadCache: Map<Hex, Hex | undefined> = new Map();
197
+
198
+ /**
199
+ * Payloads (lowercased hex) observed as the subject of an `Executed` Governance proposal. Execution
200
+ * is immutable on-chain, so this verdict never expires within a process. It restores the
201
+ * `'executed'` classification for payloads whose executed proposal has since aged past the bounded
202
+ * lookback below. Lost on restart, at which point the bounded-lookback limitation applies again.
203
+ */
204
+ private readonly executedPayloads: Set<string> = new Set();
205
+
53
206
  constructor(
54
207
  address: Hex,
55
208
  public readonly client: ViemClient,
@@ -65,21 +218,168 @@ export class ReadOnlyGovernanceContract {
65
218
  return EthAddress.fromString(await this.governanceContract.read.governanceProposer());
66
219
  }
67
220
 
68
- public getConfiguration() {
69
- return this.governanceContract.read.getConfiguration();
221
+ public async getConfiguration(): Promise<GovernanceConfiguration> {
222
+ const raw = await this.governanceContract.read.getConfiguration();
223
+ return {
224
+ proposeConfig: {
225
+ lockDelay: raw.proposeConfig.lockDelay,
226
+ lockAmount: raw.proposeConfig.lockAmount,
227
+ },
228
+ votingDelay: raw.votingDelay,
229
+ votingDuration: raw.votingDuration,
230
+ executionDelay: raw.executionDelay,
231
+ gracePeriod: raw.gracePeriod,
232
+ quorum: raw.quorum,
233
+ requiredYeaMargin: raw.requiredYeaMargin,
234
+ minimumVotes: raw.minimumVotes,
235
+ };
70
236
  }
71
237
 
72
- public getProposal(proposalId: bigint) {
73
- return this.governanceContract.read.getProposal([proposalId]);
238
+ /**
239
+ * Fetches a proposal by id together with its live state, returning the mapped {@link Proposal}
240
+ * type. Issues `getProposal` and `getProposalState` in parallel so the result carries both the
241
+ * raw stored `cachedState` and the time-derived `state` -- callers can use whichever they need
242
+ * without an extra round-trip.
243
+ *
244
+ * Backed by an in-memory cache that retains entries only when `state` is in one of the four
245
+ * terminal phases (`Executed` / `Rejected` / `Dropped` / `Expired`). At that point the entire
246
+ * proposal struct is provably immutable on-chain (no further votes can be cast and no
247
+ * state-mutating call can succeed), so caching is safe forever. Non-terminal states force a
248
+ * fresh fetch on every call so callers always see fresh `state` and `summedBallot` values.
249
+ */
250
+ public async getProposal(proposalId: bigint): Promise<Proposal> {
251
+ const cached = this.proposalCache.get(proposalId);
252
+ if (cached !== undefined) {
253
+ return cached;
254
+ }
255
+ const [raw, rawState] = await Promise.all([
256
+ this.governanceContract.read.getProposal([proposalId]),
257
+ this.governanceContract.read.getProposalState([proposalId]),
258
+ ]);
259
+ const proposal: Proposal = {
260
+ config: {
261
+ votingDelay: raw.config.votingDelay,
262
+ votingDuration: raw.config.votingDuration,
263
+ executionDelay: raw.config.executionDelay,
264
+ gracePeriod: raw.config.gracePeriod,
265
+ quorum: raw.config.quorum,
266
+ requiredYeaMargin: raw.config.requiredYeaMargin,
267
+ minimumVotes: raw.config.minimumVotes,
268
+ },
269
+ cachedState: asProposalState(raw.cachedState),
270
+ state: asProposalState(rawState),
271
+ payload: EthAddress.fromString(raw.payload),
272
+ proposer: EthAddress.fromString(raw.proposer),
273
+ creation: raw.creation,
274
+ summedBallot: { yea: raw.summedBallot.yea, nay: raw.summedBallot.nay },
275
+ };
276
+ if (TERMINAL_PROPOSAL_STATES.has(proposal.state)) {
277
+ this.proposalCache.set(proposalId, proposal);
278
+ }
279
+ return proposal;
74
280
  }
75
281
 
282
+ /**
283
+ * Returns the live state of a proposal as computed by `Governance.getProposalState`. Prefer
284
+ * {@link getProposal} when you also need any other proposal data -- it returns this same value
285
+ * via {@link Proposal.state} alongside the rest of the struct in a single round-trip.
286
+ */
76
287
  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}`);
288
+ return asProposalState(await this.governanceContract.read.getProposalState([proposalId]));
289
+ }
290
+
291
+ public getProposalCount(): Promise<bigint> {
292
+ return this.governanceContract.read.proposalCount();
293
+ }
294
+
295
+ /**
296
+ * Classifies the given original payload against the Governance proposal history. Distinguishes a
297
+ * payload that is still the subject of a live proposal (`'live'`) from one whose proposal was
298
+ * already executed (`'executed'`) from one that has no relevant proposal (`'none'`), so callers can
299
+ * stop re-signalling an executed payload while still re-signalling one whose proposal was merely
300
+ * rejected/dropped/expired (each GovernanceProposer round is independent and there is no
301
+ * payload-level uniqueness check on-chain).
302
+ *
303
+ * A proposal matches the payload either directly (its stored `payload` equals the target, as for
304
+ * `proposeWithLock` proposals) or via its `GSEPayload` wrapper unwrapping to the target. `'live'`
305
+ * (including `Droppable`) takes precedence over `'executed'`, so a payload re-submitted while a
306
+ * prior execution is still in the lookback window reads as `'live'`.
307
+ *
308
+ * Implemented as a bounded view-call sweep over `Governance.proposals` rather than an event scan,
309
+ * because `eth_getLogs` over the full deployment history of a long-lived rollup exceeds typical
310
+ * RPC block-range caps. The number of proposals (`proposalCount`) is small in practice, and we
311
+ * walk newest -> oldest with a hard early-stop on the protocol-wide proposal lifetime cap. This
312
+ * makes the `'executed'` verdict a *bounded lookback* rather than permanent suppression: a proposal
313
+ * executed longer ago than the lifetime cap is only reported once it has been observed and
314
+ * memoized in-process (memo is lost on restart).
315
+ */
316
+ public async getPayloadProposalStatus(payload: Hex): Promise<PayloadProposalStatus> {
317
+ const target = payload.toLowerCase() as Hex;
318
+
319
+ const proposalCount = await this.getProposalCount();
320
+ if (proposalCount === 0n) {
321
+ return this.executedPayloads.has(target) ? 'executed' : 'none';
322
+ }
323
+
324
+ // Anything created before this cutoff is guaranteed terminal regardless of its frozen config.
325
+ const block = await this.client.getBlock();
326
+ const hardCutoff = block.timestamp - MAX_PROPOSAL_LIFETIME_SECONDS;
327
+
328
+ // Proposals are append-only with monotonically non-decreasing creation timestamps, so iterating
329
+ // from newest -> oldest lets us early-stop as soon as we cross the lifetime cutoff.
330
+ for (let id = proposalCount - 1n; id >= 0n; id--) {
331
+ const proposal = await this.getProposal(id);
332
+
333
+ // Hard early-stop: every older proposal is also older than the cutoff and therefore terminal.
334
+ if (proposal.creation < hardCutoff) {
335
+ break;
336
+ }
337
+
338
+ const proposalPayload = proposal.payload.toString().toLowerCase();
339
+ const original = await this.getOriginalPayload(proposal.payload);
340
+ const matches = proposalPayload === target || (original !== undefined && original.toLowerCase() === target);
341
+ if (!matches) {
342
+ continue;
343
+ }
344
+
345
+ // A live or Droppable proposal blocks re-signalling and wins over any executed one.
346
+ if (LIVE_PROPOSAL_STATES.has(proposal.state) || proposal.state === ProposalState.Droppable) {
347
+ return 'live';
348
+ }
349
+
350
+ if (proposal.state === ProposalState.Executed) {
351
+ this.executedPayloads.add(target);
352
+ }
353
+
354
+ // Rejected/Dropped/Expired proposals allow re-proposing the same payload; keep scanning.
355
+ }
356
+
357
+ return this.executedPayloads.has(target) ? 'executed' : 'none';
358
+ }
359
+
360
+ /**
361
+ * Resolves the original payload behind a `GSEPayload` wrapper. Returns `undefined` if the
362
+ * wrapper does not implement `IProposerPayload.getOriginalPayload` (e.g. proposals created via
363
+ * `Governance.proposeWithLock`, which bypass GSEPayload entirely and store the raw `IPayload`
364
+ * directly). Results are memoized indefinitely because deployed wrapper bytecode is immutable.
365
+ */
366
+ private async getOriginalPayload(wrapper: EthAddress): Promise<Hex | undefined> {
367
+ const key = wrapper.toString();
368
+ if (this.originalPayloadCache.has(key)) {
369
+ return this.originalPayloadCache.get(key);
370
+ }
371
+ let original: Hex | undefined;
372
+ try {
373
+ original = await this.client.readContract({
374
+ address: key,
375
+ abi: ProposerPayloadAbi,
376
+ functionName: 'getOriginalPayload',
377
+ });
378
+ } catch {
379
+ original = undefined;
81
380
  }
82
- return state as ProposalState;
381
+ this.originalPayloadCache.set(key, original);
382
+ return original;
83
383
  }
84
384
 
85
385
  public async awaitProposalActive({ proposalId, logger }: { proposalId: bigint; logger: Logger }) {