@aztec/aztec-node 6.0.0-nightly.20260604 → 6.0.0-nightly.20260721

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.
Files changed (52) hide show
  1. package/dest/aztec-node/config.d.ts +10 -1
  2. package/dest/aztec-node/config.d.ts.map +1 -1
  3. package/dest/aztec-node/config.js +10 -0
  4. package/dest/aztec-node/node_public_calls_simulator.d.ts +97 -0
  5. package/dest/aztec-node/node_public_calls_simulator.d.ts.map +1 -0
  6. package/dest/aztec-node/node_public_calls_simulator.js +351 -0
  7. package/dest/aztec-node/register_node_rpc_handlers.d.ts +10 -0
  8. package/dest/aztec-node/register_node_rpc_handlers.d.ts.map +1 -0
  9. package/dest/aztec-node/register_node_rpc_handlers.js +31 -0
  10. package/dest/aztec-node/server.d.ts +92 -64
  11. package/dest/aztec-node/server.d.ts.map +1 -1
  12. package/dest/aztec-node/server.js +229 -1131
  13. package/dest/bin/index.js +15 -10
  14. package/dest/factory.d.ts +33 -0
  15. package/dest/factory.d.ts.map +1 -0
  16. package/dest/factory.js +523 -0
  17. package/dest/index.d.ts +3 -1
  18. package/dest/index.d.ts.map +1 -1
  19. package/dest/index.js +2 -0
  20. package/dest/modules/block_parameter.d.ts +25 -0
  21. package/dest/modules/block_parameter.d.ts.map +1 -0
  22. package/dest/modules/block_parameter.js +100 -0
  23. package/dest/modules/node_block_provider.d.ts +19 -0
  24. package/dest/modules/node_block_provider.d.ts.map +1 -0
  25. package/dest/modules/node_block_provider.js +112 -0
  26. package/dest/modules/node_tx_receipt.d.ts +24 -0
  27. package/dest/modules/node_tx_receipt.d.ts.map +1 -0
  28. package/dest/modules/node_tx_receipt.js +70 -0
  29. package/dest/modules/node_world_state_queries.d.ts +65 -0
  30. package/dest/modules/node_world_state_queries.d.ts.map +1 -0
  31. package/dest/modules/node_world_state_queries.js +272 -0
  32. package/dest/sentinel/factory.d.ts +3 -3
  33. package/dest/sentinel/factory.d.ts.map +1 -1
  34. package/dest/sentinel/factory.js +8 -1
  35. package/dest/sentinel/sentinel.d.ts +21 -21
  36. package/dest/sentinel/sentinel.d.ts.map +1 -1
  37. package/dest/sentinel/sentinel.js +27 -47
  38. package/package.json +28 -27
  39. package/src/aztec-node/config.ts +19 -0
  40. package/src/aztec-node/node_public_calls_simulator.ts +394 -0
  41. package/src/aztec-node/register_node_rpc_handlers.ts +29 -0
  42. package/src/aztec-node/server.ts +319 -1315
  43. package/src/bin/index.ts +19 -12
  44. package/src/factory.ts +688 -0
  45. package/src/index.ts +2 -0
  46. package/src/modules/block_parameter.ts +93 -0
  47. package/src/modules/node_block_provider.ts +149 -0
  48. package/src/modules/node_tx_receipt.ts +115 -0
  49. package/src/modules/node_world_state_queries.ts +374 -0
  50. package/src/sentinel/README.md +3 -3
  51. package/src/sentinel/factory.ts +15 -3
  52. package/src/sentinel/sentinel.ts +34 -72
