@aztec/sequencer-client 0.0.1-commit.3100065 → 0.0.1-commit.321f6a9

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.
@@ -1,7 +1,8 @@
1
+ import { jsonParseWithSchema, jsonStringify } from '@aztec/foundation/json-rpc';
1
2
  import { type Logger, createLogger } from '@aztec/foundation/log';
2
3
  import type { FileStore } from '@aztec/stdlib/file-store';
3
4
 
4
- import type { FailedL1Tx, FailedL1TxUri, L1TxFailedStore } from './failed_tx_store.js';
5
+ import { type FailedL1Tx, FailedL1TxSchema, type FailedL1TxUri, type L1TxFailedStore } from './failed_tx_store.js';
5
6
 
6
7
  /**
7
8
  * L1TxFailedStore implementation using the FileStore abstraction.
@@ -20,7 +21,7 @@ export class FileStoreL1TxFailedStore implements L1TxFailedStore {
20
21
  public async saveFailedTx(tx: FailedL1Tx): Promise<FailedL1TxUri> {
21
22
  const prefix = tx.receipt ? 'tx' : 'data';
22
23
  const path = `${tx.failureType}/${prefix}-${tx.id}.json`;
23
- const json = JSON.stringify(tx, null, 2);
24
+ const json = jsonStringify(tx, true);
24
25
 
25
26
  const uri = await this.fileStore.save(path, Buffer.from(json), {
26
27
  metadata: {
@@ -41,6 +42,6 @@ export class FileStoreL1TxFailedStore implements L1TxFailedStore {
41
42
 
42
43
  public async getFailedTx(uri: FailedL1TxUri): Promise<FailedL1Tx> {
43
44
  const data = await this.fileStore.read(uri);
44
- return JSON.parse(data.toString()) as FailedL1Tx;
45
+ return jsonParseWithSchema(data.toString(), FailedL1TxSchema);
45
46
  }
46
47
  }
@@ -1,3 +1,3 @@
1
- export { type FailedL1Tx, type FailedL1TxUri, type L1TxFailedStore } from './failed_tx_store.js';
1
+ export { type FailedL1Tx, FailedL1TxSchema, type FailedL1TxUri, type L1TxFailedStore } from './failed_tx_store.js';
2
2
  export { createL1TxFailedStore } from './factory.js';
3
3
  export { FileStoreL1TxFailedStore } from './file_store_failed_tx_store.js';
@@ -13,11 +13,12 @@ import {
13
13
  type SlashingProposerContract,
14
14
  buildSimulationOverridesStateOverride,
15
15
  } from '@aztec/ethereum/contracts';
16
- import { type L1FeeAnalysisResult, L1FeeAnalyzer } from '@aztec/ethereum/l1-fee-analysis';
16
+ import { type L1FeeAnalysisResult, L1FeeAnalyzer, captureWindowBlockFees } from '@aztec/ethereum/l1-fee-analysis';
17
17
  import {
18
18
  type L1BlobInputs,
19
19
  type L1TxConfig,
20
20
  type L1TxRequest,
21
+ L1TxTimeoutError,
21
22
  type L1TxUtils,
22
23
  MAX_L1_TX_LIMIT,
23
24
  type TransactionStats,
@@ -287,25 +288,97 @@ export class SequencerPublisher {
287
288
  }
288
289
 
289
290
  /**
290
- * Backs up a failed L1 transaction to the configured store for debugging.
291
- * Does nothing if no store is configured.
291
+ * Logs the gas-pricing data of a failed L1 transaction at warn so underpricing is diagnosable
292
+ * from logs even with no failed-tx store configured — and backs the record up to the store when
293
+ * one is. When captureFeeSummary is true, also records the fee data of the already-mined L1
294
+ * blocks in the target slot's inclusion window.
292
295
  */
