@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/key_store/web3signer_key_store.d.ts +10 -2
- package/dest/key_store/web3signer_key_store.d.ts.map +1 -1
- package/dest/key_store/web3signer_key_store.js +32 -41
- package/dest/proposal_handler.d.ts +25 -2
- package/dest/proposal_handler.d.ts.map +1 -1
- package/dest/proposal_handler.js +132 -6
- package/dest/validator.d.ts +11 -5
- package/dest/validator.d.ts.map +1 -1
- package/dest/validator.js +40 -38
- package/package.json +19 -19
- package/src/key_store/web3signer_key_store.ts +43 -59
- package/src/proposal_handler.ts +158 -4
- package/src/validator.ts +51 -44
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.
|
|
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.
|
|
190
|
+
return this.proposalHandler.hasProposalEquivocation(slotNumber);
|
|
220
191
|
}
|
|
221
192
|
hasInvalidProposals(slotNumber) {
|
|
222
|
-
return this.
|
|
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
|
|
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.
|
|
543
|
+
this.proposalHandler.markInvalidProposalSlot(slotNumber);
|
|
568
544
|
}
|
|
569
545
|
handleCheckpointAttestation(attestation) {
|
|
570
546
|
const slotNumber = attestation.slotNumber;
|
|
571
|
-
if (!this.
|
|
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.
|
|
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
|
|
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
|
|
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
|
|
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
|
|
90
|
-
"@aztec/world-state": "5.0.0
|
|
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
|
|
112
|
-
|
|
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
|
|
161
|
-
|
|
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
|
}
|
package/src/proposal_handler.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
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`, {
|