@@ -0,0 +1,374 @@
1
+ import { ARCHIVE_HEIGHT, type L1_TO_L2_MSG_TREE_HEIGHT, type NOTE_HASH_TREE_HEIGHT } from '@aztec/constants';
2
+ import { BlockNumber, type CheckpointNumber, type EpochNumber } from '@aztec/foundation/branded-types';
3
+ import { chunkBy } from '@aztec/foundation/collection';
4
+ import { Fr } from '@aztec/foundation/curves/bn254';
5
+ import { type Logger, createLogger } from '@aztec/foundation/log';
6
+ import { sleep } from '@aztec/foundation/sleep';
7
+ import { MembershipWitness, type SiblingPath } from '@aztec/foundation/trees';
8
+ import type { AztecAddress } from '@aztec/stdlib/aztec-address';
9
+ import {
10
+ type BlockHash,
11
+ type BlockParameter,
12
+ type DataInBlock,
13
+ type L2BlockSource,
14
+ type NormalizedBlockParameter,
15
+ inspectBlockParameter,
16
+ } from '@aztec/stdlib/block';
17
+ import { computePublicDataTreeLeafSlot } from '@aztec/stdlib/hash';
18
+ import type { WorldStateSynchronizer } from '@aztec/stdlib/interfaces/server';
19
+ import { InboxLeaf, type L1ToL2MessageSource, type L2ToL1MembershipWitness } from '@aztec/stdlib/messaging';
20
+ import {
21
+ MerkleTreeId,
22
+ type NullifierLeafPreimage,
23
+ NullifierMembershipWitness,
24
+ type PublicDataTreeLeafPreimage,
25
+ PublicDataWitness,
26
+ } from '@aztec/stdlib/trees';
27
+ import type { TxHash } from '@aztec/stdlib/tx';
28
+ import { WorldStateSynchronizerError } from '@aztec/world-state';
29
+
30
+ import { normalizeBlockParameter } from './block_parameter.js';
31
+
32
+ /** Attempts at resolving a query and syncing world state to it before giving up (see {@link NodeWorldStateQueries.getWorldState}). */
33
+ const WORLD_STATE_SYNC_ATTEMPTS = 3;
34
+
35
+ /** Delay between world-state sync attempts. */
36
+ const WORLD_STATE_SYNC_RETRY_DELAY_MS = 100;
37
+
38
+ /** Dependencies required to build a {@link NodeWorldStateQueries}. */
39
+ export interface NodeWorldStateQueriesDeps {
40
+ worldStateSynchronizer: WorldStateSynchronizer;
41
+ blockSource: L2BlockSource;
42
+ l1ToL2MessageSource: L1ToL2MessageSource;
43
+ log?: Logger;
44
+ }
45
+
46
+ /**
47
+ * Serves the node's Merkle-tree and membership-witness queries against committed world-state at a
48
+ * requested block. Extracted from `AztecNodeService` so the block-resolution and reorg-aware sync logic
49
+ * can be unit-tested without standing up the whole node, and to keep `server.ts` smaller.
50
+ */
51
+ export class NodeWorldStateQueries {
52
+ private readonly worldStateSynchronizer: WorldStateSynchronizer;
53
+ private readonly blockSource: L2BlockSource;
54
+ private readonly l1ToL2MessageSource: L1ToL2MessageSource;
55
+ private readonly log: Logger;
56
+
57
+ constructor(deps: NodeWorldStateQueriesDeps) {
58
+ this.worldStateSynchronizer = deps.worldStateSynchronizer;
59
+ this.blockSource = deps.blockSource;
60
+ this.l1ToL2MessageSource = deps.l1ToL2MessageSource;
61
+ this.log = deps.log ?? createLogger('node:world-state-queries');
62
+ }
63
+
64
+ public async findLeavesIndexes(
65
+ referenceBlock: BlockParameter,
66
+ treeId: MerkleTreeId,
67
+ leafValues: Fr[],
68
+ ): Promise<(DataInBlock<bigint> | undefined)[]> {
69
+ const committedDb = await this.getWorldState(referenceBlock);
70
+ const maybeIndices = await committedDb.findLeafIndices(
71
+ treeId,
72
+ leafValues.map(x => x.toBuffer()),
73
+ );
74
+ // Filter out undefined values to query block numbers only for found leaves
75
+ const definedIndices = maybeIndices.filter(x => x !== undefined);
76
+
77
+ // Now we find the block numbers for the defined indices
78
+ const blockNumbers = await committedDb.getBlockNumbersForLeafIndices(treeId, definedIndices);
79
+
80
+ // Build a map from leaf index to block number
81
+ const indexToBlockNumber = new Map<bigint, BlockNumber>();
82
+ for (let i = 0; i < definedIndices.length; i++) {
83
+ const blockNumber = blockNumbers[i];
84
+ if (blockNumber === undefined) {
85
+ throw new Error(
86
+ `Block number is undefined for leaf index ${definedIndices[i]} in tree ${MerkleTreeId[treeId]}`,
87
+ );
88
+ }
89
+ indexToBlockNumber.set(definedIndices[i], blockNumber);
90
+ }
91
+
92
+ // Get unique block numbers in order to optimize num calls to getLeafValue function.
93
+ const uniqueBlockNumbers = [...new Set(indexToBlockNumber.values())];
94
+
95
+ // Now we obtain the block hashes from the archive tree (block number = leaf index in archive tree).
96
+ const blockHashes = await Promise.all(
97
+ uniqueBlockNumbers.map(blockNumber => {
98
+ return committedDb.getLeafValue(MerkleTreeId.ARCHIVE, BigInt(blockNumber));
99
+ }),
100
+ );
101
+
102
+ // Build a map from block number to block hash
103
+ const blockNumberToHash = new Map<BlockNumber, BlockHash>();
104
+ for (let i = 0; i < uniqueBlockNumbers.length; i++) {
105
+ const blockHash = blockHashes[i];
106
+ if (blockHash === undefined) {
107
+ throw new Error(`Block hash is undefined for block number ${uniqueBlockNumbers[i]}`);
108
+ }
109
+ blockNumberToHash.set(uniqueBlockNumbers[i], blockHash);
110
+ }
111
+
112
+ // Create DataInBlock objects by combining indices, blockNumbers and blockHashes and return them.
113
+ return maybeIndices.map(index => {
114
+ if (index === undefined) {
115
+ return undefined;
116
+ }
117
+ const blockNumber = indexToBlockNumber.get(index);
118
+ if (blockNumber === undefined) {
119
+ throw new Error(`Block number not found for leaf index ${index} in tree ${MerkleTreeId[treeId]}`);
120
+ }
121
+ const l2BlockHash = blockNumberToHash.get(blockNumber);
122
+ if (l2BlockHash === undefined) {
123
+ throw new Error(`Block hash not found for block number ${blockNumber}`);
124
+ }
125
+ return {
126
+ l2BlockNumber: blockNumber,
127
+ l2BlockHash,
128
+ data: index,
129
+ };
130
+ });
131
+ }
132
+
133
+ public async getBlockHashMembershipWitness(
134
+ referenceBlock: BlockParameter,
135
+ blockHash: BlockHash,
136
+ ): Promise<MembershipWitness<typeof ARCHIVE_HEIGHT> | undefined> {
137
+ // The Noir circuit checks the archive membership proof against `anchor_block_header.last_archive.root`,
138
+ // which is the archive tree root BEFORE the anchor block was added (i.e. the state after block N-1).
139
+ // So we need the world state at block N-1, not block N, to produce a sibling path matching that root.
140
+ const referenceBlockNumber = await this.#resolveBlockNumber(referenceBlock);
141
+ if (referenceBlockNumber === BlockNumber.ZERO) {
142
+ // Block 0 (the initial block) has an empty archive, so no membership witness can exist.
143
+ return undefined;
144
+ }
145
+ const committedDb = await this.getWorldState(BlockNumber(referenceBlockNumber - 1));
146
+ const [pathAndIndex] = await committedDb.findSiblingPaths<MerkleTreeId.ARCHIVE>(MerkleTreeId.ARCHIVE, [blockHash]);
147
+ return pathAndIndex === undefined
148
+ ? undefined
149
+ : MembershipWitness.fromSiblingPath(pathAndIndex.index, pathAndIndex.path);
150
+ }
151
+
152
+ public async getNoteHashMembershipWitness(
153
+ referenceBlock: BlockParameter,
154
+ noteHash: Fr,
155
+ ): Promise<MembershipWitness<typeof NOTE_HASH_TREE_HEIGHT> | undefined> {
156
+ const committedDb = await this.getWorldState(referenceBlock);
157
+ const [pathAndIndex] = await committedDb.findSiblingPaths<MerkleTreeId.NOTE_HASH_TREE>(
158
+ MerkleTreeId.NOTE_HASH_TREE,
159
+ [noteHash],
160
+ );
161
+ return pathAndIndex === undefined
162
+ ? undefined
163
+ : MembershipWitness.fromSiblingPath(pathAndIndex.index, pathAndIndex.path);
164
+ }
165
+
166
+ public async getL1ToL2MessageMembershipWitness(
167
+ referenceBlock: BlockParameter,
168
+ l1ToL2Message: Fr,
169
+ ): Promise<[bigint, SiblingPath<typeof L1_TO_L2_MSG_TREE_HEIGHT>] | undefined> {
170
+ const db = await this.getWorldState(referenceBlock);
171
+ const [witness] = await db.findSiblingPaths(MerkleTreeId.L1_TO_L2_MESSAGE_TREE, [l1ToL2Message]);
172
+ if (!witness) {
173
+ return undefined;
174
+ }
175
+
176
+ // REFACTOR: Return a MembershipWitness object
177
+ return [witness.index, witness.path];
178
+ }
179
+
180
+ public async getL1ToL2MessageCheckpoint(l1ToL2Message: Fr): Promise<CheckpointNumber | undefined> {
181
+ const messageIndex = await this.l1ToL2MessageSource.getL1ToL2MessageIndex(l1ToL2Message);
182
+ return messageIndex !== undefined ? InboxLeaf.checkpointNumberFromIndex(messageIndex) : undefined;
183
+ }
184
+
185
+ /**
186
+ * Returns all the L2 to L1 messages in an epoch (empty array if the epoch is not found). The public
187
+ * `AztecNodeService.getL2ToL1Messages` that delegates here is deprecated in favor of
188
+ * {@link getL2ToL1MembershipWitness}.
189
+ * @param epoch - The epoch at which to get the data.
190
+ */
191
+ public async getL2ToL1Messages(epoch: EpochNumber): Promise<Fr[][][][]> {
192
+ const blocks = await this.blockSource.getBlocks({ epoch, onlyCheckpointed: true });
193
+ const blocksInCheckpoints = chunkBy(blocks, block => block.header.globalVariables.slotNumber);
194
+ return blocksInCheckpoints.map(slotBlocks =>
195
+ slotBlocks.map(block => block.body.txEffects.map(txEffect => txEffect.l2ToL1Msgs)),
196
+ );
197
+ }
198
+
199
+ /**
200
+ * Returns the L2-to-L1 membership witness for a message in `txHash`. Passthrough to the
201
+ * archiver's locally-cached resolver — see {@link Archiver.getL2ToL1MembershipWitness}.
202
+ */
203
+ public getL2ToL1MembershipWitness(
204
+ txHash: TxHash,
205
+ message: Fr,
206
+ messageIndexInTx?: number,
207
+ ): Promise<L2ToL1MembershipWitness | undefined> {
208
+ return this.blockSource.getL2ToL1MembershipWitness(txHash, message, messageIndexInTx);
209
+ }
210
+
211
+ public async getNullifierMembershipWitness(
212
+ referenceBlock: BlockParameter,
213
+ nullifier: Fr,
214
+ ): Promise<NullifierMembershipWitness | undefined> {
215
+ const db = await this.getWorldState(referenceBlock);
216
+ const [witness] = await db.findSiblingPaths(MerkleTreeId.NULLIFIER_TREE, [nullifier.toBuffer()]);
217
+ if (!witness) {
218
+ return undefined;
219
+ }
220
+
221
+ const { index, path } = witness;
222
+ const leafPreimage = await db.getLeafPreimage(MerkleTreeId.NULLIFIER_TREE, index);
223
+ if (!leafPreimage) {
224
+ return undefined;
225
+ }
226
+
227
+ return new NullifierMembershipWitness(index, leafPreimage as NullifierLeafPreimage, path);
228
+ }
229
+
230
+ public async getLowNullifierMembershipWitness(
231
+ referenceBlock: BlockParameter,
232
+ nullifier: Fr,
233
+ ): Promise<NullifierMembershipWitness | undefined> {
234
+ const committedDb = await this.getWorldState(referenceBlock);
235
+ const findResult = await committedDb.getPreviousValueIndex(MerkleTreeId.NULLIFIER_TREE, nullifier.toBigInt());
236
+ if (!findResult) {
237
+ return undefined;
238
+ }
239
+ const { index, alreadyPresent } = findResult;
240
+ if (alreadyPresent) {
241
+ throw new Error(
242
+ `Cannot prove nullifier non-inclusion: nullifier ${nullifier.toBigInt()} already exists in the tree`,
243
+ );
244
+ }
245
+ const preimageData = (await committedDb.getLeafPreimage(MerkleTreeId.NULLIFIER_TREE, index))!;
246
+
247
+ const siblingPath = await committedDb.getSiblingPath(MerkleTreeId.NULLIFIER_TREE, BigInt(index));
248
+ return new NullifierMembershipWitness(BigInt(index), preimageData as NullifierLeafPreimage, siblingPath);
249
+ }
250
+
251
+ async getPublicDataWitness(referenceBlock: BlockParameter, leafSlot: Fr): Promise<PublicDataWitness | undefined> {
252
+ const committedDb = await this.getWorldState(referenceBlock);
253
+ const lowLeafResult = await committedDb.getPreviousValueIndex(MerkleTreeId.PUBLIC_DATA_TREE, leafSlot.toBigInt());
254
+ if (!lowLeafResult) {
255
+ return undefined;
256
+ } else {
257
+ const preimage = (await committedDb.getLeafPreimage(
258
+ MerkleTreeId.PUBLIC_DATA_TREE,
259
+ lowLeafResult.index,
260
+ )) as PublicDataTreeLeafPreimage;
261
+ const path = await committedDb.getSiblingPath(MerkleTreeId.PUBLIC_DATA_TREE, lowLeafResult.index);
262
+ return new PublicDataWitness(lowLeafResult.index, preimage, path);
263
+ }
264
+ }
265
+
266
+ public async getPublicStorageAt(referenceBlock: BlockParameter, contract: AztecAddress, slot: Fr): Promise<Fr> {
267
+ const committedDb = await this.getWorldState(referenceBlock);
268
+ const leafSlot = await computePublicDataTreeLeafSlot(contract, slot);
269
+
270
+ const lowLeafResult = await committedDb.getPreviousValueIndex(MerkleTreeId.PUBLIC_DATA_TREE, leafSlot.toBigInt());
271
+ if (!lowLeafResult || !lowLeafResult.alreadyPresent) {
272
+ return Fr.ZERO;
273
+ }
274
+ const preimage = (await committedDb.getLeafPreimage(
275
+ MerkleTreeId.PUBLIC_DATA_TREE,
276
+ lowLeafResult.index,
277
+ )) as PublicDataTreeLeafPreimage;
278
+ return preimage.leaf.value;
279
+ }
280
+
281
+ /**
282
+ * Returns an instance of MerkleTreeOperations having first ensured the world state is synced to the requested
283
+ * block on the correct fork. Every query variant is resolved to a concrete (block number, block hash), which is
284
+ * threaded through both the sync and the snapshot read so a reorg that replaced the block at that height is
285
+ * detected rather than served silently. Transient failures — a prune landing between resolution and sync, or a
286
+ * fork flip caught at either the sync or the snapshot stage — are retried a few times, re-resolving the query
287
+ * against the updated chain each time; terminal failures — an unknown block hash at resolution, or a block whose
288
+ * history world state has pruned away — are thrown immediately.
289
+ * @param block - The block parameter (block number, block hash, or tag) at which to get the data.
290
+ * @returns An instance of a committed MerkleTreeOperations
291
+ */
292
+ public async getWorldState(block: BlockParameter) {
293
+ const query = normalizeBlockParameter(block);
294
+
295
+ for (let attempt = 1; ; attempt++) {
296
+ try {
297
+ return await this.#resolveWorldState(query);
298
+ } catch (err) {
299
+ if (attempt >= WORLD_STATE_SYNC_ATTEMPTS || !(err instanceof WorldStateSynchronizerError)) {
300
+ throw err;
301
+ }
302
+ this.log.verbose(`Retrying world state query after sync failure: ${err.message}`, {
303
+ attempt,
304
+ block: inspectBlockParameter(block),
305
+ });
306
+ await sleep(WORLD_STATE_SYNC_RETRY_DELAY_MS);
307
+ }
308
+ }
309
+ }
310
+
311
+ /**
312
+ * Resolves `query` to a concrete (block number, block hash), syncs world state to that exact fork, and returns
313
+ * the committed db (for `proposed` queries) or the fork-verified snapshot at the resolved block.
314
+ */
315
+ async #resolveWorldState(query: NormalizedBlockParameter) {
316
+ // User requests 'latest on the current fork', so the committed db is returned unverified
317
+ if ('tag' in query && query.tag === 'proposed') {
318
+ this.log.debug(`Using committed db for latest block`);
319
+ await this.worldStateSynchronizer.syncImmediate();
320
+ return this.worldStateSynchronizer.getCommitted();
321
+ }
322
+
323
+ // Resolve the query against the block source BEFORE syncing to a concrete (number, hash), and drive the sync to
324
+ // that exact fork. Resolving after the sync races the block source: the resolved tip can advance past what world
325
+ // state synced while the sync is in flight. Passing the hash makes the sync reorg-aware — it barriers until the
326
+ // archive-tree commit for that block has landed and verifies it matches the requested fork, throwing otherwise.
327
+ const { blockNumber, blockHash } = await this.#resolveBlockNumberAndHash(query);
328
+ const blockSyncedTo = await this.worldStateSynchronizer.syncImmediate(blockNumber, blockHash);
329
+
330
+ // The fork could flip between it returning and the snapshot being read, so getVerifiedSnapshot pins the
331
+ // returned view to the resolved fork (re-resolved by the retry loop on mismatch).
332
+ this.log.debug(`Using verified snapshot for block ${blockNumber}, world state synced upto ${blockSyncedTo}`);
333
+ return await this.worldStateSynchronizer.getVerifiedSnapshot(blockNumber, blockHash);
334
+ }
335
+
336
+ /** Resolves any {@link BlockParameter} variant to its concrete `(blockNumber, blockHash)` via the block source. */
337
+ async #resolveBlockNumberAndHash(
338
+ query: NormalizedBlockParameter,
339
+ ): Promise<{ blockNumber: BlockNumber; blockHash: BlockHash }> {
340
+ const blockData = await this.blockSource.getBlockData(query);
341
+ if (blockData === undefined) {
342
+ this.#throwOnUndefinedBlockData(query);
343
+ }
344
+ return { blockNumber: blockData.header.getBlockNumber(), blockHash: blockData.blockHash };
345
+ }
346
+
347
+ /** Resolves any {@link BlockParameter} variant to a concrete block number. */
348
+ async #resolveBlockNumber(block: BlockParameter): Promise<BlockNumber> {
349
+ const blockQuery = normalizeBlockParameter(block);
350
+ const blockNumber = await this.blockSource.getBlockNumber(blockQuery);
351
+ if (blockNumber === undefined) {
352
+ this.#throwOnUndefinedBlockData(blockQuery);
353
+ }
354
+ return blockNumber;
355
+ }
356
+
357
+ /**
358
+ * Hash and archive misses are terminal (an unknown block, likely a reorg); tag and number misses are transient —
359
+ * the block may have been pruned between the tag->number resolution and this data read, or may simply not have
360
+ * arrived yet — and surface as {@link WorldStateSynchronizerError} so the retry loop re-resolves against the
361
+ * current chain.
362
+ */
363
+ #throwOnUndefinedBlockData(query: NormalizedBlockParameter): never {
364
+ if ('hash' in query) {
365
+ throw new Error(
366
+ `Block hash ${query.hash.toString()} not found when resolving query. If the node API has been queried with anchor block hash possibly a reorg has occurred.`,
367
+ );
368
+ }
369
+ if ('archive' in query) {
370
+ throw new Error(`Block with archive ${query.archive.toString()} not found when resolving query.`);
371
+ }
372
+ throw new WorldStateSynchronizerError(`Block not found for ${inspectBlockParameter(query)} when resolving query.`);
373
+ }
374
+ }
@@ -17,10 +17,10 @@ The sentinel is one of several watchers registered with the slasher; it does not
17
17
  | Source | What it provides |
