@aztec-labs/aztec-node 6.0.0 → 6.1.0-nightly.20261023
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/sentinel/sentinel.d.ts +77 -11
- package/dest/sentinel/sentinel.d.ts.map +1 -1
- package/dest/sentinel/sentinel.js +200 -19
- package/dest/sentinel/store.d.ts +1 -1
- package/dest/sentinel/store.d.ts.map +1 -1
- package/dest/sentinel/store.js +8 -0
- package/package.json +27 -27
- package/src/sentinel/README.md +20 -4
- package/src/sentinel/sentinel.ts +257 -21
- package/src/sentinel/store.ts +8 -0
|
@@ -8,10 +8,27 @@ import type { SlasherConfig } from '@aztec-labs/slasher/config';
|
|
|
8
8
|
import { type L2BlockSource } from '@aztec-labs/stdlib/block';
|
|
9
9
|
import type { CheckpointReexecutionTracker } from '@aztec-labs/stdlib/checkpoint';
|
|
10
10
|
import type { ChainConfig } from '@aztec-labs/stdlib/config';
|
|
11
|
+
import { type CheckpointAttestation } from '@aztec-labs/stdlib/p2p';
|
|
11
12
|
import type { SingleValidatorStats, ValidatorStats, ValidatorStatusHistory, ValidatorStatusInSlot, ValidatorStatusType, ValidatorsEpochPerformance, ValidatorsStats } from '@aztec-labs/stdlib/validators';
|
|
12
13
|
import type { SentinelConfig } from './config.js';
|
|
13
14
|
import { SentinelStore } from './store.js';
|
|
14
15
|
export type SentinelRuntimeConfig = Pick<SlasherConfig, 'slashInactivityTargetPercentage' | 'slashInactivityPenalty' | 'slashInactivityConsecutiveEpochThreshold'> & Pick<SentinelConfig, 'sentinelEpochEndBufferSlots'> & Pick<ChainConfig, 'l1ChainId' | 'rollupAddress'>;
|
|
16
|
+
/**
|
|
17
|
+
* Why a valid checkpoint proposal that never reached L1 could not have landed, regardless of its proposer.
|
|
18
|
+
* `unexpected-parent-appeared` matches the reason the sequencer gives when it discards its own pipelined checkpoint
|
|
19
|
+
* for the same cause.
|
|
20
|
+
*/
|
|
21
|
+
type OrphanedProposalReason = 'parent-not-on-l1' | 'parent-hash-mismatch' | 'unexpected-parent-appeared';
|
|
22
|
+
/** Refined status of a valid checkpoint proposal for a slot whose checkpoint never reached L1. */
|
|
23
|
+
type UnpublishedProposalClassification = {
|
|
24
|
+
status: 'checkpoint-unpublished';
|
|
25
|
+
} | {
|
|
26
|
+
status: 'checkpoint-orphaned';
|
|
27
|
+
reason: OrphanedProposalReason;
|
|
28
|
+
} | {
|
|
29
|
+
status: 'checkpoint-valid';
|
|
30
|
+
reason: 'equivocation' | 'no-proposal-data' | 'below-quorum' | 'unexpected-parent-appeared';
|
|
31
|
+
};
|
|
15
32
|
declare const Sentinel_base: new () => WatcherEmitter;
|
|
16
33
|
/**
|
|
17
34
|
* The Sentinel observes validator behaviour every L2 slot, classifies it into a per-slot status,
|
|
@@ -42,12 +59,19 @@ declare const Sentinel_base: new () => WatcherEmitter;
|
|
|
42
59
|
* ## Six-case taxonomy in `getSlotActivity`
|
|
43
60
|
*
|
|
44
61
|
* For each slot, the sentinel assigns the proposer one of six statuses, ranked highest-confidence
|
|
45
|
-
* first:
|
|
62
|
+
* first, with case 5 refined into 5a and 5b:
|
|
46
63
|
*
|
|
47
64
|
* - `checkpoint-mined` — a checkpoint covering this slot has landed on L1
|
|
48
65
|
* (fetched on demand via `archiver.getCheckpoint({ slot })`).
|
|
66
|
+
* - `checkpoint-unpublished` — (5a) a valid checkpoint proposal built on a parent that is on L1
|
|
67
|
+
* reached quorum, yet the proposer did not land it.
|
|
68
|
+
* - `checkpoint-orphaned` — (5b) a valid checkpoint proposal could not land because the parent
|
|
69
|
+
* it built on never reached L1 (the proposer pipelining cascade), or
|
|
70
|
+
* because an earlier slot's checkpoint landed with its number after it
|
|
71
|
+
* had built on the previous one.
|
|
49
72
|
* - `checkpoint-valid` — the local node re-executed a checkpoint proposal for this slot
|
|
50
|
-
* successfully (consulted via `CheckpointReexecutionTracker`)
|
|
73
|
+
* successfully (consulted via `CheckpointReexecutionTracker`), and it
|
|
74
|
+
* could not be refined into 5a or 5b.
|
|
51
75
|
* - `checkpoint-invalid` — the local node re-executed a checkpoint proposal for this slot
|
|
52
76
|
* and rejected it (e.g. header/archive/out-hash mismatch, limit
|
|
53
77
|
* breach). Proposer-fault.
|
|
@@ -57,9 +81,15 @@ declare const Sentinel_base: new () => WatcherEmitter;
|
|
|
57
81
|
* - `checkpoint-missed` — block proposals seen on P2P but no checkpoint proposal at all.
|
|
58
82
|
* - `blocks-missed` — no block proposals seen for this slot.
|
|
59
83
|
*
|
|
60
|
-
* Missing-attestor faults are recorded only in `checkpoint-mined` and
|
|
61
|
-
*
|
|
62
|
-
*
|
|
84
|
+
* Missing-attestor faults are recorded only in `checkpoint-mined` and the three valid-proposal
|
|
85
|
+
* statuses (`checkpoint-unpublished`, `checkpoint-orphaned`, `checkpoint-valid`), where the local
|
|
86
|
+
* node has positive evidence the checkpoint was canonical or valid. In the other four cases the
|
|
87
|
+
* proposer is at fault and no attestor penalty applies. A valid proposal that conflicts with a checkpoint
|
|
88
|
+
* that landed on L1, in its parent's position or its own, is also exempt: a validator holding the landed
|
|
89
|
+
* checkpoint's blocks rightly refuses it.
|
|
90
|
+
*
|
|
91
|
+
* `checkpoint-unpublished` and `checkpoint-orphaned` are neutral for the proposer, like
|
|
92
|
+
* `checkpoint-valid`: neither counts toward `missedProposals` or inactivity slashing.
|
|
63
93
|
*
|
|
64
94
|
* ## Re-execution tracker
|
|
65
95
|
*
|
|
@@ -97,6 +127,13 @@ export declare class Sentinel extends Sentinel_base implements Watcher {
|
|
|
97
127
|
protected lastProcessedSlot: SlotNumber | undefined;
|
|
98
128
|
/** Largest epoch number for which the end-of-epoch aggregator has run. */
|
|
99
129
|
protected lastEvaluatedEpoch: EpochNumber | undefined;
|
|
130
|
+
/** Bumped on every history write so cached stats are never served from before the write. */
|
|
131
|
+
private historyGeneration;
|
|
132
|
+
/**
|
|
133
|
+
* Stats for the default window, shared by every caller until the next processed slot or history write. Computing them
|
|
134
|
+
* scans and deserializes the full history of every validator, while the inputs only change once per slot.
|
|
135
|
+
*/
|
|
136
|
+
private defaultStatsCache;
|
|
100
137
|
constructor(epochCache: EpochCache, archiver: L2BlockSource, p2p: P2PClient, store: SentinelStore, reexecutionTracker: CheckpointReexecutionTracker, config: SentinelRuntimeConfig, logger?: import("@aztec-labs/foundation/log").Logger);
|
|
101
138
|
private getSignatureContext;
|
|
102
139
|
updateConfig(config: Partial<SlasherConfig>): void;
|
|
@@ -176,8 +213,12 @@ export declare class Sentinel extends Sentinel_base implements Watcher {
|
|
|
176
213
|
*
|
|
177
214
|
* Proposer status:
|
|
178
215
|
* - case 6 `checkpoint-mined` — a checkpoint covering this slot has landed on L1.
|
|
216
|
+
* - case 5a `checkpoint-unpublished` — a valid proposal with its parent on L1 reached quorum but
|
|
217
|
+
* was not landed by the proposer.
|
|
218
|
+
* - case 5b `checkpoint-orphaned` — a valid proposal could not land because its parent never
|
|
219
|
+
* reached L1, or because an earlier slot took its checkpoint number.
|
|
179
220
|
* - case 5 `checkpoint-valid` — the local node re-executed a checkpoint proposal for this
|
|
180
|
-
* slot successfully.
|
|
221
|
+
* slot successfully, and it could not be refined into 5a or 5b.
|
|
181
222
|
* - case 4 `checkpoint-invalid` — the local node re-executed a checkpoint proposal for this
|
|
182
223
|
* slot and rejected it.
|
|
183
224
|
* - case 3 `checkpoint-unvalidated` — the local node observed a checkpoint proposal for this
|
|
@@ -185,20 +226,45 @@ export declare class Sentinel extends Sentinel_base implements Watcher {
|
|
|
185
226
|
* - case 2 `checkpoint-missed` — block proposals seen on P2P but no checkpoint proposal.
|
|
186
227
|
* - case 1 `blocks-missed` — no block proposals seen for this slot.
|
|
187
228
|
*
|
|
188
|
-
* Missing-attestor penalties apply only in cases 5 and 6, where the local
|
|
189
|
-
* evidence the checkpoint was valid or has been canonicalised on L1
|
|
229
|
+
* Missing-attestor penalties apply only in cases 5 (including 5a and 5b) and 6, where the local
|
|
230
|
+
* node has positive evidence the checkpoint was valid or has been canonicalised on L1, and never when
|
|
231
|
+
* the proposal conflicts with a checkpoint that landed in its parent's position or its own.
|
|
190
232
|
*/
|
|
191
233
|
protected getSlotActivity(slot: SlotNumber, epoch: EpochNumber, proposer: EthAddress, committee: EthAddress[]): Promise<{
|
|
192
234
|
[k: string]: ValidatorStatusInSlot | undefined;
|
|
193
235
|
}>;
|
|
236
|
+
/**
|
|
237
|
+
* Refines case 5 (a checkpoint proposal for the slot re-executed as valid, with no checkpoint for the slot on L1)
|
|
238
|
+
* into whose fault it was that the proposal did not land:
|
|
239
|
+
*
|
|
240
|
+
* - `checkpoint-orphaned` (5b) when other proposers prevented it: the parent it built on never landed on L1 (it is
|
|
241
|
+
* missing, or a different checkpoint holds the parent's position), or, with a quorum of attestations, an earlier
|
|
242
|
+
* slot's checkpoint landed with its number after it built on the previous one (`unexpected-parent-appeared`).
|
|
243
|
+
* The latter happens when the earlier slot's proposal reached this proposer too late to build on.
|
|
244
|
+
* - `checkpoint-unpublished` (5a) when its parent is on L1 and a quorum of the committee attested, so the proposer
|
|
245
|
+
* had everything it needed to publish. A later slot that lands the same checkpoint number does not exempt it:
|
|
246
|
+
* that slot only got the number because this proposer failed to land it.
|
|
247
|
+
* - `checkpoint-valid` (5) otherwise: the slot had an equivocation, the tracker has no parent or payload data to
|
|
248
|
+
* judge by, or attestations fell short of quorum. Short of quorum is a fault of the attestors, who are tagged
|
|
249
|
+
* `attestation-missed`, except after an unexpected parent appeared (`unexpected-parent-appeared`), when
|
|
250
|
+
* refusing to attest was correct.
|
|
251
|
+
*
|
|
252
|
+
* Only attestations for the proposal's own payload count toward quorum, since those are the only ones its proposer
|
|
253
|
+
* could have published.
|
|
254
|
+
*/
|
|
255
|
+
protected classifyUnpublishedProposal(slot: SlotNumber, proposer: EthAddress, committee: EthAddress[], p2pAttested: CheckpointAttestation[]): Promise<UnpublishedProposalClassification>;
|
|
194
256
|
/** Push the status for each slot for each validator. */
|
|
195
257
|
protected updateValidators(slot: SlotNumber, stats: Record<`0x${string}`, ValidatorStatusInSlot | undefined>): Promise<void>;
|
|
196
|
-
/**
|
|
197
|
-
|
|
258
|
+
/**
|
|
259
|
+
* Computes stats to be returned based on stored data. Calls without arguments cover the default window ending at the
|
|
260
|
+
* last processed slot and are served from a cache that is refreshed whenever that slot or the stored history changes.
|
|
261
|
+
*/
|
|
262
|
+
computeStats(args?: {
|
|
198
263
|
fromSlot?: SlotNumber;
|
|
199
264
|
toSlot?: SlotNumber;
|
|
200
265
|
validators?: EthAddress[];
|
|
201
266
|
}): Promise<ValidatorsStats>;
|
|
267
|
+
private computeStatsUncached;
|
|
202
268
|
/** Computes stats for a single validator. */
|
|
203
269
|
getValidatorStats(validatorAddress: EthAddress, fromSlot?: SlotNumber, toSlot?: SlotNumber): Promise<SingleValidatorStats | undefined>;
|
|
204
270
|
/** Computes stats in input order, sharing slot bounds and reads for duplicate validators. */
|
|
@@ -217,4 +283,4 @@ export declare class Sentinel extends Sentinel_base implements Watcher {
|
|
|
217
283
|
} | undefined;
|
|
218
284
|
}
|
|
219
285
|
export {};
|
|
220
|
-
//# sourceMappingURL=data:application/json;base64,
|
|
286
|
+
//# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoic2VudGluZWwuZC50cyIsInNvdXJjZVJvb3QiOiIiLCJzb3VyY2VzIjpbIi4uLy4uL3NyYy9zZW50aW5lbC9zZW50aW5lbC50cyJdLCJuYW1lcyI6W10sIm1hcHBpbmdzIjoiQUFDQSxPQUFPLEVBQUUsS0FBSyxVQUFVLEVBQTBCLE1BQU0seUJBQXlCLENBQUM7QUFDbEYsT0FBTyxFQUNMLGdCQUFnQixFQUNoQixzQkFBc0IsRUFDdEIsV0FBVyxFQUNYLFVBQVUsRUFDWCxNQUFNLHNDQUFzQyxDQUFDO0FBRTlDLE9BQU8sRUFBRSxVQUFVLEVBQUUsTUFBTSxvQ0FBb0MsQ0FBQztBQUVoRSxPQUFPLEVBQUUsY0FBYyxFQUFFLE1BQU0sd0NBQXdDLENBQUM7QUFDeEUsT0FBTyxLQUFLLEVBQUUsU0FBUyxFQUFFLE1BQU0saUJBQWlCLENBQUM7QUFDakQsT0FBTyxFQUlMLEtBQUssT0FBTyxFQUNaLEtBQUssY0FBYyxFQUVwQixNQUFNLHFCQUFxQixDQUFDO0FBQzdCLE9BQU8sS0FBSyxFQUFFLGFBQWEsRUFBRSxNQUFNLDRCQUE0QixDQUFDO0FBQ2hFLE9BQU8sRUFBRSxLQUFLLGFBQWEsRUFBNkMsTUFBTSwwQkFBMEIsQ0FBQztBQUN6RyxPQUFPLEtBQUssRUFBRSw0QkFBNEIsRUFBRSxNQUFNLCtCQUErQixDQUFDO0FBQ2xGLE9BQU8sS0FBSyxFQUFFLFdBQVcsRUFBRSxNQUFNLDJCQUEyQixDQUFDO0FBTzdELE9BQU8sRUFDTCxLQUFLLHFCQUFxQixFQUczQixNQUFNLHdCQUF3QixDQUFDO0FBQ2hDLE9BQU8sS0FBSyxFQUNWLG9CQUFvQixFQUNwQixjQUFjLEVBQ2Qsc0JBQXNCLEVBQ3RCLHFCQUFxQixFQUNyQixtQkFBbUIsRUFDbkIsMEJBQTBCLEVBQzFCLGVBQWUsRUFDaEIsTUFBTSwrQkFBK0IsQ0FBQztBQUd2QyxPQUFPLEtBQUssRUFBRSxjQUFjLEVBQUUsTUFBTSxhQUFhLENBQUM7QUFDbEQsT0FBTyxFQUFFLGFBQWEsRUFBRSxNQUFNLFlBQVksQ0FB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
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"sentinel.d.ts","sourceRoot":"","sources":["../../src/sentinel/sentinel.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"sentinel.d.ts","sourceRoot":"","sources":["../../src/sentinel/sentinel.ts"],"names":[],"mappings":"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"}
|
|
@@ -1,14 +1,27 @@
|
|
|
1
|
+
import { INITIAL_CHECKPOINT_NUMBER } from '@aztec-labs/constants';
|
|
1
2
|
import { requireStableCommittee } from '@aztec-labs/epoch-cache';
|
|
2
|
-
import { CheckpointProposalHash, EpochNumber, SlotNumber } from '@aztec-labs/foundation/branded-types';
|
|
3
|
+
import { CheckpointNumber, CheckpointProposalHash, EpochNumber, SlotNumber } from '@aztec-labs/foundation/branded-types';
|
|
3
4
|
import { countWhile, filterAsync, fromEntries, getEntries, mapValues } from '@aztec-labs/foundation/collection';
|
|
4
5
|
import { EthAddress } from '@aztec-labs/foundation/eth-address';
|
|
5
6
|
import { createLogger } from '@aztec-labs/foundation/log';
|
|
6
7
|
import { RunningPromise } from '@aztec-labs/foundation/running-promise';
|
|
7
8
|
import { OffenseType, WANT_TO_SLASH_EVENT, getOffenseTypeName } from '@aztec-labs/slasher';
|
|
8
9
|
import { getAttestationInfoFromPublishedCheckpoint } from '@aztec-labs/stdlib/block';
|
|
9
|
-
import { getEpochAtSlot, getSlotRangeForEpoch, getTimestampForSlot } from '@aztec-labs/stdlib/epoch-helpers';
|
|
10
|
+
import { computeQuorum, getEpochAtSlot, getSlotRangeForEpoch, getTimestampForSlot } from '@aztec-labs/stdlib/epoch-helpers';
|
|
10
11
|
import { ConsensusPayload } from '@aztec-labs/stdlib/p2p';
|
|
11
12
|
import EventEmitter from 'node:events';
|
|
13
|
+
const ORPHANED_PROPOSAL_REASON_DESCRIPTIONS = {
|
|
14
|
+
'parent-not-on-l1': 'the parent checkpoint it built on never landed on L1',
|
|
15
|
+
'parent-hash-mismatch': 'a different checkpoint landed on L1 in place of the parent it built on',
|
|
16
|
+
'unexpected-parent-appeared': 'a checkpoint from an earlier slot landed on L1 with its checkpoint number after it was built on the previous one'
|
|
17
|
+
};
|
|
18
|
+
/**
|
|
19
|
+
* Whether the classification shows the proposal conflicts with a checkpoint that landed on L1, either in its parent's
|
|
20
|
+
* position or in its own. A validator that already held the landed checkpoint's blocks rejects the proposal's blocks as
|
|
21
|
+
* conflicting, so it is right not to attest.
|
|
22
|
+
*/ function excusesMissingAttestations(classification) {
|
|
23
|
+
return classification !== undefined && 'reason' in classification && (classification.reason === 'parent-hash-mismatch' || classification.reason === 'unexpected-parent-appeared');
|
|
24
|
+
}
|
|
12
25
|
/** Maps a validator status to its category: proposer or attestation. */ function statusToCategory(status) {
|
|
13
26
|
switch(status){
|
|
14
27
|
case 'attestation-sent':
|
|
@@ -47,12 +60,19 @@ import EventEmitter from 'node:events';
|
|
|
47
60
|
* ## Six-case taxonomy in `getSlotActivity`
|
|
48
61
|
*
|
|
49
62
|
* For each slot, the sentinel assigns the proposer one of six statuses, ranked highest-confidence
|
|
50
|
-
* first:
|
|
63
|
+
* first, with case 5 refined into 5a and 5b:
|
|
51
64
|
*
|
|
52
65
|
* - `checkpoint-mined` — a checkpoint covering this slot has landed on L1
|
|
53
66
|
* (fetched on demand via `archiver.getCheckpoint({ slot })`).
|
|
67
|
+
* - `checkpoint-unpublished` — (5a) a valid checkpoint proposal built on a parent that is on L1
|
|
68
|
+
* reached quorum, yet the proposer did not land it.
|
|
69
|
+
* - `checkpoint-orphaned` — (5b) a valid checkpoint proposal could not land because the parent
|
|
70
|
+
* it built on never reached L1 (the proposer pipelining cascade), or
|
|
71
|
+
* because an earlier slot's checkpoint landed with its number after it
|
|
72
|
+
* had built on the previous one.
|
|
54
73
|
* - `checkpoint-valid` — the local node re-executed a checkpoint proposal for this slot
|
|
55
|
-
* successfully (consulted via `CheckpointReexecutionTracker`)
|
|
74
|
+
* successfully (consulted via `CheckpointReexecutionTracker`), and it
|
|
75
|
+
* could not be refined into 5a or 5b.
|
|
56
76
|
* - `checkpoint-invalid` — the local node re-executed a checkpoint proposal for this slot
|
|
57
77
|
* and rejected it (e.g. header/archive/out-hash mismatch, limit
|
|
58
78
|
* breach). Proposer-fault.
|
|
@@ -62,9 +82,15 @@ import EventEmitter from 'node:events';
|
|
|
62
82
|
* - `checkpoint-missed` — block proposals seen on P2P but no checkpoint proposal at all.
|
|
63
83
|
* - `blocks-missed` — no block proposals seen for this slot.
|
|
64
84
|
*
|
|
65
|
-
* Missing-attestor faults are recorded only in `checkpoint-mined` and
|
|
66
|
-
*
|
|
67
|
-
*
|
|
85
|
+
* Missing-attestor faults are recorded only in `checkpoint-mined` and the three valid-proposal
|
|
86
|
+
* statuses (`checkpoint-unpublished`, `checkpoint-orphaned`, `checkpoint-valid`), where the local
|
|
87
|
+
* node has positive evidence the checkpoint was canonical or valid. In the other four cases the
|
|
88
|
+
* proposer is at fault and no attestor penalty applies. A valid proposal that conflicts with a checkpoint
|
|
89
|
+
* that landed on L1, in its parent's position or its own, is also exempt: a validator holding the landed
|
|
90
|
+
* checkpoint's blocks rightly refuses it.
|
|
91
|
+
*
|
|
92
|
+
* `checkpoint-unpublished` and `checkpoint-orphaned` are neutral for the proposer, like
|
|
93
|
+
* `checkpoint-valid`: neither counts toward `missedProposals` or inactivity slashing.
|
|
68
94
|
*
|
|
69
95
|
* ## Re-execution tracker
|
|
70
96
|
*
|
|
@@ -100,8 +126,13 @@ import EventEmitter from 'node:events';
|
|
|
100
126
|
initialSlot;
|
|
101
127
|
lastProcessedSlot;
|
|
102
128
|
/** Largest epoch number for which the end-of-epoch aggregator has run. */ lastEvaluatedEpoch;
|
|
129
|
+
/** Bumped on every history write so cached stats are never served from before the write. */ historyGeneration;
|
|
130
|
+
/**
|
|
131
|
+
* Stats for the default window, shared by every caller until the next processed slot or history write. Computing them
|
|
132
|
+
* scans and deserializes the full history of every validator, while the inputs only change once per slot.
|
|
133
|
+
*/ defaultStatsCache;
|
|
103
134
|
constructor(epochCache, archiver, p2p, store, reexecutionTracker, config, logger = createLogger('node:sentinel')){
|
|
104
|
-
super(), this.epochCache = epochCache, this.archiver = archiver, this.p2p = p2p, this.store = store, this.reexecutionTracker = reexecutionTracker, this.config = config, this.logger = logger;
|
|
135
|
+
super(), this.epochCache = epochCache, this.archiver = archiver, this.p2p = p2p, this.store = store, this.reexecutionTracker = reexecutionTracker, this.config = config, this.logger = logger, this.historyGeneration = 0;
|
|
105
136
|
const interval = epochCache.getL1Constants().ethereumSlotDuration * 1000 / 4;
|
|
106
137
|
this.runningPromise = new RunningPromise(this.work.bind(this), logger, interval);
|
|
107
138
|
}
|
|
@@ -391,8 +422,12 @@ import EventEmitter from 'node:events';
|
|
|
391
422
|
*
|
|
392
423
|
* Proposer status:
|
|
393
424
|
* - case 6 `checkpoint-mined` — a checkpoint covering this slot has landed on L1.
|
|
425
|
+
* - case 5a `checkpoint-unpublished` — a valid proposal with its parent on L1 reached quorum but
|
|
426
|
+
* was not landed by the proposer.
|
|
427
|
+
* - case 5b `checkpoint-orphaned` — a valid proposal could not land because its parent never
|
|
428
|
+
* reached L1, or because an earlier slot took its checkpoint number.
|
|
394
429
|
* - case 5 `checkpoint-valid` — the local node re-executed a checkpoint proposal for this
|
|
395
|
-
* slot successfully.
|
|
430
|
+
* slot successfully, and it could not be refined into 5a or 5b.
|
|
396
431
|
* - case 4 `checkpoint-invalid` — the local node re-executed a checkpoint proposal for this
|
|
397
432
|
* slot and rejected it.
|
|
398
433
|
* - case 3 `checkpoint-unvalidated` — the local node observed a checkpoint proposal for this
|
|
@@ -400,8 +435,9 @@ import EventEmitter from 'node:events';
|
|
|
400
435
|
* - case 2 `checkpoint-missed` — block proposals seen on P2P but no checkpoint proposal.
|
|
401
436
|
* - case 1 `blocks-missed` — no block proposals seen for this slot.
|
|
402
437
|
*
|
|
403
|
-
* Missing-attestor penalties apply only in cases 5 and 6, where the local
|
|
404
|
-
* evidence the checkpoint was valid or has been canonicalised on L1
|
|
438
|
+
* Missing-attestor penalties apply only in cases 5 (including 5a and 5b) and 6, where the local
|
|
439
|
+
* node has positive evidence the checkpoint was valid or has been canonicalised on L1, and never when
|
|
440
|
+
* the proposal conflicts with a checkpoint that landed in its parent's position or its own.
|
|
405
441
|
*/ async getSlotActivity(slot, epoch, proposer, committee) {
|
|
406
442
|
this.logger.debug(`Computing stats for slot ${slot} at epoch ${epoch}`, {
|
|
407
443
|
slot,
|
|
@@ -419,13 +455,16 @@ import EventEmitter from 'node:events';
|
|
|
419
455
|
...p2pAttestors.map((a)=>a.toString()),
|
|
420
456
|
...checkpoint?.attestors.map((a)=>a.toString()) ?? []
|
|
421
457
|
].filter((addr)=>proposer.toString() !== addr));
|
|
422
|
-
// Determine the proposer status from the six-case taxonomy.
|
|
458
|
+
// Determine the proposer status from the six-case taxonomy. A valid proposal that never reached L1 (case 5) is
|
|
459
|
+
// refined into `checkpoint-unpublished` (5a) or `checkpoint-orphaned` (5b) when the evidence allows it.
|
|
423
460
|
const reexecutionOutcome = this.reexecutionTracker.getOutcomeForSlot(slot);
|
|
424
461
|
let status;
|
|
462
|
+
let classification;
|
|
425
463
|
if (checkpoint) {
|
|
426
464
|
status = 'checkpoint-mined';
|
|
427
465
|
} else if (reexecutionOutcome === 'valid') {
|
|
428
|
-
|
|
466
|
+
classification = await this.classifyUnpublishedProposal(slot, proposer, committee, p2pAttested);
|
|
467
|
+
status = classification.status;
|
|
429
468
|
} else if (reexecutionOutcome === 'invalid') {
|
|
430
469
|
status = 'checkpoint-invalid';
|
|
431
470
|
} else if (reexecutionOutcome === 'unvalidated') {
|
|
@@ -444,9 +483,10 @@ import EventEmitter from 'node:events';
|
|
|
444
483
|
// A local `invalid` verdict vetoes them even for a mined checkpoint (an honest validator refuses
|
|
445
484
|
// to sign what it re-executed as invalid), and so does a proposal equivocation in the slot: with
|
|
446
485
|
// two conflicting proposals an honest attestor may have seen an invalid one and correctly
|
|
447
|
-
// declined, so neither must count as a missed attestor (inactivity slashing).
|
|
486
|
+
// declined, so neither must count as a missed attestor (inactivity slashing). Nor does a proposal that conflicts
|
|
487
|
+
// with a checkpoint that landed on L1 (see excusesMissingAttestations).
|
|
448
488
|
const hasEquivocation = this.reexecutionTracker.hasEquivocation(slot);
|
|
449
|
-
const attestorsExpected = (status === 'checkpoint-mined' || status === 'checkpoint-valid') && reexecutionOutcome !== 'invalid' && !hasEquivocation;
|
|
489
|
+
const attestorsExpected = (status === 'checkpoint-mined' || status === 'checkpoint-unpublished' || status === 'checkpoint-orphaned' || status === 'checkpoint-valid') && reexecutionOutcome !== 'invalid' && !hasEquivocation && !excusesMissingAttestations(classification);
|
|
450
490
|
const missedAttestors = new Set(attestorsExpected ? committee.filter((v)=>!attestors.has(v.toString()) && !proposer.equals(v)).map((v)=>v.toString()) : []);
|
|
451
491
|
this.logger.debug(`Retrieved ${attestors.size} attestors out of ${committee.length} for slot ${slot}`, {
|
|
452
492
|
status,
|
|
@@ -478,10 +518,151 @@ import EventEmitter from 'node:events';
|
|
|
478
518
|
statusFor(who)
|
|
479
519
|
]));
|
|
480
520
|
}
|
|
481
|
-
/**
|
|
482
|
-
|
|
521
|
+
/**
|
|
522
|
+
* Refines case 5 (a checkpoint proposal for the slot re-executed as valid, with no checkpoint for the slot on L1)
|
|
523
|
+
* into whose fault it was that the proposal did not land:
|
|
524
|
+
*
|
|
525
|
+
* - `checkpoint-orphaned` (5b) when other proposers prevented it: the parent it built on never landed on L1 (it is
|
|
526
|
+
* missing, or a different checkpoint holds the parent's position), or, with a quorum of attestations, an earlier
|
|
527
|
+
* slot's checkpoint landed with its number after it built on the previous one (`unexpected-parent-appeared`).
|
|
528
|
+
* The latter happens when the earlier slot's proposal reached this proposer too late to build on.
|
|
529
|
+
* - `checkpoint-unpublished` (5a) when its parent is on L1 and a quorum of the committee attested, so the proposer
|
|
530
|
+
* had everything it needed to publish. A later slot that lands the same checkpoint number does not exempt it:
|
|
531
|
+
* that slot only got the number because this proposer failed to land it.
|
|
532
|
+
* - `checkpoint-valid` (5) otherwise: the slot had an equivocation, the tracker has no parent or payload data to
|
|
533
|
+
* judge by, or attestations fell short of quorum. Short of quorum is a fault of the attestors, who are tagged
|
|
534
|
+
* `attestation-missed`, except after an unexpected parent appeared (`unexpected-parent-appeared`), when
|
|
535
|
+
* refusing to attest was correct.
|
|
536
|
+
*
|
|
537
|
+
* Only attestations for the proposal's own payload count toward quorum, since those are the only ones its proposer
|
|
538
|
+
* could have published.
|
|
539
|
+
*/ async classifyUnpublishedProposal(slot, proposer, committee, p2pAttested) {
|
|
540
|
+
if (this.reexecutionTracker.hasEquivocation(slot)) {
|
|
541
|
+
return {
|
|
542
|
+
status: 'checkpoint-valid',
|
|
543
|
+
reason: 'equivocation'
|
|
544
|
+
};
|
|
545
|
+
}
|
|
546
|
+
const record = this.reexecutionTracker.getRecordForSlot(slot);
|
|
547
|
+
const checkpointNumber = record?.checkpointNumber;
|
|
548
|
+
const lastArchiveRoot = record?.lastArchiveRoot;
|
|
549
|
+
const payloadHash = record?.payloadHash;
|
|
550
|
+
if (checkpointNumber === undefined || lastArchiveRoot === undefined || payloadHash === undefined) {
|
|
551
|
+
return {
|
|
552
|
+
status: 'checkpoint-valid',
|
|
553
|
+
reason: 'no-proposal-data'
|
|
554
|
+
};
|
|
555
|
+
}
|
|
556
|
+
const logData = {
|
|
557
|
+
slot,
|
|
558
|
+
proposer: proposer.toString(),
|
|
559
|
+
checkpointNumber
|
|
560
|
+
};
|
|
561
|
+
const orphaned = (reason, extra = {})=>{
|
|
562
|
+
this.logger.verbose(`Valid checkpoint proposal for slot ${slot} was orphaned (${reason}): ${ORPHANED_PROPOSAL_REASON_DESCRIPTIONS[reason]}`, {
|
|
563
|
+
...logData,
|
|
564
|
+
...extra,
|
|
565
|
+
reason
|
|
566
|
+
});
|
|
567
|
+
return {
|
|
568
|
+
status: 'checkpoint-orphaned',
|
|
569
|
+
reason
|
|
570
|
+
};
|
|
571
|
+
};
|
|
572
|
+
// The first checkpoint builds on genesis, which is always on L1.
|
|
573
|
+
const parentNumber = checkpointNumber - 1;
|
|
574
|
+
if (parentNumber >= INITIAL_CHECKPOINT_NUMBER) {
|
|
575
|
+
const parent = await this.archiver.getCheckpointData({
|
|
576
|
+
number: CheckpointNumber(parentNumber)
|
|
577
|
+
});
|
|
578
|
+
if (!parent) {
|
|
579
|
+
return orphaned('parent-not-on-l1');
|
|
580
|
+
}
|
|
581
|
+
if (parent.archive.root.toString() !== lastArchiveRoot) {
|
|
582
|
+
return orphaned('parent-hash-mismatch');
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
// The proposer's own attestation counts, as it does when its sequencer collects attestations for L1.
|
|
586
|
+
const committeeMembers = new Set(committee.map((member)=>member.toString()));
|
|
587
|
+
const attestationCount = new Set(p2pAttested.filter((attestation)=>attestation.getPayloadHash() === payloadHash).map((attestation)=>attestation.getSender()?.toString()).filter((attestor)=>attestor !== undefined && committeeMembers.has(attestor))).size;
|
|
588
|
+
const quorum = computeQuorum(committee.length);
|
|
589
|
+
// A checkpoint landed for the slot itself would have made this case 6, so a holder of the same number comes from
|
|
590
|
+
// an earlier or a later slot. Only an earlier one can have stopped this proposal from landing.
|
|
591
|
+
const sameNumber = await this.archiver.getCheckpointData({
|
|
592
|
+
number: checkpointNumber
|
|
593
|
+
});
|
|
594
|
+
const sameNumberSlot = sameNumber?.header.slotNumber;
|
|
595
|
+
if (sameNumberSlot !== undefined && sameNumberSlot < slot) {
|
|
596
|
+
const conflictData = {
|
|
597
|
+
takenBySlot: sameNumberSlot,
|
|
598
|
+
attestationCount,
|
|
599
|
+
quorum
|
|
600
|
+
};
|
|
601
|
+
if (attestationCount < quorum) {
|
|
602
|
+
this.logger.verbose(`Valid checkpoint proposal for slot ${slot} fell short of quorum after checkpoint ${checkpointNumber} landed ` + `from earlier slot ${sameNumberSlot} (unexpected-parent-appeared), so missing attestations are not counted`, {
|
|
603
|
+
...logData,
|
|
604
|
+
...conflictData,
|
|
605
|
+
reason: 'unexpected-parent-appeared'
|
|
606
|
+
});
|
|
607
|
+
return {
|
|
608
|
+
status: 'checkpoint-valid',
|
|
609
|
+
reason: 'unexpected-parent-appeared'
|
|
610
|
+
};
|
|
611
|
+
}
|
|
612
|
+
return orphaned('unexpected-parent-appeared', conflictData);
|
|
613
|
+
}
|
|
614
|
+
if (attestationCount < quorum) {
|
|
615
|
+
return {
|
|
616
|
+
status: 'checkpoint-valid',
|
|
617
|
+
reason: 'below-quorum'
|
|
618
|
+
};
|
|
619
|
+
}
|
|
620
|
+
this.logger.info(`Proposer did not publish checkpoint ${checkpointNumber} for slot ${slot} despite quorum`, {
|
|
621
|
+
...logData,
|
|
622
|
+
attestationCount,
|
|
623
|
+
quorum
|
|
624
|
+
});
|
|
625
|
+
return {
|
|
626
|
+
status: 'checkpoint-unpublished'
|
|
627
|
+
};
|
|
628
|
+
}
|
|
629
|
+
/** Push the status for each slot for each validator. */ async updateValidators(slot, stats) {
|
|
630
|
+
this.historyGeneration++;
|
|
631
|
+
try {
|
|
632
|
+
await this.store.updateValidators(slot, stats);
|
|
633
|
+
} finally{
|
|
634
|
+
this.historyGeneration++;
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
/**
|
|
638
|
+
* Computes stats to be returned based on stored data. Calls without arguments cover the default window ending at the
|
|
639
|
+
* last processed slot and are served from a cache that is refreshed whenever that slot or the stored history changes.
|
|
640
|
+
*/ computeStats(args = {}) {
|
|
641
|
+
const isDefaultWindow = args.fromSlot === undefined && args.toSlot === undefined && args.validators === undefined;
|
|
642
|
+
// Before the first processed slot the default window ends at the wall-clock slot, which moves without any write.
|
|
643
|
+
if (!isDefaultWindow || this.lastProcessedSlot === undefined) {
|
|
644
|
+
return this.computeStatsUncached(args);
|
|
645
|
+
}
|
|
646
|
+
const { lastProcessedSlot, initialSlot, historyGeneration } = this;
|
|
647
|
+
if (this.defaultStatsCache?.lastProcessedSlot !== lastProcessedSlot || this.defaultStatsCache.initialSlot !== initialSlot || this.defaultStatsCache.historyGeneration !== historyGeneration) {
|
|
648
|
+
const stats = this.computeStatsUncached(args);
|
|
649
|
+
const entry = {
|
|
650
|
+
lastProcessedSlot,
|
|
651
|
+
initialSlot,
|
|
652
|
+
historyGeneration,
|
|
653
|
+
stats
|
|
654
|
+
};
|
|
655
|
+
this.defaultStatsCache = entry;
|
|
656
|
+
// A failed computation is dropped so the next caller retries instead of receiving the cached rejection.
|
|
657
|
+
stats.catch(()=>{
|
|
658
|
+
if (this.defaultStatsCache === entry) {
|
|
659
|
+
this.defaultStatsCache = undefined;
|
|
660
|
+
}
|
|
661
|
+
});
|
|
662
|
+
}
|
|
663
|
+
return this.defaultStatsCache.stats;
|
|
483
664
|
}
|
|
484
|
-
|
|
665
|
+
async computeStatsUncached({ fromSlot, toSlot, validators }) {
|
|
485
666
|
const histories = validators ? fromEntries(await Promise.all(validators.map(async (v)=>[
|
|
486
667
|
v.toString(),
|
|
487
668
|
await this.store.getHistory(v)
|
|
@@ -551,7 +732,7 @@ import EventEmitter from 'node:events';
|
|
|
551
732
|
computeStatsForValidator(address, allHistory, fromSlot, toSlot) {
|
|
552
733
|
let history = fromSlot ? allHistory.filter((h)=>BigInt(h.slot) >= fromSlot) : allHistory;
|
|
553
734
|
history = toSlot ? history.filter((h)=>BigInt(h.slot) <= toSlot) : history;
|
|
554
|
-
const lastProposal = history.filter((h)=>h.status === 'checkpoint-valid' || h.status === 'checkpoint-mined').at(-1);
|
|
735
|
+
const lastProposal = history.filter((h)=>h.status === 'checkpoint-valid' || h.status === 'checkpoint-unpublished' || h.status === 'checkpoint-orphaned' || h.status === 'checkpoint-mined').at(-1);
|
|
555
736
|
const lastAttestation = history.filter((h)=>h.status === 'attestation-sent').at(-1);
|
|
556
737
|
return {
|
|
557
738
|
address: EthAddress.fromString(address),
|
package/dest/sentinel/store.d.ts
CHANGED
|
@@ -40,4 +40,4 @@ export declare class SentinelStore {
|
|
|
40
40
|
private statusToNumber;
|
|
41
41
|
private statusFromNumber;
|
|
42
42
|
}
|
|
43
|
-
//# sourceMappingURL=data:application/json;base64,
|
|
43
|
+
//# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoic3RvcmUuZC50cyIsInNvdXJjZVJvb3QiOiIiLCJzb3VyY2VzIjpbIi4uLy4uL3NyYy9zZW50aW5lbC9zdG9yZS50cyJdLCJuYW1lcyI6W10sIm1hcHBpbmdzIjoiQUFBQSxPQUFPLEVBQUUsV0FBVyxFQUFFLFVBQVUsRUFBRSxNQUFNLHNDQUFzQyxDQUFDO0FBQy9FLE9BQU8sRUFBRSxVQUFVLEVBQUUsTUFBTSxvQ0FBb0MsQ0FBQztBQUVoRSxPQUFPLEtBQUssRUFBRSxpQkFBaUIsRUFBaUIsTUFBTSxzQkFBc0IsQ0FBQztBQUM3RSxPQUFPLEtBQUssRUFDVixzQkFBc0IsRUFDdEIscUJBQXFCLEVBQ3JCLDBCQUEwQixFQUMzQixNQUFNLCtCQUErQixDQUFDO0FBRXZDLHFCQUFhLGFBQWE7SUFXdEIsT0FBTyxDQUFDLEtBQUs7SUFDYixPQUFPLENBQUMsTUFBTTtJQVhoQixnQkFBdUIsY0FBYyxLQUFLO0lBRzFDLE9BQU8sQ0FBQyxRQUFRLENBQUMsVUFBVSxDQUF1QztJQUlsRSxPQUFPLENBQUMsUUFBUSxDQUFDLFFBQVEsQ0FBdUM7SUFFaEUsWUFDVSxLQUFLLEVBQUUsaUJBQWlCLEVBQ3hCLE1BQU0sRUFBRTtRQUFFLGFBQWEsRUFBRSxNQUFNLENBQUM7UUFBQyw4QkFBOEIsRUFBRSxNQUFNLENBQUE7S0FBRSxFQUlsRjtJQUVNLGdCQUFnQixXQUV0QjtJQUVNLGlDQUFpQyxXQUV2QztJQUVZLHNCQUFzQixDQUFDLEtBQUssRUFBRSxXQUFXLEVBQUUsV0FBVyxFQUFFLDBCQUEwQixpQkFNOUY7SUFFWSxtQkFBbUIsQ0FBQyxHQUFHLEVBQUUsVUFBVSxHQUFHLE9BQU8sQ0FBQztRQUFFLE1BQU0sRUFBRSxNQUFNLENBQUM7UUFBQyxLQUFLLEVBQUUsTUFBTSxDQUFDO1FBQUMsS0FBSyxFQUFFLFdBQVcsQ0FBQTtLQUFFLEVBQUUsQ0FBQyxDQUdsSDtJQUVELDhHQUE4RztJQUNqRyx3QkFBd0IsQ0FDbkMsU0FBUyxFQUFFLFVBQVUsRUFBRSxHQUN0QixPQUFPLENBQUM7UUFBRSxNQUFNLEVBQUUsTUFBTSxDQUFDO1FBQUMsS0FBSyxFQUFFLE1BQU0sQ0FBQztRQUFDLEtBQUssRUFBRSxXQUFXLENBQUE7S0FBRSxFQUFFLEVBQUUsQ0FBQyxDQUdwRTtZQUVhLDZCQUE2QjtJQTZCOUIsZ0JBQWdCLENBQUMsSUFBSSxFQUFFLFVBQVUsRUFBRSxRQUFRLEVBQUUsTUFBTSxDQUFDLEtBQUssTUFBTSxFQUFFLEVBQUUscUJBQXFCLEdBQUcsU0FBUyxDQUFDLGlCQVFqSDtZQUVhLDBCQUEwQjtJQVEzQixZQUFZLElBQUksT0FBTyxDQUFDLE1BQU0sQ0FBQyxLQUFLLE1BQU0sRUFBRSxFQUFFLHNCQUFzQixDQUFDLENBQUMsQ0FNbEY7SUFFWSxVQUFVLENBQUMsT0FBTyxFQUFFLFVBQVUsR0FBRyxPQUFPLENBQUMsc0JBQXNCLEdBQUcsU0FBUyxDQUFDLENBR3hGO0lBRUQsMEZBQTBGO0lBQzdFLGVBQWUsQ0FBQyxTQUFTLEVBQUUsVUFBVSxFQUFFLEdBQUcsT0FBTyxDQUFDLENBQUMsc0JBQXNCLEdBQUcsU0FBUyxDQUFDLEVBQUUsQ0FBQyxDQUdyRztJQUVELE9BQU8sQ0FBQyxvQkFBb0I7SUFNNUIsT0FBTyxDQUFDLHNCQUFzQjtJQWE5QixPQUFPLENBQUMsZ0JBQWdCO0lBTXhCLE9BQU8sQ0FBQyxrQkFBa0I7SUFXMUIsT0FBTyxDQUFDLGNBQWM7SUE2QnRCLE9BQU8sQ0FBQyxnQkFBZ0I7Q0EwQnpCIn0=
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"store.d.ts","sourceRoot":"","sources":["../../src/sentinel/store.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,WAAW,EAAE,UAAU,EAAE,MAAM,sCAAsC,CAAC;AAC/E,OAAO,EAAE,UAAU,EAAE,MAAM,oCAAoC,CAAC;AAEhE,OAAO,KAAK,EAAE,iBAAiB,EAAiB,MAAM,sBAAsB,CAAC;AAC7E,OAAO,KAAK,EACV,sBAAsB,EACtB,qBAAqB,EACrB,0BAA0B,EAC3B,MAAM,+BAA+B,CAAC;AAEvC,qBAAa,aAAa;IAWtB,OAAO,CAAC,KAAK;IACb,OAAO,CAAC,MAAM;IAXhB,gBAAuB,cAAc,KAAK;IAG1C,OAAO,CAAC,QAAQ,CAAC,UAAU,CAAuC;IAIlE,OAAO,CAAC,QAAQ,CAAC,QAAQ,CAAuC;IAEhE,YACU,KAAK,EAAE,iBAAiB,EACxB,MAAM,EAAE;QAAE,aAAa,EAAE,MAAM,CAAC;QAAC,8BAA8B,EAAE,MAAM,CAAA;KAAE,EAIlF;IAEM,gBAAgB,WAEtB;IAEM,iCAAiC,WAEvC;IAEY,sBAAsB,CAAC,KAAK,EAAE,WAAW,EAAE,WAAW,EAAE,0BAA0B,iBAM9F;IAEY,mBAAmB,CAAC,GAAG,EAAE,UAAU,GAAG,OAAO,CAAC;QAAE,MAAM,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,WAAW,CAAA;KAAE,EAAE,CAAC,CAGlH;IAED,8GAA8G;IACjG,wBAAwB,CACnC,SAAS,EAAE,UAAU,EAAE,GACtB,OAAO,CAAC;QAAE,MAAM,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,WAAW,CAAA;KAAE,EAAE,EAAE,CAAC,CAGpE;YAEa,6BAA6B;IA6B9B,gBAAgB,CAAC,IAAI,EAAE,UAAU,EAAE,QAAQ,EAAE,MAAM,CAAC,KAAK,MAAM,EAAE,EAAE,qBAAqB,GAAG,SAAS,CAAC,iBAQjH;YAEa,0BAA0B;IAQ3B,YAAY,IAAI,OAAO,CAAC,MAAM,CAAC,KAAK,MAAM,EAAE,EAAE,sBAAsB,CAAC,CAAC,CAMlF;IAEY,UAAU,CAAC,OAAO,EAAE,UAAU,GAAG,OAAO,CAAC,sBAAsB,GAAG,SAAS,CAAC,CAGxF;IAED,0FAA0F;IAC7E,eAAe,CAAC,SAAS,EAAE,UAAU,EAAE,GAAG,OAAO,CAAC,CAAC,sBAAsB,GAAG,SAAS,CAAC,EAAE,CAAC,CAGrG;IAED,OAAO,CAAC,oBAAoB;IAM5B,OAAO,CAAC,sBAAsB;IAa9B,OAAO,CAAC,gBAAgB;IAMxB,OAAO,CAAC,kBAAkB;IAW1B,OAAO,CAAC,cAAc;
|
|
1
|
+
{"version":3,"file":"store.d.ts","sourceRoot":"","sources":["../../src/sentinel/store.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,WAAW,EAAE,UAAU,EAAE,MAAM,sCAAsC,CAAC;AAC/E,OAAO,EAAE,UAAU,EAAE,MAAM,oCAAoC,CAAC;AAEhE,OAAO,KAAK,EAAE,iBAAiB,EAAiB,MAAM,sBAAsB,CAAC;AAC7E,OAAO,KAAK,EACV,sBAAsB,EACtB,qBAAqB,EACrB,0BAA0B,EAC3B,MAAM,+BAA+B,CAAC;AAEvC,qBAAa,aAAa;IAWtB,OAAO,CAAC,KAAK;IACb,OAAO,CAAC,MAAM;IAXhB,gBAAuB,cAAc,KAAK;IAG1C,OAAO,CAAC,QAAQ,CAAC,UAAU,CAAuC;IAIlE,OAAO,CAAC,QAAQ,CAAC,QAAQ,CAAuC;IAEhE,YACU,KAAK,EAAE,iBAAiB,EACxB,MAAM,EAAE;QAAE,aAAa,EAAE,MAAM,CAAC;QAAC,8BAA8B,EAAE,MAAM,CAAA;KAAE,EAIlF;IAEM,gBAAgB,WAEtB;IAEM,iCAAiC,WAEvC;IAEY,sBAAsB,CAAC,KAAK,EAAE,WAAW,EAAE,WAAW,EAAE,0BAA0B,iBAM9F;IAEY,mBAAmB,CAAC,GAAG,EAAE,UAAU,GAAG,OAAO,CAAC;QAAE,MAAM,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,WAAW,CAAA;KAAE,EAAE,CAAC,CAGlH;IAED,8GAA8G;IACjG,wBAAwB,CACnC,SAAS,EAAE,UAAU,EAAE,GACtB,OAAO,CAAC;QAAE,MAAM,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,WAAW,CAAA;KAAE,EAAE,EAAE,CAAC,CAGpE;YAEa,6BAA6B;IA6B9B,gBAAgB,CAAC,IAAI,EAAE,UAAU,EAAE,QAAQ,EAAE,MAAM,CAAC,KAAK,MAAM,EAAE,EAAE,qBAAqB,GAAG,SAAS,CAAC,iBAQjH;YAEa,0BAA0B;IAQ3B,YAAY,IAAI,OAAO,CAAC,MAAM,CAAC,KAAK,MAAM,EAAE,EAAE,sBAAsB,CAAC,CAAC,CAMlF;IAEY,UAAU,CAAC,OAAO,EAAE,UAAU,GAAG,OAAO,CAAC,sBAAsB,GAAG,SAAS,CAAC,CAGxF;IAED,0FAA0F;IAC7E,eAAe,CAAC,SAAS,EAAE,UAAU,EAAE,GAAG,OAAO,CAAC,CAAC,sBAAsB,GAAG,SAAS,CAAC,EAAE,CAAC,CAGrG;IAED,OAAO,CAAC,oBAAoB;IAM5B,OAAO,CAAC,sBAAsB;IAa9B,OAAO,CAAC,gBAAgB;IAMxB,OAAO,CAAC,kBAAkB;IAW1B,OAAO,CAAC,cAAc;IA6BtB,OAAO,CAAC,gBAAgB;CA0BzB"}
|
package/dest/sentinel/store.js
CHANGED
|
@@ -158,6 +158,10 @@ export class SentinelStore {
|
|
|
158
158
|
return 7;
|
|
159
159
|
case 'checkpoint-unvalidated':
|
|
160
160
|
return 8;
|
|
161
|
+
case 'checkpoint-unpublished':
|
|
162
|
+
return 9;
|
|
163
|
+
case 'checkpoint-orphaned':
|
|
164
|
+
return 10;
|
|
161
165
|
default:
|
|
162
166
|
{
|
|
163
167
|
const _exhaustive = status;
|
|
@@ -183,6 +187,10 @@ export class SentinelStore {
|
|
|
183
187
|
return 'checkpoint-invalid';
|
|
184
188
|
case 8:
|
|
185
189
|
return 'checkpoint-unvalidated';
|
|
190
|
+
case 9:
|
|
191
|
+
return 'checkpoint-unpublished';
|
|
192
|
+
case 10:
|
|
193
|
+
return 'checkpoint-orphaned';
|
|
186
194
|
default:
|
|
187
195
|
throw new Error(`Unknown status: ${status}`);
|
|
188
196
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@aztec-labs/aztec-node",
|
|
3
|
-
"version": "6.0.
|
|
3
|
+
"version": "6.1.0-nightly.20261023",
|
|
4
4
|
"main": "dest/index.js",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -65,38 +65,38 @@
|
|
|
65
65
|
]
|
|
66
66
|
},
|
|
67
67
|
"dependencies": {
|
|
68
|
-
"@aztec-foundation/bb.js": "6.0.0",
|
|
69
|
-
"@aztec-labs/archiver": "6.0.
|
|
70
|
-
"@aztec-labs/bb-prover": "6.0.
|
|
71
|
-
"@aztec-labs/blob-client": "6.0.
|
|
72
|
-
"@aztec-labs/blob-lib": "6.0.
|
|
73
|
-
"@aztec-labs/constants": "6.0.
|
|
74
|
-
"@aztec-labs/epoch-cache": "6.0.
|
|
75
|
-
"@aztec-labs/ethereum": "6.0.
|
|
76
|
-
"@aztec-labs/foundation": "6.0.
|
|
77
|
-
"@aztec-labs/kv-store": "6.0.
|
|
78
|
-
"@aztec-labs/node-keystore": "6.0.
|
|
79
|
-
"@aztec-labs/node-lib": "6.0.
|
|
80
|
-
"@aztec-labs/p2p": "6.0.
|
|
81
|
-
"@aztec-labs/protocol-contracts": "6.0.
|
|
82
|
-
"@aztec-labs/prover-client": "6.0.
|
|
83
|
-
"@aztec-labs/prover-node": "6.0.
|
|
84
|
-
"@aztec-labs/sequencer-client": "6.0.
|
|
85
|
-
"@aztec-labs/simulator": "6.0.
|
|
86
|
-
"@aztec-labs/slasher": "6.0.
|
|
87
|
-
"@aztec-labs/standard-contracts": "6.0.
|
|
88
|
-
"@aztec-labs/stdlib": "6.0.
|
|
89
|
-
"@aztec-labs/telemetry-client": "6.0.
|
|
90
|
-
"@aztec-labs/validator-client": "6.0.
|
|
91
|
-
"@aztec-labs/validator-ha-signer": "6.0.
|
|
92
|
-
"@aztec-labs/world-state": "6.0.
|
|
68
|
+
"@aztec-foundation/bb.js": "6.0.0-rc.7",
|
|
69
|
+
"@aztec-labs/archiver": "6.1.0-nightly.20261023",
|
|
70
|
+
"@aztec-labs/bb-prover": "6.1.0-nightly.20261023",
|
|
71
|
+
"@aztec-labs/blob-client": "6.1.0-nightly.20261023",
|
|
72
|
+
"@aztec-labs/blob-lib": "6.1.0-nightly.20261023",
|
|
73
|
+
"@aztec-labs/constants": "6.1.0-nightly.20261023",
|
|
74
|
+
"@aztec-labs/epoch-cache": "6.1.0-nightly.20261023",
|
|
75
|
+
"@aztec-labs/ethereum": "6.1.0-nightly.20261023",
|
|
76
|
+
"@aztec-labs/foundation": "6.1.0-nightly.20261023",
|
|
77
|
+
"@aztec-labs/kv-store": "6.1.0-nightly.20261023",
|
|
78
|
+
"@aztec-labs/node-keystore": "6.1.0-nightly.20261023",
|
|
79
|
+
"@aztec-labs/node-lib": "6.1.0-nightly.20261023",
|
|
80
|
+
"@aztec-labs/p2p": "6.1.0-nightly.20261023",
|
|
81
|
+
"@aztec-labs/protocol-contracts": "6.1.0-nightly.20261023",
|
|
82
|
+
"@aztec-labs/prover-client": "6.1.0-nightly.20261023",
|
|
83
|
+
"@aztec-labs/prover-node": "6.1.0-nightly.20261023",
|
|
84
|
+
"@aztec-labs/sequencer-client": "6.1.0-nightly.20261023",
|
|
85
|
+
"@aztec-labs/simulator": "6.1.0-nightly.20261023",
|
|
86
|
+
"@aztec-labs/slasher": "6.1.0-nightly.20261023",
|
|
87
|
+
"@aztec-labs/standard-contracts": "6.1.0-nightly.20261023",
|
|
88
|
+
"@aztec-labs/stdlib": "6.1.0-nightly.20261023",
|
|
89
|
+
"@aztec-labs/telemetry-client": "6.1.0-nightly.20261023",
|
|
90
|
+
"@aztec-labs/validator-client": "6.1.0-nightly.20261023",
|
|
91
|
+
"@aztec-labs/validator-ha-signer": "6.1.0-nightly.20261023",
|
|
92
|
+
"@aztec-labs/world-state": "6.1.0-nightly.20261023",
|
|
93
93
|
"koa": "^2.16.1",
|
|
94
94
|
"koa-router": "^13.1.1",
|
|
95
95
|
"tslib": "^2.4.0",
|
|
96
96
|
"viem": "2.57.1"
|
|
97
97
|
},
|
|
98
98
|
"devDependencies": {
|
|
99
|
-
"@aztec-labs/noir-protocol-circuits-types": "6.0.
|
|
99
|
+
"@aztec-labs/noir-protocol-circuits-types": "6.1.0-nightly.20261023",
|
|
100
100
|
"@jest/globals": "^30.0.0",
|
|
101
101
|
"@types/jest": "^30.0.0",
|
|
102
102
|
"@types/node": "^22.15.17",
|
package/src/sentinel/README.md
CHANGED
|
@@ -45,23 +45,39 @@ The aggregator catches up if multiple epochs become eligible at once (e.g. after
|
|
|
45
45
|
|
|
46
46
|
## Six-case taxonomy
|
|
47
47
|
|
|
48
|
-
For each slot, the proposer is assigned one of six statuses, ranked highest-confidence first:
|
|
48
|
+
For each slot, the proposer is assigned one of six statuses, ranked highest-confidence first. Case 5 is refined into 5a and 5b when the evidence allows it:
|
|
49
49
|
|
|
50
50
|
| # | Status | Trigger | Inactive party |
|
|
51
51
|
|---|---|---|---|
|
|
52
52
|
| 6 | `checkpoint-mined` | `archiver.getCheckpoint({ slot })` returns a checkpoint (one covering this slot has landed on L1) | Attestors who didn't attest |
|
|
53
|
-
|
|
|
53
|
+
| 5a | `checkpoint-unpublished` | Case 5, and the proposal's parent is on L1 and a quorum of the committee attested | Attestors who didn't attest (proposer fault recorded, not yet penalized) |
|
|
54
|
+
| 5b | `checkpoint-orphaned` | Case 5, and the parent the proposal built on never landed on L1 (missing or different archive root), or an earlier slot landed the proposal's checkpoint number after a quorum attested it | Attestors who didn't attest, unless a different checkpoint landed in the parent's position or an earlier slot took the checkpoint number |
|
|
55
|
+
| 5 | `checkpoint-valid` | `tracker.getOutcomeForSlot(slot) === 'valid'`, not refined into 5a or 5b | Attestors who didn't attest |
|
|
54
56
|
| 4 | `checkpoint-invalid` | `tracker.getOutcomeForSlot(slot) === 'invalid'` (re-executed and rejected) | Proposer |
|
|
55
57
|
| 3 | `checkpoint-unvalidated` | `tracker.getOutcomeForSlot(slot) === 'unvalidated'` (validation aborted: missing data, timeout, etc.) | Proposer |
|
|
56
58
|
| 2 | `checkpoint-missed` | `p2p.hasBlockProposalsForSlot(slot)` true (blocks seen but no checkpoint proposal observed) | Proposer |
|
|
57
59
|
| 1 | `blocks-missed` | None of the above (no block proposals observed) | Proposer |
|
|
58
60
|
|
|
59
|
-
Missing-attestor faults are only recorded in cases 5 and 6 — where the local node has positive evidence the checkpoint was valid or canonical. In cases 1–4 the proposer is at fault and no attestor penalty applies.
|
|
61
|
+
Missing-attestor faults are only recorded in cases 5 (including 5a and 5b) and 6 — where the local node has positive evidence the checkpoint was valid or canonical. In cases 1–4 the proposer is at fault and no attestor penalty applies. Nor does one apply when a different checkpoint landed in the parent's position or an earlier slot landed the proposal's checkpoint number: a validator that already held the landed checkpoint's blocks rejects the proposal (or the parent it builds on) as conflicting, and is right to.
|
|
62
|
+
|
|
63
|
+
### Refining case 5: unpublished and orphaned proposals
|
|
64
|
+
|
|
65
|
+
Under proposer pipelining, the proposer for slot N+1 builds on slot N's gossiped proposal. If slot N never lands, slot N+1 cannot land either, through no fault of its own. Cases 5a and 5b separate that cascade from a proposer that simply failed to publish. When a slot has a valid re-execution outcome but no checkpoint on L1, the sentinel reads the tracker's record for the slot (`getRecordForSlot`) and checks, in order:
|
|
66
|
+
|
|
67
|
+
1. If the slot had a proposal equivocation, or the record has no checkpoint number, parent archive root or payload hash, the status stays `checkpoint-valid`.
|
|
68
|
+
2. If the parent checkpoint (`checkpointNumber - 1`) is not on L1, or its archive root differs from the proposal's `lastArchiveRoot`, the status is `checkpoint-orphaned`. The first checkpoint builds on genesis, which always counts as on L1. When a different checkpoint holds the parent's position (`parent-hash-mismatch`), no attestor is tagged `attestation-missed`: a validator that held that checkpoint's blocks rightly rejects the proposal's parent as conflicting.
|
|
69
|
+
3. If a checkpoint from an earlier slot landed on L1 with the proposal's checkpoint number (`unexpected-parent-appeared`, the reason the sequencer gives when it discards its own pipelined checkpoint for this cause), the earlier slot's proposal reached the proposer too late to build on, and once it landed this proposal could not. With a quorum of attestations the status is `checkpoint-orphaned`. Below quorum it stays `checkpoint-valid`, and no attestor is tagged `attestation-missed`. Both are logged at `verbose` with the reason and the slot that took the number.
|
|
70
|
+
4. If fewer than `computeQuorum(committee.length)` distinct committee members attested the proposal's payload on P2P (the proposer's own attestation counts, as it does for the sequencer; attestations for another payload in the slot do not, since the proposer could not publish them), the status stays `checkpoint-valid`: the proposer could not publish, and the attestors are at fault.
|
|
71
|
+
5. Otherwise the status is `checkpoint-unpublished`, logged at `info` with the attestation count and quorum.
|
|
72
|
+
|
|
73
|
+
A checkpoint from a later slot that holds the proposal's checkpoint number does not exempt the proposer: that slot only got the number because this proposer failed to land it.
|
|
74
|
+
|
|
75
|
+
Neither `checkpoint-unpublished` nor `checkpoint-orphaned` counts toward `missedProposals` or inactivity slashing yet. Like `checkpoint-valid`, both only add to the proposer's slot total.
|
|
60
76
|
|
|
61
77
|
Each non-proposer committee member is tagged:
|
|
62
78
|
|
|
63
79
|
- `attestation-sent` — attestation seen on P2P (with valid signature) or in the L1 checkpoint's attestor set
|
|
64
|
-
- `attestation-missed` — only when proposer status is case 5 or 6 and the validator's attestation was not seen
|
|
80
|
+
- `attestation-missed` — only when proposer status is case 5 (including 5a and 5b) or 6 and the validator's attestation was not seen, unless a different checkpoint landed in the parent's position or an earlier slot landed the proposal's checkpoint number
|
|
65
81
|
- none — otherwise
|
|
66
82
|
|
|
67
83
|
## Inactivity slashing
|
package/src/sentinel/sentinel.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { INITIAL_CHECKPOINT_NUMBER } from '@aztec-labs/constants';
|
|
1
2
|
import { type EpochCache, requireStableCommittee } from '@aztec-labs/epoch-cache';
|
|
2
3
|
import {
|
|
3
4
|
CheckpointNumber,
|
|
@@ -22,8 +23,17 @@ import type { SlasherConfig } from '@aztec-labs/slasher/config';
|
|
|
22
23
|
import { type L2BlockSource, getAttestationInfoFromPublishedCheckpoint } from '@aztec-labs/stdlib/block';
|
|
23
24
|
import type { CheckpointReexecutionTracker } from '@aztec-labs/stdlib/checkpoint';
|
|
24
25
|
import type { ChainConfig } from '@aztec-labs/stdlib/config';
|
|
25
|
-
import {
|
|
26
|
-
|
|
26
|
+
import {
|
|
27
|
+
computeQuorum,
|
|
28
|
+
getEpochAtSlot,
|
|
29
|
+
getSlotRangeForEpoch,
|
|
30
|
+
getTimestampForSlot,
|
|
31
|
+
} from '@aztec-labs/stdlib/epoch-helpers';
|
|
32
|
+
import {
|
|
33
|
+
type CheckpointAttestation,
|
|
34
|
+
ConsensusPayload,
|
|
35
|
+
type CoordinationSignatureContext,
|
|
36
|
+
} from '@aztec-labs/stdlib/p2p';
|
|
27
37
|
import type {
|
|
28
38
|
SingleValidatorStats,
|
|
29
39
|
ValidatorStats,
|
|
@@ -45,6 +55,42 @@ export type SentinelRuntimeConfig = Pick<
|
|
|
45
55
|
Pick<SentinelConfig, 'sentinelEpochEndBufferSlots'> &
|
|
46
56
|
Pick<ChainConfig, 'l1ChainId' | 'rollupAddress'>;
|
|
47
57
|
|
|
58
|
+
/**
|
|
59
|
+
* Why a valid checkpoint proposal that never reached L1 could not have landed, regardless of its proposer.
|
|
60
|
+
* `unexpected-parent-appeared` matches the reason the sequencer gives when it discards its own pipelined checkpoint
|
|
61
|
+
* for the same cause.
|
|
62
|
+
*/
|
|
63
|
+
type OrphanedProposalReason = 'parent-not-on-l1' | 'parent-hash-mismatch' | 'unexpected-parent-appeared';
|
|
64
|
+
|
|
65
|
+
const ORPHANED_PROPOSAL_REASON_DESCRIPTIONS: Record<OrphanedProposalReason, string> = {
|
|
66
|
+
'parent-not-on-l1': 'the parent checkpoint it built on never landed on L1',
|
|
67
|
+
'parent-hash-mismatch': 'a different checkpoint landed on L1 in place of the parent it built on',
|
|
68
|
+
'unexpected-parent-appeared':
|
|
69
|
+
'a checkpoint from an earlier slot landed on L1 with its checkpoint number after it was built on the previous one',
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
/** Refined status of a valid checkpoint proposal for a slot whose checkpoint never reached L1. */
|
|
73
|
+
type UnpublishedProposalClassification =
|
|
74
|
+
| { status: 'checkpoint-unpublished' }
|
|
75
|
+
| { status: 'checkpoint-orphaned'; reason: OrphanedProposalReason }
|
|
76
|
+
| {
|
|
77
|
+
status: 'checkpoint-valid';
|
|
78
|
+
reason: 'equivocation' | 'no-proposal-data' | 'below-quorum' | 'unexpected-parent-appeared';
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Whether the classification shows the proposal conflicts with a checkpoint that landed on L1, either in its parent's
|
|
83
|
+
* position or in its own. A validator that already held the landed checkpoint's blocks rejects the proposal's blocks as
|
|
84
|
+
* conflicting, so it is right not to attest.
|
|
85
|
+
*/
|
|
86
|
+
function excusesMissingAttestations(classification: UnpublishedProposalClassification | undefined): boolean {
|
|
87
|
+
return (
|
|
88
|
+
classification !== undefined &&
|
|
89
|
+
'reason' in classification &&
|
|
90
|
+
(classification.reason === 'parent-hash-mismatch' || classification.reason === 'unexpected-parent-appeared')
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
|
|
48
94
|
/** Maps a validator status to its category: proposer or attestation. */
|
|
49
95
|
function statusToCategory(status: ValidatorStatusInSlot): ValidatorStatusType {
|
|
50
96
|
switch (status) {
|
|
@@ -85,12 +131,19 @@ function statusToCategory(status: ValidatorStatusInSlot): ValidatorStatusType {
|
|
|
85
131
|
* ## Six-case taxonomy in `getSlotActivity`
|
|
86
132
|
*
|
|
87
133
|
* For each slot, the sentinel assigns the proposer one of six statuses, ranked highest-confidence
|
|
88
|
-
* first:
|
|
134
|
+
* first, with case 5 refined into 5a and 5b:
|
|
89
135
|
*
|
|
90
136
|
* - `checkpoint-mined` — a checkpoint covering this slot has landed on L1
|
|
91
137
|
* (fetched on demand via `archiver.getCheckpoint({ slot })`).
|
|
138
|
+
* - `checkpoint-unpublished` — (5a) a valid checkpoint proposal built on a parent that is on L1
|
|
139
|
+
* reached quorum, yet the proposer did not land it.
|
|
140
|
+
* - `checkpoint-orphaned` — (5b) a valid checkpoint proposal could not land because the parent
|
|
141
|
+
* it built on never reached L1 (the proposer pipelining cascade), or
|
|
142
|
+
* because an earlier slot's checkpoint landed with its number after it
|
|
143
|
+
* had built on the previous one.
|
|
92
144
|
* - `checkpoint-valid` — the local node re-executed a checkpoint proposal for this slot
|
|
93
|
-
* successfully (consulted via `CheckpointReexecutionTracker`)
|
|
145
|
+
* successfully (consulted via `CheckpointReexecutionTracker`), and it
|
|
146
|
+
* could not be refined into 5a or 5b.
|
|
94
147
|
* - `checkpoint-invalid` — the local node re-executed a checkpoint proposal for this slot
|
|
95
148
|
* and rejected it (e.g. header/archive/out-hash mismatch, limit
|
|
96
149
|
* breach). Proposer-fault.
|
|
@@ -100,9 +153,15 @@ function statusToCategory(status: ValidatorStatusInSlot): ValidatorStatusType {
|
|
|
100
153
|
* - `checkpoint-missed` — block proposals seen on P2P but no checkpoint proposal at all.
|
|
101
154
|
* - `blocks-missed` — no block proposals seen for this slot.
|
|
102
155
|
*
|
|
103
|
-
* Missing-attestor faults are recorded only in `checkpoint-mined` and
|
|
104
|
-
*
|
|
105
|
-
*
|
|
156
|
+
* Missing-attestor faults are recorded only in `checkpoint-mined` and the three valid-proposal
|
|
157
|
+
* statuses (`checkpoint-unpublished`, `checkpoint-orphaned`, `checkpoint-valid`), where the local
|
|
158
|
+
* node has positive evidence the checkpoint was canonical or valid. In the other four cases the
|
|
159
|
+
* proposer is at fault and no attestor penalty applies. A valid proposal that conflicts with a checkpoint
|
|
160
|
+
* that landed on L1, in its parent's position or its own, is also exempt: a validator holding the landed
|
|
161
|
+
* checkpoint's blocks rightly refuses it.
|
|
162
|
+
*
|
|
163
|
+
* `checkpoint-unpublished` and `checkpoint-orphaned` are neutral for the proposer, like
|
|
164
|
+
* `checkpoint-valid`: neither counts toward `missedProposals` or inactivity slashing.
|
|
106
165
|
*
|
|
107
166
|
* ## Re-execution tracker
|
|
108
167
|
*
|
|
@@ -135,6 +194,21 @@ export class Sentinel extends (EventEmitter as new () => WatcherEmitter) impleme
|
|
|
135
194
|
/** Largest epoch number for which the end-of-epoch aggregator has run. */
|
|
136
195
|
protected lastEvaluatedEpoch: EpochNumber | undefined;
|
|
137
196
|
|
|
197
|
+
/** Bumped on every history write so cached stats are never served from before the write. */
|
|
198
|
+
private historyGeneration = 0;
|
|
199
|
+
/**
|
|
200
|
+
* Stats for the default window, shared by every caller until the next processed slot or history write. Computing them
|
|
201
|
+
* scans and deserializes the full history of every validator, while the inputs only change once per slot.
|
|
202
|
+
*/
|
|
203
|
+
private defaultStatsCache:
|
|
204
|
+
| {
|
|
205
|
+
lastProcessedSlot: SlotNumber;
|
|
206
|
+
initialSlot: SlotNumber | undefined;
|
|
207
|
+
historyGeneration: number;
|
|
208
|
+
stats: Promise<ValidatorsStats>;
|
|
209
|
+
}
|
|
210
|
+
| undefined;
|
|
211
|
+
|
|
138
212
|
constructor(
|
|
139
213
|
protected epochCache: EpochCache,
|
|
140
214
|
protected archiver: L2BlockSource,
|
|
@@ -490,8 +564,12 @@ export class Sentinel extends (EventEmitter as new () => WatcherEmitter) impleme
|
|
|
490
564
|
*
|
|
491
565
|
* Proposer status:
|
|
492
566
|
* - case 6 `checkpoint-mined` — a checkpoint covering this slot has landed on L1.
|
|
567
|
+
* - case 5a `checkpoint-unpublished` — a valid proposal with its parent on L1 reached quorum but
|
|
568
|
+
* was not landed by the proposer.
|
|
569
|
+
* - case 5b `checkpoint-orphaned` — a valid proposal could not land because its parent never
|
|
570
|
+
* reached L1, or because an earlier slot took its checkpoint number.
|
|
493
571
|
* - case 5 `checkpoint-valid` — the local node re-executed a checkpoint proposal for this
|
|
494
|
-
* slot successfully.
|
|
572
|
+
* slot successfully, and it could not be refined into 5a or 5b.
|
|
495
573
|
* - case 4 `checkpoint-invalid` — the local node re-executed a checkpoint proposal for this
|
|
496
574
|
* slot and rejected it.
|
|
497
575
|
* - case 3 `checkpoint-unvalidated` — the local node observed a checkpoint proposal for this
|
|
@@ -499,8 +577,9 @@ export class Sentinel extends (EventEmitter as new () => WatcherEmitter) impleme
|
|
|
499
577
|
* - case 2 `checkpoint-missed` — block proposals seen on P2P but no checkpoint proposal.
|
|
500
578
|
* - case 1 `blocks-missed` — no block proposals seen for this slot.
|
|
501
579
|
*
|
|
502
|
-
* Missing-attestor penalties apply only in cases 5 and 6, where the local
|
|
503
|
-
* evidence the checkpoint was valid or has been canonicalised on L1
|
|
580
|
+
* Missing-attestor penalties apply only in cases 5 (including 5a and 5b) and 6, where the local
|
|
581
|
+
* node has positive evidence the checkpoint was valid or has been canonicalised on L1, and never when
|
|
582
|
+
* the proposal conflicts with a checkpoint that landed in its parent's position or its own.
|
|
504
583
|
*/
|
|
505
584
|
protected async getSlotActivity(slot: SlotNumber, epoch: EpochNumber, proposer: EthAddress, committee: EthAddress[]) {
|
|
506
585
|
this.logger.debug(`Computing stats for slot ${slot} at epoch ${epoch}`, { slot, epoch, proposer, committee });
|
|
@@ -517,19 +596,24 @@ export class Sentinel extends (EventEmitter as new () => WatcherEmitter) impleme
|
|
|
517
596
|
),
|
|
518
597
|
);
|
|
519
598
|
|
|
520
|
-
// Determine the proposer status from the six-case taxonomy.
|
|
599
|
+
// Determine the proposer status from the six-case taxonomy. A valid proposal that never reached L1 (case 5) is
|
|
600
|
+
// refined into `checkpoint-unpublished` (5a) or `checkpoint-orphaned` (5b) when the evidence allows it.
|
|
521
601
|
const reexecutionOutcome = this.reexecutionTracker.getOutcomeForSlot(slot);
|
|
522
602
|
let status:
|
|
523
603
|
| 'checkpoint-mined'
|
|
604
|
+
| 'checkpoint-unpublished'
|
|
605
|
+
| 'checkpoint-orphaned'
|
|
524
606
|
| 'checkpoint-valid'
|
|
525
607
|
| 'checkpoint-invalid'
|
|
526
608
|
| 'checkpoint-unvalidated'
|
|
527
609
|
| 'checkpoint-missed'
|
|
528
610
|
| 'blocks-missed';
|
|
611
|
+
let classification: UnpublishedProposalClassification | undefined;
|
|
529
612
|
if (checkpoint) {
|
|
530
613
|
status = 'checkpoint-mined';
|
|
531
614
|
} else if (reexecutionOutcome === 'valid') {
|
|
532
|
-
|
|
615
|
+
classification = await this.classifyUnpublishedProposal(slot, proposer, committee, p2pAttested);
|
|
616
|
+
status = classification.status;
|
|
533
617
|
} else if (reexecutionOutcome === 'invalid') {
|
|
534
618
|
status = 'checkpoint-invalid';
|
|
535
619
|
} else if (reexecutionOutcome === 'unvalidated') {
|
|
@@ -546,12 +630,17 @@ export class Sentinel extends (EventEmitter as new () => WatcherEmitter) impleme
|
|
|
546
630
|
// A local `invalid` verdict vetoes them even for a mined checkpoint (an honest validator refuses
|
|
547
631
|
// to sign what it re-executed as invalid), and so does a proposal equivocation in the slot: with
|
|
548
632
|
// two conflicting proposals an honest attestor may have seen an invalid one and correctly
|
|
549
|
-
// declined, so neither must count as a missed attestor (inactivity slashing).
|
|
633
|
+
// declined, so neither must count as a missed attestor (inactivity slashing). Nor does a proposal that conflicts
|
|
634
|
+
// with a checkpoint that landed on L1 (see excusesMissingAttestations).
|
|
550
635
|
const hasEquivocation = this.reexecutionTracker.hasEquivocation(slot);
|
|
551
636
|
const attestorsExpected =
|
|
552
|
-
(status === 'checkpoint-mined' ||
|
|
637
|
+
(status === 'checkpoint-mined' ||
|
|
638
|
+
status === 'checkpoint-unpublished' ||
|
|
639
|
+
status === 'checkpoint-orphaned' ||
|
|
640
|
+
status === 'checkpoint-valid') &&
|
|
553
641
|
reexecutionOutcome !== 'invalid' &&
|
|
554
|
-
!hasEquivocation
|
|
642
|
+
!hasEquivocation &&
|
|
643
|
+
!excusesMissingAttestations(classification);
|
|
555
644
|
const missedAttestors = new Set(
|
|
556
645
|
attestorsExpected
|
|
557
646
|
? committee.filter(v => !attestors.has(v.toString()) && !proposer.equals(v)).map(v => v.toString())
|
|
@@ -584,17 +673,156 @@ export class Sentinel extends (EventEmitter as new () => WatcherEmitter) impleme
|
|
|
584
673
|
return Object.fromEntries(committee.map(v => v.toString()).map(who => [who, statusFor(who)]));
|
|
585
674
|
}
|
|
586
675
|
|
|
676
|
+
/**
|
|
677
|
+
* Refines case 5 (a checkpoint proposal for the slot re-executed as valid, with no checkpoint for the slot on L1)
|
|
678
|
+
* into whose fault it was that the proposal did not land:
|
|
679
|
+
*
|
|
680
|
+
* - `checkpoint-orphaned` (5b) when other proposers prevented it: the parent it built on never landed on L1 (it is
|
|
681
|
+
* missing, or a different checkpoint holds the parent's position), or, with a quorum of attestations, an earlier
|
|
682
|
+
* slot's checkpoint landed with its number after it built on the previous one (`unexpected-parent-appeared`).
|
|
683
|
+
* The latter happens when the earlier slot's proposal reached this proposer too late to build on.
|
|
684
|
+
* - `checkpoint-unpublished` (5a) when its parent is on L1 and a quorum of the committee attested, so the proposer
|
|
685
|
+
* had everything it needed to publish. A later slot that lands the same checkpoint number does not exempt it:
|
|
686
|
+
* that slot only got the number because this proposer failed to land it.
|
|
687
|
+
* - `checkpoint-valid` (5) otherwise: the slot had an equivocation, the tracker has no parent or payload data to
|
|
688
|
+
* judge by, or attestations fell short of quorum. Short of quorum is a fault of the attestors, who are tagged
|
|
689
|
+
* `attestation-missed`, except after an unexpected parent appeared (`unexpected-parent-appeared`), when
|
|
690
|
+
* refusing to attest was correct.
|
|
691
|
+
*
|
|
692
|
+
* Only attestations for the proposal's own payload count toward quorum, since those are the only ones its proposer
|
|
693
|
+
* could have published.
|
|
694
|
+
*/
|
|
695
|
+
protected async classifyUnpublishedProposal(
|
|
696
|
+
slot: SlotNumber,
|
|
697
|
+
proposer: EthAddress,
|
|
698
|
+
committee: EthAddress[],
|
|
699
|
+
p2pAttested: CheckpointAttestation[],
|
|
700
|
+
): Promise<UnpublishedProposalClassification> {
|
|
701
|
+
if (this.reexecutionTracker.hasEquivocation(slot)) {
|
|
702
|
+
return { status: 'checkpoint-valid', reason: 'equivocation' };
|
|
703
|
+
}
|
|
704
|
+
const record = this.reexecutionTracker.getRecordForSlot(slot);
|
|
705
|
+
const checkpointNumber = record?.checkpointNumber;
|
|
706
|
+
const lastArchiveRoot = record?.lastArchiveRoot;
|
|
707
|
+
const payloadHash = record?.payloadHash;
|
|
708
|
+
if (checkpointNumber === undefined || lastArchiveRoot === undefined || payloadHash === undefined) {
|
|
709
|
+
return { status: 'checkpoint-valid', reason: 'no-proposal-data' };
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
const logData = { slot, proposer: proposer.toString(), checkpointNumber };
|
|
713
|
+
const orphaned = (
|
|
714
|
+
reason: OrphanedProposalReason,
|
|
715
|
+
extra: Record<string, unknown> = {},
|
|
716
|
+
): UnpublishedProposalClassification => {
|
|
717
|
+
this.logger.verbose(
|
|
718
|
+
`Valid checkpoint proposal for slot ${slot} was orphaned (${reason}): ${ORPHANED_PROPOSAL_REASON_DESCRIPTIONS[reason]}`,
|
|
719
|
+
{ ...logData, ...extra, reason },
|
|
720
|
+
);
|
|
721
|
+
return { status: 'checkpoint-orphaned', reason };
|
|
722
|
+
};
|
|
723
|
+
|
|
724
|
+
// The first checkpoint builds on genesis, which is always on L1.
|
|
725
|
+
const parentNumber = checkpointNumber - 1;
|
|
726
|
+
if (parentNumber >= INITIAL_CHECKPOINT_NUMBER) {
|
|
727
|
+
const parent = await this.archiver.getCheckpointData({ number: CheckpointNumber(parentNumber) });
|
|
728
|
+
if (!parent) {
|
|
729
|
+
return orphaned('parent-not-on-l1');
|
|
730
|
+
}
|
|
731
|
+
if (parent.archive.root.toString() !== lastArchiveRoot) {
|
|
732
|
+
return orphaned('parent-hash-mismatch');
|
|
733
|
+
}
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
// The proposer's own attestation counts, as it does when its sequencer collects attestations for L1.
|
|
737
|
+
const committeeMembers = new Set(committee.map(member => member.toString()));
|
|
738
|
+
const attestationCount = new Set(
|
|
739
|
+
p2pAttested
|
|
740
|
+
.filter(attestation => attestation.getPayloadHash() === payloadHash)
|
|
741
|
+
.map(attestation => attestation.getSender()?.toString())
|
|
742
|
+
.filter(attestor => attestor !== undefined && committeeMembers.has(attestor)),
|
|
743
|
+
).size;
|
|
744
|
+
const quorum = computeQuorum(committee.length);
|
|
745
|
+
|
|
746
|
+
// A checkpoint landed for the slot itself would have made this case 6, so a holder of the same number comes from
|
|
747
|
+
// an earlier or a later slot. Only an earlier one can have stopped this proposal from landing.
|
|
748
|
+
const sameNumber = await this.archiver.getCheckpointData({ number: checkpointNumber });
|
|
749
|
+
const sameNumberSlot = sameNumber?.header.slotNumber;
|
|
750
|
+
if (sameNumberSlot !== undefined && sameNumberSlot < slot) {
|
|
751
|
+
const conflictData = { takenBySlot: sameNumberSlot, attestationCount, quorum };
|
|
752
|
+
if (attestationCount < quorum) {
|
|
753
|
+
this.logger.verbose(
|
|
754
|
+
`Valid checkpoint proposal for slot ${slot} fell short of quorum after checkpoint ${checkpointNumber} landed ` +
|
|
755
|
+
`from earlier slot ${sameNumberSlot} (unexpected-parent-appeared), so missing attestations are not counted`,
|
|
756
|
+
{ ...logData, ...conflictData, reason: 'unexpected-parent-appeared' },
|
|
757
|
+
);
|
|
758
|
+
return { status: 'checkpoint-valid', reason: 'unexpected-parent-appeared' };
|
|
759
|
+
}
|
|
760
|
+
return orphaned('unexpected-parent-appeared', conflictData);
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
if (attestationCount < quorum) {
|
|
764
|
+
return { status: 'checkpoint-valid', reason: 'below-quorum' };
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
this.logger.info(`Proposer did not publish checkpoint ${checkpointNumber} for slot ${slot} despite quorum`, {
|
|
768
|
+
...logData,
|
|
769
|
+
attestationCount,
|
|
770
|
+
quorum,
|
|
771
|
+
});
|
|
772
|
+
return { status: 'checkpoint-unpublished' };
|
|
773
|
+
}
|
|
774
|
+
|
|
587
775
|
/** Push the status for each slot for each validator. */
|
|
588
|
-
protected updateValidators(slot: SlotNumber, stats: Record<`0x${string}`, ValidatorStatusInSlot | undefined>) {
|
|
589
|
-
|
|
776
|
+
protected async updateValidators(slot: SlotNumber, stats: Record<`0x${string}`, ValidatorStatusInSlot | undefined>) {
|
|
777
|
+
this.historyGeneration++;
|
|
778
|
+
try {
|
|
779
|
+
await this.store.updateValidators(slot, stats);
|
|
780
|
+
} finally {
|
|
781
|
+
this.historyGeneration++;
|
|
782
|
+
}
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
/**
|
|
786
|
+
* Computes stats to be returned based on stored data. Calls without arguments cover the default window ending at the
|
|
787
|
+
* last processed slot and are served from a cache that is refreshed whenever that slot or the stored history changes.
|
|
788
|
+
*/
|
|
789
|
+
public computeStats(
|
|
790
|
+
args: { fromSlot?: SlotNumber; toSlot?: SlotNumber; validators?: EthAddress[] } = {},
|
|
791
|
+
): Promise<ValidatorsStats> {
|
|
792
|
+
const isDefaultWindow = args.fromSlot === undefined && args.toSlot === undefined && args.validators === undefined;
|
|
793
|
+
// Before the first processed slot the default window ends at the wall-clock slot, which moves without any write.
|
|
794
|
+
if (!isDefaultWindow || this.lastProcessedSlot === undefined) {
|
|
795
|
+
return this.computeStatsUncached(args);
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
const { lastProcessedSlot, initialSlot, historyGeneration } = this;
|
|
799
|
+
if (
|
|
800
|
+
this.defaultStatsCache?.lastProcessedSlot !== lastProcessedSlot ||
|
|
801
|
+
this.defaultStatsCache.initialSlot !== initialSlot ||
|
|
802
|
+
this.defaultStatsCache.historyGeneration !== historyGeneration
|
|
803
|
+
) {
|
|
804
|
+
const stats = this.computeStatsUncached(args);
|
|
805
|
+
const entry = { lastProcessedSlot, initialSlot, historyGeneration, stats };
|
|
806
|
+
this.defaultStatsCache = entry;
|
|
807
|
+
// A failed computation is dropped so the next caller retries instead of receiving the cached rejection.
|
|
808
|
+
stats.catch(() => {
|
|
809
|
+
if (this.defaultStatsCache === entry) {
|
|
810
|
+
this.defaultStatsCache = undefined;
|
|
811
|
+
}
|
|
812
|
+
});
|
|
813
|
+
}
|
|
814
|
+
return this.defaultStatsCache.stats;
|
|
590
815
|
}
|
|
591
816
|
|
|
592
|
-
|
|
593
|
-
public async computeStats({
|
|
817
|
+
private async computeStatsUncached({
|
|
594
818
|
fromSlot,
|
|
595
819
|
toSlot,
|
|
596
820
|
validators,
|
|
597
|
-
}: {
|
|
821
|
+
}: {
|
|
822
|
+
fromSlot?: SlotNumber;
|
|
823
|
+
toSlot?: SlotNumber;
|
|
824
|
+
validators?: EthAddress[];
|
|
825
|
+
}): Promise<ValidatorsStats> {
|
|
598
826
|
const histories = validators
|
|
599
827
|
? fromEntries(await Promise.all(validators.map(async v => [v.toString(), await this.store.getHistory(v)])))
|
|
600
828
|
: await this.store.getHistories();
|
|
@@ -685,7 +913,15 @@ export class Sentinel extends (EventEmitter as new () => WatcherEmitter) impleme
|
|
|
685
913
|
): ValidatorStats {
|
|
686
914
|
let history = fromSlot ? allHistory.filter(h => BigInt(h.slot) >= fromSlot) : allHistory;
|
|
687
915
|
history = toSlot ? history.filter(h => BigInt(h.slot) <= toSlot) : history;
|
|
688
|
-
const lastProposal = history
|
|
916
|
+
const lastProposal = history
|
|
917
|
+
.filter(
|
|
918
|
+
h =>
|
|
919
|
+
h.status === 'checkpoint-valid' ||
|
|
920
|
+
h.status === 'checkpoint-unpublished' ||
|
|
921
|
+
h.status === 'checkpoint-orphaned' ||
|
|
922
|
+
h.status === 'checkpoint-mined',
|
|
923
|
+
)
|
|
924
|
+
.at(-1);
|
|
689
925
|
const lastAttestation = history.filter(h => h.status === 'attestation-sent').at(-1);
|
|
690
926
|
return {
|
|
691
927
|
address: EthAddress.fromString(address),
|
package/src/sentinel/store.ts
CHANGED
|
@@ -175,6 +175,10 @@ export class SentinelStore {
|
|
|
175
175
|
return 7;
|
|
176
176
|
case 'checkpoint-unvalidated':
|
|
177
177
|
return 8;
|
|
178
|
+
case 'checkpoint-unpublished':
|
|
179
|
+
return 9;
|
|
180
|
+
case 'checkpoint-orphaned':
|
|
181
|
+
return 10;
|
|
178
182
|
default: {
|
|
179
183
|
const _exhaustive: never = status;
|
|
180
184
|
throw new Error(`Unknown status: ${status}`);
|
|
@@ -200,6 +204,10 @@ export class SentinelStore {
|
|
|
200
204
|
return 'checkpoint-invalid';
|
|
201
205
|
case 8:
|
|
202
206
|
return 'checkpoint-unvalidated';
|
|
207
|
+
case 9:
|
|
208
|
+
return 'checkpoint-unpublished';
|
|
209
|
+
case 10:
|
|
210
|
+
return 'checkpoint-orphaned';
|
|
203
211
|
default:
|
|
204
212
|
throw new Error(`Unknown status: ${status}`);
|
|
205
213
|
}
|