@aztec/sequencer-client 5.0.0-rc.1 → 5.0.0

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 (64) hide show
  1. package/dest/client/sequencer-client.d.ts +16 -3
  2. package/dest/client/sequencer-client.d.ts.map +1 -1
  3. package/dest/client/sequencer-client.js +17 -3
  4. package/dest/config.d.ts +2 -1
  5. package/dest/config.d.ts.map +1 -1
  6. package/dest/config.js +8 -3
  7. package/dest/global_variable_builder/fee_predictor.d.ts +1 -1
  8. package/dest/global_variable_builder/fee_predictor.d.ts.map +1 -1
  9. package/dest/global_variable_builder/fee_predictor.js +11 -1
  10. package/dest/global_variable_builder/fee_provider.d.ts +1 -1
  11. package/dest/global_variable_builder/fee_provider.d.ts.map +1 -1
  12. package/dest/global_variable_builder/fee_provider.js +15 -2
  13. package/dest/global_variable_builder/global_builder.d.ts +3 -16
  14. package/dest/global_variable_builder/global_builder.d.ts.map +1 -1
  15. package/dest/global_variable_builder/global_builder.js +2 -25
  16. package/dest/publisher/config.d.ts +5 -1
  17. package/dest/publisher/config.d.ts.map +1 -1
  18. package/dest/publisher/config.js +11 -1
  19. package/dest/publisher/l1_to_l2_messaging.d.ts +21 -0
  20. package/dest/publisher/l1_to_l2_messaging.d.ts.map +1 -0
  21. package/dest/publisher/l1_to_l2_messaging.js +70 -0
  22. package/dest/publisher/sequencer-publisher.d.ts +17 -4
  23. package/dest/publisher/sequencer-publisher.d.ts.map +1 -1
  24. package/dest/publisher/sequencer-publisher.js +119 -22
  25. package/dest/publisher/write_json.d.ts +11 -0
  26. package/dest/publisher/write_json.d.ts.map +1 -0
  27. package/dest/publisher/write_json.js +57 -0
  28. package/dest/sequencer/automine/automine_sequencer.d.ts +8 -3
  29. package/dest/sequencer/automine/automine_sequencer.d.ts.map +1 -1
  30. package/dest/sequencer/automine/automine_sequencer.js +27 -14
  31. package/dest/sequencer/checkpoint_proposal_job.d.ts +4 -6
  32. package/dest/sequencer/checkpoint_proposal_job.d.ts.map +1 -1
  33. package/dest/sequencer/checkpoint_proposal_job.js +28 -26
  34. package/dest/sequencer/events.d.ts +1 -2
  35. package/dest/sequencer/events.d.ts.map +1 -1
  36. package/dest/sequencer/requests_tracker.d.ts +22 -0
  37. package/dest/sequencer/requests_tracker.d.ts.map +1 -0
  38. package/dest/sequencer/requests_tracker.js +33 -0
  39. package/dest/sequencer/sequencer.d.ts +47 -10
  40. package/dest/sequencer/sequencer.d.ts.map +1 -1
  41. package/dest/sequencer/sequencer.js +125 -60
  42. package/dest/test/utils.d.ts +1 -1
  43. package/dest/test/utils.d.ts.map +1 -1
  44. package/dest/test/utils.js +2 -1
  45. package/package.json +27 -27
  46. package/src/client/sequencer-client.ts +19 -3
  47. package/src/config.ts +8 -3
  48. package/src/global_variable_builder/fee_predictor.ts +11 -1
  49. package/src/global_variable_builder/fee_provider.ts +19 -2
  50. package/src/global_variable_builder/global_builder.ts +2 -34
  51. package/src/publisher/config.ts +22 -1
  52. package/src/publisher/l1_to_l2_messaging.ts +85 -0
  53. package/src/publisher/sequencer-publisher.ts +116 -23
  54. package/src/publisher/write_json.ts +78 -0
  55. package/src/sequencer/automine/automine_sequencer.ts +27 -14
  56. package/src/sequencer/checkpoint_proposal_job.ts +43 -34
  57. package/src/sequencer/events.ts +1 -1
  58. package/src/sequencer/requests_tracker.ts +43 -0
  59. package/src/sequencer/sequencer.ts +133 -61
  60. package/src/test/utils.ts +2 -0
  61. package/dest/sequencer/chain_state_overrides.d.ts +0 -61
  62. package/dest/sequencer/chain_state_overrides.d.ts.map +0 -1
  63. package/dest/sequencer/chain_state_overrides.js +0 -98
  64. package/src/sequencer/chain_state_overrides.ts +0 -169
@@ -4,18 +4,16 @@ import {
4
4
  buildSimulationOverridesStateOverride,
5
5
  } from '@aztec/ethereum/contracts';
6
6
  import type { ViemPublicClient } from '@aztec/ethereum/types';