18
18
  |---|---|
19
19
  | `EpochCache` | Slot/epoch helpers, committee + proposer for a slot, escape-hatch state |
20
- | `L2BlockSource` (archiver) | Synced slot, `chain-checkpointed` events, block headers |
20
+ | `L2BlockSource` (archiver) | Synced slot, `getCheckpoint({ slot })`, `getL2Tips()`, block headers |
21
21
  | `P2PClient` | `getCheckpointAttestationsForSlot(slot, payloadHash)`, `hasBlockProposalsForSlot(slot)` |
22
22
  | `CheckpointReexecutionTracker` | Local re-execution outcome for the proposal at each slot (`valid` / `invalid` / `unvalidated`) — populated by the validator client's `ProposalHandler` |
23
- | L1-checkpointed events | `chain-checkpointed` populates `slotNumberToCheckpoint` with the canonical attestor set |
23
+ | L1-confirmed checkpoints | Fetched on demand per slot via `archiver.getCheckpoint({ slot })`, yielding the canonical attestor set |
24
24
 
25
25
  ## Two cadences
26
26
 
@@ -49,7 +49,7 @@ For each slot, the proposer is assigned one of six statuses, ranked highest-conf
49
49
 
50
50
  | # | Status | Trigger | Inactive party |
51
51
  |---|---|---|---|
