@aztec/validator-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.
package/dest/validator.js CHANGED
@@ -19,40 +19,12 @@ import { ValidationService } from './duties/validation_service.js';
19
19
  import { HAKeyStore } from './key_store/ha_key_store.js';
20
20
  import { NodeKeystoreAdapter } from './key_store/node_keystore_adapter.js';
21
21
  import { ValidatorMetrics } from './metrics.js';
22
- import { ProposalHandler } from './proposal_handler.js';
22
+ import { ProposalHandler, SLASHABLE_BLOCK_PROPOSAL_VALIDATION_RESULT, SLASHABLE_CHECKPOINT_PROPOSAL_VALIDATION_RESULT } from './proposal_handler.js';
23
23
  // We maintain a set of proposers who have proposed invalid blocks.
24
24
  // Just cap the set to avoid unbounded growth.
25
25
  const MAX_PROPOSERS_OF_INVALID_BLOCKS = 1000;
26
- const MAX_TRACKED_INVALID_PROPOSAL_SLOTS = 1000;
27
26
  const MAX_TRACKED_INVALID_CHECKPOINT_PROPOSALS = 1000;
28
27
  const MAX_TRACKED_BAD_ATTESTATIONS = 10_000;
29
- // What errors from the block proposal handler result in slashing
30
- const SLASHABLE_BLOCK_PROPOSAL_VALIDATION_RESULT = [
31
- 'state_mismatch',
32
- 'failed_txs',
33
- 'global_variables_mismatch',
34
- 'invalid_proposal',
35
- 'parent_block_wrong_slot',
36
- 'in_hash_mismatch'
37
- ];
38
- const SLASHABLE_CHECKPOINT_PROPOSAL_VALIDATION_RESULT = {
39
- // enabled
40
- ['invalid_fee_asset_price_modifier']: true,
41
- ['checkpoint_header_mismatch']: true,
42
- // These late mismatches should normally be caught by earlier checks, but if reached after validating the local
43
- // checkpoint inputs, the proposer-signed payload disagrees with deterministic recomputation.
44
- ['archive_mismatch']: true,
45
- ['out_hash_mismatch']: true,
46
- ['no_blocks_for_slot']: true,
47
- ['too_many_blocks_in_checkpoint']: true,
48
- ['checkpoint_validation_failed']: true,
49
- ['last_block_archive_mismatch']: true,
50
- // disabled
51
- ['invalid_signature']: false,
52
- ['last_block_not_found']: false,
53
- ['block_fetch_error']: false,
54
- ['checkpoint_already_published']: false
55
- };
56
28
  /**
57
29
  * Validator Client
58
30
  */ export class ValidatorClient extends EventEmitter {
@@ -80,13 +52,12 @@ const SLASHABLE_CHECKPOINT_PROPOSAL_VALIDATION_RESULT = {
80
52
  epochCacheUpdateLoop;
81
53
  /** Tracks the last epoch in which each attester successfully submitted at least one attestation. */ lastAttestedEpochByAttester;
82
54
  proposersOfInvalidBlocks;
83
- slotsWithInvalidProposals;
84
55
  invalidCheckpointProposalOffenseKeys;
56
+ oversizedProposalOffenseKeys;
85
57
  badAttestationOffenseKeys;
86
- slotsWithProposalEquivocation;
87
58
  /** Tracks the last checkpoint proposal we attested to, to prevent equivocation. */ lastAttestedProposal;
88
59
  constructor(keyStore, epochCache, p2pClient, proposalHandler, blockSource, checkpointsBuilder, worldState, l1ToL2MessageSource, config, blobClient, slashingProtectionSigner, dateProvider = new DateProvider(), telemetry = getTelemetryClient(), log = createLogger('validator')){
89
- super(), this.keyStore = keyStore, this.epochCache = epochCache, this.p2pClient = p2pClient, this.proposalHandler = proposalHandler, this.blockSource = blockSource, this.checkpointsBuilder = checkpointsBuilder, this.worldState = worldState, this.l1ToL2MessageSource = l1ToL2MessageSource, this.config = config, this.blobClient = blobClient, this.slashingProtectionSigner = slashingProtectionSigner, this.dateProvider = dateProvider, this.hasRegisteredHandlers = false, this.lastAttestedEpochByAttester = new Map(), this.proposersOfInvalidBlocks = FifoSet.withLimit(MAX_PROPOSERS_OF_INVALID_BLOCKS), this.slotsWithInvalidProposals = FifoSet.withLimit(MAX_TRACKED_INVALID_PROPOSAL_SLOTS), this.invalidCheckpointProposalOffenseKeys = FifoSet.withLimit(MAX_TRACKED_INVALID_CHECKPOINT_PROPOSALS), this.badAttestationOffenseKeys = FifoSet.withLimit(MAX_TRACKED_BAD_ATTESTATIONS), this.slotsWithProposalEquivocation = FifoSet.withLimit(MAX_TRACKED_INVALID_PROPOSAL_SLOTS);
60
+ super(), this.keyStore = keyStore, this.epochCache = epochCache, this.p2pClient = p2pClient, this.proposalHandler = proposalHandler, this.blockSource = blockSource, this.checkpointsBuilder = checkpointsBuilder, this.worldState = worldState, this.l1ToL2MessageSource = l1ToL2MessageSource, this.config = config, this.blobClient = blobClient, this.slashingProtectionSigner = slashingProtectionSigner, this.dateProvider = dateProvider, this.hasRegisteredHandlers = false, this.lastAttestedEpochByAttester = new Map(), this.proposersOfInvalidBlocks = FifoSet.withLimit(MAX_PROPOSERS_OF_INVALID_BLOCKS), this.invalidCheckpointProposalOffenseKeys = FifoSet.withLimit(MAX_TRACKED_INVALID_CHECKPOINT_PROPOSALS), this.oversizedProposalOffenseKeys = FifoSet.withLimit(MAX_TRACKED_INVALID_CHECKPOINT_PROPOSALS), this.badAttestationOffenseKeys = FifoSet.withLimit(MAX_TRACKED_BAD_ATTESTATIONS);
90
61
  // Create child logger with fisherman prefix if in fisherman mode
91
62
  this.log = config.fishermanMode ? log.createChild('[FISHERMAN]') : log;
92
63
  this.tracer = telemetry.getTracer('Validator');
@@ -216,10 +187,10 @@ const SLASHABLE_CHECKPOINT_PROPOSAL_VALIDATION_RESULT = {
216
187
  return this.config;
217
188
  }
218
189
  hasProposalEquivocation(slotNumber) {
219
- return this.slotsWithProposalEquivocation.has(slotNumber);
190
+ return this.proposalHandler.hasProposalEquivocation(slotNumber);
220
191
  }
221
192
  hasInvalidProposals(slotNumber) {
222
- return this.slotsWithInvalidProposals.has(slotNumber);
193
+ return this.proposalHandler.hasInvalidProposals(slotNumber);
223
194
  }
224
195
  updateConfig(config) {
225
196
  this.config = {
@@ -269,6 +240,10 @@ const SLASHABLE_CHECKPOINT_PROPOSAL_VALIDATION_RESULT = {
269
240
  this.p2pClient.registerDuplicateProposalCallback((info)=>{
270
241
  this.handleDuplicateProposal(info);
271
242
  });
243
+ // Oversized proposal handler - triggers slashing for proposals beyond the per-checkpoint block limit
244
+ this.p2pClient.registerOversizedProposalCallback((info)=>{
245
+ this.handleOversizedProposal(info);
246
+ });
272
247
  // Duplicate attestation handler - triggers slashing for attestation equivocation
273
248
  this.p2pClient.registerDuplicateAttestationCallback((info)=>{
274
249
  this.handleDuplicateAttestation(info);
@@ -529,7 +504,8 @@ const SLASHABLE_CHECKPOINT_PROPOSAL_VALIDATION_RESULT = {
529
504
  if (!SLASHABLE_CHECKPOINT_PROPOSAL_VALIDATION_RESULT[result.reason]) {
530
505
  return;
531
506
  }
532
- this.markInvalidProposalSlot(proposal.slotNumber);
507
+ // The slot is already marked invalid by the all-nodes checkpoint handler that invokes this callback,
508
+ // so we only emit the proposer slash event here.
533
509
  if (this.slashInvalidCheckpointProposal(proposal)) {
534
510
  this.log.info(`Detected invalid checkpoint proposal offense`, {
535
511
  ...proposalInfo,
@@ -564,11 +540,11 @@ const SLASHABLE_CHECKPOINT_PROPOSAL_VALIDATION_RESULT = {
564
540
  return true;
565
541
  }
566
542
  markInvalidProposalSlot(slotNumber) {
567
- this.slotsWithInvalidProposals.add(slotNumber);
543
+ this.proposalHandler.markInvalidProposalSlot(slotNumber);
568
544
  }
569
545
  handleCheckpointAttestation(attestation) {
570
546
  const slotNumber = attestation.slotNumber;
571
- if (!this.slotsWithInvalidProposals.has(slotNumber) || this.slotsWithProposalEquivocation.has(slotNumber)) {
547
+ if (!this.proposalHandler.hasInvalidProposals(slotNumber) || this.proposalHandler.hasProposalEquivocation(slotNumber)) {
572
548
  return;
573
549
  }
574
550
  const attester = attestation.getSender();
@@ -602,11 +578,37 @@ const SLASHABLE_CHECKPOINT_PROPOSAL_VALIDATION_RESULT = {
602
578
  ]);
603
579
  }
604
580
  /**
581
+ * Handle detection of an oversized block proposal: one whose index within its checkpoint lands at or
582
+ * beyond the consensus per-checkpoint block limit. A single signed proposal at an illegal index is
583
+ * self-contained evidence, so emit an invalid-block-proposal slash event for the proposer, deduped per
584
+ * (proposer, slot) since the p2p layer reports every oversized proposal it stores.
585
+ */ handleOversizedProposal(info) {
586
+ const { slot, proposer } = info;
587
+ const offenseType = OffenseType.BROADCASTED_INVALID_BLOCK_PROPOSAL;
588
+ if (!this.oversizedProposalOffenseKeys.addIfAbsent(`${proposer.toString()}:${offenseType}:${slot}`)) {
589
+ return;
590
+ }
591
+ this.log.info(`Detected oversized block proposal offense from ${proposer.toString()} at slot ${slot}`, {
592
+ proposer: proposer.toString(),
593
+ slot,
594
+ amount: this.config.slashBroadcastedInvalidBlockPenalty,
595
+ offenseType: getOffenseTypeName(offenseType)
596
+ });
597
+ this.emit(WANT_TO_SLASH_EVENT, [
598
+ {
599
+ validator: proposer,
600
+ amount: this.config.slashBroadcastedInvalidBlockPenalty,
601
+ offenseType,
602
+ epochOrSlot: BigInt(slot)
603
+ }
604
+ ]);
605
+ }
606
+ /**
605
607
  * Handle detection of a duplicate proposal (equivocation).
606
608
  * Emits a slash event when a proposer sends multiple proposals for the same position.
607
609
  */ handleDuplicateProposal(info) {
608
610
  const { slot, proposer, type } = info;
609
- this.slotsWithProposalEquivocation.add(slot);
611
+ this.proposalHandler.markProposalEquivocation(slot);
610
612
  this.log.info(`Detected duplicate ${type} proposal offense from ${proposer.toString()} at slot ${slot}`, {
611
613
  proposer: proposer.toString(),
612
614
  slot,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aztec/validator-client",
3
- "version": "5.0.0-rc.1",
3
+ "version": "5.0.0",
4
4
  "main": "dest/index.js",
5
5
  "type": "module",
6
6
  "exports": {
@@ -64,30 +64,30 @@
64
64
  ]
65
65
  },
66
66
  "dependencies": {
67
- "@aztec/blob-client": "5.0.0-rc.1",
68
- "@aztec/blob-lib": "5.0.0-rc.1",
69
- "@aztec/constants": "5.0.0-rc.1",
70
- "@aztec/epoch-cache": "5.0.0-rc.1",
71
- "@aztec/ethereum": "5.0.0-rc.1",
72
- "@aztec/foundation": "5.0.0-rc.1",
73
- "@aztec/node-keystore": "5.0.0-rc.1",
74
- "@aztec/noir-protocol-circuits-types": "5.0.0-rc.1",
75
- "@aztec/p2p": "5.0.0-rc.1",
76
- "@aztec/protocol-contracts": "5.0.0-rc.1",
77
- "@aztec/prover-client": "5.0.0-rc.1",
78
- "@aztec/simulator": "5.0.0-rc.1",
79
- "@aztec/slasher": "5.0.0-rc.1",
80
- "@aztec/stdlib": "5.0.0-rc.1",
81
- "@aztec/telemetry-client": "5.0.0-rc.1",
82
- "@aztec/validator-ha-signer": "5.0.0-rc.1",
67
+ "@aztec/blob-client": "5.0.0",
68
+ "@aztec/blob-lib": "5.0.0",
69
+ "@aztec/constants": "5.0.0",
70
+ "@aztec/epoch-cache": "5.0.0",
71
+ "@aztec/ethereum": "5.0.0",
72
+ "@aztec/foundation": "5.0.0",
73
+ "@aztec/node-keystore": "5.0.0",
74
+ "@aztec/noir-protocol-circuits-types": "5.0.0",
75
+ "@aztec/p2p": "5.0.0",
76
+ "@aztec/protocol-contracts": "5.0.0",
77
+ "@aztec/prover-client": "5.0.0",
78
+ "@aztec/simulator": "5.0.0",
79
+ "@aztec/slasher": "5.0.0",
80
+ "@aztec/stdlib": "5.0.0",
81
+ "@aztec/telemetry-client": "5.0.0",
82
+ "@aztec/validator-ha-signer": "5.0.0",
83
83
  "koa": "^2.16.1",
84
84
  "koa-router": "^13.1.1",
85
85
  "tslib": "^2.4.0",
86
86
  "viem": "npm:@aztec/viem@2.38.2"
87
87
  },
88
88
  "devDependencies": {
89
- "@aztec/archiver": "5.0.0-rc.1",
90
- "@aztec/world-state": "5.0.0-rc.1",
89
+ "@aztec/archiver": "5.0.0",
90
+ "@aztec/world-state": "5.0.0",
91
91
  "@electric-sql/pglite": "^0.3.14",
92
92
  "@jest/globals": "^30.0.0",
93
93
  "@types/jest": "^30.0.0",
@@ -8,6 +8,9 @@ import type { TypedDataDefinition } from 'viem';
8
8
 
9
9
  import type { ValidatorKeyStore } from './interface.js';
10
10
 
11
+ /** Default hard timeout (ms) applied to each Web3Signer HTTP request. */
12
+ const DEFAULT_WEB3SIGNER_REQUEST_TIMEOUT_MS = 30_000;
13
+
11
14
  /**
12
15
  * Web3Signer Key Store
13
16
  *
@@ -15,10 +18,15 @@ import type { ValidatorKeyStore } from './interface.js';
15
18
  * This implementation uses the Web3Signer JSON-RPC API for secp256k1 signatures.
16
19
  */
17
20
  export class Web3SignerKeyStore implements ValidatorKeyStore {
21
+ private readonly requestTimeoutMs: number;
22
+
18
23
  constructor(
19
24
  private addresses: EthAddress[],
20
25
  private baseUrl: string,
21
- ) {}
26
+ requestTimeoutMs: number = DEFAULT_WEB3SIGNER_REQUEST_TIMEOUT_MS,
27
+ ) {
28
+ this.requestTimeoutMs = requestTimeoutMs;
29
+ }
22
30
 
23
31
  /**
24
32
  * Get the address of a signer by index
@@ -108,75 +116,50 @@ export class Web3SignerKeyStore implements ValidatorKeyStore {
108
116
  * @param data - The data to sign
109
117
  * @returns The signature
110
118
  */
111
- private async makeJsonRpcSignRequest(address: EthAddress, data: Buffer32): Promise<Signature> {
112
- const url = this.baseUrl;
113
-
114
- // Use JSON-RPC eth_sign method which automatically applies Ethereum message prefixing
115
- const body = {
119
+ private makeJsonRpcSignRequest(address: EthAddress, data: Buffer32): Promise<Signature> {
120
+ // eth_sign automatically applies Ethereum message prefixing to the raw data.
121
+ return this.sendSignRequest({
116
122
  jsonrpc: '2.0',
117
123
  method: 'eth_sign',
118
- params: [
119
- address.toString(), // Ethereum address as identifier
120
- data.toString(), // Raw data to sign (eth_sign will apply Ethereum message prefix)
121
- ],
124
+ params: [address.toString(), data.toString()],
122
125
  id: 1,
123
- };
124
-
125
- const response = await fetch(url, {
126
- method: 'POST',
127
- headers: {
128
- 'Content-Type': 'application/json',
129
- },
130
- body: JSON.stringify(body),
131
126
  });
132
-
133
- if (!response.ok) {
134
- const errorText = await response.text();
135
- throw new Error(`Web3Signer request failed: ${response.status} ${response.statusText} - ${errorText}`);
136
- }
137
-
138
- const result = await response.json();
139
-
140
- // Handle JSON-RPC response format
141
- if (result.error) {
142
- throw new Error(`Web3Signer JSON-RPC error: ${result.error.code} - ${result.error.message}`);
143
- }
144
-
145
- if (!result.result) {
146
- throw new Error('Invalid response from Web3Signer: no result found');
147
- }
148
-
149
- let signatureHex = result.result;
150
-
151
- // Ensure the signature has the 0x prefix
152
- if (!signatureHex.startsWith('0x')) {
153
- signatureHex = '0x' + signatureHex;
154
- }
155
-
156
- // Parse the signature from the hex string
157
- return normalizeSignature(Signature.fromString(signatureHex as `0x${string}`));
158
127
  }
159
128
 
160
- private async makeJsonRpcSignTypedDataRequest(
161
- address: EthAddress,
162
- typedData: TypedDataDefinition,
163
- ): Promise<Signature> {
164
- const url = this.baseUrl;
165
-
166
- const body = {
129
+ private makeJsonRpcSignTypedDataRequest(address: EthAddress, typedData: TypedDataDefinition): Promise<Signature> {
130
+ return this.sendSignRequest({
167
131
  jsonrpc: '2.0',
168
132
  method: 'eth_signTypedData',
169
133
  params: [address.toString(), JSON.stringify(typedData)],
170
134
  id: 1,
171
- };
172
-
173
- const response = await fetch(url, {
174
- method: 'POST',
175
- headers: {
176
- 'Content-Type': 'application/json',
177
- },
178
- body: JSON.stringify(body),
179
135
  });
136
+ }
137
+
138
+ /**
139
+ * Send a JSON-RPC request to Web3Signer under a hard request timeout and parse the signature.
140
+ * A timed-out or aborted request is surfaced as a clear timeout error rather than hanging, so a
141
+ * slow or unreachable signer cannot stall an HA signing operation past its own timeout budget.
142
+ */
143
+ private async sendSignRequest(body: object): Promise<Signature> {
144
+ let response: Response;
145
+ try {
146
+ response = await fetch(this.baseUrl, {
147
+ method: 'POST',
148
+ headers: {
149
+ 'Content-Type': 'application/json',
150
+ },
151
+ body: JSON.stringify(body),
152
+ signal: AbortSignal.timeout(this.requestTimeoutMs),
153
+ });
154
+ } catch (err) {
155
+ if (
156
+ (err instanceof Error || err instanceof DOMException) &&
157
+ (err.name === 'TimeoutError' || err.name === 'AbortError')
158
+ ) {
159
+ throw new Error(`Web3Signer request timed out after ${this.requestTimeoutMs}ms`);
160
+ }
161
+ throw err;
162
+ }
180
163
 
181
164
  if (!response.ok) {
182
165
  const errorText = await response.text();
@@ -185,6 +168,7 @@ export class Web3SignerKeyStore implements ValidatorKeyStore {
185
168
 
186
169
  const result = await response.json();
187
170
 
171
+ // Handle JSON-RPC response format
188
172
  if (result.error) {
189
173
  throw new Error(`Web3Signer JSON-RPC error: ${result.error.code} - ${result.error.message}`);
190
174
  }
@@ -13,6 +13,7 @@ import {
13
13
  import { pick } from '@aztec/foundation/collection';
14
14
  import { Fr } from '@aztec/foundation/curves/bn254';
15
15
  import { TimeoutError } from '@aztec/foundation/error';
16
+ import { FifoSet } from '@aztec/foundation/fifo-set';
16
17
  import type { LogData } from '@aztec/foundation/log';
17
18
  import { createLogger } from '@aztec/foundation/log';
18
19
  import { retryUntil } from '@aztec/foundation/retry';
@@ -152,7 +153,49 @@ type BlockProposalSlotValidationResult =
152
153
  | { isValid: true }
153
154
  | { isValid: false; reason: 'block_proposal_beyond_checkpoint' | 'checkpoint_proposal_equivocation' };
154
155
 
155
- /** Handles block and checkpoint proposals for both validator and non-validator nodes. */
156
+ const MAX_TRACKED_INVALID_PROPOSAL_SLOTS = 1000;
157
+
158
+ /** Block-proposal validation failures that constitute a slashable invalid-block offense. */
159
+ export const SLASHABLE_BLOCK_PROPOSAL_VALIDATION_RESULT: BlockProposalValidationFailureReason[] = [
160
+ 'state_mismatch',
161
+ 'failed_txs',
162
+ 'global_variables_mismatch',
163
+ 'invalid_proposal',
164
+ 'parent_block_wrong_slot',
165
+ 'in_hash_mismatch',
166
+ ];
167
+
168
+ /** Checkpoint-proposal validation failures that constitute a slashable invalid-checkpoint offense. */
169
+ export const SLASHABLE_CHECKPOINT_PROPOSAL_VALIDATION_RESULT: Record<
170
+ CheckpointProposalValidationFailureReason,
171
+ boolean
172
+ > = {
173
+ // enabled
174
+ ['invalid_fee_asset_price_modifier']: true,
175
+ ['checkpoint_header_mismatch']: true,
176
+ // These late mismatches should normally be caught by earlier checks, but if reached after validating the local
177
+ // checkpoint inputs, the proposer-signed payload disagrees with deterministic recomputation.
178
+ ['archive_mismatch']: true,
179
+ ['out_hash_mismatch']: true,
180
+ ['no_blocks_for_slot']: true,
181
+ ['too_many_blocks_in_checkpoint']: true,
182
+ ['checkpoint_validation_failed']: true,
183
+ ['last_block_archive_mismatch']: true,
184
+
185
+ // disabled
186
+ ['invalid_signature']: false,
187
+ ['last_block_not_found']: false,
188
+ ['block_fetch_error']: false,
189
+ ['checkpoint_already_published']: false,
190
+ };
191
+
192
+ /**
193
+ * Handles block and checkpoint proposals for both validator and non-validator nodes. Also tracks which slots
194
+ * had a slashable invalid proposal or a proposal equivocation, exposing them via the
195
+ * `InvalidProposalSlotSource` interface consumed by the attested-invalid-proposal slashing watcher. The
196
+ * tracking is populated as a side effect of validating/re-executing proposals, so any node that re-executes
197
+ * proposals (the default) can serve it — not only validators.
198
+ */
156
199
  export class ProposalHandler {
157
200
  public readonly tracer: Tracer;
158
201
 
@@ -175,6 +218,12 @@ export class ProposalHandler {
175
218
 
176
219
  private checkpointProposalValidationFailureCallback?: CheckpointProposalValidationFailureCallback;
177
220
 
221
+ /** Slots at which a slashable invalid block or checkpoint proposal was observed. */
222
+ private readonly slotsWithInvalidProposals = FifoSet.withLimit<SlotNumber>(MAX_TRACKED_INVALID_PROPOSAL_SLOTS);
223
+
224
+ /** Slots at which a proposal equivocation was observed; suppresses attested-to-invalid-proposal slashing. */
225
+ private readonly slotsWithProposalEquivocation = FifoSet.withLimit<SlotNumber>(MAX_TRACKED_INVALID_PROPOSAL_SLOTS);
226
+
178
227
  constructor(
179
228
  private checkpointsBuilder: FullNodeCheckpointsBuilder,
180
229
  private worldState: WorldStateSynchronizer,
@@ -221,6 +270,26 @@ export class ProposalHandler {
221
270
  this.reexecutionTracker.recordOutcome(slot, archive, 'valid', checkpointNumber);
222
271
  }
223
272
 
273
+ /** Whether a slashable invalid block or checkpoint proposal was observed at the given slot (InvalidProposalSlotSource). */
274
+ public hasInvalidProposals(slotNumber: SlotNumber): boolean {
275
+ return this.slotsWithInvalidProposals.has(slotNumber);
276
+ }
277
+
278
+ /** Whether a proposal equivocation was observed at the given slot (InvalidProposalSlotSource). */
279
+ public hasProposalEquivocation(slotNumber: SlotNumber): boolean {
280
+ return this.slotsWithProposalEquivocation.has(slotNumber);
281
+ }
282
+
283
+ /** Records a slot as having a slashable invalid proposal, for offense observers (sentinel/slasher watchers). */
284
+ public markInvalidProposalSlot(slotNumber: SlotNumber): void {
285
+ this.slotsWithInvalidProposals.add(slotNumber);
286
+ }
287
+
288
+ /** Records a slot as having a proposal equivocation, which suppresses attested-to-invalid-proposal slashing. */
289
+ public markProposalEquivocation(slotNumber: SlotNumber): void {
290
+ this.slotsWithProposalEquivocation.add(slotNumber);
291
+ }
292
+
224
293
  /**
225
294
  * Registers handlers for block and checkpoint proposals on the p2p client.
226
295
  * Records the p2p client so validation can inspect retained proposals.
@@ -256,6 +325,18 @@ export class ProposalHandler {
256
325
  });
257
326
  return true;
258
327
  } else {
328
+ // Track invalid proposals / equivocations so offense observers (the attested-invalid-proposal
329
+ // watcher) work on non-validator nodes too. Validators populate these via their own handlers.
330
+ // Skip invalid-proposal marking while the escape hatch is open, matching the validator path,
331
+ // which intentionally disables invalid-block slashing then.
332
+ if (result.reason === 'checkpoint_proposal_equivocation') {
333
+ this.markProposalEquivocation(slotNumber);
334
+ } else if (
335
+ SLASHABLE_BLOCK_PROPOSAL_VALIDATION_RESULT.includes(result.reason) &&
336
+ !(await this.epochCache.isEscapeHatchOpenAtSlot(slotNumber))
337
+ ) {
338
+ this.markInvalidProposalSlot(slotNumber);
339
+ }
259
340
  this.log.warn(
260
341
  `Non-validator block proposal ${blockNumber} at slot ${slotNumber} failed processing with ${result.reason}`,
261
342
  { blockNumber: result.blockNumber, slotNumber, reason: result.reason },
@@ -270,6 +351,11 @@ export class ProposalHandler {
270
351
 
271
352
  p2pClient.registerBlockProposalHandler(blockHandler);
272
353
 
354
+ // p2p detects duplicate (equivocated) proposals without routing them through the handlers above, so mark
355
+ // the slot as equivocated here. This suppresses false-positive attested-to-invalid-proposal slashing on
356
+ // non-validator offense collectors. Validators overwrite this with their own richer handler.
357
+ p2pClient.registerDuplicateProposalCallback(info => this.markProposalEquivocation(info.slot));
358
+
273
359
  // All-nodes checkpoint proposal handler: validates, caches, and sets proposed checkpoint for pipelining.
274
360
  // Runs for all nodes (validators and non-validators). Validators get the cached result in the
275
361
  // validator-specific callback (attestToCheckpointProposal) which runs after this one.
@@ -318,6 +404,12 @@ export class ProposalHandler {
318
404
 
319
405
  const result = await this.handleCheckpointProposal(proposal, proposalInfo);
320
406
  if (!result.isValid) {
407
+ // Track invalid checkpoint proposals so offense observers (the attested-invalid-proposal watcher)
408
+ // work on non-validator nodes too. This handler runs for all nodes; validators also mark via the
409
+ // failure callback below (idempotent).
410
+ if (SLASHABLE_CHECKPOINT_PROPOSAL_VALIDATION_RESULT[result.reason]) {
411
+ this.markInvalidProposalSlot(proposal.slotNumber);
412
+ }
321
413
  await this.checkpointProposalValidationFailureCallback?.(proposal, result, proposalInfo);
322
414
  } else if (this.archiver) {
323
415
  const set = await this.setProposedCheckpoint(proposal);
@@ -410,8 +502,12 @@ export class ProposalHandler {
410
502
  : BlockNumber(parentBlock.header.getBlockNumber() + 1);
411
503
  proposalInfo.blockNumber = blockNumber;
412
504
 
413
- // Check that this block number does not exist already
414
- const existingBlock = await this.blockSource.getBlockData({ number: blockNumber });
505
+ // Check that this block number does not exist already. During a reorg the archiver can still hold a
506
+ // stale block at this number (a different archive, about to be pruned) while the proposal carries the
507
+ // rebuilt replacement; resolveExistingBlockAtNumber waits for the local prune in that case so the
508
+ // rebuilt block is processed in time to attest, rather than being permanently dropped on a bare
509
+ // number collision.
510
+ const existingBlock = await this.resolveExistingBlockAtNumber(blockNumber, proposal.archive, slotNumber);
415
511
  if (existingBlock) {
416
512
  this.log.warn(`Block number ${blockNumber} already exists, skipping processing`, proposalInfo);
417
513
  return { isValid: false, blockNumber, reason: 'block_number_already_exists' };
@@ -495,7 +591,7 @@ export class ProposalHandler {
495
591
  }
496
592
 
497
593
  // If we succeeded, push this block into the archiver (unless disabled)
498
- if (reexecutionResult?.block && this.config.skipPushProposedBlocksToArchiver === false) {
594
+ if (reexecutionResult?.block && !this.config.skipPushProposedBlocksToArchiver) {
499
595
  await this.blockSource.addBlock(reexecutionResult.block);
500
596
  }
501
597
 
@@ -561,6 +657,63 @@ export class ProposalHandler {
561
657
  }
562
658
  }
563
659
 
660
+ /**
661
+ * Resolves whether a block genuinely already exists at `blockNumber`. Returns the existing block only if
662
+ * it is a true duplicate of the proposal (matching archive). During a reorg the archiver can still hold a
663
+ * stale fork at this number (different archive) that is about to be pruned; in that case this forces L1
664
+ * sync and waits, bounded by the re-execution deadline, for the prune to land, then returns `undefined` so
665
+ * the rebuilt block proposal can be processed in time to attest. If the prune does not complete before the
666
+ * deadline it returns the stale block, so the caller falls back to the safe `block_number_already_exists`
667
+ * rejection.
668
+ */
669
+ private async resolveExistingBlockAtNumber(
670
+ blockNumber: BlockNumber,
671
+ proposalArchive: Fr,
672
+ slotNumber: SlotNumber,
673
+ ): Promise<BlockData | undefined> {
674
+ const existingBlock = await this.blockSource.getBlockData({ number: blockNumber });
675
+ if (!existingBlock || existingBlock.archive.root.equals(proposalArchive)) {
676
+ return existingBlock;
677
+ }
678
+
679
+ // A different block already occupies this number: it may be a stale fork being pruned during a reorg, not a
680
+ // genuine duplicate. Wait for the local prune rather than permanently rejecting the proposal.
681
+ const deadline = this.getReexecutionDeadline(slotNumber);
682
+ if (deadline.getTime() - this.dateProvider.now() <= 0) {
683
+ return existingBlock;
684
+ }
685
+
686
+ this.log.warn(`Block number ${blockNumber} already exists, awaiting potential prune`, {
687
+ blockNumber,
688
+ existingArchive: existingBlock.archive.root.toString(),
689
+ proposalArchive: proposalArchive.toString(),
690
+ });
691
+
692
+ try {
693
+ const { block } = await retryUntil(
694
+ async () => {
695
+ await this.blockSource.syncImmediate();
696
+ const block = await this.blockSource.getBlockData({ number: blockNumber });
697
+ // Resolve once the existing block is gone (pruned) or has been replaced by one matching the
698
+ // proposal — the same condition as the early return above. A matching block is returned so the
699
+ // caller still treats it as a genuine duplicate; an `undefined` (pruned) block lets the proposal
700
+ // be processed. Wrap in an object so the `undefined` case is still a truthy retry result.
701
+ return block === undefined || block.archive.root.equals(proposalArchive) ? { block } : undefined;
702
+ },
703
+ `prune of stale block ${blockNumber}`,
704
+ { deadline, dateProvider: this.dateProvider },
705
+ 0.5,
706
+ );
707
+ return block;
708
+ } catch (err) {
709
+ if (err instanceof TimeoutError) {
710
+ this.log.warn(`Timed out waiting for stale block ${blockNumber} to be pruned`, { blockNumber });
711
+ return existingBlock;
712
+ }
713
+ throw err;
714
+ }
715
+ }
716
+
564
717
  private computeCheckpointNumber(
565
718
  proposal: BlockProposal,
566
719
  parentBlock: 'genesis' | BlockData,
@@ -968,6 +1121,7 @@ export class ProposalHandler {
968
1121
  };
969
1122
  }
970
1123
 
1124
+ // Note this condition should never trigger, since we dont process block proposals that exceed indexWithinCheckpoint
971
1125
  const maxBlocksPerCheckpoint = this.config.maxBlocksPerCheckpoint;
972
1126
  if (maxBlocksPerCheckpoint !== undefined && blocks.length > maxBlocksPerCheckpoint) {
973
1127
  this.log.warn(`Checkpoint proposal exceeds maxBlocksPerCheckpoint`, {