@aztec/aztec-node 0.87.7 → 1.0.0-nightly.20250605

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.
@@ -5,6 +5,8 @@ import { createLogger } from '@aztec/foundation/log';
5
5
  import { RunningPromise } from '@aztec/foundation/running-promise';
6
6
  import { L2TipsMemoryStore, type L2TipsStore } from '@aztec/kv-store/stores';
7
7
  import type { P2PClient } from '@aztec/p2p';
8
+ import type { SlasherConfig, WantToSlashArgs, Watcher, WatcherEmitter } from '@aztec/slasher/config';
9
+ import { Offence, WANT_TO_SLASH_EVENT } from '@aztec/slasher/config';
8
10
  import {
9
11
  type L2BlockSource,
10
12
  L2BlockStream,
@@ -12,18 +14,21 @@ import {
12
14
  type L2BlockStreamEventHandler,
13
15
  getAttestationsFromPublishedL2Block,
14
16
  } from '@aztec/stdlib/block';
15
- import { getTimestampForSlot } from '@aztec/stdlib/epoch-helpers';
17
+ import { getEpochAtSlot, getTimestampForSlot } from '@aztec/stdlib/epoch-helpers';
16
18
  import type {
17
19
  ValidatorStats,
18
20
  ValidatorStatusHistory,
19
21
  ValidatorStatusInSlot,
20
22
  ValidatorStatusType,
23
+ ValidatorsEpochPerformance,
21
24
  ValidatorsStats,
22
25
  } from '@aztec/stdlib/validators';
23
26
 
27
+ import EventEmitter from 'node:events';
28
+
24
29
  import { SentinelStore } from './store.js';
25
30
 
26
- export class Sentinel implements L2BlockStreamEventHandler {
31
+ export class Sentinel extends (EventEmitter as new () => WatcherEmitter) implements L2BlockStreamEventHandler, Watcher {
27
32
  protected runningPromise: RunningPromise;
28
33
  protected blockStream!: L2BlockStream;
29
34
  protected l2TipsStore: L2TipsStore;
@@ -38,8 +43,17 @@ export class Sentinel implements L2BlockStreamEventHandler {
38
43
  protected archiver: L2BlockSource,
39
44
  protected p2p: P2PClient,
40
45
  protected store: SentinelStore,
46
+ protected config: Pick<
47
+ SlasherConfig,
48
+ | 'slashInactivityCreateTargetPercentage'
49
+ | 'slashInactivityCreatePenalty'
50
+ | 'slashInactivitySignalTargetPercentage'
51
+ | 'slashInactivityMaxPenalty'
52
+ | 'slashPayloadTtlSeconds'
53
+ >,
41
54
  protected logger = createLogger('node:sentinel'),
42
55
  ) {
56
+ super();
43
57
  this.l2TipsStore = new L2TipsMemoryStore();
44
58
  const interval = (epochCache.getL1Constants().ethereumSlotDuration * 1000) / 4;
45
59
  this.runningPromise = new RunningPromise(this.work.bind(this), logger, interval);
@@ -84,7 +98,110 @@ export class Sentinel implements L2BlockStreamEventHandler {
84
98
  this.slotNumberToBlock.delete(key);
85
99
  }
86
100
  }
101
+ } else if (event.type === 'chain-proven') {
102
+ await this.handleChainProven(event);
103
+ }
104
+ }
105
+
106
+ protected async handleChainProven(event: L2BlockStreamEvent) {
107
+ if (event.type !== 'chain-proven') {
108
+ return;
109
+ }
110
+ const blockNumber = event.block.number;
111
+ const block = await this.archiver.getBlock(blockNumber);
112
+ if (!block) {
113
+ this.logger.error(`Failed to get block ${blockNumber}`, { block });
114
+ return;
115
+ }
116
+
117
+ const epoch = getEpochAtSlot(block.header.getSlot(), await this.archiver.getL1Constants());
118
+ this.logger.info(`Computing proven performance for epoch ${epoch}`);
119
+ const performance = await this.computeProvenPerformance(epoch);
120
+ this.logger.info(`Proven performance for epoch ${epoch}`, performance);
121
+
122
+ await this.updateProvenPerformance(epoch, performance);
123
+ this.handleProvenPerformance(performance);
124
+ }
125
+
126
+ protected async computeProvenPerformance(epoch: bigint) {
127
+ const headers = await this.archiver.getBlockHeadersForEpoch(epoch);
128
+ const provenSlots = headers.map(h => h.getSlot());
129
+ const fromSlot = provenSlots[0];
130
+ const toSlot = provenSlots[provenSlots.length - 1];
131
+ const { committee } = await this.epochCache.getCommittee(fromSlot);
132
+ const stats = await this.computeStats({ fromSlot, toSlot });
133
+ this.logger.debug(`Stats for epoch ${epoch}`, stats);
134
+
135
+ const performance: ValidatorsEpochPerformance = {};
136
+ for (const validator of Object.keys(stats.stats)) {
137
+ let address;
138
+ try {
139
+ address = EthAddress.fromString(validator);
140
+ } catch (e) {
141
+ this.logger.error(`Invalid validator address ${validator}`, e);
142
+ continue;
143
+ }
144
+ if (!committee.find(v => v.equals(address))) {
145
+ continue;
146
+ }
147
+ let missed = 0;
148
+ for (const history of stats.stats[validator].history) {
149
+ if (provenSlots.includes(history.slot) && history.status === 'attestation-missed') {
150
+ missed++;
151
+ }
152
+ }
153
+ performance[address.toString()] = { missed, total: provenSlots.length };
154
+ }
155
+ return performance;
156
+ }
157
+
158
+ protected updateProvenPerformance(epoch: bigint, performance: ValidatorsEpochPerformance) {
159
+ return this.store.updateProvenPerformance(epoch, performance);
160
+ }
161
+
162
+ protected handleProvenPerformance(performance: ValidatorsEpochPerformance) {
163
+ const criminals = Object.entries(performance)
164
+ .filter(([_, { missed, total }]) => {
165
+ return missed / total >= this.config.slashInactivityCreateTargetPercentage;
166
+ })
167
+ .map(([address]) => address as `0x${string}`);
168
+
169
+ const args = criminals.map(address => ({
170
+ validator: EthAddress.fromString(address),
171
+ amount: this.config.slashInactivityCreatePenalty,
172
+ offense: Offence.INACTIVITY,
173
+ }));
174
+
175
+ this.logger.info(`Criminals: ${criminals.length}`, { args });
176
+
177
+ if (criminals.length > 0) {
178
+ this.emit(WANT_TO_SLASH_EVENT, args);
179
+ }
180
+ }
181
+
182
+ public async shouldSlash({ validator, amount }: WantToSlashArgs): Promise<boolean> {
183
+ const l1Constants = this.epochCache.getL1Constants();
184
+ const ttlL2Slots = this.config.slashPayloadTtlSeconds / l1Constants.slotDuration;
185
+ const ttlEpochs = BigInt(Math.ceil(ttlL2Slots / l1Constants.epochDuration));
186
+
187
+ const currentEpoch = this.epochCache.getEpochAndSlotNow().epoch;
188
+ const performance = await this.store.getProvenPerformance(validator);
189
+ const isCriminal =
190
+ performance
191
+ .filter(p => p.epoch >= currentEpoch - ttlEpochs)
192
+ .findIndex(p => p.missed / p.total >= this.config.slashInactivitySignalTargetPercentage) !== -1;
193
+ if (isCriminal) {
194
+ if (amount <= this.config.slashInactivityMaxPenalty) {
195
+ return true;
196
+ } else {
197
+ this.logger.warn(`Validator ${validator} is a criminal but the penalty is too high`, {
198
+ amount,
199
+ maxPenalty: this.config.slashInactivityMaxPenalty,
200
+ });
201
+ return false;
202
+ }
87
203
  }
204
+ return false;
88
205
  }
89
206
 
90
207
  /**
@@ -232,14 +349,18 @@ export class Sentinel implements L2BlockStreamEventHandler {
232
349
  }
233
350
 
234
351
  /** Computes stats to be returned based on stored data. */
235
- public async computeStats(): Promise<ValidatorsStats> {
352
+ public async computeStats({
353
+ fromSlot: _fromSlot,
354
+ toSlot: _toSlot,
355
+ }: { fromSlot?: bigint; toSlot?: bigint } = {}): Promise<ValidatorsStats> {
236
356
  const histories = await this.store.getHistories();
237
357
  const slotNow = this.epochCache.getEpochAndSlotNow().slot;
238
- const fromSlot = (this.lastProcessedSlot ?? slotNow) - BigInt(this.store.getHistoryLength());
358
+ const fromSlot = _fromSlot ?? (this.lastProcessedSlot ?? slotNow) - BigInt(this.store.getHistoryLength());
359
+ const toSlot = _toSlot ?? this.lastProcessedSlot ?? slotNow;
239
360
  const result: Record<`0x${string}`, ValidatorStats> = {};
240
361
  for (const [address, history] of Object.entries(histories)) {
241
362
  const validatorAddress = address as `0x${string}`;
242
- result[validatorAddress] = this.computeStatsForValidator(validatorAddress, history, fromSlot);
363
+ result[validatorAddress] = this.computeStatsForValidator(validatorAddress, history, fromSlot, toSlot);
243
364
  }
244
365
  return {
245
366
  stats: result,
@@ -253,8 +374,10 @@ export class Sentinel implements L2BlockStreamEventHandler {
253
374
  address: `0x${string}`,
254
375
  allHistory: ValidatorStatusHistory,
255
376
  fromSlot?: bigint,
377
+ toSlot?: bigint,
256
378
  ): ValidatorStats {
257
- const history = fromSlot ? allHistory.filter(h => h.slot >= fromSlot) : allHistory;
379
+ let history = fromSlot ? allHistory.filter(h => h.slot >= fromSlot) : allHistory;
380
+ history = toSlot ? history.filter(h => h.slot <= toSlot) : history;
258
381
  return {
259
382
  address: EthAddress.fromString(address),
260
383
  lastProposal: this.computeFromSlot(
@@ -1,56 +1,128 @@
1
+ import { EthAddress } from '@aztec/foundation/eth-address';
1
2
  import { BufferReader, numToUInt8, numToUInt32BE, serializeToBuffer } from '@aztec/foundation/serialize';
2
3
  import type { AztecAsyncKVStore, AztecAsyncMap } from '@aztec/kv-store';
3
- import type { ValidatorStatusHistory, ValidatorStatusInSlot } from '@aztec/stdlib/validators';
4
+ import type {
5
+ ValidatorStatusHistory,
6
+ ValidatorStatusInSlot,
7
+ ValidatorsEpochPerformance,
8
+ } from '@aztec/stdlib/validators';
4
9
 
5
10
  export class SentinelStore {
6
- public static readonly SCHEMA_VERSION = 1;
11
+ public static readonly SCHEMA_VERSION = 2;
7
12
 
8
- private readonly map: AztecAsyncMap<`0x${string}`, Buffer>;
13
+ // a map from validator address to their ValidatorStatusHistory
14
+ private readonly historyMap: AztecAsyncMap<`0x${string}`, Buffer>;
15
+
16
+ // a map from validator address to their historical proven epoch performance
17
+ // e.g. { validator: [{ epoch: 1, missed: 1, total: 10 }, { epoch: 2, missed: 3, total: 7 }, ...] }
18
+ private readonly provenMap: AztecAsyncMap<`0x${string}`, Buffer>;
9
19
 
10
20
  constructor(
11
21
  private store: AztecAsyncKVStore,
12
22
  private config: { historyLength: number },
13
23
  ) {
14
- this.map = store.openMap('sentinel-validator-status');
24
+ this.historyMap = store.openMap('sentinel-validator-status');
25
+ this.provenMap = store.openMap('sentinel-validator-proven');
15
26
  }
16
27
 
17
28
  public getHistoryLength() {
18
29
  return this.config.historyLength;
19
30
  }
20
31
 
32
+ public async updateProvenPerformance(epoch: bigint, performance: ValidatorsEpochPerformance) {
33
+ await this.store.transactionAsync(async () => {
34
+ for (const [who, { missed, total }] of Object.entries(performance)) {
35
+ await this.pushValidatorProvenPerformanceForEpoch({ who: EthAddress.fromString(who), missed, total, epoch });
36
+ }
37
+ });
38
+ }
39
+
40
+ public async getProvenPerformance(who: EthAddress): Promise<{ missed: number; total: number; epoch: bigint }[]> {
41
+ const currentPerformanceBuffer = await this.provenMap.getAsync(who.toString());
42
+ return currentPerformanceBuffer ? this.deserializePerformance(currentPerformanceBuffer) : [];
43
+ }
44
+
45
+ private async pushValidatorProvenPerformanceForEpoch({
46
+ who,
47
+ missed,
48
+ total,
49
+ epoch,
50
+ }: {
51
+ who: EthAddress;
52
+ missed: number;
53
+ total: number;
54
+ epoch: bigint;
55
+ }) {
56
+ const currentPerformance = await this.getProvenPerformance(who);
57
+ const existingIndex = currentPerformance.findIndex(p => p.epoch === epoch);
58
+ if (existingIndex !== -1) {
59
+ currentPerformance[existingIndex] = { missed, total, epoch };
60
+ } else {
61
+ currentPerformance.push({ missed, total, epoch });
62
+ }
63
+
64
+ // This should be sorted by epoch, but just in case.
65
+ // Since we keep the size small, this is not a big deal.
66
+ currentPerformance.sort((a, b) => Number(a.epoch - b.epoch));
67
+
68
+ // keep the most recent `historyLength` entries.
69
+ const performanceToKeep = currentPerformance.slice(-this.config.historyLength);
70
+
71
+ await this.provenMap.set(who.toString(), this.serializePerformance(performanceToKeep));
72
+ }
73
+
21
74
  public async updateValidators(slot: bigint, statuses: Record<`0x${string}`, ValidatorStatusInSlot | undefined>) {
22
75
  await this.store.transactionAsync(async () => {
23
76
  for (const [who, status] of Object.entries(statuses)) {
24
77
  if (status) {
25
- await this.pushValidatorStatusForSlot(who as `0x${string}`, slot, status);
78
+ await this.pushValidatorStatusForSlot(EthAddress.fromString(who), slot, status);
26
79
  }
27
80
  }
28
81
  });
29
82
  }
30
83
 
31
84
  private async pushValidatorStatusForSlot(
32
- who: `0x${string}`,
85
+ who: EthAddress,
33
86
  slot: bigint,
34
87
  status: 'block-mined' | 'block-proposed' | 'block-missed' | 'attestation-sent' | 'attestation-missed',
35
88
  ) {
36
89
  const currentHistory = (await this.getHistory(who)) ?? [];
37
90
  const newHistory = [...currentHistory, { slot, status }].slice(-this.config.historyLength);
38
- await this.map.set(who, this.serializeHistory(newHistory));
91
+ await this.historyMap.set(who.toString(), this.serializeHistory(newHistory));
39
92
  }
40
93
 
41
94
  public async getHistories(): Promise<Record<`0x${string}`, ValidatorStatusHistory>> {
42
95
  const histories: Record<`0x${string}`, ValidatorStatusHistory> = {};
43
- for await (const [address, history] of this.map.entriesAsync()) {
96
+ for await (const [address, history] of this.historyMap.entriesAsync()) {
44
97
  histories[address] = this.deserializeHistory(history);
45
98
  }
46
99
  return histories;
47
100
  }
48
101
 
49
- private async getHistory(address: `0x${string}`): Promise<ValidatorStatusHistory | undefined> {
50
- const data = await this.map.getAsync(address);
102
+ private async getHistory(address: EthAddress): Promise<ValidatorStatusHistory | undefined> {
103
+ const data = await this.historyMap.getAsync(address.toString());
51
104
  return data && this.deserializeHistory(data);
52
105
  }
53
106
 
107
+ private serializePerformance(performance: { missed: number; total: number; epoch: bigint }[]): Buffer {
108
+ return serializeToBuffer(
109
+ performance.map(p => [numToUInt32BE(Number(p.epoch)), numToUInt32BE(p.missed), numToUInt32BE(p.total)]),
110
+ );
111
+ }
112
+
113
+ private deserializePerformance(buffer: Buffer): { missed: number; total: number; epoch: bigint }[] {
114
+ const reader = new BufferReader(buffer);
115
+ const performance: { missed: number; total: number; epoch: bigint }[] = [];
116
+ while (!reader.isEmpty()) {
117
+ performance.push({
118
+ epoch: BigInt(reader.readNumber()),
119
+ missed: reader.readNumber(),
120
+ total: reader.readNumber(),
121
+ });
122
+ }
123
+ return performance;
124
+ }
125
+
54
126
  private serializeHistory(history: ValidatorStatusHistory): Buffer {
55
127
  return serializeToBuffer(
56
128
  history.map(h => [numToUInt32BE(Number(h.slot)), numToUInt8(this.statusToNumber(h.status))]),