52
- | 6 | `checkpoint-mined` | `slotNumberToCheckpoint.has(slot)` (a checkpoint covering this slot has landed on L1) | Attestors who didn't attest |
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
  | 5 | `checkpoint-valid` | `tracker.getOutcomeForSlot(slot) === 'valid'` | Attestors who didn't attest |
54
54
  | 4 | `checkpoint-invalid` | `tracker.getOutcomeForSlot(slot) === 'invalid'` (re-executed and rejected) | Proposer |
55
55
  | 3 | `checkpoint-unvalidated` | `tracker.getOutcomeForSlot(slot) === 'unvalidated'` (validation aborted: missing data, timeout, etc.) | Proposer |
@@ -5,7 +5,7 @@ import type { P2PClient } from '@aztec/p2p';
5
5
  import type { L2BlockSource } from '@aztec/stdlib/block';
6
6
  import type { CheckpointReexecutionTracker } from '@aztec/stdlib/checkpoint';
7
7
  import type { ChainConfig } from '@aztec/stdlib/config';
8
- import type { SlasherConfig } from '@aztec/stdlib/interfaces/server';
8
+ import type { SlasherConfig, ValidatorClientConfig } from '@aztec/stdlib/interfaces/server';
9
9
  import type { DataStoreConfig } from '@aztec/stdlib/kv-store';