293
- private backupFailedTx(failedTx: Omit<FailedL1Tx, 'timestamp'>): void {
294
- if (!this.failedTxStore) {
295
- return;
296
- }
297
-
296
+ private backupFailedTx(
297
+ failedTx: Omit<FailedL1Tx, 'timestamp'>,
298
+ opts?: { captureFeeSummary?: boolean; targetSlot?: SlotNumber; sharedFeeSummary?: FailedL1Tx['gasInfo'] },
299
+ ): void {
298
300
  const tx: FailedL1Tx = {
299
301
  ...failedTx,
300
302
  timestamp: Date.now(),
301
303
  };
302
304
 
303
305
  // Fire and forget - don't block on backup
304
- void this.failedTxStore
305
- .then(store => store?.saveFailedTx(tx))
306
- .catch(err => {
306
+ void (async () => {
307
+ try {
308
+ // Prefer a pre-captured summary (shared across a batch of failures in the same slot) so we
309
+ // don't re-read the fee window per record. A capture error must not lose the record itself.
310
+ const feeSummary =
311
+ opts?.sharedFeeSummary ??
312
+ (opts?.captureFeeSummary
313
+ ? await this.captureFeeEnvironment(opts.targetSlot).catch(() => undefined)
314
+ : undefined);
315
+ if (feeSummary) {
316
+ tx.gasInfo = { ...tx.gasInfo, ...feeSummary };
317
+ }
318
+ if (tx.gasInfo) {
319
+ this.log.warn(`Gas pricing data for failed L1 tx (${tx.failureType})`, {
320
+ failureType: tx.failureType,
321
+ actions: tx.context.actions,
322
+ slot: tx.context.slot,
323
+ ...tx.gasInfo,
324
+ ...tx.timing,
325
+ });
326
+ }
327
+ const store = await this.failedTxStore;
328
+ if (store) {
329
+ await store.saveFailedTx(tx);
330
+ }
331
+ } catch (err) {
307
332
  this.log.warn(`Failed to backup failed L1 tx to store`, err);
308
- });
333
+ }
334
+ })();
335
+ }
336
+
337
+ /**
338
+ * Captures per-block fee data for the L1 blocks in the target slot's inclusion window (the blocks the
339
+ * tx could have landed in) for underpricing diagnostics. Reads only already-mined blocks, so it never
340
+ * waits on the chain. Safe to call off the critical path: the underlying capture never throws, and this
341
+ * returns undefined when there is no target slot or the window is not yet mined (e.g. an early send
342
+ * failure), in which case the record simply carries no window data.
343
+ */
344
+ private async captureFeeEnvironment(targetL2Slot: SlotNumber | undefined): Promise<FailedL1Tx['gasInfo']> {
345
+ if (targetL2Slot === undefined) {
346
+ return undefined;
347
+ }
348
+ const l1Constants = this.epochCache.getL1Constants();
349
+ // The inclusion window is [start of slot N, start of slot N+1): all L1 blocks that can include a tx
350
+ // for this L2 slot. getTimestampForSlot returns seconds, matching block.timestamp.
351
+ const windowStartS = getTimestampForSlot(targetL2Slot, l1Constants);
352
+ const windowEndS = getTimestampForSlot(SlotNumber(Number(targetL2Slot) + 1), l1Constants);
353
+ const windowBlocks = await captureWindowBlockFees(this.l1TxUtils.client, windowStartS, windowEndS);
354
+ if (windowBlocks.length === 0) {
355
+ return undefined;
356
+ }
357
+ return { windowBlocks };
358
+ }
359
+
360
+ /** Computes timing info relative to the L2 slot deadline. */
361
+ private computeTimingInfo(targetL2Slot: SlotNumber | undefined): FailedL1Tx['timing'] {
362
+ if (targetL2Slot === undefined) {
363
+ return undefined;
364
+ }
365
+ const l1Constants = this.epochCache.getL1Constants();
366
+ const slotDeadlineS = getTimestampForSlot(SlotNumber(Number(targetL2Slot) + 1), l1Constants);
367
+ const slotDeadlineMs = Number(slotDeadlineS) * 1000;
368
+ return {
369
+ targetL2Slot: Number(targetL2Slot),
370
+ slotDeadlineTimestampS: slotDeadlineS,
371
+ msUntilSlotDeadline: slotDeadlineMs - this.dateProvider.now(),
372
+ };
373
+ }
374
+
375
+ /**
376
+ * Builds an id for a synthetic failure record (send-error/timeout) that has no on-chain tx hash.
377
+ * Includes the failure time so each attempt — including retries of the same slot — is stored as its
378
+ * own record rather than overwriting the previous one.
379
+ */
380
+ private failureRecordId(actions: string[], targetSlot: SlotNumber | undefined): Hex {
381
+ return keccak256(toHex(`${actions.join(',')}:${targetSlot ?? ''}:${Date.now()}`));
309
382
  }
310
383
 
311
384
  public getRollupContract(): RollupContract {
@@ -469,7 +542,7 @@ export class SequencerPublisher {
469
542
 
470
543
  if (bundleResult.kind === 'aborted') {
471
544
  this.logDroppedInSim(bundleResult.droppedRequests);
472
- void this.backupDroppedInSim(bundleResult.droppedRequests).catch(err =>
545
+ void this.backupDroppedInSim(bundleResult.droppedRequests, currentL2Slot).catch(err =>
473
546
  this.log.error(`Failed to backup requests dropped in simulation`, err),
474
547
  );
475
548
  return undefined;
@@ -495,7 +568,7 @@ export class SequencerPublisher {
495
568
  requests: requests.map(request => request.action),
496
569
  txConfig,
497
570
  });
498
- const result = await this.forwardWithPublisherRotation(requests, txConfig, blobConfig);
571
+ const result = await this.forwardWithPublisherRotation(requests, txConfig, blobConfig, currentL2Slot);
499
572
  if (result === undefined) {
500
573
  return undefined;
501
574
  }
@@ -511,6 +584,44 @@ export class SequencerPublisher {
511
584
  } catch (err) {
512
585
  const viemError = formatViemError(err);
513
586
  this.log.error(`Failed to publish bundled transactions`, viemError);
587
+ if (err instanceof TimeoutError) {
588
+ const timeoutState = err instanceof L1TxTimeoutError ? err.txState : undefined;
589
+ void (async () => {
590
+ // The RPC is likely degraded right after a timeout, so back up without the block number
591
+ // rather than leaking an unhandled rejection.
592
+ const l1BlockNumber = await this.l1TxUtils.getBlockNumber().catch(() => 0n);
593
+ this.backupFailedTx(
594
+ {
595
+ id: this.failureRecordId(
596
+ validRequests.map(r => r.action),
597
+ currentL2Slot,
598
+ ),
599
+ failureType: 'timeout',
600
+ request: { to: MULTI_CALL_3_ADDRESS as Hex, data: '0x' as Hex },
601
+ l1BlockNumber,
602
+ error: { message: viemError.message, name: 'TimeoutError' },
603
+ context: {
604
+ actions: validRequests.map(r => r.action),
605
+ requests: validRequests
606
+ .filter(r => r.request.to !== null)
607
+ .map(r => ({ action: r.action, to: r.request.to! as Hex, data: r.request.data! })),
608
+ sender: this.getSenderAddress().toString(),
609
+ slot: Number(currentL2Slot),
610
+ },
611
+ timing: this.computeTimingInfo(currentL2Slot),
612
+ gasInfo: timeoutState
613
+ ? {
614
+ sentGasPriceLadder: timeoutState.gasPriceHistory,
615
+ attempts: timeoutState.attempts,
616
+ gasLimit: timeoutState.gasLimit,
617
+ nonce: timeoutState.nonce,
618
+ }
619
+ : undefined,
620
+ },
621
+ { captureFeeSummary: true, targetSlot: currentL2Slot },
622
+ );
623
+ })();
624
+ }
514
625
  return undefined;
515
626
  } finally {
516
627
  try {
@@ -538,23 +649,37 @@ export class SequencerPublisher {
538
649
  }
539
650
 
540
651
  /** Backs up entries dropped by bundle simulation, one record per dropped action. */
541
- private async backupDroppedInSim(dropped: DroppedRequest[]): Promise<void> {
652
+ private async backupDroppedInSim(dropped: DroppedRequest[], targetSlot?: SlotNumber): Promise<void> {
542
653
  if (dropped.length === 0) {
543
654
  return;
544
655
  }
545
- const l1BlockNumber = await this.l1TxUtils.getBlockNumber();
546
- for (const { request: req } of dropped) {
547
- this.backupFailedTx({
548
- id: keccak256(req.request.data!),
549
- failureType: 'simulation',
550
- request: { to: req.request.to! as Hex, data: req.request.data! },
551
- l1BlockNumber: l1BlockNumber.toString(),
552
- error: { message: 'Bundle entry dropped: action reverted in sim' },
553
- context: {
554
- actions: [req.action],
555
- sender: this.getSenderAddress().toString(),
556
- },
557
- });
656
+ // Invoked as `void backupDroppedInSim(...)` on the publish path, so it must not throw.
657
+ try {
658
+ const l1BlockNumber = await this.l1TxUtils.getBlockNumber();
659
+ // Every dropped entry failed in the same slot against the same L1 fee conditions, so capture
660
+ // the fee environment once and share it rather than re-reading the window per entry.
661
+ const sharedFeeSummary = await this.captureFeeEnvironment(targetSlot).catch(() => undefined);
662
+ const timing = this.computeTimingInfo(targetSlot);
663
+ for (const { request: req } of dropped) {
664
+ this.backupFailedTx(
665
+ {
666
+ id: keccak256(req.request.data!),
667
+ failureType: 'simulation',
668
+ request: { to: req.request.to! as Hex, data: req.request.data! },
669
+ l1BlockNumber,
670
+ error: { message: 'Bundle entry dropped: action reverted in sim' },
671
+ context: {
672
+ actions: [req.action],
673
+ sender: this.getSenderAddress().toString(),
674
+ slot: targetSlot !== undefined ? Number(targetSlot) : undefined,
675
+ },
676
+ timing,
677
+ },
678
+ { sharedFeeSummary },
679
+ );
680
+ }
681
+ } catch (err) {
682
+ this.log.warn(`Failed to back up dropped-in-sim entries`, err);
558
683
  }
559
684
  }
560
685
 
@@ -567,6 +692,7 @@ export class SequencerPublisher {
567
692
  validRequests: RequestWithExpiry[],
568
693
  txConfig: RequestWithExpiry['gasConfig'],
569
694
  blobConfig: L1BlobInputs | undefined,
695
+ targetSlot?: SlotNumber,
570
696
  ) {
571
697
  if (!txConfig?.gasLimit) {
572
698
  throw new Error('gasLimit is required for bundled transactions');
@@ -603,11 +729,13 @@ export class SequencerPublisher {
603
729
  this.log.error('Forwarder transaction reverted on-chain; not rotating publisher', err, {
604
730
  transactionHash: err.receipt.transactionHash,
605
731
  });
732
+ this.backupRevertFailure(validRequests, err, currentPublisher, targetSlot);
606
733
  return undefined;
607
734
  }
608
735
  const viemError = formatViemError(err);
609
736
  if (!this.getNextPublisher) {
610
737
  this.log.error('Failed to publish bundled transactions', viemError);
738
+ this.backupSendFailure(validRequests, viemError, currentPublisher, targetSlot);
611
739
  return undefined;
612
740
  }
613
741
  this.log.warn(
@@ -621,6 +749,7 @@ export class SequencerPublisher {
621
749
  viemError,
622
750
  { triedAddresses: triedAddresses.map(a => a.toString()) },
623
751
  );
752
+ this.backupSendFailure(validRequests, viemError, currentPublisher, targetSlot);
624
753
  return undefined;
625
754
  }
626
755
  currentPublisher = nextPublisher;
@@ -628,6 +757,87 @@ export class SequencerPublisher {
628
757
  }
629
758
  }
630
759
 
760
+ /** Backs up an on-chain revert failure to the failed tx store. */
761
+ private backupRevertFailure(
762
+ requests: RequestWithExpiry[],
763
+ err: MulticallForwarderRevertedError,
764
+ publisher: L1TxUtils,
765
+ targetSlot?: SlotNumber,
766
+ ): void {
767
+ this.backupFailedTx(
768
+ {
769
+ id: err.receipt.transactionHash,
770
+ failureType: 'revert',
771
+ request: { to: MULTI_CALL_3_ADDRESS as Hex, data: '0x' as Hex },
772
+ l1BlockNumber: err.receipt.blockNumber,
773
+ receipt: {
774
+ transactionHash: err.receipt.transactionHash,
775
+ blockNumber: err.receipt.blockNumber,
776
+ gasUsed: err.receipt.gasUsed,
777
+ status: 'reverted',
778
+ },
779
+ error: { message: err.message, name: err.name },
780
+ context: {
781
+ actions: requests.map(r => r.action),
782
+ requests: requests
783
+ .filter(r => r.request.to !== null)
784
+ .map(r => ({ action: r.action, to: r.request.to! as Hex, data: r.request.data! })),
785
+ sender: publisher.getSenderAddress().toString(),
786
+ slot: targetSlot !== undefined ? Number(targetSlot) : undefined,
787
+ },
788
+ gasInfo: err.txState
789
+ ? {
790
+ sentGasPrice: err.txState.gasPrice,
791
+ gasLimit: err.txState.gasLimit,
792
+ nonce: err.txState.nonce,
793
+ }
794
+ : undefined,
795
+ timing: this.computeTimingInfo(targetSlot),
796
+ },
797
+ { captureFeeSummary: true, targetSlot },
798
+ );
799
+ }
800
+
801
+ /** Backs up a send failure (tx never reached chain) to the failed tx store. */
802
+ private backupSendFailure(
803
+ requests: RequestWithExpiry[],
804
+ error: FormattedViemError | Error,
805
+ publisher: L1TxUtils,
806
+ targetSlot?: SlotNumber,
807
+ ): void {
808
+ // If we can't get the block number, still back up without it.
809
+ void this.l1TxUtils
810
+ .getBlockNumber()
811
+ .catch(() => 0n)
812
+ .then(l1BlockNumber => {
813
+ this.backupFailedTx(
814
+ {
815
+ id: this.failureRecordId(
816
+ requests.map(r => r.action),
817
+ targetSlot,
818
+ ),
819
+ failureType: 'send-error',
820
+ request: { to: MULTI_CALL_3_ADDRESS as Hex, data: '0x' as Hex },
821
+ l1BlockNumber,
822
+ error: {
823
+ message: error.message,
824
+ name: 'name' in error ? error.name : undefined,
825
+ },
826
+ context: {
827
+ actions: requests.map(r => r.action),
828
+ requests: requests
829
+ .filter(r => r.request.to !== null)
830
+ .map(r => ({ action: r.action, to: r.request.to! as Hex, data: r.request.data! })),
831
+ sender: publisher.getSenderAddress().toString(),
832
+ slot: targetSlot !== undefined ? Number(targetSlot) : undefined,
833
+ },
834
+ timing: this.computeTimingInfo(targetSlot),
835
+ },
836
+ { captureFeeSummary: true, targetSlot },
837
+ );
838
+ });
839
+ }
840
+
631
841
  /*
632
842
  * Schedules sending all enqueued requests at (or after) the start of the given L2 slot.
633
843
  */
@@ -889,18 +1099,22 @@ export class SequencerPublisher {
889
1099
 
890
1100
  // Otherwise, throw. We cannot build the next checkpoint if we cannot invalidate the previous one.
891
1101
  this.log.error(`Simulation for invalidate checkpoint ${checkpointNumber} failed`, viemError, logData);
892
- this.backupFailedTx({
893
- id: keccak256(request.data!),
894
- failureType: 'simulation',
895
- request: { to: request.to!, data: request.data!, value: request.value?.toString() },
896
- l1BlockNumber: l1BlockNumber.toString(),
897
- error: { message: viemError.message, name: viemError.name },
898
- context: {
899
- actions: [`invalidate-${reason}`],
900
- checkpointNumber,
901
- sender: this.getSenderAddress().toString(),
1102
+ this.backupFailedTx(
1103
+ {
1104
+ id: keccak256(request.data!),
1105
+ failureType: 'simulation',
1106
+ request: { to: request.to!, data: request.data!, value: request.value },
1107
+ l1BlockNumber,
1108
+ error: { message: viemError.message, name: viemError.name },
1109
+ context: {
1110
+ actions: [`invalidate-${reason}`],
1111
+ checkpointNumber,
1112
+ sender: this.getSenderAddress().toString(),
1113
+ },
1114
+ timing: this.computeTimingInfo(this.getCurrentL2Slot()),
902
1115
  },
903
- });
1116
+ { captureFeeSummary: true, targetSlot: this.getCurrentL2Slot() },
1117
+ );
904
1118
  throw new Error(`Failed to simulate invalidate checkpoint ${checkpointNumber}`, { cause: viemError });
905
1119
  }
906
1120
  }
@@ -1346,18 +1560,22 @@ export class SequencerPublisher {
1346
1560
  args: [blobInput],
1347
1561
  });
1348
1562
  const l1BlockNumber = await this.l1TxUtils.getBlockNumber();
1349
- this.backupFailedTx({
1350
- id: keccak256(validateBlobsData),
1351
- failureType: 'simulation',
1352
- request: { to: this.rollupContract.address as Hex, data: validateBlobsData },
1353
- blobData: encodedData.blobs.map(b => toHex(b.data)) as Hex[],
1354
- l1BlockNumber: l1BlockNumber.toString(),
1355
- error: { message: viemError.message, name: viemError.name },
1356
- context: {
1357
- actions: ['validate-blobs'],
1358
- sender: this.getSenderAddress().toString(),
1563
+ this.backupFailedTx(
1564
+ {
1565
+ id: keccak256(validateBlobsData),
1566
+ failureType: 'simulation',
1567
+ request: { to: this.rollupContract.address as Hex, data: validateBlobsData },
1568
+ blobData: encodedData.blobs.map(b => toHex(b.data)) as Hex[],
1569
+ l1BlockNumber,
1570
+ error: { message: viemError.message, name: viemError.name },
1571
+ context: {
1572
+ actions: ['validate-blobs'],
1573
+ sender: this.getSenderAddress().toString(),
1574
+ },
1575
+ timing: this.computeTimingInfo(this.getCurrentL2Slot()),
1359
1576
  },
1360
- });
1577
+ { captureFeeSummary: true, targetSlot: this.getCurrentL2Slot() },
1578
+ );
1361
1579
  throw new Error('Failed to validate blobs');
1362
1580
  });
1363
1581
  }