@aztec/aztec-node 3.0.0-canary.a9708bd → 3.0.0-devnet.2-patch.1

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.
@@ -2,6 +2,7 @@ import { type ConfigMappingsType, booleanConfigHelper, numberConfigHelper } from
2
2
 
3
3
  export type SentinelConfig = {
4
4
  sentinelHistoryLengthInEpochs: number;
5
+ sentinelHistoricProvenPerformanceLengthInEpochs: number;
5
6
  sentinelEnabled: boolean;
6
7
  };
7
8
 
@@ -11,6 +12,23 @@ export const sentinelConfigMappings: ConfigMappingsType<SentinelConfig> = {
11
12
  env: 'SENTINEL_HISTORY_LENGTH_IN_EPOCHS',
12
13
  ...numberConfigHelper(24),
13
14
  },
15
+ /**
16
+ * The number of L2 epochs kept of proven performance history for each validator.
17
+ * This value must be large enough so that we have proven performance for every validator
18
+ * for at least slashInactivityConsecutiveEpochThreshold. Assuming this value is 3,
19
+ * and the committee size is 48, and we have 10k validators, then we pick 48 out of 10k each draw.
20
+ * For any fixed element, per-draw prob = 48/10000 = 0.0048.
21
+ * After n draws, count ~ Binomial(n, 0.0048). We want P(X >= 3).
22
+ * Results (exact binomial):
23
+ * - 90% chance: n = 1108
24
+ * - 95% chance: n = 1310
25
+ * - 99% chance: n = 1749
26
+ */
27
+ sentinelHistoricProvenPerformanceLengthInEpochs: {
28
+ description: 'The number of L2 epochs kept of proven performance history for each validator.',
29
+ env: 'SENTINEL_HISTORIC_PROVEN_PERFORMANCE_LENGTH_IN_EPOCHS',
30
+ ...numberConfigHelper(2000),
31
+ },
14
32
  sentinelEnabled: {
15
33
  description: 'Whether the sentinel is enabled or not.',
16
34
  env: 'SENTINEL_ENABLED',
@@ -27,6 +27,10 @@ export async function createSentinel(
27
27
  createLogger('node:sentinel:lmdb'),
28
28
  );
29
29
  const storeHistoryLength = config.sentinelHistoryLengthInEpochs * epochCache.getL1Constants().epochDuration;
30
- const sentinelStore = new SentinelStore(kvStore, { historyLength: storeHistoryLength });
30
+ const storeHistoricProvenPerformanceLength = config.sentinelHistoricProvenPerformanceLengthInEpochs;
31
+ const sentinelStore = new SentinelStore(kvStore, {
32
+ historyLength: storeHistoryLength,
33
+ historicProvenPerformanceLength: storeHistoricProvenPerformanceLength,
34
+ });
31
35
  return new Sentinel(epochCache, archiver, p2p, sentinelStore, config, logger);
32
36
  }
@@ -1,20 +1,27 @@
1
1
  import type { EpochCache } from '@aztec/epoch-cache';
2
- import { countWhile, filterAsync } from '@aztec/foundation/collection';
2
+ import { BlockNumber, EpochNumber, SlotNumber } from '@aztec/foundation/branded-types';
3
+ import { countWhile, filterAsync, fromEntries, getEntries, mapValues } from '@aztec/foundation/collection';
3
4
  import { EthAddress } from '@aztec/foundation/eth-address';
4
5
  import { createLogger } from '@aztec/foundation/log';
5
6
  import { RunningPromise } from '@aztec/foundation/running-promise';
6
7
  import { L2TipsMemoryStore, type L2TipsStore } from '@aztec/kv-store/stores';
7
8
  import type { P2PClient } from '@aztec/p2p';
8
- import { OffenseType, WANT_TO_SLASH_EVENT, type Watcher, type WatcherEmitter } from '@aztec/slasher';
9
+ import {
10
+ OffenseType,
11
+ WANT_TO_SLASH_EVENT,
12
+ type WantToSlashArgs,
13
+ type Watcher,
14
+ type WatcherEmitter,
15
+ } from '@aztec/slasher';
9
16
  import type { SlasherConfig } from '@aztec/slasher/config';
10
17
  import {
11
18
  type L2BlockSource,
12
19
  L2BlockStream,
13
20
  type L2BlockStreamEvent,
14
21
  type L2BlockStreamEventHandler,
15
- getAttestationsFromPublishedL2Block,
22
+ getAttestationInfoFromPublishedL2Block,
16
23
  } from '@aztec/stdlib/block';
