@aztec/ethereum 0.0.1-commit.e558bd1c → 0.0.1-commit.e57c76e

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 (159) hide show
  1. package/dest/client.d.ts +25 -3
  2. package/dest/client.d.ts.map +1 -1
  3. package/dest/client.js +60 -8
  4. package/dest/config.d.ts +8 -6
  5. package/dest/config.d.ts.map +1 -1
  6. package/dest/config.js +15 -11
  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/fee_asset_price_oracle.d.ts +105 -0
  11. package/dest/contracts/fee_asset_price_oracle.d.ts.map +1 -0
  12. package/dest/contracts/fee_asset_price_oracle.js +656 -0
  13. package/dest/contracts/governance.d.ts +108 -33
  14. package/dest/contracts/governance.d.ts.map +1 -1
  15. package/dest/contracts/governance.js +196 -15
  16. package/dest/contracts/governance_proposer.d.ts +33 -1
  17. package/dest/contracts/governance_proposer.d.ts.map +1 -1
  18. package/dest/contracts/governance_proposer.js +53 -2
  19. package/dest/contracts/inbox.d.ts +3 -3
  20. package/dest/contracts/inbox.d.ts.map +1 -1
  21. package/dest/contracts/inbox.js +12 -7
  22. package/dest/contracts/index.d.ts +4 -3
  23. package/dest/contracts/index.d.ts.map +1 -1
  24. package/dest/contracts/index.js +3 -2
  25. package/dest/contracts/multicall.d.ts +127 -12
  26. package/dest/contracts/multicall.d.ts.map +1 -1
  27. package/dest/contracts/multicall.js +201 -94
  28. package/dest/contracts/outbox.d.ts +27 -4
  29. package/dest/contracts/outbox.d.ts.map +1 -1
  30. package/dest/contracts/outbox.js +34 -7
  31. package/dest/contracts/registry.d.ts +3 -1
  32. package/dest/contracts/registry.d.ts.map +1 -1
  33. package/dest/contracts/registry.js +30 -1
  34. package/dest/contracts/rollup.d.ts +5259 -29
  35. package/dest/contracts/rollup.d.ts.map +1 -1
  36. package/dest/contracts/rollup.js +388 -71
  37. package/dest/contracts/{tally_slashing_proposer.d.ts → slashing_proposer.d.ts} +3 -4
  38. package/dest/contracts/slashing_proposer.d.ts.map +1 -0
  39. package/dest/contracts/{tally_slashing_proposer.js → slashing_proposer.js} +13 -15
  40. package/dest/deploy_aztec_l1_contracts.d.ts +4 -8
  41. package/dest/deploy_aztec_l1_contracts.d.ts.map +1 -1
  42. package/dest/deploy_aztec_l1_contracts.js +35 -23
  43. package/dest/deploy_l1_contract.js +3 -3
  44. package/dest/generated/l1-contracts-defaults.d.ts +2 -2
  45. package/dest/generated/l1-contracts-defaults.js +2 -2
  46. package/dest/l1_artifacts.d.ts +14259 -15724
  47. package/dest/l1_artifacts.d.ts.map +1 -1
  48. package/dest/l1_artifacts.js +9 -24
  49. package/dest/l1_contract_addresses.d.ts +61 -65
  50. package/dest/l1_contract_addresses.d.ts.map +1 -1
  51. package/dest/l1_contract_addresses.js +101 -82
  52. package/dest/l1_reader.d.ts +5 -5
  53. package/dest/l1_reader.d.ts.map +1 -1
  54. package/dest/l1_reader.js +9 -7
  55. package/dest/l1_tx_utils/config.d.ts +7 -1
  56. package/dest/l1_tx_utils/config.d.ts.map +1 -1
  57. package/dest/l1_tx_utils/config.js +14 -1
  58. package/dest/l1_tx_utils/factory.d.ts +18 -10
  59. package/dest/l1_tx_utils/factory.d.ts.map +1 -1
  60. package/dest/l1_tx_utils/factory.js +17 -7
  61. package/dest/l1_tx_utils/fee-strategies/p75_competitive.js +1 -1
  62. package/dest/l1_tx_utils/fee-strategies/p75_competitive_blob_txs_only.js +1 -1
  63. package/dest/l1_tx_utils/forwarder_l1_tx_utils.d.ts +15 -15
  64. package/dest/l1_tx_utils/forwarder_l1_tx_utils.d.ts.map +1 -1
  65. package/dest/l1_tx_utils/forwarder_l1_tx_utils.js +9 -15
  66. package/dest/l1_tx_utils/index-blobs.d.ts +3 -3
  67. package/dest/l1_tx_utils/index-blobs.d.ts.map +1 -1
  68. package/dest/l1_tx_utils/index-blobs.js +2 -2
  69. package/dest/l1_tx_utils/index.d.ts +2 -1
  70. package/dest/l1_tx_utils/index.d.ts.map +1 -1
  71. package/dest/l1_tx_utils/index.js +1 -0
  72. package/dest/l1_tx_utils/l1_tx_utils.d.ts +26 -7
  73. package/dest/l1_tx_utils/l1_tx_utils.d.ts.map +1 -1
  74. package/dest/l1_tx_utils/l1_tx_utils.js +95 -60
  75. package/dest/l1_tx_utils/readonly_l1_tx_utils.d.ts +1 -1
  76. package/dest/l1_tx_utils/readonly_l1_tx_utils.d.ts.map +1 -1
  77. package/dest/l1_tx_utils/readonly_l1_tx_utils.js +10 -5
  78. package/dest/l1_tx_utils/tx_delayer.d.ts +58 -0
  79. package/dest/l1_tx_utils/tx_delayer.d.ts.map +1 -0
  80. package/dest/{test → l1_tx_utils}/tx_delayer.js +67 -37
  81. package/dest/publisher_manager.d.ts +21 -7
  82. package/dest/publisher_manager.d.ts.map +1 -1
  83. package/dest/publisher_manager.js +81 -7
  84. package/dest/queries.d.ts +13 -2
  85. package/dest/queries.d.ts.map +1 -1
  86. package/dest/queries.js +62 -3
  87. package/dest/test/chain_monitor.d.ts +23 -3
  88. package/dest/test/chain_monitor.d.ts.map +1 -1
  89. package/dest/test/chain_monitor.js +38 -2
  90. package/dest/test/eth_cheat_codes.d.ts +12 -4
  91. package/dest/test/eth_cheat_codes.d.ts.map +1 -1
  92. package/dest/test/eth_cheat_codes.js +17 -4
  93. package/dest/test/index.d.ts +1 -3
  94. package/dest/test/index.d.ts.map +1 -1
  95. package/dest/test/index.js +0 -2
  96. package/dest/test/rollup_cheat_codes.d.ts +31 -3
  97. package/dest/test/rollup_cheat_codes.d.ts.map +1 -1
  98. package/dest/test/rollup_cheat_codes.js +59 -7
  99. package/dest/test/start_anvil.d.ts +23 -3
  100. package/dest/test/start_anvil.d.ts.map +1 -1
  101. package/dest/test/start_anvil.js +143 -29
  102. package/dest/test/upgrade_utils.js +2 -2
  103. package/dest/utils.d.ts +9 -2
  104. package/dest/utils.d.ts.map +1 -1
  105. package/dest/utils.js +48 -16
  106. package/package.json +6 -8
  107. package/src/client.ts +66 -2
  108. package/src/config.ts +24 -15
  109. package/src/contracts/chain_state_override.ts +200 -0
  110. package/src/contracts/fee_asset_price_oracle.ts +285 -0
  111. package/src/contracts/governance.ts +269 -12
  112. package/src/contracts/governance_proposer.ts +54 -1
  113. package/src/contracts/inbox.ts +13 -5
  114. package/src/contracts/index.ts +3 -2
  115. package/src/contracts/multicall.ts +243 -105
  116. package/src/contracts/outbox.ts +42 -8
  117. package/src/contracts/registry.ts +31 -1
  118. package/src/contracts/rollup.ts +439 -73
  119. package/src/contracts/{tally_slashing_proposer.ts → slashing_proposer.ts} +14 -16
  120. package/src/deploy_aztec_l1_contracts.ts +57 -34
  121. package/src/deploy_l1_contract.ts +3 -3
  122. package/src/generated/l1-contracts-defaults.ts +2 -2
  123. package/src/l1_artifacts.ts +12 -35
  124. package/src/l1_contract_addresses.ts +120 -94
  125. package/src/l1_reader.ts +17 -10
  126. package/src/l1_tx_utils/config.ts +20 -0
  127. package/src/l1_tx_utils/factory.ts +31 -31
  128. package/src/l1_tx_utils/fee-strategies/p75_competitive.ts +1 -1
  129. package/src/l1_tx_utils/fee-strategies/p75_competitive_blob_txs_only.ts +1 -1
  130. package/src/l1_tx_utils/forwarder_l1_tx_utils.ts +43 -54
  131. package/src/l1_tx_utils/index-blobs.ts +2 -2
  132. package/src/l1_tx_utils/index.ts +1 -0
  133. package/src/l1_tx_utils/l1_tx_utils.ts +93 -42
  134. package/src/l1_tx_utils/readonly_l1_tx_utils.ts +10 -5
  135. package/src/{test → l1_tx_utils}/tx_delayer.ts +93 -56
  136. package/src/publisher_manager.ts +105 -10
  137. package/src/queries.ts +70 -4
  138. package/src/test/chain_monitor.ts +65 -3
  139. package/src/test/eth_cheat_codes.ts +16 -4
  140. package/src/test/index.ts +0 -2
  141. package/src/test/rollup_cheat_codes.ts +64 -6
  142. package/src/test/start_anvil.ts +177 -29
  143. package/src/test/upgrade_utils.ts +2 -2
  144. package/src/utils.ts +45 -18
  145. package/dest/contracts/empire_slashing_proposer.d.ts +0 -67
  146. package/dest/contracts/empire_slashing_proposer.d.ts.map +0 -1
  147. package/dest/contracts/empire_slashing_proposer.js +0 -207
  148. package/dest/contracts/tally_slashing_proposer.d.ts.map +0 -1
  149. package/dest/l1_tx_utils/l1_tx_utils_with_blobs.d.ts +0 -26
  150. package/dest/l1_tx_utils/l1_tx_utils_with_blobs.d.ts.map +0 -1
  151. package/dest/l1_tx_utils/l1_tx_utils_with_blobs.js +0 -26
  152. package/dest/test/delayed_tx_utils.d.ts +0 -13
  153. package/dest/test/delayed_tx_utils.d.ts.map +0 -1
  154. package/dest/test/delayed_tx_utils.js +0 -28
  155. package/dest/test/tx_delayer.d.ts +0 -36
  156. package/dest/test/tx_delayer.d.ts.map +0 -1
  157. package/src/contracts/empire_slashing_proposer.ts +0 -259
  158. package/src/l1_tx_utils/l1_tx_utils_with_blobs.ts +0 -77
  159. package/src/test/delayed_tx_utils.ts +0 -52