10
10
 
11
11
  import type { SentinelConfig } from './config.js';
@@ -17,12 +17,24 @@ export async function createSentinel(
17
17
  archiver: L2BlockSource,
18
18
  p2p: P2PClient,
19
19
  reexecutionTracker: CheckpointReexecutionTracker,
20
- config: SentinelConfig & DataStoreConfig & SlasherConfig & Pick<ChainConfig, 'l1ChainId' | 'rollupAddress'>,
20
+ config: SentinelConfig &
21
+ DataStoreConfig &
22
+ SlasherConfig &
23
+ Pick<ChainConfig, 'l1ChainId' | 'rollupAddress'> &
24
+ Pick<ValidatorClientConfig, 'disableValidator'>,
21
25
  logger = createLogger('node:sentinel'),
22
26
  ): Promise<Sentinel | undefined> {
23
- if (!config.sentinelEnabled) {
27
+ const runsValidator = !config.disableValidator;
28
+ if (!runsValidator && !config.sentinelEnabled) {
29
+ logger.verbose('Sentinel is disabled');
24
30
  return undefined;
25
31
  }
32
+ if (runsValidator) {
33
+ logger.info('Enabling sentinel since this node runs a validator');
34
+ } else {
35
+ logger.info('Enabling sentinel from SENTINEL_ENABLED configuration');
36
+ }
37
+
26
38
  const kvStore = await createStore('sentinel', SentinelStore.SCHEMA_VERSION, config, logger.getBindings());
27
39
  const storeHistoryLength = config.sentinelHistoryLengthInEpochs * epochCache.getL1Constants().epochDuration;
28
40
  const storeHistoricEpochPerformanceLength = config.sentinelHistoricEpochPerformanceLengthInEpochs;
@@ -1,16 +1,9 @@
1
1
  import type { EpochCache } from '@aztec/epoch-cache';
2
- import {
3
- BlockNumber,
4
- CheckpointNumber,
5
- CheckpointProposalHash,
6
- EpochNumber,
7
- SlotNumber,
8
- } from '@aztec/foundation/branded-types';
2
+ import { CheckpointNumber, CheckpointProposalHash, EpochNumber, SlotNumber } from '@aztec/foundation/branded-types';
9
3
  import { countWhile, filterAsync, fromEntries, getEntries, mapValues } from '@aztec/foundation/collection';
10
4
  import { EthAddress } from '@aztec/foundation/eth-address';
11
5
  import { createLogger } from '@aztec/foundation/log';
12
6
  import { RunningPromise } from '@aztec/foundation/running-promise';
13
- import { L2TipsMemoryStore, type L2TipsStore } from '@aztec/kv-store/stores';
14
7
  import type { P2PClient } from '@aztec/p2p';
15
8
  import {
16
9
  OffenseType,
@@ -21,13 +14,7 @@ import {
21
14
  getOffenseTypeName,
22
15
  } from '@aztec/slasher';
23
16
  import type { SlasherConfig } from '@aztec/slasher/config';
24
- import {
25
- type L2BlockSource,
26
- L2BlockStream,
27
- type L2BlockStreamEvent,
28
- type L2BlockStreamEventHandler,
29
- getAttestationInfoFromPublishedCheckpoint,
30
- } from '@aztec/stdlib/block';
17
+ import { type L2BlockSource, getAttestationInfoFromPublishedCheckpoint } from '@aztec/stdlib/block';
31
18
  import type { CheckpointReexecutionTracker } from '@aztec/stdlib/checkpoint';
32
19
  import type { ChainConfig } from '@aztec/stdlib/config';
33
20
  import { getEpochAtSlot, getSlotRangeForEpoch, getTimestampForSlot } from '@aztec/stdlib/epoch-helpers';
@@ -97,7 +84,7 @@ function statusToCategory(status: ValidatorStatusInSlot): ValidatorStatusType {
97
84
  * first:
98
85
  *
99
86
  * - `checkpoint-mined` — a checkpoint covering this slot has landed on L1
100
- * (`slotNumberToCheckpoint` populated from `chain-checkpointed`).
87
+ * (fetched on demand via `archiver.getCheckpoint({ slot })`).
101
88
  * - `checkpoint-valid` — the local node re-executed a checkpoint proposal for this slot
102
89
  * successfully (consulted via `CheckpointReexecutionTracker`).
103
90
  * - `checkpoint-invalid` — the local node re-executed a checkpoint proposal for this slot
@@ -135,25 +122,13 @@ function statusToCategory(status: ValidatorStatusInSlot): ValidatorStatusType {
135
122
  * (no history entries for that slot) and per-epoch evaluation writes an empty performance map
136
123
  * (no slashing).
137
124
  */
138
- export class Sentinel extends (EventEmitter as new () => WatcherEmitter) implements L2BlockStreamEventHandler, Watcher {
125
+ export class Sentinel extends (EventEmitter as new () => WatcherEmitter) implements Watcher {
139
126
  protected runningPromise: RunningPromise;
140
- protected blockStream!: L2BlockStream;
141
- protected l2TipsStore: L2TipsStore;
142
127
 
143
128
  protected initialSlot: SlotNumber | undefined;
144
129
  protected lastProcessedSlot: SlotNumber | undefined;
145
130
  /** Largest epoch number for which the end-of-epoch aggregator has run. */
146
131
  protected lastEvaluatedEpoch: EpochNumber | undefined;
147
- protected slotNumberToCheckpoint: Map<
148
- SlotNumber,
149
- {
150
- checkpointNumber: CheckpointNumber;
151
- archive: string;
152
- /** Hex keccak256 of the consensus payload bytes; used to fetch matching p2p attestations. */
153
- proposalPayloadHash: CheckpointProposalHash;
154
- attestors: EthAddress[];
155
- }
156
- > = new Map();
157
132
 
158
133
  constructor(
159
134
  protected epochCache: EpochCache,
@@ -165,7 +140,6 @@ export class Sentinel extends (EventEmitter as new () => WatcherEmitter) impleme
165
140
  protected logger = createLogger('node:sentinel'),
166
141
  ) {
167
142
  super();
168
- this.l2TipsStore = new L2TipsMemoryStore(archiver.getGenesisBlockHash());
169
143
  const interval = (epochCache.getL1Constants().ethereumSlotDuration * 1000) / 4;
170
144
  this.runningPromise = new RunningPromise(this.work.bind(this), logger, interval);
171
145
  }
@@ -187,17 +161,14 @@ export class Sentinel extends (EventEmitter as new () => WatcherEmitter) impleme
187
161
  }
188
162
 
189
163
  /**
190
- * Loads initial slot and initializes blockstream. We will not process anything at or before
191
- * the initial slot. Floors at the archiver's synced L2 slot so the sentinel keeps making
192
- * forward progress when L1 is advancing but L2 has no activity (the synced slot is driven by
193
- * L1 sync, not by L2 blocks). Falls back to the wallclock if the archiver isn't ready yet
194
- * (cold start).
164
+ * Loads the initial slot. We will not process anything at or before the initial slot. Floors at the
165
+ * archiver's synced L2 slot so the sentinel keeps making forward progress when L1 is advancing but L2 has no
166
+ * activity (the synced slot is driven by L1 sync, not by L2 blocks). Falls back to the wallclock if the
167
+ * archiver isn't ready yet (cold start).
195
168
  */
196
169
  protected async init() {
197
170
  this.initialSlot = await this.getCurrentSlot();
198
- const startingBlock = BlockNumber(await this.archiver.getBlockNumber());
199
- this.logger.info(`Starting validator sentinel with initial slot ${this.initialSlot} and block ${startingBlock}`);
200
- this.blockStream = new L2BlockStream(this.archiver, this.l2TipsStore, this, this.logger, { startingBlock });
171
+ this.logger.info(`Starting validator sentinel with initial slot ${this.initialSlot}`);
201
172
  }
202
173
 
203
174
  /**
@@ -215,44 +186,38 @@ export class Sentinel extends (EventEmitter as new () => WatcherEmitter) impleme
215
186
  return this.runningPromise.stop();
216
187
  }
217
188
 
218
- public async handleBlockStreamEvent(event: L2BlockStreamEvent): Promise<void> {
219
- await this.l2TipsStore.handleBlockStreamEvent(event);
220
- if (event.type === 'chain-checkpointed') {
221
- this.handleCheckpoint(event);
222
- }
223
- }
224
-
225
- protected handleCheckpoint(event: L2BlockStreamEvent) {
226
- if (event.type !== 'chain-checkpointed') {
227
- return;
189
+ /**
190
+ * Fetches the L1-confirmed checkpoint covering a slot (if any) and derives the slot-level data the
191
+ * activity classifier needs: the checkpoint number, archive root, consensus payload hash (used to fetch
192
+ * matching p2p attestations regardless of feeAssetPriceModifier variants), and the recovered attestor set.
193
+ * Reads on demand so the result is always against the canonical chain — a reorged-out checkpoint simply
194
+ * stops being returned, with no stale mapping to clean up.
195
+ */
196
+ protected async getCheckpointForSlot(slot: SlotNumber): Promise<
197
+ | {
198
+ checkpointNumber: CheckpointNumber;
199
+ archive: string;
200
+ proposalPayloadHash: CheckpointProposalHash;
201
+ attestors: EthAddress[];
202
+ }
203
+ | undefined
204
+ > {
205
+ const checkpoint = await this.archiver.getCheckpoint({ slot });
206
+ if (!checkpoint) {
207
+ return undefined;
228
208
  }
229
- const checkpoint = event.checkpoint;
230
-
231
- // Store mapping from slot to archive, checkpoint number, attestors, and the consensus payload
232
- // hash (used to query matching p2p attestations regardless of feeAssetPriceModifier variants).
233
209
  const signatureContext = this.getSignatureContext();
234
210
  const proposalPayloadHash = CheckpointProposalHash.fromBuffer(
235
211
  ConsensusPayload.fromCheckpoint(checkpoint.checkpoint, signatureContext).getPayloadHash(),
236
212
  );
237
- this.slotNumberToCheckpoint.set(checkpoint.checkpoint.header.slotNumber, {
213
+ return {
238
214
  checkpointNumber: checkpoint.checkpoint.number,
239
215
  archive: checkpoint.checkpoint.archive.root.toString(),
240
216
  proposalPayloadHash,
241
217
  attestors: getAttestationInfoFromPublishedCheckpoint(checkpoint, signatureContext)
242
218
  .filter(a => a.status === 'recovered-from-signature')
243
219
  .map(a => a.address!),
244
- });
245
-
246
- // Prune the archive map to only keep at most N entries
247
- const historyLength = this.store.getHistoryLength();
248
- if (this.slotNumberToCheckpoint.size > historyLength) {
249
- const toDelete = Array.from(this.slotNumberToCheckpoint.keys())
250
- .sort((a, b) => Number(a - b))
251
- .slice(0, this.slotNumberToCheckpoint.size - historyLength);
252
- for (const key of toDelete) {
253
- this.slotNumberToCheckpoint.delete(key);
254
- }
255
- }
220
+ };
256
221
  }
257
222
 
258
223
  /**
@@ -387,10 +352,6 @@ export class Sentinel extends (EventEmitter as new () => WatcherEmitter) impleme
387
352
  public async work() {
388
353
  const currentSlot = await this.getCurrentSlot();
389
354
  try {
390
- // Manually sync the block stream to ensure we have the latest data.
391
- // Note we never `start` the blockstream, so it loops at the same pace as we do.
392
- await this.blockStream.sync();
393
-
394
355
  // Per-slot activity recording (lag = 2 slots for P2P attestation settlement).
395
356
  const targetSlot = await this.isReadyToProcess(currentSlot);
396
357
  if (targetSlot !== false) {
@@ -478,7 +439,7 @@ export class Sentinel extends (EventEmitter as new () => WatcherEmitter) impleme
478
439
  return false;
479
440
  }
480
441
 
481
- const archiverLastBlockHash = await this.l2TipsStore.getL2Tips().then(tip => tip.proposed.hash);
442
+ const archiverLastBlockHash = await this.archiver.getL2Tips().then(tip => tip.proposed.hash);
482
443
  const p2pLastBlockHash = await this.p2p.getL2Tips().then(tips => tips.proposed.hash);
483
444
  const isP2pSynced = archiverLastBlockHash === p2pLastBlockHash;
484
445
  if (!isP2pSynced) {
@@ -534,8 +495,9 @@ export class Sentinel extends (EventEmitter as new () => WatcherEmitter) impleme
534
495
  this.logger.debug(`Computing stats for slot ${slot} at epoch ${epoch}`, { slot, epoch, proposer, committee });
535
496
 
536
497
  // Gather attestors from both p2p (live attestations) and the archiver (signers on the
537
- // checkpoint if one has landed on L1). Used regardless of which case applies.
538
- const checkpoint = this.slotNumberToCheckpoint.get(slot);
498
+ // checkpoint if one has landed on L1). Fetched on demand so it always reflects the canonical chain.
499
+ // Used regardless of which case applies.
500
+ const checkpoint = await this.getCheckpointForSlot(slot);
539
501
  const p2pAttested = await this.p2p.getCheckpointAttestationsForSlot(slot, checkpoint?.proposalPayloadHash);
540
502
  const p2pAttestors = p2pAttested.map(a => a.getSender()).filter((s): s is EthAddress => s !== undefined);
541
503
  const attestors = new Set(