@aztec/ethereum 0.0.1-commit.3100065 → 0.0.1-commit.330febf

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 (69) hide show
  1. package/dest/config.d.ts +13 -3
  2. package/dest/config.d.ts.map +1 -1
  3. package/dest/config.js +30 -3
  4. package/dest/contracts/governance.d.ts +36 -11
  5. package/dest/contracts/governance.d.ts.map +1 -1
  6. package/dest/contracts/governance.js +54 -24
  7. package/dest/contracts/governance_proposer.d.ts +7 -8
  8. package/dest/contracts/governance_proposer.d.ts.map +1 -1
  9. package/dest/contracts/governance_proposer.js +7 -10
  10. package/dest/contracts/gse.d.ts +5 -1
  11. package/dest/contracts/gse.d.ts.map +1 -1
  12. package/dest/contracts/gse.js +9 -0
  13. package/dest/contracts/multicall.d.ts +5 -3
  14. package/dest/contracts/multicall.d.ts.map +1 -1
  15. package/dest/contracts/multicall.js +7 -5
  16. package/dest/contracts/rollup.d.ts +9 -1
  17. package/dest/contracts/rollup.d.ts.map +1 -1
  18. package/dest/contracts/rollup.js +8 -0
  19. package/dest/contracts/slashing_proposer.d.ts +37 -6
  20. package/dest/contracts/slashing_proposer.d.ts.map +1 -1
  21. package/dest/contracts/slashing_proposer.js +447 -17
  22. package/dest/deploy_aztec_l1_contracts.d.ts +15 -4
  23. package/dest/deploy_aztec_l1_contracts.d.ts.map +1 -1
  24. package/dest/deploy_aztec_l1_contracts.js +24 -13
  25. package/dest/foundry_binary.d.ts +13 -0
  26. package/dest/foundry_binary.d.ts.map +1 -0
  27. package/dest/foundry_binary.js +52 -0
  28. package/dest/l1_artifacts.d.ts +24 -19
  29. package/dest/l1_artifacts.d.ts.map +1 -1
  30. package/dest/l1_tx_utils/l1_fee_analyzer.d.ts +23 -4
  31. package/dest/l1_tx_utils/l1_fee_analyzer.d.ts.map +1 -1
  32. package/dest/l1_tx_utils/l1_fee_analyzer.js +65 -3
  33. package/dest/l1_tx_utils/l1_tx_utils.d.ts +1 -1
  34. package/dest/l1_tx_utils/l1_tx_utils.d.ts.map +1 -1
  35. package/dest/l1_tx_utils/l1_tx_utils.js +27 -7
  36. package/dest/l1_tx_utils/types.d.ts +27 -1
  37. package/dest/l1_tx_utils/types.d.ts.map +1 -1
  38. package/dest/l1_tx_utils/types.js +10 -0
  39. package/dest/queries.d.ts +1 -1
  40. package/dest/queries.d.ts.map +1 -1
  41. package/dest/queries.js +7 -1
  42. package/dest/test/eth_cheat_codes.d.ts +7 -1
  43. package/dest/test/eth_cheat_codes.d.ts.map +1 -1
  44. package/dest/test/eth_cheat_codes.js +29 -4
  45. package/dest/test/start_anvil.d.ts +1 -1
  46. package/dest/test/start_anvil.d.ts.map +1 -1
  47. package/dest/test/start_anvil.js +41 -5
  48. package/dest/utils.js +8 -11
  49. package/package.json +5 -6
  50. package/src/config.ts +38 -3
  51. package/src/contracts/governance.ts +63 -20
  52. package/src/contracts/governance_proposer.ts +11 -15
  53. package/src/contracts/gse.ts +12 -0
  54. package/src/contracts/multicall.ts +8 -5
  55. package/src/contracts/rollup.ts +10 -0
  56. package/src/contracts/slashing_proposer.ts +78 -15
  57. package/src/deploy_aztec_l1_contracts.ts +29 -12
  58. package/src/foundry_binary.ts +57 -0
  59. package/src/l1_tx_utils/l1_fee_analyzer.ts +75 -3
  60. package/src/l1_tx_utils/l1_tx_utils.ts +19 -2
  61. package/src/l1_tx_utils/types.ts +32 -0
  62. package/src/queries.ts +6 -0
  63. package/src/test/eth_cheat_codes.ts +23 -2
  64. package/src/test/start_anvil.ts +38 -6
  65. package/src/utils.ts +11 -11
  66. package/dest/generated/l1-contracts-defaults.d.ts +0 -30
  67. package/dest/generated/l1-contracts-defaults.d.ts.map +0 -1
  68. package/dest/generated/l1-contracts-defaults.js +0 -30
  69. package/src/generated/l1-contracts-defaults.ts +0 -32