17
- import { getEpochAtSlot, getTimestampForSlot } from '@aztec/stdlib/epoch-helpers';
24
+ import { getEpochAtSlot, getSlotRangeForEpoch, getTimestampForSlot } from '@aztec/stdlib/epoch-helpers';
18
25
  import type {
19
26
  SingleValidatorStats,
20
27
  ValidatorStats,
@@ -34,9 +41,10 @@ export class Sentinel extends (EventEmitter as new () => WatcherEmitter) impleme
34
41
  protected blockStream!: L2BlockStream;
35
42
  protected l2TipsStore: L2TipsStore;
36
43
 
37
- protected initialSlot: bigint | undefined;
38
- protected lastProcessedSlot: bigint | undefined;
39
- protected slotNumberToBlock: Map<bigint, { blockNumber: number; archive: string; attestors: EthAddress[] }> =
44
+ protected initialSlot: SlotNumber | undefined;
45
+ protected lastProcessedSlot: SlotNumber | undefined;
46
+ // eslint-disable-next-line aztec-custom/no-non-primitive-in-collections
47
+ protected slotNumberToBlock: Map<SlotNumber, { blockNumber: BlockNumber; archive: string; attestors: EthAddress[] }> =
40
48
  new Map();
41
49
 
42
50
  constructor(
@@ -68,7 +76,7 @@ export class Sentinel extends (EventEmitter as new () => WatcherEmitter) impleme
68
76
  /** Loads initial slot and initializes blockstream. We will not process anything at or before the initial slot. */
69
77
  protected async init() {
70
78
  this.initialSlot = this.epochCache.getEpochAndSlotNow().slot;
71
- const startingBlock = await this.archiver.getBlockNumber();
79
+ const startingBlock = BlockNumber(await this.archiver.getBlockNumber());
72
80
  this.logger.info(`Starting validator sentinel with initial slot ${this.initialSlot} and block ${startingBlock}`);
73
81
  this.blockStream = new L2BlockStream(this.archiver, this.l2TipsStore, this, this.logger, { startingBlock });
74
82
  }
@@ -83,9 +91,11 @@ export class Sentinel extends (EventEmitter as new () => WatcherEmitter) impleme
83
91
  // Store mapping from slot to archive, block number, and attestors
84
92
  for (const block of event.blocks) {
85
93
  this.slotNumberToBlock.set(block.block.header.getSlot(), {
86
- blockNumber: block.block.number,
94
+ blockNumber: BlockNumber(block.block.number),
87
95
  archive: block.block.archive.root.toString(),
88
- attestors: getAttestationsFromPublishedL2Block(block).map(att => att.getSender()),
96
+ attestors: getAttestationInfoFromPublishedL2Block(block)
97
+ .filter(a => a.status === 'recovered-from-signature')
98
+ .map(a => a.address!),
89
99
  });
90
100
  }
91
101
 
@@ -108,60 +118,49 @@ export class Sentinel extends (EventEmitter as new () => WatcherEmitter) impleme
108
118
  if (event.type !== 'chain-proven') {
109
119
  return;
110
120
  }
111
- const blockNumber = event.block.number;
121
+ const blockNumber = BlockNumber(event.block.number);
112
122
  const block = await this.archiver.getBlock(blockNumber);
113
123
  if (!block) {
114
124
  this.logger.error(`Failed to get block ${blockNumber}`, { block });
115
125
  return;
116
126
  }
117
127
 
118
- const epoch = getEpochAtSlot(block.header.getSlot(), await this.archiver.getL1Constants());
128
+ // TODO(palla/slash): We should only be computing proven performance if this is
129
+ // a full proof epoch and not a partial one, otherwise we'll end up with skewed stats.
130
+ const epoch = getEpochAtSlot(block.header.getSlot(), this.epochCache.getL1Constants());
119
131
  this.logger.debug(`Computing proven performance for epoch ${epoch}`);
120
132
  const performance = await this.computeProvenPerformance(epoch);
121
133
  this.logger.info(`Computed proven performance for epoch ${epoch}`, performance);
122
134
 
123
- await this.updateProvenPerformance(epoch, performance);
135
+ await this.store.updateProvenPerformance(epoch, performance);
124
136
  await this.handleProvenPerformance(epoch, performance);
125
137
  }
126
138
 
127
- protected async computeProvenPerformance(epoch: bigint) {
128
- const headers = await this.archiver.getBlockHeadersForEpoch(epoch);
129
- const provenSlots = headers.map(h => h.getSlot());
130
- const fromSlot = provenSlots[0];
131
- const toSlot = provenSlots[provenSlots.length - 1];
139
+ protected async computeProvenPerformance(epoch: EpochNumber): Promise<ValidatorsEpochPerformance> {
140
+ const [fromSlot, toSlot] = getSlotRangeForEpoch(epoch, this.epochCache.getL1Constants());
132
141
  const { committee } = await this.epochCache.getCommittee(fromSlot);
133
142
  if (!committee) {
134
143
  this.logger.trace(`No committee found for slot ${fromSlot}`);
135
144
  return {};
136
145
  }
137
- const stats = await this.computeStats({ fromSlot, toSlot });
138
- this.logger.debug(`Stats for epoch ${epoch}`, stats);
139
-
140
- const performance: ValidatorsEpochPerformance = {};
141
- for (const validator of Object.keys(stats.stats)) {
142
- let address;
143
- try {
144
- address = EthAddress.fromString(validator);
145
- } catch (e) {
146
- this.logger.error(`Invalid validator address ${validator}`, e);
147
- continue;
148
- }
149
- if (!committee.find(v => v.equals(address))) {
150
- continue;
151
- }
152
- let missed = 0;
153
- for (const history of stats.stats[validator].history) {
154
- if (provenSlots.includes(history.slot) && history.status === 'attestation-missed') {
155
- missed++;
156
- }
157
- }
158
- performance[address.toString()] = { missed, total: provenSlots.length };
159
- }
160
- return performance;
161
- }
162
146
 
163
- protected updateProvenPerformance(epoch: bigint, performance: ValidatorsEpochPerformance) {
164
- return this.store.updateProvenPerformance(epoch, performance);
147
+ const stats = await this.computeStats({
148
+ fromSlot,
149
+ toSlot,
150
+ validators: committee,
151
+ });
152
+ this.logger.debug(`Stats for epoch ${epoch}`, { ...stats, fromSlot, toSlot, epoch });
153
+
154
+ // Note that we are NOT using the total slots in the epoch as `total` here, since we only
155
+ // compute missed attestations over the blocks that had a proposal in them. So, let's say
156
+ // we have an epoch with 10 slots, but only 5 had a block proposal. A validator that was
157
+ // offline, assuming they were not picked as proposer, will then be reported as having missed
158
+ // 5/5 attestations. If we used the total, they'd be reported as 5/10, which would probably
159
+ // allow them to avoid being slashed.
160
+ return mapValues(stats.stats, stat => ({
161
+ missed: stat.missedAttestations.count + stat.missedProposals.count,
162
+ total: stat.missedAttestations.total + stat.missedProposals.total,
163
+ }));
165
164
  }
166
165
 
167
166
  /**
@@ -172,7 +171,7 @@ export class Sentinel extends (EventEmitter as new () => WatcherEmitter) impleme
172
171
  */
173
172
  protected async checkPastInactivity(
174
173
  validator: EthAddress,
175
- currentEpoch: bigint,
174
+ currentEpoch: EpochNumber,
176
175
  requiredConsecutiveEpochs: number,
177
176
  ): Promise<boolean> {
178
177
  if (requiredConsecutiveEpochs === 0) {
@@ -182,28 +181,31 @@ export class Sentinel extends (EventEmitter as new () => WatcherEmitter) impleme
182
181
  // Get all historical performance for this validator
183
182
  const allPerformance = await this.store.getProvenPerformance(validator);
184
183
 
184
+ // Sort by epoch descending to get most recent first, keep only epochs strictly before the current one, and get the first N
185
+ const pastEpochs = allPerformance.sort((a, b) => Number(b.epoch - a.epoch)).filter(p => p.epoch < currentEpoch);
186
+
185
187
  // If we don't have enough historical data, don't slash
186
- if (allPerformance.length < requiredConsecutiveEpochs) {
188
+ if (pastEpochs.length < requiredConsecutiveEpochs) {
187
189
  this.logger.debug(
188
190
  `Not enough historical data for slashing ${validator} for inactivity (${allPerformance.length} epochs < ${requiredConsecutiveEpochs} required)`,
189
191
  );
190
192
  return false;
191
193
  }
192
194
 
193
- // Sort by epoch descending to get most recent first, keep only epochs strictly before the current one, and get the first N
194
- return allPerformance
195
- .sort((a, b) => Number(b.epoch - a.epoch))
196
- .filter(p => p.epoch < currentEpoch)
195
+ // Check that we have at least requiredConsecutiveEpochs and that all of them are above the inactivity threshold
196
+ return pastEpochs
197
197
  .slice(0, requiredConsecutiveEpochs)
198
198
  .every(p => p.missed / p.total >= this.config.slashInactivityTargetPercentage);
199
199
  }
200
200
 
201
- protected async handleProvenPerformance(epoch: bigint, performance: ValidatorsEpochPerformance) {
202
- const inactiveValidators = Object.entries(performance)
203
- .filter(([_, { missed, total }]) => {
204
- return missed / total >= this.config.slashInactivityTargetPercentage;
205
- })
206
- .map(([address]) => address as `0x${string}`);
201
+ protected async handleProvenPerformance(epoch: EpochNumber, performance: ValidatorsEpochPerformance) {
202
+ if (this.config.slashInactivityPenalty === 0n) {
203
+ return;
204
+ }
205
+
206
+ const inactiveValidators = getEntries(performance)
207
+ .filter(([_, { missed, total }]) => missed / total >= this.config.slashInactivityTargetPercentage)
208
+ .map(([address]) => address);
207
209
 
208
210
  this.logger.debug(`Found ${inactiveValidators.length} inactive validators in epoch ${epoch}`, {
209
211
  inactiveValidators,
@@ -216,15 +218,15 @@ export class Sentinel extends (EventEmitter as new () => WatcherEmitter) impleme
216
218
  this.checkPastInactivity(EthAddress.fromString(address), epoch, epochThreshold - 1),
217
219
  );
218
220
 
219
- const args = criminals.map(address => ({
221
+ const args: WantToSlashArgs[] = criminals.map(address => ({
220
222
  validator: EthAddress.fromString(address),
221
223
  amount: this.config.slashInactivityPenalty,
222
224
  offenseType: OffenseType.INACTIVITY,
223
- epochOrSlot: epoch,
225
+ epochOrSlot: BigInt(epoch),
224
226
  }));
225
227
 
226
228
  if (criminals.length > 0) {
227
- this.logger.info(
229
+ this.logger.verbose(
228
230
  `Identified ${criminals.length} validators to slash due to inactivity in at least ${epochThreshold} consecutive epochs`,
229
231
  { ...args, epochThreshold },
230
232
  );
@@ -261,8 +263,13 @@ export class Sentinel extends (EventEmitter as new () => WatcherEmitter) impleme
261
263
  * We also don't move past the archiver last synced L2 slot, as we don't want to process data that is not yet available.
262
264
  * Last, we check the p2p is synced with the archiver, so it has pulled all attestations from it.
263
265
  */
264
- protected async isReadyToProcess(currentSlot: bigint) {
265
- const targetSlot = currentSlot - 2n;
266
+ protected async isReadyToProcess(currentSlot: SlotNumber): Promise<SlotNumber | false> {
267
+ if (currentSlot < 2) {
268
+ this.logger.trace(`Current slot ${currentSlot} too early.`);
269
+ return false;
270
+ }
271
+
272
+ const targetSlot = SlotNumber(currentSlot - 2);
266
273
  if (this.lastProcessedSlot && this.lastProcessedSlot >= targetSlot) {
267
274
  this.logger.trace(`Already processed slot ${targetSlot}`, { lastProcessedSlot: this.lastProcessedSlot });
268
275
  return false;
@@ -279,7 +286,7 @@ export class Sentinel extends (EventEmitter as new () => WatcherEmitter) impleme
279
286
  }
280
287
 
281
288
  const archiverSlot = await this.archiver.getL2SlotNumber();
282
- if (archiverSlot < targetSlot) {
289
+ if (archiverSlot === undefined || archiverSlot < targetSlot) {
283
290
  this.logger.debug(`Waiting for archiver to sync with L2 slot ${targetSlot}`, { archiverSlot, targetSlot });
284
291
  return false;
285
292
  }
@@ -299,7 +306,7 @@ export class Sentinel extends (EventEmitter as new () => WatcherEmitter) impleme
299
306
  * Gathers committee and proposer data for a given slot, computes slot stats,
300
307
  * and updates overall stats.
301
308
  */
302
- protected async processSlot(slot: bigint) {
309
+ protected async processSlot(slot: SlotNumber) {
303
310
  const { epoch, seed, committee } = await this.epochCache.getCommittee(slot);
304
311
  if (!committee || committee.length === 0) {
305
312
  this.logger.trace(`No committee found for slot ${slot} at epoch ${epoch}`);
@@ -315,7 +322,7 @@ export class Sentinel extends (EventEmitter as new () => WatcherEmitter) impleme
315
322
  }
316
323
 
317
324
  /** Computes activity for a given slot. */
318
- protected async getSlotActivity(slot: bigint, epoch: bigint, proposer: EthAddress, committee: EthAddress[]) {
325
+ protected async getSlotActivity(slot: SlotNumber, epoch: EpochNumber, proposer: EthAddress, committee: EthAddress[]) {
319
326
  this.logger.debug(`Computing stats for slot ${slot} at epoch ${epoch}`, { slot, epoch, proposer, committee });
320
327
 
321
328
  // Check if there is an L2 block in L1 for this L2 slot
@@ -326,15 +333,20 @@ export class Sentinel extends (EventEmitter as new () => WatcherEmitter) impleme
326
333
  // (contains the ones synced from mined blocks, which we may have missed from p2p).
327
334
  const block = this.slotNumberToBlock.get(slot);
328
335
  const p2pAttested = await this.p2p.getAttestationsForSlot(slot, block?.archive);
329
- const attestors = new Set([
330
- ...p2pAttested.map(a => a.getSender().toString()),
331
- ...(block?.attestors.map(a => a.toString()) ?? []),
332
- ]);
336
+ // Filter out attestations with invalid signatures
337
+ const p2pAttestors = p2pAttested.map(a => a.getSender()).filter((s): s is EthAddress => s !== undefined);
338
+ const attestors = new Set(
339
+ [...p2pAttestors.map(a => a.toString()), ...(block?.attestors.map(a => a.toString()) ?? [])].filter(
340
+ addr => proposer.toString() !== addr, // Exclude the proposer from the attestors
341
+ ),
342
+ );
333
343
 
334
- // We assume that there was a block proposal if at least one of the validators attested to it.
344
+ // We assume that there was a block proposal if at least one of the validators (other than the proposer) attested to it.
335
345
  // It could be the case that every single validator failed, and we could differentiate it by having
336
346
  // this node re-execute every block proposal it sees and storing it in the attestation pool.
337
347
  // But we'll leave that corner case out to reduce pressure on the node.
348
+ // TODO(palla/slash): This breaks if a given node has more than one validator in the current committee,
349
+ // since they will attest to their own proposal it even if it's not re-executable.
338
350
  const blockStatus = block ? 'mined' : attestors.size > 0 ? 'proposed' : 'missed';
339
351
  this.logger.debug(`Block for slot ${slot} was ${blockStatus}`, { ...block, slot });
340
352
 
@@ -372,26 +384,30 @@ export class Sentinel extends (EventEmitter as new () => WatcherEmitter) impleme
372
384
  }
373
385
 
374
386
  /** Push the status for each slot for each validator. */
375
- protected updateValidators(slot: bigint, stats: Record<`0x${string}`, ValidatorStatusInSlot | undefined>) {
387
+ protected updateValidators(slot: SlotNumber, stats: Record<`0x${string}`, ValidatorStatusInSlot | undefined>) {
376
388
  return this.store.updateValidators(slot, stats);
377
389
  }
378
390
 
379
391
  /** Computes stats to be returned based on stored data. */
380
392
  public async computeStats({
381
- fromSlot: _fromSlot,
382
- toSlot: _toSlot,
383
- }: { fromSlot?: bigint; toSlot?: bigint } = {}): Promise<ValidatorsStats> {
384
- const histories = await this.store.getHistories();
393
+ fromSlot,
394
+ toSlot,
395
+ validators,
396
+ }: { fromSlot?: SlotNumber; toSlot?: SlotNumber; validators?: EthAddress[] } = {}): Promise<ValidatorsStats> {
397
+ const histories = validators
398
+ ? fromEntries(await Promise.all(validators.map(async v => [v.toString(), await this.store.getHistory(v)])))
399
+ : await this.store.getHistories();
400
+
385
401
  const slotNow = this.epochCache.getEpochAndSlotNow().slot;
386
- const fromSlot = _fromSlot ?? (this.lastProcessedSlot ?? slotNow) - BigInt(this.store.getHistoryLength());
387
- const toSlot = _toSlot ?? this.lastProcessedSlot ?? slotNow;
388
- const result: Record<`0x${string}`, ValidatorStats> = {};
389
- for (const [address, history] of Object.entries(histories)) {
390
- const validatorAddress = address as `0x${string}`;
391
- result[validatorAddress] = this.computeStatsForValidator(validatorAddress, history, fromSlot, toSlot);
392
- }
402
+ fromSlot ??= SlotNumber(Math.max((this.lastProcessedSlot ?? slotNow) - this.store.getHistoryLength(), 0));
403
+ toSlot ??= this.lastProcessedSlot ?? slotNow;
404
+
405
+ const stats = mapValues(histories, (history, address) =>
406
+ this.computeStatsForValidator(address, history ?? [], fromSlot, toSlot),
407
+ );
408
+
393
409
  return {
394
- stats: result,
410
+ stats,
395
411
  lastProcessedSlot: this.lastProcessedSlot,
396
412
  initialSlot: this.initialSlot,
397
413
  slotWindow: this.store.getHistoryLength(),
@@ -401,8 +417,8 @@ export class Sentinel extends (EventEmitter as new () => WatcherEmitter) impleme
401
417
  /** Computes stats for a single validator. */
402
418
  public async getValidatorStats(
403
419
  validatorAddress: EthAddress,
404
- fromSlot?: bigint,
405
- toSlot?: bigint,
420
+ fromSlot?: SlotNumber,
421
+ toSlot?: SlotNumber,
406
422
  ): Promise<SingleValidatorStats | undefined> {
407
423
  const history = await this.store.getHistory(validatorAddress);
408
424
 
@@ -411,13 +427,14 @@ export class Sentinel extends (EventEmitter as new () => WatcherEmitter) impleme
411
427
  }
412
428
 
413
429
  const slotNow = this.epochCache.getEpochAndSlotNow().slot;
414
- const effectiveFromSlot = fromSlot ?? (this.lastProcessedSlot ?? slotNow) - BigInt(this.store.getHistoryLength());
430
+ const effectiveFromSlot =
431
+ fromSlot ?? SlotNumber(Math.max((this.lastProcessedSlot ?? slotNow) - this.store.getHistoryLength(), 0));
415
432
  const effectiveToSlot = toSlot ?? this.lastProcessedSlot ?? slotNow;
416
433
 
417
434
  const historyLength = BigInt(this.store.getHistoryLength());
418
- if (effectiveToSlot - effectiveFromSlot > historyLength) {
435
+ if (BigInt(effectiveToSlot) - BigInt(effectiveFromSlot) > historyLength) {
419
436
  throw new Error(
420
- `Slot range (${effectiveToSlot - effectiveFromSlot}) exceeds history length (${historyLength}). ` +
437
+ `Slot range (${BigInt(effectiveToSlot) - BigInt(effectiveFromSlot)}) exceeds history length (${historyLength}). ` +
421
438
  `Requested range: ${effectiveFromSlot} to ${effectiveToSlot}.`,
422
439
  );
423
440
  }
@@ -428,11 +445,10 @@ export class Sentinel extends (EventEmitter as new () => WatcherEmitter) impleme
428
445
  effectiveFromSlot,
429
446
  effectiveToSlot,
430
447
  );
431
- const allTimeProvenPerformance = await this.store.getProvenPerformance(validatorAddress);
432
448
 
433
449
  return {
434
450
  validator,
435
- allTimeProvenPerformance,
451
+ allTimeProvenPerformance: await this.store.getProvenPerformance(validatorAddress),
436
452
  lastProcessedSlot: this.lastProcessedSlot,
437
453
  initialSlot: this.initialSlot,
438
454
  slotWindow: this.store.getHistoryLength(),
@@ -442,39 +458,40 @@ export class Sentinel extends (EventEmitter as new () => WatcherEmitter) impleme
442
458
  protected computeStatsForValidator(
443
459
  address: `0x${string}`,
444
460
  allHistory: ValidatorStatusHistory,
445
- fromSlot?: bigint,
446
- toSlot?: bigint,
461
+ fromSlot?: SlotNumber,
462
+ toSlot?: SlotNumber,
447
463
  ): ValidatorStats {
448
- let history = fromSlot ? allHistory.filter(h => h.slot >= fromSlot) : allHistory;
449
- history = toSlot ? history.filter(h => h.slot <= toSlot) : history;
464
+ let history = fromSlot ? allHistory.filter(h => BigInt(h.slot) >= fromSlot) : allHistory;
465
+ history = toSlot ? history.filter(h => BigInt(h.slot) <= toSlot) : history;
466
+ const lastProposal = history.filter(h => h.status === 'block-proposed' || h.status === 'block-mined').at(-1);
467
+ const lastAttestation = history.filter(h => h.status === 'attestation-sent').at(-1);
450
468
  return {
451
469
  address: EthAddress.fromString(address),
452
- lastProposal: this.computeFromSlot(
453
- history.filter(h => h.status === 'block-proposed' || h.status === 'block-mined').at(-1)?.slot,
454
- ),
455
- lastAttestation: this.computeFromSlot(history.filter(h => h.status === 'attestation-sent').at(-1)?.slot),
470
+ lastProposal: this.computeFromSlot(lastProposal?.slot),
471
+ lastAttestation: this.computeFromSlot(lastAttestation?.slot),
456
472
  totalSlots: history.length,
457
- missedProposals: this.computeMissed(history, 'block', 'block-missed'),
458
- missedAttestations: this.computeMissed(history, 'attestation', 'attestation-missed'),
473
+ missedProposals: this.computeMissed(history, 'block', ['block-missed']),
474
+ missedAttestations: this.computeMissed(history, 'attestation', ['attestation-missed']),
459
475
  history,
460
476
  };
461
477
  }
462
478
 
463
479
  protected computeMissed(
464
480
  history: ValidatorStatusHistory,
465
- computeOverPrefix: ValidatorStatusType,
466
- filter: ValidatorStatusInSlot,
481
+ computeOverPrefix: ValidatorStatusType | undefined,
482
+ filter: ValidatorStatusInSlot[],
467
483
  ) {
468
- const relevantHistory = history.filter(h => h.status.startsWith(computeOverPrefix));
469
- const filteredHistory = relevantHistory.filter(h => h.status === filter);
484
+ const relevantHistory = history.filter(h => !computeOverPrefix || h.status.startsWith(computeOverPrefix));
485
+ const filteredHistory = relevantHistory.filter(h => filter.includes(h.status));
470
486
  return {
471
- currentStreak: countWhile([...relevantHistory].reverse(), h => h.status === filter),
487
+ currentStreak: countWhile([...relevantHistory].reverse(), h => filter.includes(h.status)),
472
488
  rate: relevantHistory.length === 0 ? undefined : filteredHistory.length / relevantHistory.length,
473
489
  count: filteredHistory.length,
490
+ total: relevantHistory.length,
474
491
  };
475
492
  }
476
493
 
477
- protected computeFromSlot(slot: bigint | undefined) {
494
+ protected computeFromSlot(slot: SlotNumber | undefined) {
478
495
  if (slot === undefined) {
479
496
  return undefined;
480
497
  }
@@ -1,3 +1,4 @@
1
+ import { EpochNumber, SlotNumber } from '@aztec/foundation/branded-types';
1
2
  import { EthAddress } from '@aztec/foundation/eth-address';
2
3
  import { BufferReader, numToUInt8, numToUInt32BE, serializeToBuffer } from '@aztec/foundation/serialize';
3
4
  import type { AztecAsyncKVStore, AztecAsyncMap } from '@aztec/kv-store';
@@ -19,7 +20,7 @@ export class SentinelStore {
19
20
 
20
21
  constructor(
21
22
  private store: AztecAsyncKVStore,
22
- private config: { historyLength: number },
23
+ private config: { historyLength: number; historicProvenPerformanceLength: number },
23
24
  ) {
24
25
  this.historyMap = store.openMap('sentinel-validator-status');
25
26
  this.provenMap = store.openMap('sentinel-validator-proven');
@@ -29,7 +30,11 @@ export class SentinelStore {
29
30
  return this.config.historyLength;
30
31
  }
31
32
 
32
- public async updateProvenPerformance(epoch: bigint, performance: ValidatorsEpochPerformance) {
33
+ public getHistoricProvenPerformanceLength() {
34
+ return this.config.historicProvenPerformanceLength;
35
+ }
36
+
37
+ public async updateProvenPerformance(epoch: EpochNumber, performance: ValidatorsEpochPerformance) {
33
38
  await this.store.transactionAsync(async () => {
34
39
  for (const [who, { missed, total }] of Object.entries(performance)) {
35
40
  await this.pushValidatorProvenPerformanceForEpoch({ who: EthAddress.fromString(who), missed, total, epoch });
@@ -37,7 +42,7 @@ export class SentinelStore {
37
42
  });
38
43
  }
39
44
 
40
- public async getProvenPerformance(who: EthAddress): Promise<{ missed: number; total: number; epoch: bigint }[]> {
45
+ public async getProvenPerformance(who: EthAddress): Promise<{ missed: number; total: number; epoch: EpochNumber }[]> {
41
46
  const currentPerformanceBuffer = await this.provenMap.getAsync(who.toString());
42
47
  return currentPerformanceBuffer ? this.deserializePerformance(currentPerformanceBuffer) : [];
43
48
  }
@@ -51,7 +56,7 @@ export class SentinelStore {
51
56
  who: EthAddress;
52
57
  missed: number;
53
58
  total: number;
54
- epoch: bigint;
59
+ epoch: EpochNumber;
55
60
  }) {
56
61
  const currentPerformance = await this.getProvenPerformance(who);
57
62
  const existingIndex = currentPerformance.findIndex(p => p.epoch === epoch);
@@ -65,13 +70,13 @@ export class SentinelStore {
65
70
  // Since we keep the size small, this is not a big deal.
66
71
  currentPerformance.sort((a, b) => Number(a.epoch - b.epoch));
67
72
 
68
- // keep the most recent `historyLength` entries.
69
- const performanceToKeep = currentPerformance.slice(-this.config.historyLength);
73
+ // keep the most recent `historicProvenPerformanceLength` entries.
74
+ const performanceToKeep = currentPerformance.slice(-this.config.historicProvenPerformanceLength);
70
75
 
71
76
  await this.provenMap.set(who.toString(), this.serializePerformance(performanceToKeep));
72
77
  }
73
78
 
74
- public async updateValidators(slot: bigint, statuses: Record<`0x${string}`, ValidatorStatusInSlot | undefined>) {
79
+ public async updateValidators(slot: SlotNumber, statuses: Record<`0x${string}`, ValidatorStatusInSlot | undefined>) {
75
80
  await this.store.transactionAsync(async () => {
76
81
  for (const [who, status] of Object.entries(statuses)) {
77
82
  if (status) {
@@ -83,7 +88,7 @@ export class SentinelStore {
83
88
 
84
89
  private async pushValidatorStatusForSlot(
85
90
  who: EthAddress,
86
- slot: bigint,
91
+ slot: SlotNumber,
87
92
  status: 'block-mined' | 'block-proposed' | 'block-missed' | 'attestation-sent' | 'attestation-missed',
88
93
  ) {
89
94
  await this.store.transactionAsync(async () => {
@@ -106,18 +111,18 @@ export class SentinelStore {
106
111
  return data && this.deserializeHistory(data);
107
112
  }
108
113
 
109
- private serializePerformance(performance: { missed: number; total: number; epoch: bigint }[]): Buffer {
114
+ private serializePerformance(performance: { missed: number; total: number; epoch: EpochNumber }[]): Buffer {
110
115
  return serializeToBuffer(
111
116
  performance.map(p => [numToUInt32BE(Number(p.epoch)), numToUInt32BE(p.missed), numToUInt32BE(p.total)]),
112
117
  );
113
118
  }
114
119
 
115
- private deserializePerformance(buffer: Buffer): { missed: number; total: number; epoch: bigint }[] {
120
+ private deserializePerformance(buffer: Buffer): { missed: number; total: number; epoch: EpochNumber }[] {
116
121
  const reader = new BufferReader(buffer);
117
- const performance: { missed: number; total: number; epoch: bigint }[] = [];
122
+ const performance: { missed: number; total: number; epoch: EpochNumber }[] = [];
118
123
  while (!reader.isEmpty()) {
119
124
  performance.push({
120
- epoch: BigInt(reader.readNumber()),
125
+ epoch: EpochNumber(reader.readNumber()),
121
126
  missed: reader.readNumber(),
122
127
  total: reader.readNumber(),
123
128
  });
@@ -135,7 +140,7 @@ export class SentinelStore {
135
140
  const reader = new BufferReader(buffer);
136
141
  const history: ValidatorStatusHistory = [];
137
142
  while (!reader.isEmpty()) {
138
- const slot = BigInt(reader.readNumber());
143
+ const slot = SlotNumber(reader.readNumber());
139
144
  const status = this.statusFromNumber(reader.readUInt8());
140
145
  history.push({ slot, status });
141
146
  }