@@ -0,0 +1,285 @@
1
+ import { memoize } from '@aztec/foundation/decorators';
2
+ import { EthAddress } from '@aztec/foundation/eth-address';
3
+ import { type Logger, createLogger } from '@aztec/foundation/log';
4
+
5
+ import { type Hex, encodeAbiParameters, getContract, keccak256, parseAbiParameters } from 'viem';
6
+
7
+ import type { ViemClient } from '../types.js';
8
+ import { RollupContract } from './rollup.js';
9
+
10
+ /** Maximum price modifier per checkpoint in basis points. ±100 bps = ±1% */
11
+ export const MAX_FEE_ASSET_PRICE_MODIFIER_BPS = 100n;
12
+
13
+ /**
14
+ * Validates that a fee asset price modifier is within the allowed range.
15
+ * Validators should call this before attesting to a checkpoint proposal.
16
+ *
17
+ * @param modifier - The fee asset price modifier in basis points
18
+ * @returns true if the modifier is valid (between -100 and +100 bps)
19
+ */
20
+ export function validateFeeAssetPriceModifier(modifier: bigint): boolean {
21
+ return modifier >= -MAX_FEE_ASSET_PRICE_MODIFIER_BPS && modifier <= MAX_FEE_ASSET_PRICE_MODIFIER_BPS;
22
+ }
23
+
24
+ /**
25
+ * Oracle for computing fee asset price modifiers based on Uniswap V4 pool prices.
26
+ * Only active on Ethereum mainnet - returns 0 on other chains.
27
+ */
28
+ export class FeeAssetPriceOracle {
29
+ constructor(
30
+ private client: ViemClient,
31
+ private readonly rollupContract: RollupContract,
32
+ private log: Logger = createLogger('fee-asset-price-oracle'),
33
+ ) {}
34
+
35
+ @memoize
36
+ async getUniswapOracle(): Promise<UniswapPriceOracle | undefined> {
37
+ const code = await this.client.getCode({ address: STATE_VIEW_ADDRESS.toString() });
38
+ if (code === undefined || code === '0x') {
39
+ this.log.warn('Uniswap V4 StateView contract not found, skipping fee asset price oracle');
40
+ return undefined;
41
+ }
42
+ this.log.info('Uniswap V4 StateView contract found, initializing fee asset price oracle');
43
+ const oracle = new UniswapPriceOracle(this.client, this.log);
44
+
45
+ try {
46
+ if (!(await oracle.isPoolInitialized())) {
47
+ this.log.warn('Uniswap V4 pool not initialized, skipping fee asset price oracle');
48
+ return undefined;
49
+ }
50
+ } catch (err) {
51
+ this.log.warn(`Failed to check if Uniswap V4 pool is initialized: ${err}`);
52
+ return undefined;
53
+ }
54
+
55
+ return oracle;
56
+ }
57
+
58
+ /**
59
+ * Computes the fee asset price modifier to be used in the next checkpoint proposal.
60
+ *
61
+ * The modifier adjusts the on-chain fee asset price toward the oracle price,
62
+ * clamped to ±1% (±100 basis points) per checkpoint.
63
+ *
64
+ * Returns 0 if not on mainnet or if the oracle query fails.
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.
70
+ * @returns The price modifier in basis points (positive to increase price, negative to decrease)
71
+ */
72
+ async computePriceModifier(currentPriceE12?: bigint): Promise<bigint> {
73
+ const uniswapOracle = await this.getUniswapOracle();
74
+ if (!uniswapOracle) {
75
+ return 0n;
76
+ }
77
+
78
+ try {
79
+ // Get current on-chain price (ETH per fee asset, E12), preferring the caller-supplied value.
80
+ const resolvedCurrentPriceE12 = currentPriceE12 ?? (await this.rollupContract.getEthPerFeeAsset());
81
+
82
+ // Get oracle price (median of last N blocks, ETH per fee asset, E12)
83
+ const oraclePriceE12 = await uniswapOracle.getMeanEthPerFeeAssetE12();
84
+
85
+ // Compute modifier in basis points
86
+ const modifier = this.computePriceModifierBps(resolvedCurrentPriceE12, oraclePriceE12);
87
+
88
+ this.log.debug('Computed price modifier', {
89
+ currentPriceE12: resolvedCurrentPriceE12.toString(),
90
+ oraclePriceE12: oraclePriceE12.toString(),
91
+ modifierBps: modifier.toString(),
92
+ currentPriceFromCaller: currentPriceE12 !== undefined,
93
+ });
94
+
95
+ return modifier;
96
+ } catch (err) {
97
+ this.log.warn(`Failed to compute price modifier, using 0: ${err}`);
98
+ return 0n;
99
+ }
100
+ }
101
+
102
+ /**
103
+ * Gets the current oracle price (ETH per fee asset, scaled by 1e12).
104
+ * Returns undefined if not on mainnet or if the oracle query fails.
105
+ */
106
+ async getOraclePrice(): Promise<bigint | undefined> {
107
+ const uniswapOracle = await this.getUniswapOracle();
108
+ if (!uniswapOracle) {
109
+ return undefined;
110
+ }
111
+
112
+ try {
113
+ return await uniswapOracle.getMeanEthPerFeeAssetE12();
114
+ } catch (err) {
115
+ this.log.warn(`Failed to get oracle price: ${err}`);
116
+ return undefined;
117
+ }
118
+ }
119
+
120
+ /**
121
+ * Computes the basis points modifier needed to move from current price toward target price.
122
+ *
123
+ * @param currentPrice - Current ETH per fee asset (E12 scale)
124
+ * @param targetPrice - Target ETH per fee asset (E12 scale)
125
+ * @returns Basis points modifier clamped to ±100 (±1%)
126
+ */
127
+ computePriceModifierBps(currentPrice: bigint, targetPrice: bigint): bigint {
128
+ if (currentPrice === 0n) {
129
+ return MAX_FEE_ASSET_PRICE_MODIFIER_BPS;
130
+ }
131
+
132
+ // Calculate percentage difference in basis points
133
+ // modifierBps = ((targetPrice - currentPrice) / currentPrice) * 10000
134
+ const diff = targetPrice - currentPrice;
135
+ const rawModifierBps = (diff * 10_000n) / currentPrice;
136
+
137
+ // Clamp to ±MAX_FEE_ASSET_PRICE_MODIFIER_BPS
138
+ if (rawModifierBps > MAX_FEE_ASSET_PRICE_MODIFIER_BPS) {
139
+ return MAX_FEE_ASSET_PRICE_MODIFIER_BPS;
140
+ }
141
+ if (rawModifierBps < -MAX_FEE_ASSET_PRICE_MODIFIER_BPS) {
142
+ return -MAX_FEE_ASSET_PRICE_MODIFIER_BPS;
143
+ }
144
+ return rawModifierBps;
145
+ }
146
+ }
147
+
148
+ /** Mainnet Uniswap V4 StateView contract address */
149
+ export const STATE_VIEW_ADDRESS = EthAddress.fromString('0x7ffe42c4a5deea5b0fec41c94c136cf115597227');
150
+
151
+ const PRECISION_Q192 = 10n ** 12n * 2n ** 192n;
152
+
153
+ /**
154
+ * Converts Uniswap's sqrtPriceX96 directly to ETH per FeeAsset (E12).
155
+ *
156
+ * For an ETH/FeeAsset pool where ETH is currency0 and FeeAsset is currency1:
157
+ * - Uniswap's sqrtPriceX96 = sqrt(FeeAsset/ETH) * 2^96
158
+ * - We need: ETH/FeeAsset with 1e12 precision
159
+ *
160
+ * Math:
161
+ * price = (sqrtPriceX96 / 2^96)^2 = sqrtPriceX96^2 / 2^192 (FeeAsset per ETH)
162
+ * ethPerFeeAsset = 1 / price = 2^192 / sqrtPriceX96^2
163
+ * ethPerFeeAssetE12 = ethPerFeeAsset * 1e12 = 1e12 * 2^192 / sqrtPriceX96^2
164
+ */
165
+ export function sqrtPriceX96ToEthPerFeeAssetE12(sqrtPriceX96: bigint): bigint {
166
+ if (sqrtPriceX96 === 0n) {
167
+ throw new Error('Cannot convert zero sqrtPriceX96');
168
+ }
169
+ return PRECISION_Q192 / (sqrtPriceX96 * sqrtPriceX96);
170
+ }
171
+ /**
172
+ * Uniswap V4 StateView ABI - only the functions we need
173
+ */
174
+ const StateViewAbi = [
175
+ {
176
+ type: 'function',
177
+ name: 'getSlot0',
178
+ inputs: [{ name: 'poolId', type: 'bytes32', internalType: 'PoolId' }],
179
+ outputs: [
180
+ { name: 'sqrtPriceX96', type: 'uint160', internalType: 'uint160' },
181
+ { name: 'tick', type: 'int24', internalType: 'int24' },
182
+ { name: 'protocolFee', type: 'uint24', internalType: 'uint24' },
183
+ { name: 'lpFee', type: 'uint24', internalType: 'uint24' },
184
+ ],
185
+ stateMutability: 'view',
186
+ },
187
+ ] as const;
188
+
189
+ /**
190
+ * Client for querying the ETH/FeeAsset price from Uniswap V4.
191
+ * Returns prices in ETH per FeeAsset format (E12) to match the rollup contract.
192
+ */
193
+ class UniswapPriceOracle {
194
+ private readonly stateView;
195
+ private readonly poolId: Hex;
196
+ private readonly log: Logger;
197
+
198
+ constructor(
199
+ private readonly client: ViemClient,
200
+ log?: Logger,
201
+ ) {
202
+ this.log = log ?? createLogger('uniswap-price-oracle');
203
+ this.stateView = getContract({
204
+ address: STATE_VIEW_ADDRESS.toString(),
205
+ abi: StateViewAbi,
206
+ client,
207
+ });
208
+ this.poolId = this.computePoolId();
209
+ this.log.debug(`Initialized UniswapPriceOracle with poolId: ${this.poolId}`);
210
+ }
211
+
212
+ /**
213
+ * Computes the PoolId from the pool configuration by hashing its components.
214
+ * PoolId = keccak256(abi.encode(currency0, currency1, fee, tickSpacing, hooks))
215
+ * For mainnet, the value is expected to be: 0xce2899b16743cfd5a954d8122d5e07f410305b1aebee39fd73d9f3b9ebf10c2f
216
+ * Derived anyway to make it simpler to change if needed.
217
+ */
218
+ @memoize
219
+ computePoolId(): Hex {
220
+ /** ETH/FeeAsset pool configuration (hardcoded for mainnet) */
221
+ const encoded = encodeAbiParameters(parseAbiParameters('address, address, uint24, int24, address'), [
222
+ EthAddress.ZERO.toString(),
223
+ EthAddress.fromString('0xA27EC0006e59f245217Ff08CD52A7E8b169E62D2').toString(),
224
+ 500, // 0.05%
225
+ 10,
226
+ EthAddress.fromString('0xd53006d1e3110fD319a79AEEc4c527a0d265E080').toString(),
227
+ ]);
228
+ return keccak256(encoded);
229
+ }
230
+
231
+ async isPoolInitialized(): Promise<boolean> {
232
+ const [sqrtPriceX96] = await this.stateView.read.getSlot0([this.poolId], undefined);
233
+ return sqrtPriceX96 !== 0n;
234
+ }
235
+
236
+ /**
237
+ * Gets the price as ETH per FeeAsset, scaled by 1e12.
238
+ * This is the format expected by the rollup contract.
239
+ *
240
+ * @param blockNumber - Optional block number to query at (defaults to latest)
241
+ */
242
+ async getEthPerFeeAssetE12(blockNumber?: bigint): Promise<bigint> {
243
+ const [sqrtPriceX96] = await this.stateView.read.getSlot0(
244
+ [this.poolId],
245
+ blockNumber !== undefined ? { blockNumber } : undefined,
246
+ );
247
+ return sqrtPriceX96ToEthPerFeeAssetE12(sqrtPriceX96);
248
+ }
249
+
250
+ /**
251
+ * Gets the median price over the last N blocks as ETH per FeeAsset (E12).
252
+ * Using median helps protect against single-block manipulation.
253
+ *
254
+ * @param numBlocks - Number of recent blocks to sample (default: 5)
255
+ * @returns Median price as ETH per FeeAsset, scaled by 1e12
256
+ */
257
+ async getMeanEthPerFeeAssetE12(numBlocks: number = 5): Promise<bigint> {
258
+ const currentBlock = await this.client.getBlockNumber();
259
+ const prices: bigint[] = [];
260
+
261
+ for (let i = 0; i < numBlocks; i++) {
262
+ const blockNumber = currentBlock - BigInt(i);
263
+ if (blockNumber < 0n) {
264
+ break;
265
+ }
266
+
267
+ try {
268
+ const price = await this.getEthPerFeeAssetE12(blockNumber);
269
+ prices.push(price);
270
+ } catch (err) {
271
+ this.log.warn(`Failed to get price at block ${blockNumber}: ${err}`);
272
+ // Continue with fewer samples
273
+ }
274
+ }
275
+
276
+ const filteredPrices = prices.filter(price => price !== 0n);
277
+
278
+ if (filteredPrices.length === 0) {
279
+ throw new Error('Failed to get any price samples from Uniswap oracle');
280
+ }
281
+
282
+ const mean = filteredPrices.reduce((a, b) => a + b, 0n) / BigInt(filteredPrices.length);
283
+ return mean;
284
+ }
285
+ }
@@ -14,9 +14,24 @@ import {
14
14
  } from 'viem';
