@aztec/sequencer-client 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 (42) hide show
  1. package/dest/config.d.ts +2 -1
  2. package/dest/config.d.ts.map +1 -1
  3. package/dest/config.js +6 -0
  4. package/dest/publisher/l1_tx_failed_store/failed_tx_store.d.ts +48 -8
  5. package/dest/publisher/l1_tx_failed_store/failed_tx_store.d.ts.map +1 -1
  6. package/dest/publisher/l1_tx_failed_store/failed_tx_store.js +68 -1
  7. package/dest/publisher/l1_tx_failed_store/file_store_failed_tx_store.d.ts +2 -2
  8. package/dest/publisher/l1_tx_failed_store/file_store_failed_tx_store.d.ts.map +1 -1
  9. package/dest/publisher/l1_tx_failed_store/file_store_failed_tx_store.js +4 -2
  10. package/dest/publisher/l1_tx_failed_store/index.d.ts +2 -2
  11. package/dest/publisher/l1_tx_failed_store/index.d.ts.map +1 -1
  12. package/dest/publisher/l1_tx_failed_store/index.js +1 -0
  13. package/dest/publisher/sequencer-publisher.d.ts +20 -4
  14. package/dest/publisher/sequencer-publisher.d.ts.map +1 -1
  15. package/dest/publisher/sequencer-publisher.js +278 -49
  16. package/dest/sequencer/automine/automine_sequencer.d.ts +1 -1
  17. package/dest/sequencer/automine/automine_sequencer.d.ts.map +1 -1
  18. package/dest/sequencer/automine/automine_sequencer.js +7 -1
  19. package/dest/sequencer/checkpoint_proposal_job.d.ts +1 -1
  20. package/dest/sequencer/checkpoint_proposal_job.d.ts.map +1 -1
  21. package/dest/sequencer/checkpoint_proposal_job.js +9 -1
  22. package/dest/sequencer/errors.d.ts +8 -1
  23. package/dest/sequencer/errors.d.ts.map +1 -1
  24. package/dest/sequencer/errors.js +9 -0
  25. package/dest/sequencer/missing_committee.d.ts +72 -0
  26. package/dest/sequencer/missing_committee.d.ts.map +1 -0
  27. package/dest/sequencer/missing_committee.js +139 -0
  28. package/dest/sequencer/sequencer.d.ts +4 -3
  29. package/dest/sequencer/sequencer.d.ts.map +1 -1
  30. package/dest/sequencer/sequencer.js +13 -4
  31. package/package.json +28 -28
  32. package/src/config.ts +7 -0
  33. package/src/publisher/l1_tx_failed_store/failed_tx_store.ts +114 -7
  34. package/src/publisher/l1_tx_failed_store/file_store_failed_tx_store.ts +4 -3
  35. package/src/publisher/l1_tx_failed_store/index.ts +1 -1
  36. package/src/publisher/sequencer-publisher.ts +308 -59
  37. package/src/sequencer/README.md +243 -0
  38. package/src/sequencer/automine/automine_sequencer.ts +5 -1
  39. package/src/sequencer/checkpoint_proposal_job.ts +5 -1
  40. package/src/sequencer/errors.ts +15 -0
  41. package/src/sequencer/missing_committee.ts +192 -0
  42. package/src/sequencer/sequencer.ts +14 -5
@@ -8,16 +8,18 @@ import {
8
8
  MULTI_CALL_3_ADDRESS,
9
9
  Multicall3,
10
10
  MulticallForwarderRevertedError,
11
+ type PayloadProposalStatus,
11
12
  type RollupContract,
12
13
  type SimulationOverridesPlan,
13
14
  type SlashingProposerContract,
14
15
  buildSimulationOverridesStateOverride,
15
16
  } from '@aztec/ethereum/contracts';