7
- import { BlockNumber, SlotNumber } from '@aztec/foundation/branded-types';
7
+ import type { SlotNumber } from '@aztec/foundation/branded-types';
8
8
  import { Fr } from '@aztec/foundation/curves/bn254';
9
9
  import type { EthAddress } from '@aztec/foundation/eth-address';
10
- import type { DateProvider } from '@aztec/foundation/timer';
11
10
  import type { AztecAddress } from '@aztec/stdlib/aztec-address';
12
- import { type L1RollupConstants, getNextL1SlotTimestamp, getTimestampForSlot } from '@aztec/stdlib/epoch-helpers';
11
+ import { type L1RollupConstants, getTimestampForSlot } from '@aztec/stdlib/epoch-helpers';
13
12
  import { GasFees } from '@aztec/stdlib/gas';
14
13
  import type {
15
14
  CheckpointGlobalVariables,
16
15
  GlobalVariableBuilder as GlobalVariableBuilderInterface,
17
16
  } from '@aztec/stdlib/tx';
18
- import { GlobalVariables } from '@aztec/stdlib/tx';
19
17
 
20
18
  /** Configuration for the GlobalVariableBuilder (excludes L1 client config). */
21
19
  export type GlobalVariableBuilderConfig = {
@@ -29,7 +27,6 @@ export type GlobalVariableBuilderConfig = {
29
27
  */
30
28
  export class GlobalVariableBuilder implements GlobalVariableBuilderInterface {
31
29
  private readonly rollupContract: RollupContract;
32
- private readonly ethereumSlotDuration: number;
33
30
  private readonly aztecSlotDuration: number;
34
31
  private readonly l1GenesisTime: bigint;
35
32
 
@@ -37,47 +34,18 @@ export class GlobalVariableBuilder implements GlobalVariableBuilderInterface {
37
34
  private version: Fr;
38
35
 
39
36
  constructor(
40
- private readonly dateProvider: DateProvider,
41
37
  private readonly publicClient: ViemPublicClient,
42
38
  config: GlobalVariableBuilderConfig,
43
39
  ) {
44
40
  this.version = new Fr(config.rollupVersion);
45
41
  this.chainId = new Fr(this.publicClient.chain!.id);
46
42
 
47
- this.ethereumSlotDuration = config.ethereumSlotDuration;
48
43
  this.aztecSlotDuration = config.slotDuration;
49
44
  this.l1GenesisTime = config.l1GenesisTime;
50
45
 
51
46
  this.rollupContract = new RollupContract(this.publicClient, config.rollupAddress);
52
47
  }
53
48
 
54
- /**
55
- * Simple builder of global variables.
56
- * @param blockNumber - The block number to build global variables for.
57
- * @param coinbase - The address to receive block reward.
58
- * @param feeRecipient - The address to receive fees.
59
- * @param slotNumber - The slot number to use for the global variables, if undefined it will be calculated.
60
- * @returns The global variables for the given block number.
61
- */
62
- public async buildGlobalVariables(
63
- blockNumber: BlockNumber,
64
- coinbase: EthAddress,
65
- feeRecipient: AztecAddress,
66
- maybeSlot?: SlotNumber,
67
- ): Promise<GlobalVariables> {
68
- const slot: SlotNumber =
69
- maybeSlot ??
70
- (await this.rollupContract.getSlotAt(
71
- getNextL1SlotTimestamp(this.dateProvider.nowInSeconds(), {
72
- l1GenesisTime: this.l1GenesisTime,
73
- ethereumSlotDuration: this.ethereumSlotDuration,
74
- }),
75
- ));
76
-
77
- const checkpointGlobalVariables = await this.buildCheckpointGlobalVariables(coinbase, feeRecipient, slot);
78
- return GlobalVariables.from({ blockNumber, ...checkpointGlobalVariables });
79
- }
80
-
81
49
  /** Builds global variables that are constant throughout a checkpoint. */
82
50
  public async buildCheckpointGlobalVariables(
83
51
  coinbase: EthAddress,
@@ -1,7 +1,12 @@
1
1
  import { type BlobClientConfig, blobClientConfigMapping } from '@aztec/blob-client/client/config';
2
2
  import { type L1ReaderConfig, l1ReaderConfigMappings } from '@aztec/ethereum/l1-reader';
3
3
  import { type L1TxUtilsConfig, l1TxUtilsConfigMappings } from '@aztec/ethereum/l1-tx-utils/config';
4
- import { type ConfigMappingsType, SecretValue, booleanConfigHelper } from '@aztec/foundation/config';
4
+ import {
5
+ type ConfigMappingsType,
6
+ SecretValue,
7
+ booleanConfigHelper,
8
+ numberConfigHelper,
9
+ } from '@aztec/foundation/config';
5
10
  import { EthAddress } from '@aztec/foundation/eth-address';
6
11
 
7
12
  import { parseEther } from 'viem';
@@ -90,6 +95,10 @@ export type SequencerPublisherConfig = L1TxUtilsConfig &
90
95
  fishermanMode?: boolean;
91
96
  sequencerPublisherAllowInvalidStates?: boolean;
92
97
  sequencerPublisherForwarderAddress?: EthAddress;
98
+ /** How long to wait for the previous L1 block before sending scheduled publisher txs anyway. */
99
+ sequencerPublisherPreviousL1BlockWaitTimeoutMs: number;
100
+ /** Poll interval while waiting for the previous L1 block before scheduled publisher txs. */
101
+ sequencerPublisherPreviousL1BlockWaitPollIntervalMs: number;
93
102
  /** Store for failed L1 transaction inputs (test networks only). Format: gs://bucket/path */
94
103
  l1TxFailedStore?: string;
95
104
  /** Min ETH balance below which a publisher gets funded. Undefined = funding disabled. */
@@ -170,6 +179,18 @@ export const sequencerPublisherConfigMappings: ConfigMappingsType<SequencerPubli
170
179
  description: 'Address of the forwarder contract to wrap all L1 transactions through (for testing purposes only)',
171
180
  parseEnv: (val: string) => EthAddress.fromString(val),
172
181
  },
182
+ sequencerPublisherPreviousL1BlockWaitTimeoutMs: {
183
+ env: `SEQ_PUBLISHER_PREVIOUS_L1_BLOCK_WAIT_TIMEOUT_MS`,
184
+ description:
185
+ 'How long to wait for the previous L1 block before sending scheduled publisher txs anyway, in milliseconds.',
186
+ ...numberConfigHelper(8_000),
187
+ },
188
+ sequencerPublisherPreviousL1BlockWaitPollIntervalMs: {
189
+ env: `SEQ_PUBLISHER_PREVIOUS_L1_BLOCK_WAIT_POLL_INTERVAL_MS`,
190
+ description:
191
+ 'Poll interval while waiting for the previous L1 block before scheduled publisher txs, in milliseconds.',
192
+ ...numberConfigHelper(500),
193
+ },
173
194
  l1TxFailedStore: {
174
195
  env: 'L1_TX_FAILED_STORE',
175
196
  description: 'Store for failed L1 transaction inputs (test networks only). Format: gs://bucket/path',
@@ -0,0 +1,85 @@
1
+ import { RollupContract } from '@aztec/ethereum/contracts';
2
+ import type { L1ContractAddresses } from '@aztec/ethereum/l1-contract-addresses';
3
+ import type { ExtendedViemWalletClient } from '@aztec/ethereum/types';
4
+ import { Fr } from '@aztec/foundation/curves/bn254';
5
+ import { tryJsonStringify } from '@aztec/foundation/json-rpc';
6
+ import { createLogger } from '@aztec/foundation/log';
7
+ import { InboxAbi } from '@aztec/l1-artifacts';
8
+ import type { AztecAddress } from '@aztec/stdlib/aztec-address';
9
+
10
+ import { decodeEventLog, getContract } from 'viem';
11
+
12
+ /**
13
+ * Sends an L1-to-L2 message via the Inbox contract and returns its hash and global leaf index.
14
+ * Test helper copied from the end-to-end fixtures so this integration test can live in sequencer-client.
15
+ */
16
+ export async function sendL1ToL2Message(
17
+ message: { recipient: AztecAddress; content: Fr; secretHash: Fr },
18
+ ctx: {
19
+ l1Client: ExtendedViemWalletClient;
20
+ l1ContractAddresses: Pick<L1ContractAddresses, 'inboxAddress' | 'rollupAddress'>;
21
+ },
22
+ ) {
23
+ const logger = createLogger('sequencer-client:l1_to_l2_messaging');
24
+ const inbox = getContract({
25
+ address: ctx.l1ContractAddresses.inboxAddress.toString(),
26
+ abi: InboxAbi,
27
+ client: ctx.l1Client,
28
+ });
29
+
30
+ const { recipient, content, secretHash } = message;
31
+
32
+ const version = await new RollupContract(ctx.l1Client, ctx.l1ContractAddresses.rollupAddress.toString()).getVersion();
33
+
34
+ // We inject the message to Inbox
35
+ const txHash = await inbox.write.sendL2Message(
36
+ [{ actor: recipient.toString(), version: BigInt(version) }, content.toString(), secretHash.toString()],
37
+ {
38
+ gas: 1_000_000n,
39
+ },
40
+ );
41
+ logger.info(`L1 to L2 message sent in tx ${txHash}`);
42
+
43
+ // We check that the message was correctly injected by checking the emitted event
44
+ const txReceipt = await ctx.l1Client.waitForTransactionReceipt({ hash: txHash });
45
+
46
+ if (txReceipt.status !== 'success') {
47
+ throw new Error(`L1 to L2 message failed to be sent in tx ${txHash}. Status: ${txReceipt.status}`);
48
+ }
49
+
50
+ logger.info(`L1 to L2 message receipt retrieved for tx ${txReceipt.transactionHash}`, txReceipt);
51
+
52
+ if (txReceipt.transactionHash !== txHash) {
53
+ throw new Error(`Receipt transaction hash mismatch: ${txReceipt.transactionHash} !== ${txHash}`);
54
+ }
55
+
56
+ // Filter for MessageSent events from the Inbox contract by trying to decode each log
57
+ const messageSentLogs = txReceipt.logs
58
+ .filter(log => log.address.toLowerCase() === ctx.l1ContractAddresses.inboxAddress.toString().toLowerCase())
59
+ .map(log => {
60
+ try {
61
+ const decoded = decodeEventLog({
62
+ abi: InboxAbi,
63
+ data: log.data,
64
+ topics: log.topics,
65
+ });
66
+ return { log, decoded };
67
+ } catch {
68
+ return null; // Not a decodable event from this ABI
69
+ }
70
+ })
71
+ .filter((item): item is { log: any; decoded: any } => item !== null && item.decoded.eventName === 'MessageSent');
72
+
73
+ if (messageSentLogs.length !== 1) {
74
+ throw new Error(
75
+ `Wrong number of MessageSent logs found in ${txHash} transaction (got ${messageSentLogs.length} expected 1)\n${tryJsonStringify(messageSentLogs.map(item => item.log))}`,
76
+ );
77
+ }
78
+
79
+ // We already have the decoded event
80
+ const topics = messageSentLogs[0].decoded;
81
+ const receivedMsgHash = topics.args.hash;
82
+ const receivedGlobalLeafIndex = topics.args.index;
83
+
84
+ return { msgHash: Fr.fromHexString(receivedMsgHash), globalLeafIndex: new Fr(receivedGlobalLeafIndex), txReceipt };
85
+ }
@@ -117,6 +117,7 @@ type L1ProcessArgs = {
117
117
  export const Actions = [
118
118
  'invalidate-by-invalid-attestation',
119
119
  'invalidate-by-insufficient-attestations',
120
+ 'prune',
120
121
  'propose',
121
122
  'governance-signal',
122
123
  'vote-offenses',
@@ -177,6 +178,8 @@ export class SequencerPublisher {
177
178
  protected log: Logger;
178
179
  protected ethereumSlotDuration: bigint;
179
180
  protected aztecSlotDuration: bigint;
181
+ private readonly previousL1BlockWaitTimeoutMs: number;
182
+ private readonly previousL1BlockWaitPollIntervalMs: number;
180
183
 
181
184
  /** Date provider for wall-clock time. */
182
185
  private readonly dateProvider: DateProvider;
@@ -205,7 +208,13 @@ export class SequencerPublisher {
205
208
  protected requests: RequestWithExpiry[] = [];
206
209
 
207
210
  constructor(
208
- private config: Pick<SequencerPublisherConfig, 'fishermanMode' | 'l1TxFailedStore'> &
211
+ private config: Pick<
212
+ SequencerPublisherConfig,
213
+ | 'fishermanMode'
214
+ | 'l1TxFailedStore'
215
+ | 'sequencerPublisherPreviousL1BlockWaitTimeoutMs'
216
+ | 'sequencerPublisherPreviousL1BlockWaitPollIntervalMs'
217
+ > &
209
218
  Pick<L1ContractsConfig, 'ethereumSlotDuration' | 'aztecSlotDuration'> & { l1ChainId: number },
210
219
  deps: {
211
220
  telemetry?: TelemetryClient;
@@ -225,6 +234,8 @@ export class SequencerPublisher {
225
234
  this.log = deps.log ?? createLogger('sequencer:publisher');
226
235
  this.ethereumSlotDuration = BigInt(config.ethereumSlotDuration);
227
236
  this.aztecSlotDuration = BigInt(config.aztecSlotDuration);
237
+ this.previousL1BlockWaitTimeoutMs = config.sequencerPublisherPreviousL1BlockWaitTimeoutMs;
238
+ this.previousL1BlockWaitPollIntervalMs = config.sequencerPublisherPreviousL1BlockWaitPollIntervalMs;
228
239
  this.dateProvider = deps.dateProvider;
229
240
  this.epochCache = deps.epochCache;
230
241
  this.lastActions = deps.lastActions;
@@ -458,7 +469,9 @@ export class SequencerPublisher {
458
469
 
459
470
  if (bundleResult.kind === 'aborted') {
460
471
  this.logDroppedInSim(bundleResult.droppedRequests);
461
- void this.backupDroppedInSim(bundleResult.droppedRequests);
472
+ void this.backupDroppedInSim(bundleResult.droppedRequests).catch(err =>
473
+ this.log.error(`Failed to backup requests dropped in simulation`, err),
474
+ );
462
475
  return undefined;
463
476
  }
464
477
 
@@ -617,34 +630,69 @@ export class SequencerPublisher {
617
630
 
618
631
  /*
619
632
  * Schedules sending all enqueued requests at (or after) the start of the given L2 slot.
620
- * Sleeps until one L1 slot before the L2 slot boundary so the tx has a chance of being
621
- * picked up by the first L1 block of the L2 slot.
622
- * NB: there is a known correctness risk — being included in the L1 block right before the
623
- * L2 slot starts would revert propose with HeaderLib__InvalidSlotNumber.
624
- * Uses InterruptibleSleep so it can be cancelled via interrupt().
625
633
  */
626
634
  public async sendRequestsAt(targetSlot: SlotNumber): Promise<SendRequestsResult | undefined> {
627
- const l1Constants = this.epochCache.getL1Constants();
628
- // Start of the target L2 slot, in ms (getTimestampForSlot returns seconds).
629
- const startOfTargetSlotMs = Number(getTimestampForSlot(targetSlot, l1Constants)) * 1000;
630
- // Aim to be in the mempool one L1 slot before the L2 slot starts, so we have a chance of
631
- // being picked up by the first L1 block of the L2 slot.
632
- const submitAfterMs = startOfTargetSlotMs - Number(this.ethereumSlotDuration) * 1000;
635
+ await this.waitForTargetSlot(targetSlot);
633
636
  if (this.interrupted) {
634
637
  return undefined;
635
638
  }
636
- const sleepMs = submitAfterMs - this.dateProvider.now();
637
- if (sleepMs > 0) {
638
- this.log.debug(`Sleeping ${sleepMs}ms before sending requests`, {
639
- targetSlot,
640
- submitAfterMs,
641
- });
639
+
640
+ return this.sendRequests(targetSlot);
641
+ }
642
+
643
+ /**
644
+ * Sleeps until one L1 slot before the L2 slot boundary, and then waits for that L1 block
645
+ * to be mined, so we don't risk being included in it. If that block never gets mined after
646
+ * a timeout, we assume it got skipped on L1, so we send the tx anyway.
647
+ */
648
+ private async waitForTargetSlot(targetSlot: SlotNumber): Promise<void> {
649
+ const l1Constants = this.epochCache.getL1Constants();
650
+ const nowInSeconds = this.dateProvider.nowInSeconds();
651
+ const startOfTargetSlotTs = getTimestampForSlot(targetSlot, l1Constants);
652
+ const previousL1BlockTs = startOfTargetSlotTs - this.ethereumSlotDuration;
653
+ const waitDeadlineTs = previousL1BlockTs + BigInt(this.previousL1BlockWaitTimeoutMs / 1000);
654
+ const logCtx = { targetSlot, startOfTargetSlotTs, nowInSeconds, previousL1BlockTs, waitDeadlineTs };
655
+
656
+ // Check if we are already past time
657
+ if (nowInSeconds >= startOfTargetSlotTs) {
658
+ this.log.verbose(`Target slot ${targetSlot} already started, sending requests immediately`, logCtx);
659
+ return;
660
+ }
661
+
662
+ // Otherwise we wait
663
+ this.log.debug(`Waiting for slot ${targetSlot} before sending requests`, logCtx);
664
+
665
+ // Wait until previous L1 block timestamp first
666
+ const sleepMs = (Number(previousL1BlockTs) - nowInSeconds) * 1000;
667
+ if (sleepMs > 0 && !this.interrupted) {
668
+ this.log.trace(`Sleeping ${sleepMs}ms before waiting for previous L1 block`, logCtx);
642
669
  await this.interruptibleSleep.sleep(sleepMs);
643
670
  }
644
- if (this.interrupted) {
645
- return undefined;
671
+
672
+ // Then loop until we see the previous L1 block, so we know that we cannot be included in it.
673
+ // We time out after a while, once we are sure that that block is skipped in L1.
674
+ while (!this.interrupted) {
675
+ try {
676
+ const nowInSeconds = this.dateProvider.nowInSeconds();
677
+ logCtx.nowInSeconds = nowInSeconds;
678
+
679
+ if (nowInSeconds >= waitDeadlineTs) {
680
+ this.log.warn(`Timed out waiting for previous L1 block before sending requests, proceeding`, logCtx);
681
+ return;
682
+ }
683
+
684
+ const latestBlockTs = await this.l1TxUtils.getBlock().then(b => b.timestamp);
685
+ if (latestBlockTs >= previousL1BlockTs) {
686
+ this.log.debug(`Previous L1 block mined, proceeding to send requests`, { ...logCtx, latestBlockTs });
687
+ return;
688
+ }
689
+ this.log.trace(`Previous L1 block not mined yet, continuing to wait`, { ...logCtx, latestBlockTs });
690
+ } catch (err) {
691
+ this.log.error(`Error while waiting for previous L1 block before sending requests; retrying`, err, logCtx);
692
+ } finally {
693
+ await this.interruptibleSleep.sleep(this.previousL1BlockWaitPollIntervalMs);
694
+ }
646
695
  }
647
- return this.sendRequests(targetSlot);
648
696
  }
649
697
 
650
698
  private callbackBundledTransactions(
@@ -866,7 +914,11 @@ export class SequencerPublisher {
866
914
  const logData = { ...checkpoint, reason };
867
915
  this.log.debug(`Building invalidate checkpoint ${checkpoint.checkpointNumber} request`, logData);
868
916
 
869
- const attestationsAndSigners = CommitteeAttestationsAndSigners.packAttestations(validationResult.attestations);
917
+ // Use the exact packed tuple posted to L1 verbatim. A repack via `packAttestations` is not a
918
+ // byte-faithful inverse of `fromPacked` (a canonicalized yParity byte or an all-zero signature slot
919
+ // round-trips differently), so it would diverge from the stored `attestationsHash` and revert the
920
+ // invalidation.
921
+ const attestationsAndSigners = validationResult.verbatimAttestations;
870
922
 
871
923
  if (reason === 'invalid-attestation') {
872
924
  return this.rollupContract.buildInvalidateBadAttestationRequest(
@@ -1015,6 +1067,47 @@ export class SequencerPublisher {
1015
1067
  );
1016
1068
  }
1017
1069
 
1070
+ /**
1071
+ * Enqueues a `prune()` transaction if the rollup is prunable at the given slot's L1 timestamp.
1072
+ * `prune()` is permissionless and idempotent — if the chain is no longer prunable by send time the
1073
+ * bundle simulation usually drops the entry; on a node without `eth_simulateV1` the bundle is sent
1074
+ * as-is and the prune reverts `Rollup__NothingToPrune` inside `aggregate3(allowFailure: true)`
1075
+ * (a failed action, never a whole-tx revert). Used by the failed-sync fallback so a stuck pending
1076
+ * chain (e.g. bad data blocking sync) can be wound back to recover.
1077
+ * @returns true if a prune request was enqueued, false otherwise.
1078
+ */
1079
+ public async enqueuePruneIfPrunable(slotNumber: SlotNumber): Promise<boolean> {
1080
+ if (this.lastActions['prune'] === slotNumber) {
1081
+ this.log.debug(`Skipping duplicate prune for slot ${slotNumber}`, { slotNumber });
1082
+ return false;
1083
+ }
1084
+ // Use the SAME timestamp the bundle simulator overrides block.timestamp with at send time
1085
+ // (sequencer-bundle-simulator.ts) so this upfront check and the send-time sim agree. Slot-start
1086
+ // and last-L1-slot both fall within the same L2 slot (and epoch, which is what `canPruneAtTime`
1087
+ // derives), so they agree today; matching the simulator keeps it robust if the contract ever uses
1088
+ // the timestamp more granularly.
1089
+ const ts = getLastL1SlotTimestampForL2Slot(slotNumber, this.epochCache.getL1Constants());
1090
+ const canPrune = await this.rollupContract.canPruneAtTime(ts).catch(err => {
1091
+ this.log.error(`Failed to check canPruneAtTime for slot ${slotNumber}`, err, { slotNumber });
1092
+ return false;
1093
+ });
1094
+ if (!canPrune) {
1095
+ this.log.debug(`Rollup not prunable at slot ${slotNumber}`, { slotNumber });
1096
+ return false;
1097
+ }
1098
+ const request: L1TxRequest = {
1099
+ to: this.rollupContract.address,
1100
+ data: encodeFunctionData({ abi: RollupAbi, functionName: 'prune', args: [] }),
1101
+ };
1102
+ this.log.info(`Enqueuing rollup prune for slot ${slotNumber}`, { slotNumber });
1103
+ return this.enqueueRequest(
1104
+ 'prune',
1105
+ request,
1106
+ { address: this.rollupContract.address, abi: RollupAbi, eventName: 'PrunedPending' },
1107
+ slotNumber,
1108
+ );
1109
+ }
1110
+
1018
1111
  /** Enqueues all slashing actions as returned by the slasher client. */
1019
1112
  public async enqueueSlashingActions(
1020
1113
  actions: ProposerSlashAction[],
@@ -0,0 +1,78 @@
1
+ import { AztecAddress } from '@aztec/aztec.js/addresses';
2
+ import { Fr } from '@aztec/aztec.js/fields';
3
+ import { BatchedBlob, Blob, getEthBlobEvaluationInputs, getPrefixedEthBlobCommitments } from '@aztec/blob-lib';
4
+ import { EthAddress } from '@aztec/foundation/eth-address';
5
+ import { L2Block } from '@aztec/stdlib/block';
6
+ import { CheckpointHeader } from '@aztec/stdlib/rollup';
7
+
8
+ import { writeFile } from 'fs/promises';
9
+
10
+ const AZTEC_GENERATE_TEST_DATA = !!process.env.AZTEC_GENERATE_TEST_DATA;
11
+
12
+ /**
13
+ * Creates a json object that can be used to test the solidity contract.
14
+ * The json object must be put into
15
+ */
16
+ export async function writeJson(
17
+ fileName: string,
18
+ checkpointHeader: CheckpointHeader,
19
+ block: L2Block,
20
+ l1ToL2Content: Fr[],
21
+ blobs: Blob[],
22
+ batchedBlob: BatchedBlob,
23
+ recipientAddress: AztecAddress,
24
+ deployerAddress: `0x${string}`,
25
+ ): Promise<void> {
26
+ if (!AZTEC_GENERATE_TEST_DATA) {
27
+ return;
28
+ }
29
+ // Path relative to the package.json in the end-to-end folder
30
+ const path = `../../l1-contracts/test/fixtures/${fileName}.json`;
31
+
32
+ const asHex = (value: Fr | Buffer | EthAddress | AztecAddress, size = 64) => {
33
+ const buffer = Buffer.isBuffer(value) ? value : value.toBuffer();
34
+ return `0x${buffer.toString('hex').padStart(size, '0')}`;
35
+ };
36
+
37
+ const jsonObject = {
38
+ populate: {
39
+ l1ToL2Content: l1ToL2Content.map(value => asHex(value)),
40
+ recipient: asHex(recipientAddress.toField()),
41
+ sender: deployerAddress,
42
+ },
43
+ messages: {
44
+ l2ToL1Messages: block.body.txEffects.flatMap(txEffect => txEffect.l2ToL1Msgs).map(value => asHex(value)),
45
+ },
46
+ checkpoint: {
47
+ // The json formatting in forge is a bit brittle, so we convert Fr to a number in the few values below.
48
+ // This should not be a problem for testing as long as the values are not larger than u32.
49
+ archive: asHex(block.archive.root),
50
+ blobCommitments: getPrefixedEthBlobCommitments(blobs),
51
+ batchedBlobInputs: getEthBlobEvaluationInputs(batchedBlob),
52
+ checkpointNumber: block.number,
53
+ body: `0x${block.body.toBuffer().toString('hex')}`,
54
+ header: {
55
+ lastArchiveRoot: asHex(checkpointHeader.lastArchiveRoot),
56
+ blockHeadersHash: asHex(checkpointHeader.blockHeadersHash),
57
+ blobsHash: asHex(checkpointHeader.blobsHash),
58
+ inHash: asHex(checkpointHeader.inHash),
59
+ outHash: asHex(checkpointHeader.epochOutHash),
60
+ slotNumber: Number(checkpointHeader.slotNumber),
61
+ timestamp: Number(checkpointHeader.timestamp),
62
+ coinbase: asHex(checkpointHeader.coinbase, 40),
63
+ feeRecipient: asHex(checkpointHeader.feeRecipient),
64
+ gasFees: {
65
+ feePerDaGas: Number(checkpointHeader.gasFees.feePerDaGas),
66
+ feePerL2Gas: Number(checkpointHeader.gasFees.feePerL2Gas),
67
+ },
68
+ totalManaUsed: checkpointHeader.totalManaUsed.toNumber(),
69
+ accumulatedFees: checkpointHeader.accumulatedFees.toNumber(),
70
+ },
71
+ headerHash: asHex(checkpointHeader.hash()),
72
+ numTxs: block.body.txEffects.length,
73
+ },
74
+ };
75
+
76
+ const output = JSON.stringify(jsonObject, null, 2);
77
+ await writeFile(path, output, 'utf8');
78
+ }
@@ -274,14 +274,22 @@ export class AutomineSequencer {
274
274
  /**
275
275
  * Warps L1 timestamp to `targetTimestampSec`. Rounded up to the next aztec-slot
276
276
  * boundary so the next build lands on a fresh slot. Atomic with respect to builds —
277
- * the queue ensures no build is in flight while the warp executes.
277
+ * the queue ensures no build is in flight while the warp executes. A no-op when the
278
+ * target is already at or behind the current L1 time (see {@link runWarp}).
278
279
  */
279
280
  public warpTo(targetTimestampSec: number): Promise<void> {
280
281
  return this.queue.put(() => this.runWarp(targetTimestampSec));
281
282
  }
282
283
 
283
- /** Warps L1 timestamp forward by `deltaSec` seconds from the current L1 time. */
284
+ /**
285
+ * Warps L1 timestamp forward by `deltaSec` seconds from the current L1 time, rounded up to the next
286
+ * aztec-slot boundary. Throws if `deltaSec` is not positive (warping "by" a non-positive amount is a
287
+ * caller bug — unlike {@link warpTo}, which no-ops on a past target).
288
+ */
284
289
  public warpBy(deltaSec: number): Promise<void> {
290
+ if (deltaSec <= 0) {
291
+ throw new Error(`warpL2TimeAtLeastBy: duration must be positive, got ${deltaSec} seconds.`);
292
+ }
285
293
  return this.queue.put(async () => {
286
294
  const current = await this.deps.ethCheatCodes.lastBlockTimestamp();
287
295
  await this.runWarp(current + deltaSec);
@@ -354,8 +362,7 @@ export class AutomineSequencer {
354
362
  return;
355
363
  }
356
364
  try {
357
- const pending = await this.deps.p2pClient.getPendingTxCount();
358
- if (pending > 0) {
365
+ if (await this.deps.p2pClient.hasEligiblePendingTxs(1)) {
359
366
  // Fire-and-forget; the build result is delivered via `buildIfPending()` callers,
360
367
  // not via the poller.
361
368
  void this.buildIfPending().catch(err => {
@@ -381,13 +388,13 @@ export class AutomineSequencer {
381
388
  }
382
389
  await this.reconcileDateProvider();
383
390
 
384
- const txCount = await this.deps.p2pClient.getPendingTxCount();
385
- // For mempool-driven builds, wait for at least `minTxsPerBlock` pending txs (or 1 if not set)
391
+ // For mempool-driven builds, wait for at least `minTxsPerBlock` age-eligible txs (or 1 if not set)
386
392
  // before building. This mirrors the production sequencer's `waitForMinTxs` behavior, and is
387
393
  // required for tests that bundle multiple txs into one block via `setConfig({ minTxsPerBlock })`.
388
- // Explicit empty-block / warp paths pass `allowEmpty: true` and bypass this gate.
394
+ // Explicit empty-block / warp paths pass `allowEmpty: true`, giving minRequired 0, which
395
+ // hasEligiblePendingTxs treats as always satisfied so the gate is bypassed.
389
396
  const minRequired = allowEmpty ? 0 : Math.max(this.deps.config.minTxsPerBlock ?? 1, 1);
390
- if (txCount < minRequired) {
397
+ if (!(await this.deps.p2pClient.hasEligiblePendingTxs(minRequired))) {
391
398
  return undefined;
392
399
  }
393
400
 
@@ -409,7 +416,10 @@ export class AutomineSequencer {
409
416
  await this.deps.ethCheatCodes.setNextBlockTimestamp(slotBoundaryTs);
410
417
  }
411
418
 
412
- const tips = await this.deps.l2BlockSource.getL2Tips();
419
+ const [tips, proposedCheckpoint] = await Promise.all([
420
+ this.deps.l2BlockSource.getL2Tips(),
421
+ this.deps.l2BlockSource.getProposedCheckpointData(),
422
+ ]);
413
423
  const syncedToBlockNumber = tips.proposed.number;
414
424
 
415
425
  // Ensure world state has processed the archiver's tip before forking. Without this,
@@ -419,7 +429,8 @@ export class AutomineSequencer {
419
429
  await this.deps.worldState.syncImmediate(BlockNumber(syncedToBlockNumber));
420
430
 
421
431
  const nextBlockNumber = BlockNumber(syncedToBlockNumber + 1);
422
- const checkpointNumber = CheckpointNumber(tips.proposedCheckpoint.checkpoint.number + 1);
432
+ const parentCheckpointNumber = proposedCheckpoint?.checkpointNumber ?? tips.checkpointed.checkpoint.number;
433
+ const checkpointNumber = CheckpointNumber(parentCheckpointNumber + 1);
423
434
  const targetEpoch = getEpochAtSlot(SlotNumber(targetSlot), this.deps.l1Constants);
424
435
 
425
436
  this.log.verbose(`Building automine checkpoint`, {
@@ -427,7 +438,6 @@ export class AutomineSequencer {
427
438
  blockNumber: nextBlockNumber,
428
439
  slot: targetSlot,
429
440
  slotTimestamp: slotBoundaryTs,
430
- txCount,
431
441
  allowEmpty,
432
442
  });
433
443
 
@@ -705,9 +715,12 @@ export class AutomineSequencer {
705
715
  await rollupCheatCodes.markAsProven(target);
706
716
  // Settlement is a direct L1 storage write that mines no block, unlike a real epoch proof landing
707
717
  // on L1. The archiver's L1 sync short-circuits while the L1 block hash is unchanged, so it would
708
- // never re-read the proven tip until the next build/warp mines a block. Mine one empty L1 block so
709
- // the block hash advances, then force an immediate sync that observes the new proven checkpoint.
710
- await this.deps.ethCheatCodes.mineEmptyBlock();
718
+ // never re-read the proven tip until the next build/warp mines a block. Mine one L1 block so the
719
+ // block hash advances, then force an immediate sync that observes the new proven checkpoint. Use
720
+ // evmMine (not mineEmptyBlock): mineEmptyBlock drops+re-adds the mempool non-atomically, which can
721
+ // silently lose a test's concurrently-submitted direct L1 tx. evm_mine just includes whatever is
722
+ // pending, which is fine here — we only need the block hash to advance.
723
+ await this.deps.ethCheatCodes.evmMine();
711
724
  await this.deps.archiver.syncImmediate();
712
725
 
713
726
  this.log.verbose(`Proved up to checkpoint ${target}`, { target, proven, startEpoch, endEpoch });