15
15
 
16
16
  import type { L1ContractAddresses } from '../l1_contract_addresses.js';
17
- import { createL1TxUtilsFromViemWallet } from '../l1_tx_utils/index.js';
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'
@@ -34,6 +49,98 @@ export enum ProposalState {
34
49
  Expired,
35
50
  }
36
51
 
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
+
37
144
  export function extractProposalIdFromLogs(logs: Log[]): bigint {
38
145
  const parsedLogs = parseEventLogs({
39
146
  abi: GovernanceAbi,
@@ -50,6 +157,22 @@ export function extractProposalIdFromLogs(logs: Log[]): bigint {
50
157
  export class ReadOnlyGovernanceContract {
51
158
  protected readonly governanceContract: GetContractReturnType<typeof GovernanceAbi, ViemClient>;
52
159
 
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
+
53
176
  constructor(
54
177
  address: Hex,
55
178
  public readonly client: ViemClient,
@@ -65,21 +188,155 @@ export class ReadOnlyGovernanceContract {
65
188
  return EthAddress.fromString(await this.governanceContract.read.governanceProposer());
66
189
  }
67
190
 
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
+ };
70
206
  }
71
207
 
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;
74
250
  }
75
251
 
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
+ */
76
257
  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;
81
337
  }
82
- return state as ProposalState;
338
+ this.originalPayloadCache.set(key, original);
339
+ return original;
83
340
  }
84
341
 
85
342
  public async awaitProposalActive({ proposalId, logger }: { proposalId: bigint; logger: Logger }) {
@@ -194,7 +451,7 @@ export class GovernanceContract extends ReadOnlyGovernanceContract {
194
451
  retries: number;
195
452
  logger: Logger;
196
453
  }) {
197
- const l1TxUtils = createL1TxUtilsFromViemWallet(this.client, { logger });
454
+ const l1TxUtils = createL1TxUtils(this.client, { logger });
198
455
  const retryDelaySeconds = 12;
199
456
 
200
457
  voteAmount = voteAmount ?? (await this.getPowerForProposal(proposalId));
@@ -252,7 +509,7 @@ export class GovernanceContract extends ReadOnlyGovernanceContract {
252
509
  retries: number;
253
510
  logger: Logger;
254
511
  }) {
255
- const l1TxUtils = createL1TxUtilsFromViemWallet(this.client, { logger });
512
+ const l1TxUtils = createL1TxUtils(this.client, { logger });
256
513
  const retryDelaySeconds = 12;
257
514
  let success = false;
258
515
  for (let i = 0; i < retries; i++) {
@@ -15,11 +15,19 @@ import {
15
15
  import type { L1TxRequest, L1TxUtils } from '../l1_tx_utils/index.js';
16
16
  import type { ViemClient } from '../types.js';
17
17
  import { type IEmpireBase, encodeSignal, encodeSignalWithSignature, signSignalWithSig } from './empire_base.js';
18
- import { extractProposalIdFromLogs } from './governance.js';
18
+ import { ReadOnlyGovernanceContract, extractProposalIdFromLogs } from './governance.js';
19
19
 
20
20
  export class GovernanceProposerContract implements IEmpireBase {
21
21
  private readonly proposer: GetContractReturnType<typeof GovernanceProposerAbi, ViemClient>;
22
22
 
23
+ /**
24
+ * Cache of bytecode-existence checks keyed by payload address. The check is stable for a
25
+ * contract's lifetime -- a contract either has code or it does not, and code cannot be removed
26
+ * after deployment (selfdestruct aside, which is not relevant here). Safe to memoize
27
+ * indefinitely for the lifetime of this instance.
28
+ */
29
+ private readonly emptyPayloadCache: Map<Hex, boolean> = new Map();
30
+
23
31
  constructor(
24
32
  public readonly client: ViemClient,
25
33
  address: Hex | EthAddress,
@@ -110,6 +118,51 @@ export class GovernanceProposerContract implements IEmpireBase {
110
118
  };
111
119
  }
112
120
 
121
+ /**
122
+ * Resolves the Governance contract this proposer submits winners to. Lazily reads
123
+ * `GovernanceProposer.getGovernance()` (which itself looks the address up via the registry) and
124
+ * memoizes the resulting wrapper.
125
+ */
126
+ @memoize
127
+ public async getGovernance(): Promise<ReadOnlyGovernanceContract> {
128
+ const address = await this.proposer.read.getGovernance();
129
+ return new ReadOnlyGovernanceContract(address, this.client);
130
+ }
131
+
132
+ /**
133
+ * Returns true iff the given original payload is currently the subject of a live (non-terminal)
134
+ * Governance proposal. Delegates to `ReadOnlyGovernanceContract.hasActiveProposalWithPayload`, which
135
+ * implements the actual sweep against the Governance contract -- this method exists only as a
136
+ * convenience wrapper so callers that already hold a GovernanceProposer reference don't have to
137
+ * resolve the Governance address themselves.
138
+ */
139
+ public async hasActiveProposalWithPayload(payload: Hex): Promise<boolean> {
140
+ const governance = await this.getGovernance();
141
+ return governance.hasActiveProposalWithPayload(payload);
142
+ }
143
+
144
+ /**
145
+ * Returns true if the given payload address has no deployed bytecode. Used as a cheap
146
+ * pre-flight check before casting a governance signal — voting for a zero-code address
147
+ * is unrecoverable.
148
+ *
149
+ * We only cache the `false` result (address has bytecode). The `true` result is NOT
150
+ * cached because a CREATE2-redeployed address could go from empty to populated, and
151
+ * caching `true` would make us keep skipping a payload that later becomes valid.
152
+ */
153
+ public async isPayloadEmpty(payload: EthAddress): Promise<boolean> {
154
+ const key = payload.toString() as Hex;
155
+ if (this.emptyPayloadCache.get(key) === false) {
156
+ return false;
157
+ }
158
+ const code = await this.client.getCode({ address: key });
159
+ const isEmpty = !code || code === '0x';
160
+ if (!isEmpty) {
161
+ this.emptyPayloadCache.set(key, false);
162
+ }
163
+ return isEmpty;
164
+ }
165
+
113
166
  public async submitRoundWinner(
114
167
  round: bigint,
115
168
  l1TxUtils: L1TxUtils,