16
- import { type L1FeeAnalysisResult, L1FeeAnalyzer } from '@aztec/ethereum/l1-fee-analysis';
17
+ import { type L1FeeAnalysisResult, L1FeeAnalyzer, captureWindowBlockFees } from '@aztec/ethereum/l1-fee-analysis';
17
18
  import {
18
19
  type L1BlobInputs,
19
20
  type L1TxConfig,
20
21
  type L1TxRequest,
22
+ L1TxTimeoutError,
21
23
  type L1TxUtils,
22
24
  MAX_L1_TX_LIMIT,
23
25
  type TransactionStats,
@@ -45,6 +47,7 @@ import { EmpireBaseAbi, ErrorsAbi, RollupAbi, SlashingProposerAbi } from '@aztec
45
47
  import { type ProposerSlashAction, encodeSlashConsensusVotes } from '@aztec/slasher';
46
48
  import { CommitteeAttestationsAndSigners, type ValidateCheckpointResult } from '@aztec/stdlib/block';
47
49
  import type { Checkpoint } from '@aztec/stdlib/checkpoint';
50
+ import type { SequencerConfig } from '@aztec/stdlib/config';
48
51
  import {
49
52
  getLastL1SlotTimestampForL2Slot,
50
53
  getNextL1SlotTimestamp,
@@ -215,6 +218,7 @@ export class SequencerPublisher {
215
218
  | 'sequencerPublisherPreviousL1BlockWaitTimeoutMs'
216
219
  | 'sequencerPublisherPreviousL1BlockWaitPollIntervalMs'
217
220
  > &
221
+ Pick<SequencerConfig, 'governanceProposerForcePayloadVote'> &
218
222
  Pick<L1ContractsConfig, 'ethereumSlotDuration' | 'aztecSlotDuration'> & { l1ChainId: number },
219
223
  deps: {
220
224
  telemetry?: TelemetryClient;
@@ -287,25 +291,97 @@ export class SequencerPublisher {
287
291
  }
288
292
 
289
293
  /**
290
- * Backs up a failed L1 transaction to the configured store for debugging.
291
- * Does nothing if no store is configured.
294
+ * Logs the gas-pricing data of a failed L1 transaction at warn so underpricing is diagnosable
295
+ * from logs even with no failed-tx store configured — and backs the record up to the store when
296
+ * one is. When captureFeeSummary is true, also records the fee data of the already-mined L1
297
+ * blocks in the target slot's inclusion window.
292
298
  */
293
- private backupFailedTx(failedTx: Omit<FailedL1Tx, 'timestamp'>): void {
294
- if (!this.failedTxStore) {
295
- return;
296
- }
297
-
299
+ private backupFailedTx(
300
+ failedTx: Omit<FailedL1Tx, 'timestamp'>,
301
+ opts?: { captureFeeSummary?: boolean; targetSlot?: SlotNumber; sharedFeeSummary?: FailedL1Tx['gasInfo'] },
302
+ ): void {
298
303
  const tx: FailedL1Tx = {
299
304
  ...failedTx,
300
305
  timestamp: Date.now(),
301
306
  };
302
307
 
303
308
  // Fire and forget - don't block on backup
304
- void this.failedTxStore
305
- .then(store => store?.saveFailedTx(tx))
306
- .catch(err => {
309
+ void (async () => {
310
+ try {
311
+ // Prefer a pre-captured summary (shared across a batch of failures in the same slot) so we
312
+ // don't re-read the fee window per record. A capture error must not lose the record itself.
313
+ const feeSummary =
314
+ opts?.sharedFeeSummary ??
315
+ (opts?.captureFeeSummary
316
+ ? await this.captureFeeEnvironment(opts.targetSlot).catch(() => undefined)
317
+ : undefined);
318
+ if (feeSummary) {
319
+ tx.gasInfo = { ...tx.gasInfo, ...feeSummary };
320
+ }
321
+ if (tx.gasInfo) {
322
+ this.log.warn(`Gas pricing data for failed L1 tx (${tx.failureType})`, {
323
+ failureType: tx.failureType,
324
+ actions: tx.context.actions,
325
+ slot: tx.context.slot,
326
+ ...tx.gasInfo,
327
+ ...tx.timing,
328
+ });
329
+ }
330
+ const store = await this.failedTxStore;
331
+ if (store) {
332
+ await store.saveFailedTx(tx);
333
+ }
334
+ } catch (err) {
307
335
  this.log.warn(`Failed to backup failed L1 tx to store`, err);
308
- });
336
+ }
337
+ })();
338
+ }
339
+
340
+ /**
341
+ * Captures per-block fee data for the L1 blocks in the target slot's inclusion window (the blocks the
342
+ * tx could have landed in) for underpricing diagnostics. Reads only already-mined blocks, so it never
343
+ * waits on the chain. Safe to call off the critical path: the underlying capture never throws, and this
344
+ * returns undefined when there is no target slot or the window is not yet mined (e.g. an early send
345
+ * failure), in which case the record simply carries no window data.
346
+ */
347
+ private async captureFeeEnvironment(targetL2Slot: SlotNumber | undefined): Promise<FailedL1Tx['gasInfo']> {
348
+ if (targetL2Slot === undefined) {
349
+ return undefined;
350
+ }
351
+ const l1Constants = this.epochCache.getL1Constants();
352
+ // The inclusion window is [start of slot N, start of slot N+1): all L1 blocks that can include a tx
353
+ // for this L2 slot. getTimestampForSlot returns seconds, matching block.timestamp.
354
+ const windowStartS = getTimestampForSlot(targetL2Slot, l1Constants);
355
+ const windowEndS = getTimestampForSlot(SlotNumber(Number(targetL2Slot) + 1), l1Constants);
356
+ const windowBlocks = await captureWindowBlockFees(this.l1TxUtils.client, windowStartS, windowEndS);
357
+ if (windowBlocks.length === 0) {
358
+ return undefined;
359
+ }
360
+ return { windowBlocks };
361
+ }
362
+
363
+ /** Computes timing info relative to the L2 slot deadline. */
364
+ private computeTimingInfo(targetL2Slot: SlotNumber | undefined): FailedL1Tx['timing'] {
365
+ if (targetL2Slot === undefined) {
366
+ return undefined;
367
+ }
368
+ const l1Constants = this.epochCache.getL1Constants();
369
+ const slotDeadlineS = getTimestampForSlot(SlotNumber(Number(targetL2Slot) + 1), l1Constants);
370
+ const slotDeadlineMs = Number(slotDeadlineS) * 1000;
371
+ return {
372
+ targetL2Slot: Number(targetL2Slot),
373
+ slotDeadlineTimestampS: slotDeadlineS,
374
+ msUntilSlotDeadline: slotDeadlineMs - this.dateProvider.now(),
375
+ };
376
+ }
377
+
378
+ /**
379
+ * Builds an id for a synthetic failure record (send-error/timeout) that has no on-chain tx hash.
380
+ * Includes the failure time so each attempt — including retries of the same slot — is stored as its
381
+ * own record rather than overwriting the previous one.
382
+ */
383
+ private failureRecordId(actions: string[], targetSlot: SlotNumber | undefined): Hex {
384
+ return keccak256(toHex(`${actions.join(',')}:${targetSlot ?? ''}:${Date.now()}`));
309
385
  }
310
386
 
311
387
  public getRollupContract(): RollupContract {
@@ -469,7 +545,7 @@ export class SequencerPublisher {
469
545
 
470
546
  if (bundleResult.kind === 'aborted') {
471
547
  this.logDroppedInSim(bundleResult.droppedRequests);
472
- void this.backupDroppedInSim(bundleResult.droppedRequests).catch(err =>
548
+ void this.backupDroppedInSim(bundleResult.droppedRequests, currentL2Slot).catch(err =>
473
549
  this.log.error(`Failed to backup requests dropped in simulation`, err),
474
550
  );
475
551
  return undefined;
@@ -495,7 +571,7 @@ export class SequencerPublisher {
495
571
  requests: requests.map(request => request.action),
496
572
  txConfig,
497
573
  });
498
- const result = await this.forwardWithPublisherRotation(requests, txConfig, blobConfig);
574
+ const result = await this.forwardWithPublisherRotation(requests, txConfig, blobConfig, currentL2Slot);
499
575
  if (result === undefined) {
500
576
  return undefined;
501
577
  }
@@ -511,6 +587,44 @@ export class SequencerPublisher {
511
587
  } catch (err) {
512
588
  const viemError = formatViemError(err);
513
589
  this.log.error(`Failed to publish bundled transactions`, viemError);
590
+ if (err instanceof TimeoutError) {
591
+ const timeoutState = err instanceof L1TxTimeoutError ? err.txState : undefined;
592
+ void (async () => {
593
+ // The RPC is likely degraded right after a timeout, so back up without the block number
594
+ // rather than leaking an unhandled rejection.
595
+ const l1BlockNumber = await this.l1TxUtils.getBlockNumber().catch(() => 0n);
596
+ this.backupFailedTx(
597
+ {
598
+ id: this.failureRecordId(
599
+ validRequests.map(r => r.action),
600
+ currentL2Slot,
601
+ ),
602
+ failureType: 'timeout',
603
+ request: { to: MULTI_CALL_3_ADDRESS as Hex, data: '0x' as Hex },
604
+ l1BlockNumber,
605
+ error: { message: viemError.message, name: 'TimeoutError' },
606
+ context: {
607
+ actions: validRequests.map(r => r.action),
608
+ requests: validRequests
609
+ .filter(r => r.request.to !== null)
610
+ .map(r => ({ action: r.action, to: r.request.to! as Hex, data: r.request.data! })),
611
+ sender: this.getSenderAddress().toString(),
612
+ slot: Number(currentL2Slot),
613
+ },
614
+ timing: this.computeTimingInfo(currentL2Slot),
615
+ gasInfo: timeoutState
616
+ ? {
617
+ sentGasPriceLadder: timeoutState.gasPriceHistory,
618
+ attempts: timeoutState.attempts,
619
+ gasLimit: timeoutState.gasLimit,
620
+ nonce: timeoutState.nonce,
621
+ }
622
+ : undefined,
623
+ },
624
+ { captureFeeSummary: true, targetSlot: currentL2Slot },
625
+ );
626
+ })();
627
+ }
514
628
  return undefined;
515
629
  } finally {
516
630
  try {
@@ -538,23 +652,37 @@ export class SequencerPublisher {
538
652
  }
539
653
 
540
654
  /** Backs up entries dropped by bundle simulation, one record per dropped action. */
541
- private async backupDroppedInSim(dropped: DroppedRequest[]): Promise<void> {
655
+ private async backupDroppedInSim(dropped: DroppedRequest[], targetSlot?: SlotNumber): Promise<void> {
542
656
  if (dropped.length === 0) {
543
657
  return;
544
658
  }
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
- });
659
+ // Invoked as `void backupDroppedInSim(...)` on the publish path, so it must not throw.
660
+ try {
661
+ const l1BlockNumber = await this.l1TxUtils.getBlockNumber();
662
+ // Every dropped entry failed in the same slot against the same L1 fee conditions, so capture
663
+ // the fee environment once and share it rather than re-reading the window per entry.
664
+ const sharedFeeSummary = await this.captureFeeEnvironment(targetSlot).catch(() => undefined);
665
+ const timing = this.computeTimingInfo(targetSlot);
666
+ for (const { request: req } of dropped) {
667
+ this.backupFailedTx(
668
+ {
669
+ id: keccak256(req.request.data!),
670
+ failureType: 'simulation',
671
+ request: { to: req.request.to! as Hex, data: req.request.data! },
672
+ l1BlockNumber,
673
+ error: { message: 'Bundle entry dropped: action reverted in sim' },
674
+ context: {
675
+ actions: [req.action],
676
+ sender: this.getSenderAddress().toString(),
677
+ slot: targetSlot !== undefined ? Number(targetSlot) : undefined,
678
+ },
679
+ timing,
680
+ },
681
+ { sharedFeeSummary },
682
+ );
683
+ }
684
+ } catch (err) {
685
+ this.log.warn(`Failed to back up dropped-in-sim entries`, err);
558
686
  }
559
687
  }
560
688
 
@@ -567,6 +695,7 @@ export class SequencerPublisher {
567
695
  validRequests: RequestWithExpiry[],
568
696
  txConfig: RequestWithExpiry['gasConfig'],
569
697
  blobConfig: L1BlobInputs | undefined,
698
+ targetSlot?: SlotNumber,
570
699
  ) {
571
700
  if (!txConfig?.gasLimit) {
572
701
  throw new Error('gasLimit is required for bundled transactions');
@@ -603,11 +732,13 @@ export class SequencerPublisher {
603
732
  this.log.error('Forwarder transaction reverted on-chain; not rotating publisher', err, {
604
733
  transactionHash: err.receipt.transactionHash,
605
734
  });
735
+ this.backupRevertFailure(validRequests, err, currentPublisher, targetSlot);
606
736
  return undefined;
607
737
  }
608
738
  const viemError = formatViemError(err);
609
739
  if (!this.getNextPublisher) {
610
740
  this.log.error('Failed to publish bundled transactions', viemError);
741
+ this.backupSendFailure(validRequests, viemError, currentPublisher, targetSlot);
611
742
  return undefined;
612
743
  }
613
744
  this.log.warn(
@@ -621,6 +752,7 @@ export class SequencerPublisher {
621
752
  viemError,
622
753
  { triedAddresses: triedAddresses.map(a => a.toString()) },
623
754
  );
755
+ this.backupSendFailure(validRequests, viemError, currentPublisher, targetSlot);
624
756
  return undefined;
625
757
  }
626
758
  currentPublisher = nextPublisher;
@@ -628,6 +760,87 @@ export class SequencerPublisher {
628
760
  }
629
761
  }
630
762
 
763
+ /** Backs up an on-chain revert failure to the failed tx store. */
764
+ private backupRevertFailure(
765
+ requests: RequestWithExpiry[],
766
+ err: MulticallForwarderRevertedError,
767
+ publisher: L1TxUtils,
768
+ targetSlot?: SlotNumber,
769
+ ): void {
770
+ this.backupFailedTx(
771
+ {
772
+ id: err.receipt.transactionHash,
773
+ failureType: 'revert',
774
+ request: { to: MULTI_CALL_3_ADDRESS as Hex, data: '0x' as Hex },
775
+ l1BlockNumber: err.receipt.blockNumber,
776
+ receipt: {
777
+ transactionHash: err.receipt.transactionHash,
778
+ blockNumber: err.receipt.blockNumber,
779
+ gasUsed: err.receipt.gasUsed,
780
+ status: 'reverted',
781
+ },
782
+ error: { message: err.message, name: err.name },
783
+ context: {
784
+ actions: requests.map(r => r.action),
785
+ requests: requests
786
+ .filter(r => r.request.to !== null)
787
+ .map(r => ({ action: r.action, to: r.request.to! as Hex, data: r.request.data! })),
788
+ sender: publisher.getSenderAddress().toString(),
789
+ slot: targetSlot !== undefined ? Number(targetSlot) : undefined,
790
+ },
791
+ gasInfo: err.txState
792
+ ? {
793
+ sentGasPrice: err.txState.gasPrice,
794
+ gasLimit: err.txState.gasLimit,
795
+ nonce: err.txState.nonce,
796
+ }
797
+ : undefined,
798
+ timing: this.computeTimingInfo(targetSlot),
799
+ },
800
+ { captureFeeSummary: true, targetSlot },
801
+ );
802
+ }
803
+
804
+ /** Backs up a send failure (tx never reached chain) to the failed tx store. */
805
+ private backupSendFailure(
806
+ requests: RequestWithExpiry[],
807
+ error: FormattedViemError | Error,
808
+ publisher: L1TxUtils,
809
+ targetSlot?: SlotNumber,
810
+ ): void {
811
+ // If we can't get the block number, still back up without it.
812
+ void this.l1TxUtils
813
+ .getBlockNumber()
814
+ .catch(() => 0n)
815
+ .then(l1BlockNumber => {
816
+ this.backupFailedTx(
817
+ {
818
+ id: this.failureRecordId(
819
+ requests.map(r => r.action),
820
+ targetSlot,
821
+ ),
822
+ failureType: 'send-error',
823
+ request: { to: MULTI_CALL_3_ADDRESS as Hex, data: '0x' as Hex },
824
+ l1BlockNumber,
825
+ error: {
826
+ message: error.message,
827
+ name: 'name' in error ? error.name : undefined,
828
+ },
829
+ context: {
830
+ actions: requests.map(r => r.action),
831
+ requests: requests
832
+ .filter(r => r.request.to !== null)
833
+ .map(r => ({ action: r.action, to: r.request.to! as Hex, data: r.request.data! })),
834
+ sender: publisher.getSenderAddress().toString(),
835
+ slot: targetSlot !== undefined ? Number(targetSlot) : undefined,
836
+ },
837
+ timing: this.computeTimingInfo(targetSlot),
838
+ },
839
+ { captureFeeSummary: true, targetSlot },
840
+ );
841
+ });
842
+ }
843
+
631
844
  /*
632
845
  * Schedules sending all enqueued requests at (or after) the start of the given L2 slot.
633
846
  */
@@ -889,18 +1102,22 @@ export class SequencerPublisher {
889
1102
 
890
1103
  // Otherwise, throw. We cannot build the next checkpoint if we cannot invalidate the previous one.
891
1104
  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(),
1105
+ this.backupFailedTx(
1106
+ {
1107
+ id: keccak256(request.data!),
1108
+ failureType: 'simulation',
1109
+ request: { to: request.to!, data: request.data!, value: request.value },
1110
+ l1BlockNumber,
1111
+ error: { message: viemError.message, name: viemError.name },
1112
+ context: {
1113
+ actions: [`invalidate-${reason}`],
1114
+ checkpointNumber,
1115
+ sender: this.getSenderAddress().toString(),
1116
+ },
1117
+ timing: this.computeTimingInfo(this.getCurrentL2Slot()),
902
1118
  },
903
- });
1119
+ { captureFeeSummary: true, targetSlot: this.getCurrentL2Slot() },
1120
+ );
904
1121
  throw new Error(`Failed to simulate invalidate checkpoint ${checkpointNumber}`, { cause: viemError });
905
1122
  }
906
1123
  }
@@ -958,6 +1175,19 @@ export class SequencerPublisher {
958
1175
  this.log.warn(`Cannot enqueue vote cast signal ${signalType} for address zero at slot ${slotNumber}`);
959
1176
  return false;
960
1177
  }
1178
+
1179
+ const canonicalRollup = await base.getRollupAddress();
1180
+ if (!canonicalRollup.equals(EthAddress.fromString(this.rollupContract.address))) {
1181
+ this.log.warn(`Rollup ${this.rollupContract.address} is not canonical, skipping governance signal`, {
1182
+ slotNumber,
1183
+ signalType,
1184
+ canonicalRollup,
1185
+ targetRollup: this.rollupContract.address,
1186
+ payload: payload.toString(),
1187
+ });
1188
+ return false;
1189
+ }
1190
+
961
1191
  const round = await base.computeRound(slotNumber);
962
1192
  const roundInfo = await base.getRoundInfo(this.rollupContract.address, round);
963
1193
 
@@ -974,23 +1204,38 @@ export class SequencerPublisher {
974
1204
  return false;
975
1205
  }
976
1206
 
977
- // Skip signaling if there is already a live (non-terminal) Governance proposal for this
978
- // payload. This is intentionally not cached: a previously-live proposal may transition to
979
- // a terminal state (Dropped/Rejected/Expired/Executed), at which point we may want to re-signal
980
- // the same payload in a future round.
981
- let proposed = false;
1207
+ // Classify the payload against the Governance proposal history so we stop signalling once its
1208
+ // proposal is live or was already executed, while still re-signalling one whose proposal was
1209
+ // merely rejected/dropped/expired.
1210
+ let status: PayloadProposalStatus = 'none';
982
1211
  try {
983
- proposed = await base.hasActiveProposalWithPayload(payload.toString());
1212
+ status = await base.getPayloadProposalStatus(payload.toString());
984
1213
  } catch (err) {
985
1214
  // We deliberately swallow the error and proceed to signal. Failing closed (skipping the
986
1215
  // signal) on transient RPC errors would let a flaky L1 endpoint silence governance
987
1216
  // participation entirely; failing open at worst produces a duplicate signal that the
988
1217
  // contract will simply count alongside others in the round.
989
- this.log.error(`Failed to check if payload ${payload} was already proposed (signalling anyway)`, err);
1218
+ this.log.error(`Failed to check governance proposal status for payload ${payload} (signalling anyway)`, err, {
1219
+ slotNumber,
1220
+ signalType,
1221
+ });
990
1222
  }
991
1223
 
992
- if (proposed) {
993
- this.log.info(`Payload ${payload} has a live governance proposal, stopping signals`);
1224
+ if (status === 'live') {
1225
+ this.log.info(`Payload ${payload} has a live governance proposal, stopping signals`, {
1226
+ slotNumber,
1227
+ signalType,
1228
+ payload: payload.toString(),
1229
+ });
1230
+ return false;
1231
+ }
1232
+
1233
+ if (status === 'executed' && !this.config.governanceProposerForcePayloadVote) {
1234
+ this.log.info(
1235
+ `Payload ${payload} was executed by governance within lookback, stopping signals ` +
1236
+ `(set GOVERNANCE_PROPOSER_FORCE_PAYLOAD_VOTE to re-signal)`,
1237
+ { slotNumber, signalType, payload: payload.toString() },
1238
+ );
994
1239
  return false;
995
1240
  }
996
1241
 
@@ -1346,18 +1591,22 @@ export class SequencerPublisher {
1346
1591
  args: [blobInput],
1347
1592
  });
1348
1593
  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(),
1594
+ this.backupFailedTx(
1595
+ {
1596
+ id: keccak256(validateBlobsData),
1597
+ failureType: 'simulation',
1598
+ request: { to: this.rollupContract.address as Hex, data: validateBlobsData },
1599
+ blobData: encodedData.blobs.map(b => toHex(b.data)) as Hex[],
1600
+ l1BlockNumber,
1601
+ error: { message: viemError.message, name: viemError.name },
1602
+ context: {
1603
+ actions: ['validate-blobs'],
1604
+ sender: this.getSenderAddress().toString(),
1605
+ },
1606
+ timing: this.computeTimingInfo(this.getCurrentL2Slot()),
1359
1607
  },
1360
- });
1608
+ { captureFeeSummary: true, targetSlot: this.getCurrentL2Slot() },
1609
+ );
1361
1610
  throw new Error('Failed to validate blobs');
1362
1611
  });
1363
1612
  }