@@ -37,7 +37,7 @@ export type L1GovernanceContractAddresses = Pick<
37
37
  'governanceAddress' | 'rollupAddress' | 'registryAddress' | 'governanceProposerAddress'
38
38
  >;
39
39
 
40
- // 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
41
41
  export enum ProposalState {
42
42
  Pending,
43
43
  Active,
@@ -45,10 +45,21 @@ export enum ProposalState {
45
45
  Executable,
46
46
  Rejected,
47
47
  Executed,
48
+ Droppable,
48
49
  Dropped,
49
50
  Expired,
50
51
  }
51
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
+
52
63
  /** Vote tallies on a single proposal. Both fields are mutated by `Governance.vote`. */
53
64
  export interface Ballot {
54
65
  yea: bigint;
@@ -119,6 +130,17 @@ const TERMINAL_PROPOSAL_STATES: ReadonlySet<ProposalState> = new Set([
119
130
  ProposalState.Expired,
120
131
  ]);
121
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
+
122
144
  // Hard upper bound on the wall-clock lifetime of any Governance proposal, in seconds.
123
145
  // Each proposal stores its own snapshot of `ProposalConfiguration` at creation time and progresses
124
146
  // through Pending -> Active -> Queued -> Executable using those frozen durations
@@ -173,6 +195,14 @@ export class ReadOnlyGovernanceContract {
173
195
  */
174
196
  private readonly originalPayloadCache: Map<Hex, Hex | undefined> = new Map();
175
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
+
176
206
  constructor(
177
207
  address: Hex,
178
208
  public readonly client: ViemClient,
@@ -263,30 +293,38 @@ export class ReadOnlyGovernanceContract {
263
293
  }
264
294
 
265
295
  /**
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).
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'`.
272
307
  *
273
308
  * Implemented as a bounded view-call sweep over `Governance.proposals` rather than an event scan,
274
309
  * because `eth_getLogs` over the full deployment history of a long-lived rollup exceeds typical
275
310
  * 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.
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).
277
315
  */
278
- public async hasActiveProposalWithPayload(payload: Hex): Promise<boolean> {
316
+ public async getPayloadProposalStatus(payload: Hex): Promise<PayloadProposalStatus> {
317
+ const target = payload.toLowerCase() as Hex;
318
+
279
319
  const proposalCount = await this.getProposalCount();
280
320
  if (proposalCount === 0n) {
281
- return false;
321
+ return this.executedPayloads.has(target) ? 'executed' : 'none';
282
322
  }
283
323
 
284
324
  // Anything created before this cutoff is guaranteed terminal regardless of its frozen config.
285
325
  const block = await this.client.getBlock();
286
326
  const hardCutoff = block.timestamp - MAX_PROPOSAL_LIFETIME_SECONDS;
287
327
 
288
- const target = payload.toLowerCase() as Hex;
289
-
290
328
  // Proposals are append-only with monotonically non-decreasing creation timestamps, so iterating
291
329
  // from newest -> oldest lets us early-stop as soon as we cross the lifetime cutoff.
292
330
  for (let id = proposalCount - 1n; id >= 0n; id--) {
@@ -294,24 +332,29 @@ export class ReadOnlyGovernanceContract {
294
332
 
295
333
  // Hard early-stop: every older proposal is also older than the cutoff and therefore terminal.
296
334
  if (proposal.creation < hardCutoff) {
297
- return false;
335
+ break;
298
336
  }
299
337
 
338
+ const proposalPayload = proposal.payload.toString().toLowerCase();
300
339
  const original = await this.getOriginalPayload(proposal.payload);
301
- if (original === undefined || original.toLowerCase() !== target) {
340
+ const matches = proposalPayload === target || (original !== undefined && original.toLowerCase() === target);
341
+ if (!matches) {
302
342
  continue;
303
343
  }
304
344
 
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;
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);
309
352
  }
310
353
 
311
- return true;
354
+ // Rejected/Dropped/Expired proposals allow re-proposing the same payload; keep scanning.
312
355
  }
313
356
 
314
- return false;
357
+ return this.executedPayloads.has(target) ? 'executed' : 'none';
315
358
  }
316
359
 
317
360
  /**
@@ -15,7 +15,7 @@ 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 { ReadOnlyGovernanceContract, extractProposalIdFromLogs } from './governance.js';
18
+ import { type PayloadProposalStatus, ReadOnlyGovernanceContract, extractProposalIdFromLogs } from './governance.js';
19
19
 
20
20
  export class GovernanceProposerContract implements IEmpireBase {
21
21
  private readonly proposer: GetContractReturnType<typeof GovernanceProposerAbi, ViemClient>;
@@ -38,16 +38,16 @@ export class GovernanceProposerContract implements IEmpireBase {
38
38
  this.proposer = getContract({ address, abi: GovernanceProposerAbi, client });
39
39
  }
40
40
 
41
- public get address() {
41
+ public get address(): EthAddress {
42
42
  return EthAddress.fromString(this.proposer.address);
43
43
  }
44
44
 
45
- public async getRollupAddress() {
45
+ public async getRollupAddress(): Promise<EthAddress> {
46
46
  return EthAddress.fromString(await this.proposer.read.getInstance());
47
47
  }
48
48
 
49
49
  @memoize
50
- public async getRegistryAddress() {
50
+ public async getRegistryAddress(): Promise<EthAddress> {
51
51
  return EthAddress.fromString(await this.proposer.read.REGISTRY());
52
52
  }
53
53
 
@@ -59,10 +59,6 @@ export class GovernanceProposerContract implements IEmpireBase {
59
59
  return this.proposer.read.ROUND_SIZE();
60
60
  }
61
61
 
62
- public getInstance() {
63
- return this.proposer.read.getInstance();
64
- }
65
-
66
62
  public computeRound(slot: SlotNumber): Promise<bigint> {
67
63
  return this.proposer.read.computeRound([BigInt(slot)]);
68
64
  }
@@ -107,7 +103,7 @@ export class GovernanceProposerContract implements IEmpireBase {
107
103
  signer,
108
104
  payload,
109
105
  slot,
110
- await this.getInstance(),
106
+ (await this.getRollupAddress()).toString(),
111
107
  this.address.toString(),
112
108
  chainId,
113
109
  );
@@ -130,15 +126,15 @@ export class GovernanceProposerContract implements IEmpireBase {
130
126
  }
131
127
 
132
128
  /**
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
129
+ * Classifies the given original payload against the Governance proposal history (`'live'` /
130
+ * `'executed'` / `'none'`). Delegates to `ReadOnlyGovernanceContract.getPayloadProposalStatus`,
131
+ * which implements the actual sweep against the Governance contract -- this method exists only as
132
+ * a convenience wrapper so callers that already hold a GovernanceProposer reference don't have to
137
133
  * resolve the Governance address themselves.
138
134
  */
139
- public async hasActiveProposalWithPayload(payload: Hex): Promise<boolean> {
135
+ public async getPayloadProposalStatus(payload: Hex): Promise<PayloadProposalStatus> {
140
136
  const governance = await this.getGovernance();
141
- return governance.hasActiveProposalWithPayload(payload);
137
+ return governance.getPayloadProposalStatus(payload);
142
138
  }
143
139
 
144
140
  /**
@@ -59,6 +59,18 @@ export class GSEContract {
59
59
  return this.gse.read.getAttestersFromIndicesAtTime([instance, ts, indices], options);
60
60
  }
61
61
 
62
+ /** Number of attesters registered to `instance` as of the given L1 timestamp. */
63
+ public async getAttesterCountAtTime(
64
+ instance: Hex | EthAddress,
65
+ ts: bigint,
66
+ options?: { blockNumber?: bigint },
67
+ ): Promise<number> {
68
+ if (instance instanceof EthAddress) {
69
+ instance = instance.toString();
70
+ }
71
+ return Number(await this.gse.read.getAttesterCountAtTime([instance, ts], options));
72
+ }
73
+
62
74
  public async getRegistrationDigest(publicKey: ProjPointType<bigint>): Promise<ProjPointType<bigint>> {
63
75
  const affinePublicKey = publicKey.toAffine();
64
76
  const g1PointDigest = await this.gse.read.getRegistrationDigest([{ x: affinePublicKey.x, y: affinePublicKey.y }]);
@@ -14,7 +14,7 @@ import {
14
14
  multicall3Abi,
15
15
  } from 'viem';
16
16
 
17
- import type { L1BlobInputs, L1TxConfig, L1TxRequest, L1TxUtils } from '../l1_tx_utils/index.js';
17
+ import type { L1BlobInputs, L1TxConfig, L1TxRequest, L1TxState, L1TxUtils } from '../l1_tx_utils/index.js';
18
18
  import type { ExtendedViemWalletClient } from '../types.js';
19
19
  import { tryDecodeRevertReason } from '../utils.js';
20
20
 
@@ -26,7 +26,10 @@ export const MULTI_CALL_3_ADDRESS = '0xcA11bde05977b3631167028862bE2a173976CA11'
26
26
  * treat it as a fatal on-chain failure rather than retrying on a different publisher.
27
27
  */
28
28
  export class MulticallForwarderRevertedError extends Error {
29
- constructor(public readonly receipt: TransactionReceipt) {
29
+ constructor(
30
+ public readonly receipt: TransactionReceipt,
31
+ public readonly txState?: L1TxState,
32
+ ) {
30
33
  super(`Multicall3 forwarder tx reverted: ${receipt.transactionHash}`);
31
34
  this.name = 'MulticallForwarderRevertedError';
32
35
  }
@@ -209,7 +212,7 @@ export class Multicall3 {
209
212
  args: [args],
210
213
  });
211
214
 
212
- const { receipt } = await l1TxUtils.sendAndMonitorTransaction(
215
+ const { receipt, state } = await l1TxUtils.sendAndMonitorTransaction(
213
216
  {
214
217
  to: MULTI_CALL_3_ADDRESS,
215
218
  data: encodedForwarderData,
@@ -223,11 +226,11 @@ export class Multicall3 {
223
226
  // allowFailure to true for all calls, so a reverted status here would indicate a problem with
224
227
  // the Multicall3 contract itself or the forwarder transaction (such as an out-of-gas).
225
228
  if (receipt.status !== 'success') {
226
- throw new MulticallForwarderRevertedError(receipt);
229
+ throw new MulticallForwarderRevertedError(receipt, state);
227
230
  }
228
231
 
229
232
  const stats = await l1TxUtils.getTransactionStats(receipt.transactionHash);
230
- return { receipt, stats, multicallData: encodedForwarderData };
233
+ return { receipt, stats, multicallData: encodedForwarderData, state };
231
234
  }
232
235
 
233
236
  /** Batch multiple value transfers into a single aggregate3Value call on Multicall3. */
@@ -560,6 +560,16 @@ export class RollupContract {
560
560
  return Number(await this.rollup.read.getActiveAttesterCount(options));
561
561
  }
562
562
 
563
+ /**
564
+ * Number of attesters that were staked at the given (historical) L1 timestamp. This is the count that
565
+ * validator-set sampling uses to decide whether an epoch gets a committee, and the historical counterpart
566
+ * of {@link getActiveAttesterCount} (which reads at the latest L1 block).
567
+ */
568
+ async getAttesterCountAtTime(timestamp: bigint, options?: { blockNumber?: bigint }): Promise<number> {
569
+ const gse = new GSEContract(this.client, await this.getGSE());
570
+ return gse.getAttesterCountAtTime(this.address, timestamp, options);
571
+ }
572
+
563
573
  public async getSlashingProposerAddress() {
564
574
  const slasher = await this.getSlasherContract();
565
575
  if (!slasher) {
@@ -3,6 +3,7 @@ import type { ViemClient } from '@aztec/ethereum/types';
3
3
  import { mergeAbis, tryExtractEvent } from '@aztec/ethereum/utils';
4
4
  import { SlotNumber } from '@aztec/foundation/branded-types';
5
5
  import { Buffer32 } from '@aztec/foundation/buffer';
6
+ import { memoize } from '@aztec/foundation/decorators';
6
7
  import { EthAddress } from '@aztec/foundation/eth-address';
7
8
  import { Signature } from '@aztec/foundation/eth-signature';
8
9
  import { hexToBuffer } from '@aztec/foundation/string';
@@ -25,6 +26,14 @@ import {
25
26
  export class SlashingProposerContract {
26
27
  private readonly contract: GetContractReturnType<typeof SlashingProposerAbi, ViemClient>;
27
28
 
29
+ /**
30
+ * Slash target validators of the last round asked for. Safe to cache because a round's targets are the committees
31
+ * of epochs that had already ended when the round opened (see SLASH_OFFSET_IN_ROUNDS), and those committees are
32
+ * sampled from validator set and randao snapshots taken before the epoch started, so they cannot change while the
33
+ * round is live. The promise is cached rather than the result, so concurrent callers share one in-flight read.
34
+ */
35
+ private slashTargetValidators: { round: bigint; validators: Promise<EthAddress[]> } | undefined;
36
+
28
37
  constructor(
29
38
  public readonly client: ViemClient,
30
39
  address: Hex | EthAddress,
@@ -64,6 +73,8 @@ export class SlashingProposerContract {
64
73
  return this.contract.read.EXECUTION_DELAY_IN_ROUNDS();
65
74
  }
66
75
 
76
+ /** Returns the slash amounts for the three slash unit levels. Immutable on the contract, so memoized. */
77
+ @memoize
67
78
  public getSlashingAmounts(): Promise<[bigint, bigint, bigint]> {
68
79
  return Promise.all([
69
80
  this.contract.read.SLASH_AMOUNT_SMALL(),
@@ -234,35 +245,70 @@ export class SlashingProposerContract {
234
245
  };
235
246
  }
236
247
 
248
+ /**
249
+ * Returns the validators eligible to be voted against in a round, in the order votes encode them. Cached for the
250
+ * last round asked for: reading them runs a committee sampling simulation on L1, and every vote of a round decodes
251
+ * against the same list, so a caller walking a round's votes would otherwise repeat that call per vote. Only the
252
+ * last round is kept since callers move forward round by round.
253
+ */
254
+ public getSlashTargetValidators(round: bigint): Promise<EthAddress[]> {
255
+ const cached = this.slashTargetValidators;
256
+ if (cached?.round === round) {
257
+ return cached.validators;
258
+ }
259
+
260
+ const entry = { round, validators: this.fetchSlashTargetValidators(round) };
261
+ this.slashTargetValidators = entry;
262
+ // A failed read must not stay cached, or every later vote of the round would replay the same rejection
263
+ entry.validators.catch(() => {
264
+ if (this.slashTargetValidators === entry) {
265
+ this.slashTargetValidators = undefined;
266
+ }
267
+ });
268
+ return entry.validators;
269
+ }
270
+
271
+ private async fetchSlashTargetValidators(round: bigint): Promise<EthAddress[]> {
272
+ const { result } = await this.contract.simulate.getSlashTargetCommittees([round]);
273
+ return result.flat().map(validator => EthAddress.fromString(validator));
274
+ }
275
+
276
+ /**
277
+ * Returns the slash amount voted for each target validator by a single vote of a round.
278
+ * @param index - Position of the vote within the round, from 0 (inclusive) to the round's vote count (exclusive)
279
+ */
280
+ public async getVoteAt(round: bigint, index: bigint): Promise<SlashVoteTarget[]> {
281
+ const [validators, vote, slashAmounts] = await Promise.all([
282
+ this.getSlashTargetValidators(round),
283
+ this.contract.read.getVotes([round, index]),
284
+ this.getSlashingAmounts(),
285
+ ]);
286
+ return decodeVote(vote, validators, slashAmounts);
287
+ }
288
+
237
289
  /** Returns the last vote emitted for a given round */
238
290
  public async getLastVote(round: bigint) {
239
291
  const { voteCount } = await this.getRound(round);
240
- const validators = (await this.contract.simulate.getSlashTargetCommittees([round])).result.flat();
241
- const vote = await this.contract.read.getVotes([round, voteCount - 1n]);
242
- const decoded = decodeSlashConsensusVotes(hexToBuffer(vote));
243
- const slashAmounts = await this.getSlashingAmounts();
244
- return decoded
245
- .map((units, i) => ({
246
- validator: EthAddress.fromString(validators[i]),
247
- slashAmount: slashAmounts[units - 1] ?? 0n,
248
- }))
249
- .filter(v => v.slashAmount > 0n);
292
+ return await this.getVoteAt(round, voteCount - 1n);
250
293
  }
251
294
 
252
295
  /**
253
296
  * Listen for VoteCast events
254
- * @param callback - Callback function to handle vote cast events
297
+ * @param callback - Callback receiving the round, the vote's index within the round as accepted by `getVoteAt`,
298
+ * and the proposer that cast it
255
299
  * @returns Unwatch function
256
300
  */
257
- public listenToVoteCast(callback: (args: { round: bigint; proposer: string }) => void): () => void {
301
+ public listenToVoteCast(
302
+ callback: (args: { round: bigint; voteIndex: bigint; proposer: string }) => void,
303
+ ): () => void {
258
304
  return this.contract.watchEvent.VoteCast(
259
305
  {},
260
306
  {
261
307
  onLogs: logs => {
262
308
  for (const log of logs) {
263
- const { round, proposer } = log.args;
264
- if (round !== undefined && proposer) {
265
- callback({ round, proposer });
309
+ const { round, voteIndex, proposer } = log.args;
310
+ if (round !== undefined && voteIndex !== undefined && proposer) {
311
+ callback({ round, voteIndex, proposer });
266
312
  }
267
313
  }
268
314
  },
@@ -294,6 +340,23 @@ export class SlashingProposerContract {
294
340
  }
295
341
  }
296
342
 
343
+ /**
344
+ * A validator targeted by a slashing vote, with the amount voted. The position is the validator's index in the
345
+ * round's flattened slash target committees — the unit the contract tallies quorum by. A validator sitting in
346
+ * several of the round's committees holds several positions, each with its own tally.
347
+ */
348
+ export type SlashVoteTarget = { validator: EthAddress; slashAmount: bigint; position: number };
349
+
350
+ function decodeVote(vote: Hex, validators: EthAddress[], slashAmounts: [bigint, bigint, bigint]): SlashVoteTarget[] {
351
+ return decodeSlashConsensusVotes(hexToBuffer(vote))
352
+ .map((units, position) => ({
353
+ validator: validators[position],
354
+ slashAmount: slashAmounts[units - 1] ?? 0n,
355
+ position,
356
+ }))
357
+ .filter(v => v.slashAmount > 0n);
358
+ }
359
+
297
360
  /**
298
361
  * Decodes a Buffer containing slash votes back into an array of numbers.
299
362
  * Each vote is represented as a 2-bit value (0, 1, 2, or 3) representing slashing units.
@@ -5,12 +5,12 @@ import { jsonStringify } from '@aztec/foundation/json-rpc';
5
5
  import { createLogger } from '@aztec/foundation/log';
6
6
  import { promiseWithResolvers } from '@aztec/foundation/promise';
7
7
  import type { Fr } from '@aztec/foundation/schemas';
8
- import { fileURLToPath } from '@aztec/foundation/url';
9
8
 
10
9
  import { bn254 } from '@noble/curves/bn254';
11
10
  import type { Abi, Narrow } from 'abitype';
12
11
  import { spawn } from 'child_process';
13
12
  import { cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'fs';
13
+ import { createRequire } from 'node:module';
14
14
  import { tmpdir } from 'os';
15
15
  import { dirname, join, resolve } from 'path';
16
16
  import readline from 'readline';
@@ -22,11 +22,14 @@ import { createExtendedL1Client } from './client.js';
22
22
  import { type L1ContractsConfig, assertValidSlotDurations } from './config.js';
23
23
  import { deployMulticall3 } from './contracts/multicall.js';
24
24
  import { RollupContract } from './contracts/rollup.js';
25
+ import { resolveFoundryBinary } from './foundry_binary.js';
25
26
  import type { L1ContractAddresses } from './l1_contract_addresses.js';
26
27
  import type { ExtendedViemWalletClient } from './types.js';
27
28
 
28
29
  const logger = createLogger('ethereum:deploy_aztec_l1_contracts');
29
30
 
31
+ const require = createRequire(import.meta.url);
32
+
30
33
  const JSON_DEPLOY_RESULT_PREFIX = 'JSON DEPLOY RESULT:';
31
34
 
32
35
  /**
@@ -93,11 +96,16 @@ function runProcess<T>(
93
96
 
94
97
  // Covers an edge where where we may have a cached BlobLib that is not meant for production.
95
98
  // Despite the profile apparently sometimes cached code remains (so says Lasse after his ignition-monorepo arc).
96
- async function maybeForgeForceProductionBuild(l1ContractsPath: string, script: string, chainId: number) {
99
+ async function maybeForgeForceProductionBuild(
100
+ forgeBin: string,
101
+ l1ContractsPath: string,
102
+ script: string,
103
+ chainId: number,
104
+ ) {
97
105
  if (chainId === mainnet.id) {
98
106
  logger.info(`Recompiling ${script} with production profile for mainnet deployment`);
99
107
  logger.info('This may take a minute but ensures production BlobLib is used.');
100
- await runProcess('forge', ['build', script, '--force'], { FOUNDRY_PROFILE: 'production' }, l1ContractsPath);
108
+ await runProcess(forgeBin, ['build', script, '--force'], { FOUNDRY_PROFILE: 'production' }, l1ContractsPath);
101
109
  }
102
110
  }
103
111
 
@@ -123,15 +131,13 @@ export interface ValidatorJson {
123
131
  }
124
132
 
125
133
  /**
126
- * Gets the path to the l1-contracts foundry artifacts directory.
127
- * These are copied from l1-contracts to yarn-project/l1-artifacts/l1-contracts
128
- * during build to make yarn-project self-contained.
134
+ * Gets the path to the l1-contracts foundry artifacts directory bundled inside @aztec/l1-artifacts.
135
+ * Resolved through the package (its "." export -> dest/index.js) so it works whether the package is
136
+ * linked via portal (monorepo) or installed under node_modules (published npm) — resolution follows
137
+ * the symlink in the portal case. The bundled foundry subtree sits alongside dest/, at <pkg>/l1-contracts.
129
138
  */
130
139
  export function getL1ContractsPath(): string {
131
- const currentDir = dirname(fileURLToPath(import.meta.url));
132
- // Go up from yarn-project/ethereum/dest to yarn-project, then to l1-artifacts/l1-contracts
133
- const l1ContractsPath = resolve(currentDir, '..', '..', 'l1-artifacts', 'l1-contracts');
134
- return l1ContractsPath;
140
+ return resolve(dirname(require.resolve('@aztec/l1-artifacts')), '..', 'l1-contracts');
135
141
  }
136
142
 
137
143
  // Cached deployment directory
@@ -320,8 +326,9 @@ export async function deployAztecL1Contracts(
320
326
  // Use foundry-artifacts from l1-artifacts package
321
327
  const l1ContractsPath = prepareL1ContractsForDeployment();
322
328
 
329
+ const forgeBin = resolveFoundryBinary('forge');
323
330
  const FORGE_SCRIPT = 'script/deploy/DeployAztecL1Contracts.s.sol';
324
- await maybeForgeForceProductionBuild(l1ContractsPath, FORGE_SCRIPT, chainId);
331
+ await maybeForgeForceProductionBuild(forgeBin, l1ContractsPath, FORGE_SCRIPT, chainId);
325
332
 
326
333
  // Verify contracts on Etherscan when on mainnet/sepolia and ETHERSCAN_API_KEY is available.
327
334
  const isVerifiableChain = chainId === mainnet.id || chainId === sepolia.id;
@@ -346,6 +353,8 @@ export async function deployAztecL1Contracts(
346
353
  ...(shouldVerify ? ['--verify'] : []),
347
354
  ];
348
355
  const forgeEnv = {
356
+ // Resolved forge binary picked up by forge_broadcast.js, so it works without forge on PATH.
357
+ FORGE_BIN: forgeBin,
349
358
  // Env vars required by l1-contracts/script/deploy/DeploymentConfiguration.sol.
350
359
  NETWORK: getActiveNetworkName(),
351
360
  FOUNDRY_PROFILE: chainId === mainnet.id ? 'production' : undefined,
@@ -590,6 +599,11 @@ export function getDeployRollupForUpgradeEnvVars(
590
599
  AZTEC_SLASH_AMOUNT_SMALL: args.slashAmountSmall.toString(),
591
600
  AZTEC_SLASH_AMOUNT_MEDIUM: args.slashAmountMedium.toString(),
592
601
  AZTEC_SLASH_AMOUNT_LARGE: args.slashAmountLarge.toString(),
602
+ AZTEC_ENTRY_QUEUE_BOOTSTRAP_VALIDATOR_SET_SIZE: args.entryQueueBootstrapValidatorSetSize.toString(),
603
+ AZTEC_ENTRY_QUEUE_BOOTSTRAP_FLUSH_SIZE: args.entryQueueBootstrapFlushSize.toString(),
604
+ AZTEC_ENTRY_QUEUE_FLUSH_SIZE_MIN: args.entryQueueFlushSizeMin.toString(),
605
+ AZTEC_ENTRY_QUEUE_FLUSH_SIZE_QUOTIENT: args.entryQueueFlushSizeQuotient.toString(),
606
+ AZTEC_ENTRY_QUEUE_MAX_FLUSH_SIZE: args.entryQueueMaxFlushSize.toString(),
593
607
  } as const;
594
608
  }
595
609
 
@@ -613,12 +627,15 @@ export const deployRollupForUpgrade = async (
613
627
  // Use foundry-artifacts from l1-artifacts package
614
628
  const l1ContractsPath = prepareL1ContractsForDeployment();
615
629
 
630
+ const forgeBin = resolveFoundryBinary('forge');
616
631
  const FORGE_SCRIPT = 'script/deploy/DeployRollupForUpgrade.s.sol';
617
- await maybeForgeForceProductionBuild(l1ContractsPath, FORGE_SCRIPT, chainId);
632
+ await maybeForgeForceProductionBuild(forgeBin, l1ContractsPath, FORGE_SCRIPT, chainId);
618
633
 
619
634
  const scriptPath = join(getL1ContractsPath(), 'scripts', 'forge_broadcast.js');
620
635
  const forgeArgs = [FORGE_SCRIPT, '--sig', 'run()', '--private-key', privateKey, '--rpc-url', rpcUrl];
621
636
  const forgeEnv = {
637
+ // Resolved forge binary picked up by forge_broadcast.js, so it works without forge on PATH.
638
+ FORGE_BIN: forgeBin,
622
639
  FOUNDRY_PROFILE: chainId === mainnet.id ? 'production' : undefined,
623
640
  // Env vars required by l1-contracts/script/deploy/RollupConfiguration.sol.
624
641
  REGISTRY_ADDRESS: registryAddress.toString(),
@@ -0,0 +1,57 @@
1
+ import { spawnSync } from 'child_process';
2
+ import { accessSync, constants } from 'fs';
3
+ import { homedir } from 'os';
4
+ import { join } from 'path';
5
+
6
+ function isExecutable(path: string): boolean {
7
+ try {
8
+ accessSync(path, constants.X_OK);
9
+ return true;
10
+ } catch {
11
+ return false;
12
+ }
13
+ }
14
+
15
+ /**
16
+ * Locate a Foundry binary (`anvil`, `forge`, ...) without relying on the caller's PATH. Order:
17
+ * 1. `$<NAME>_BIN` (e.g. `$ANVIL_BIN`, `$FORGE_BIN`) — explicit override, e.g. for CI with a pinned
18
+ * version. Throws if set but not pointing at an executable, instead of silently falling back.
19
+ * 2. `~/.aztec/current/internal-bin/<name>` — where aztec-up installs it.
20
+ * 3. `~/.aztec/current/bin/aztec-<name>` — the publicly-exposed symlink.
21
+ * 4. `~/.foundry/bin/<name>` — standalone foundryup install.
22
+ * 5. `command -v <name>` — anything else on PATH.
23
+ *
24
+ * Throws with a directive message if none work.
25
+ */
26
+ export function resolveFoundryBinary(name: string): string {
27
+ const envVar = `${name.toUpperCase()}_BIN`;
28
+ const envBin = process.env[envVar];
29
+ if (envBin) {
30
+ if (!isExecutable(envBin)) {
31
+ throw new Error(`$${envVar} is set to ${envBin}, which does not exist or is not executable.`);
32
+ }
33
+ return envBin;
34
+ }
35
+
36
+ const candidates = [
37
+ join(homedir(), '.aztec', 'current', 'internal-bin', name),
38
+ join(homedir(), '.aztec', 'current', 'bin', `aztec-${name}`),
39
+ join(homedir(), '.foundry', 'bin', name),
40
+ ];
41
+ for (const path of candidates) {
42
+ if (isExecutable(path)) {
43
+ return path;
44
+ }
45
+ }
46
+
47
+ const which = spawnSync('sh', ['-c', `command -v ${name}`], { encoding: 'utf8' });
48
+ if (which.status === 0 && which.stdout.trim()) {
49
+ return which.stdout.trim();
50
+ }
51
+
52
+ throw new Error(
53
+ `${name} binary not found. Tried $${envVar}, ~/.aztec/current/internal-bin/${name}, ` +
54
+ `~/.aztec/current/bin/aztec-${name}, ~/.foundry/bin/${name}, and $PATH. ` +
55
+ `Install via \`aztec-up\` or set ${envVar} to a working binary.`,
56
+ );
57
+ }