@aztec/aztec-node 5.0.0-private.20260319 → 5.0.0-rc.2
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/aztec-node/block_response_helpers.d.ts +25 -0
- package/dest/aztec-node/block_response_helpers.d.ts.map +1 -0
- package/dest/aztec-node/block_response_helpers.js +112 -0
- package/dest/aztec-node/config.d.ts +16 -4
- package/dest/aztec-node/config.d.ts.map +1 -1
- package/dest/aztec-node/config.js +15 -5
- package/dest/aztec-node/node_public_calls_simulator.d.ts +90 -0
- package/dest/aztec-node/node_public_calls_simulator.d.ts.map +1 -0
- package/dest/aztec-node/node_public_calls_simulator.js +346 -0
- package/dest/aztec-node/public_data_overrides.d.ts +13 -0
- package/dest/aztec-node/public_data_overrides.d.ts.map +1 -0
- package/dest/aztec-node/public_data_overrides.js +21 -0
- package/dest/aztec-node/register_node_rpc_handlers.d.ts +10 -0
- package/dest/aztec-node/register_node_rpc_handlers.d.ts.map +1 -0
- package/dest/aztec-node/register_node_rpc_handlers.js +31 -0
- package/dest/aztec-node/server.d.ts +128 -134
- package/dest/aztec-node/server.d.ts.map +1 -1
- package/dest/aztec-node/server.js +393 -820
- package/dest/bin/index.js +15 -10
- package/dest/factory.d.ts +33 -0
- package/dest/factory.d.ts.map +1 -0
- package/dest/factory.js +496 -0
- package/dest/index.d.ts +3 -1
- package/dest/index.d.ts.map +1 -1
- package/dest/index.js +2 -0
- package/dest/modules/block_parameter.d.ts +25 -0
- package/dest/modules/block_parameter.d.ts.map +1 -0
- package/dest/modules/block_parameter.js +100 -0
- package/dest/modules/node_block_provider.d.ts +19 -0
- package/dest/modules/node_block_provider.d.ts.map +1 -0
- package/dest/modules/node_block_provider.js +112 -0
- package/dest/modules/node_tx_receipt.d.ts +24 -0
- package/dest/modules/node_tx_receipt.d.ts.map +1 -0
- package/dest/modules/node_tx_receipt.js +70 -0
- package/dest/modules/node_world_state_queries.d.ts +61 -0
- package/dest/modules/node_world_state_queries.d.ts.map +1 -0
- package/dest/modules/node_world_state_queries.js +257 -0
- package/dest/sentinel/config.d.ts +3 -2
- package/dest/sentinel/config.d.ts.map +1 -1
- package/dest/sentinel/config.js +15 -5
- package/dest/sentinel/factory.d.ts +5 -3
- package/dest/sentinel/factory.d.ts.map +1 -1
- package/dest/sentinel/factory.js +12 -5
- package/dest/sentinel/sentinel.d.ts +145 -21
- package/dest/sentinel/sentinel.d.ts.map +1 -1
- package/dest/sentinel/sentinel.js +227 -105
- package/dest/sentinel/store.d.ts +8 -8
- package/dest/sentinel/store.d.ts.map +1 -1
- package/dest/sentinel/store.js +25 -17
- package/dest/test/index.d.ts +3 -3
- package/dest/test/index.d.ts.map +1 -1
- package/package.json +28 -26
- package/src/aztec-node/block_response_helpers.ts +161 -0
- package/src/aztec-node/config.ts +30 -7
- package/src/aztec-node/node_public_calls_simulator.ts +383 -0
- package/src/aztec-node/public_data_overrides.ts +35 -0
- package/src/aztec-node/register_node_rpc_handlers.ts +29 -0
- package/src/aztec-node/server.ts +514 -1070
- package/src/bin/index.ts +19 -12
- package/src/factory.ts +656 -0
- package/src/index.ts +2 -0
- package/src/modules/block_parameter.ts +93 -0
- package/src/modules/node_block_provider.ts +149 -0
- package/src/modules/node_tx_receipt.ts +115 -0
- package/src/modules/node_world_state_queries.ts +360 -0
- package/src/sentinel/README.md +103 -0
- package/src/sentinel/config.ts +18 -6
- package/src/sentinel/factory.ts +21 -6
- package/src/sentinel/sentinel.ts +277 -130
- package/src/sentinel/store.ts +26 -18
- package/src/test/index.ts +2 -2
|
@@ -0,0 +1,360 @@
|
|
|
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 { MembershipWitness, type SiblingPath } from '@aztec/foundation/trees';
|
|
7
|
+
import type { AztecAddress } from '@aztec/stdlib/aztec-address';
|
|
8
|
+
import {
|
|
9
|
+
type BlockHash,
|
|
10
|
+
type BlockParameter,
|
|
11
|
+
type DataInBlock,
|
|
12
|
+
type L2BlockSource,
|
|
13
|
+
inspectBlockParameter,
|
|
14
|
+
} from '@aztec/stdlib/block';
|
|
15
|
+
import { computePublicDataTreeLeafSlot } from '@aztec/stdlib/hash';
|
|
16
|
+
import type { WorldStateSynchronizer } from '@aztec/stdlib/interfaces/server';
|
|
17
|
+
import { InboxLeaf, type L1ToL2MessageSource, type L2ToL1MembershipWitness } from '@aztec/stdlib/messaging';
|
|
18
|
+
import {
|
|
19
|
+
MerkleTreeId,
|
|
20
|
+
type NullifierLeafPreimage,
|
|
21
|
+
NullifierMembershipWitness,
|
|
22
|
+
type PublicDataTreeLeafPreimage,
|
|
23
|
+
PublicDataWitness,
|
|
24
|
+
} from '@aztec/stdlib/trees';
|
|
25
|
+
import type { TxHash } from '@aztec/stdlib/tx';
|
|
26
|
+
|
|
27
|
+
import { normalizeBlockParameter } from './block_parameter.js';
|
|
28
|
+
|
|
29
|
+
/** Dependencies required to build a {@link NodeWorldStateQueries}. */
|
|
30
|
+
export interface NodeWorldStateQueriesDeps {
|
|
31
|
+
worldStateSynchronizer: WorldStateSynchronizer;
|
|
32
|
+
blockSource: L2BlockSource;
|
|
33
|
+
l1ToL2MessageSource: L1ToL2MessageSource;
|
|
34
|
+
log?: Logger;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Serves the node's Merkle-tree and membership-witness queries against committed world-state at a
|
|
39
|
+
* requested block. Extracted from `AztecNodeService` so the block-resolution and reorg-aware sync logic
|
|
40
|
+
* can be unit-tested without standing up the whole node, and to keep `server.ts` smaller.
|
|
41
|
+
*/
|
|
42
|
+
export class NodeWorldStateQueries {
|
|
43
|
+
private readonly worldStateSynchronizer: WorldStateSynchronizer;
|
|
44
|
+
private readonly blockSource: L2BlockSource;
|
|
45
|
+
private readonly l1ToL2MessageSource: L1ToL2MessageSource;
|
|
46
|
+
private readonly log: Logger;
|
|
47
|
+
|
|
48
|
+
constructor(deps: NodeWorldStateQueriesDeps) {
|
|
49
|
+
this.worldStateSynchronizer = deps.worldStateSynchronizer;
|
|
50
|
+
this.blockSource = deps.blockSource;
|
|
51
|
+
this.l1ToL2MessageSource = deps.l1ToL2MessageSource;
|
|
52
|
+
this.log = deps.log ?? createLogger('node:world-state-queries');
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
public async findLeavesIndexes(
|
|
56
|
+
referenceBlock: BlockParameter,
|
|
57
|
+
treeId: MerkleTreeId,
|
|
58
|
+
leafValues: Fr[],
|
|
59
|
+
): Promise<(DataInBlock<bigint> | undefined)[]> {
|
|
60
|
+
const committedDb = await this.getWorldState(referenceBlock);
|
|
61
|
+
const maybeIndices = await committedDb.findLeafIndices(
|
|
62
|
+
treeId,
|
|
63
|
+
leafValues.map(x => x.toBuffer()),
|
|
64
|
+
);
|
|
65
|
+
// Filter out undefined values to query block numbers only for found leaves
|
|
66
|
+
const definedIndices = maybeIndices.filter(x => x !== undefined);
|
|
67
|
+
|
|
68
|
+
// Now we find the block numbers for the defined indices
|
|
69
|
+
const blockNumbers = await committedDb.getBlockNumbersForLeafIndices(treeId, definedIndices);
|
|
70
|
+
|
|
71
|
+
// Build a map from leaf index to block number
|
|
72
|
+
const indexToBlockNumber = new Map<bigint, BlockNumber>();
|
|
73
|
+
for (let i = 0; i < definedIndices.length; i++) {
|
|
74
|
+
const blockNumber = blockNumbers[i];
|
|
75
|
+
if (blockNumber === undefined) {
|
|
76
|
+
throw new Error(
|
|
77
|
+
`Block number is undefined for leaf index ${definedIndices[i]} in tree ${MerkleTreeId[treeId]}`,
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
indexToBlockNumber.set(definedIndices[i], blockNumber);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Get unique block numbers in order to optimize num calls to getLeafValue function.
|
|
84
|
+
const uniqueBlockNumbers = [...new Set(indexToBlockNumber.values())];
|
|
85
|
+
|
|
86
|
+
// Now we obtain the block hashes from the archive tree (block number = leaf index in archive tree).
|
|
87
|
+
const blockHashes = await Promise.all(
|
|
88
|
+
uniqueBlockNumbers.map(blockNumber => {
|
|
89
|
+
return committedDb.getLeafValue(MerkleTreeId.ARCHIVE, BigInt(blockNumber));
|
|
90
|
+
}),
|
|
91
|
+
);
|
|
92
|
+
|
|
93
|
+
// Build a map from block number to block hash
|
|
94
|
+
const blockNumberToHash = new Map<BlockNumber, BlockHash>();
|
|
95
|
+
for (let i = 0; i < uniqueBlockNumbers.length; i++) {
|
|
96
|
+
const blockHash = blockHashes[i];
|
|
97
|
+
if (blockHash === undefined) {
|
|
98
|
+
throw new Error(`Block hash is undefined for block number ${uniqueBlockNumbers[i]}`);
|
|
99
|
+
}
|
|
100
|
+
blockNumberToHash.set(uniqueBlockNumbers[i], blockHash);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Create DataInBlock objects by combining indices, blockNumbers and blockHashes and return them.
|
|
104
|
+
return maybeIndices.map(index => {
|
|
105
|
+
if (index === undefined) {
|
|
106
|
+
return undefined;
|
|
107
|
+
}
|
|
108
|
+
const blockNumber = indexToBlockNumber.get(index);
|
|
109
|
+
if (blockNumber === undefined) {
|
|
110
|
+
throw new Error(`Block number not found for leaf index ${index} in tree ${MerkleTreeId[treeId]}`);
|
|
111
|
+
}
|
|
112
|
+
const l2BlockHash = blockNumberToHash.get(blockNumber);
|
|
113
|
+
if (l2BlockHash === undefined) {
|
|
114
|
+
throw new Error(`Block hash not found for block number ${blockNumber}`);
|
|
115
|
+
}
|
|
116
|
+
return {
|
|
117
|
+
l2BlockNumber: blockNumber,
|
|
118
|
+
l2BlockHash,
|
|
119
|
+
data: index,
|
|
120
|
+
};
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
public async getBlockHashMembershipWitness(
|
|
125
|
+
referenceBlock: BlockParameter,
|
|
126
|
+
blockHash: BlockHash,
|
|
127
|
+
): Promise<MembershipWitness<typeof ARCHIVE_HEIGHT> | undefined> {
|
|
128
|
+
// The Noir circuit checks the archive membership proof against `anchor_block_header.last_archive.root`,
|
|
129
|
+
// which is the archive tree root BEFORE the anchor block was added (i.e. the state after block N-1).
|
|
130
|
+
// So we need the world state at block N-1, not block N, to produce a sibling path matching that root.
|
|
131
|
+
const referenceBlockNumber = await this.resolveBlockNumber(referenceBlock);
|
|
132
|
+
if (referenceBlockNumber === BlockNumber.ZERO) {
|
|
133
|
+
// Block 0 (the initial block) has an empty archive, so no membership witness can exist.
|
|
134
|
+
return undefined;
|
|
135
|
+
}
|
|
136
|
+
const committedDb = await this.getWorldState(BlockNumber(referenceBlockNumber - 1));
|
|
137
|
+
const [pathAndIndex] = await committedDb.findSiblingPaths<MerkleTreeId.ARCHIVE>(MerkleTreeId.ARCHIVE, [blockHash]);
|
|
138
|
+
return pathAndIndex === undefined
|
|
139
|
+
? undefined
|
|
140
|
+
: MembershipWitness.fromSiblingPath(pathAndIndex.index, pathAndIndex.path);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
public async getNoteHashMembershipWitness(
|
|
144
|
+
referenceBlock: BlockParameter,
|
|
145
|
+
noteHash: Fr,
|
|
146
|
+
): Promise<MembershipWitness<typeof NOTE_HASH_TREE_HEIGHT> | undefined> {
|
|
147
|
+
const committedDb = await this.getWorldState(referenceBlock);
|
|
148
|
+
const [pathAndIndex] = await committedDb.findSiblingPaths<MerkleTreeId.NOTE_HASH_TREE>(
|
|
149
|
+
MerkleTreeId.NOTE_HASH_TREE,
|
|
150
|
+
[noteHash],
|
|
151
|
+
);
|
|
152
|
+
return pathAndIndex === undefined
|
|
153
|
+
? undefined
|
|
154
|
+
: MembershipWitness.fromSiblingPath(pathAndIndex.index, pathAndIndex.path);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
public async getL1ToL2MessageMembershipWitness(
|
|
158
|
+
referenceBlock: BlockParameter,
|
|
159
|
+
l1ToL2Message: Fr,
|
|
160
|
+
): Promise<[bigint, SiblingPath<typeof L1_TO_L2_MSG_TREE_HEIGHT>] | undefined> {
|
|
161
|
+
const db = await this.getWorldState(referenceBlock);
|
|
162
|
+
const [witness] = await db.findSiblingPaths(MerkleTreeId.L1_TO_L2_MESSAGE_TREE, [l1ToL2Message]);
|
|
163
|
+
if (!witness) {
|
|
164
|
+
return undefined;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// REFACTOR: Return a MembershipWitness object
|
|
168
|
+
return [witness.index, witness.path];
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
public async getL1ToL2MessageCheckpoint(l1ToL2Message: Fr): Promise<CheckpointNumber | undefined> {
|
|
172
|
+
const messageIndex = await this.l1ToL2MessageSource.getL1ToL2MessageIndex(l1ToL2Message);
|
|
173
|
+
return messageIndex !== undefined ? InboxLeaf.checkpointNumberFromIndex(messageIndex) : undefined;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Returns all the L2 to L1 messages in an epoch (empty array if the epoch is not found). The public
|
|
178
|
+
* `AztecNodeService.getL2ToL1Messages` that delegates here is deprecated in favor of
|
|
179
|
+
* {@link getL2ToL1MembershipWitness}.
|
|
180
|
+
* @param epoch - The epoch at which to get the data.
|
|
181
|
+
*/
|
|
182
|
+
public async getL2ToL1Messages(epoch: EpochNumber): Promise<Fr[][][][]> {
|
|
183
|
+
const blocks = await this.blockSource.getBlocks({ epoch, onlyCheckpointed: true });
|
|
184
|
+
const blocksInCheckpoints = chunkBy(blocks, block => block.header.globalVariables.slotNumber);
|
|
185
|
+
return blocksInCheckpoints.map(slotBlocks =>
|
|
186
|
+
slotBlocks.map(block => block.body.txEffects.map(txEffect => txEffect.l2ToL1Msgs)),
|
|
187
|
+
);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Returns the L2-to-L1 membership witness for a message in `txHash`. Passthrough to the
|
|
192
|
+
* archiver's locally-cached resolver — see {@link Archiver.getL2ToL1MembershipWitness}.
|
|
193
|
+
*/
|
|
194
|
+
public getL2ToL1MembershipWitness(
|
|
195
|
+
txHash: TxHash,
|
|
196
|
+
message: Fr,
|
|
197
|
+
messageIndexInTx?: number,
|
|
198
|
+
): Promise<L2ToL1MembershipWitness | undefined> {
|
|
199
|
+
return this.blockSource.getL2ToL1MembershipWitness(txHash, message, messageIndexInTx);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
public async getNullifierMembershipWitness(
|
|
203
|
+
referenceBlock: BlockParameter,
|
|
204
|
+
nullifier: Fr,
|
|
205
|
+
): Promise<NullifierMembershipWitness | undefined> {
|
|
206
|
+
const db = await this.getWorldState(referenceBlock);
|
|
207
|
+
const [witness] = await db.findSiblingPaths(MerkleTreeId.NULLIFIER_TREE, [nullifier.toBuffer()]);
|
|
208
|
+
if (!witness) {
|
|
209
|
+
return undefined;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const { index, path } = witness;
|
|
213
|
+
const leafPreimage = await db.getLeafPreimage(MerkleTreeId.NULLIFIER_TREE, index);
|
|
214
|
+
if (!leafPreimage) {
|
|
215
|
+
return undefined;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
return new NullifierMembershipWitness(index, leafPreimage as NullifierLeafPreimage, path);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
public async getLowNullifierMembershipWitness(
|
|
222
|
+
referenceBlock: BlockParameter,
|
|
223
|
+
nullifier: Fr,
|
|
224
|
+
): Promise<NullifierMembershipWitness | undefined> {
|
|
225
|
+
const committedDb = await this.getWorldState(referenceBlock);
|
|
226
|
+
const findResult = await committedDb.getPreviousValueIndex(MerkleTreeId.NULLIFIER_TREE, nullifier.toBigInt());
|
|
227
|
+
if (!findResult) {
|
|
228
|
+
return undefined;
|
|
229
|
+
}
|
|
230
|
+
const { index, alreadyPresent } = findResult;
|
|
231
|
+
if (alreadyPresent) {
|
|
232
|
+
throw new Error(
|
|
233
|
+
`Cannot prove nullifier non-inclusion: nullifier ${nullifier.toBigInt()} already exists in the tree`,
|
|
234
|
+
);
|
|
235
|
+
}
|
|
236
|
+
const preimageData = (await committedDb.getLeafPreimage(MerkleTreeId.NULLIFIER_TREE, index))!;
|
|
237
|
+
|
|
238
|
+
const siblingPath = await committedDb.getSiblingPath(MerkleTreeId.NULLIFIER_TREE, BigInt(index));
|
|
239
|
+
return new NullifierMembershipWitness(BigInt(index), preimageData as NullifierLeafPreimage, siblingPath);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
async getPublicDataWitness(referenceBlock: BlockParameter, leafSlot: Fr): Promise<PublicDataWitness | undefined> {
|
|
243
|
+
const committedDb = await this.getWorldState(referenceBlock);
|
|
244
|
+
const lowLeafResult = await committedDb.getPreviousValueIndex(MerkleTreeId.PUBLIC_DATA_TREE, leafSlot.toBigInt());
|
|
245
|
+
if (!lowLeafResult) {
|
|
246
|
+
return undefined;
|
|
247
|
+
} else {
|
|
248
|
+
const preimage = (await committedDb.getLeafPreimage(
|
|
249
|
+
MerkleTreeId.PUBLIC_DATA_TREE,
|
|
250
|
+
lowLeafResult.index,
|
|
251
|
+
)) as PublicDataTreeLeafPreimage;
|
|
252
|
+
const path = await committedDb.getSiblingPath(MerkleTreeId.PUBLIC_DATA_TREE, lowLeafResult.index);
|
|
253
|
+
return new PublicDataWitness(lowLeafResult.index, preimage, path);
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
public async getPublicStorageAt(referenceBlock: BlockParameter, contract: AztecAddress, slot: Fr): Promise<Fr> {
|
|
258
|
+
const committedDb = await this.getWorldState(referenceBlock);
|
|
259
|
+
const leafSlot = await computePublicDataTreeLeafSlot(contract, slot);
|
|
260
|
+
|
|
261
|
+
const lowLeafResult = await committedDb.getPreviousValueIndex(MerkleTreeId.PUBLIC_DATA_TREE, leafSlot.toBigInt());
|
|
262
|
+
if (!lowLeafResult || !lowLeafResult.alreadyPresent) {
|
|
263
|
+
return Fr.ZERO;
|
|
264
|
+
}
|
|
265
|
+
const preimage = (await committedDb.getLeafPreimage(
|
|
266
|
+
MerkleTreeId.PUBLIC_DATA_TREE,
|
|
267
|
+
lowLeafResult.index,
|
|
268
|
+
)) as PublicDataTreeLeafPreimage;
|
|
269
|
+
return preimage.leaf.value;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* Returns an instance of MerkleTreeOperations having first ensured the world state is fully synched
|
|
274
|
+
* @param block - The block parameter (block number, block hash, or 'latest') at which to get the data.
|
|
275
|
+
* @returns An instance of a committed MerkleTreeOperations
|
|
276
|
+
*/
|
|
277
|
+
public async getWorldState(block: BlockParameter) {
|
|
278
|
+
const query = normalizeBlockParameter(block);
|
|
279
|
+
|
|
280
|
+
// When the request anchors on a specific block hash, resolve it against the archiver up front and
|
|
281
|
+
// drive the world-state sync to that exact block number and hash. Resolving against the archiver
|
|
282
|
+
// first fails fast with a clear reorg error if the hash is unknown, and passing the hash to the
|
|
283
|
+
// synchronizer makes the sync reorg-aware: it barriers until the archive-tree commit for that block
|
|
284
|
+
// has landed and verifies it matches the requested fork, instead of syncing to bare latest height
|
|
285
|
+
// and then racing the snapshot read below against an in-flight archive-tree write.
|
|
286
|
+
const requestedHash = 'hash' in query ? query.hash : undefined;
|
|
287
|
+
const anchorBlockNumber = requestedHash !== undefined ? await this.resolveBlockNumber(query) : undefined;
|
|
288
|
+
|
|
289
|
+
let blockSyncedTo: BlockNumber = BlockNumber.ZERO;
|
|
290
|
+
try {
|
|
291
|
+
// Attempt to sync the world state if necessary
|
|
292
|
+
blockSyncedTo = await this.#syncWorldState(anchorBlockNumber, requestedHash);
|
|
293
|
+
} catch (err) {
|
|
294
|
+
this.log.error(`Error getting world state: ${err}`);
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
if ('tag' in query && query.tag === 'proposed') {
|
|
298
|
+
this.log.debug(`Using committed db for latest block, world state synced upto ${blockSyncedTo}`);
|
|
299
|
+
return this.worldStateSynchronizer.getCommitted();
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
const blockNumber = anchorBlockNumber ?? (await this.resolveBlockNumber(query));
|
|
303
|
+
|
|
304
|
+
// Check it's within world state sync range
|
|
305
|
+
if (blockNumber > blockSyncedTo) {
|
|
306
|
+
throw new Error(
|
|
307
|
+
`Queried block ${inspectBlockParameter(block)} not yet synced by the node (node is synced upto ${blockSyncedTo}).`,
|
|
308
|
+
);
|
|
309
|
+
}
|
|
310
|
+
this.log.debug(`Using snapshot for block ${blockNumber}, world state synced upto ${blockSyncedTo}`);
|
|
311
|
+
|
|
312
|
+
const snapshot = this.worldStateSynchronizer.getSnapshot(blockNumber);
|
|
313
|
+
|
|
314
|
+
// Double-check world-state synced to the same block hash as was requested.
|
|
315
|
+
// Block 0 is skipped: the snapshot returned by `getSnapshot(0)` is the *pre*-genesis archive
|
|
316
|
+
// (size 0), so leaf 0 is not yet inserted from that snapshot's view even though block 0's hash
|
|
317
|
+
// does live at archive index 0 in the committed tree. The genesis hash is already validated by
|
|
318
|
+
// the archiver when it resolves the hash query to block number 0.
|
|
319
|
+
if (requestedHash !== undefined && blockNumber !== BlockNumber.ZERO) {
|
|
320
|
+
const blockHash = await snapshot.getLeafValue(MerkleTreeId.ARCHIVE, BigInt(blockNumber));
|
|
321
|
+
if (!blockHash || !requestedHash.equals(blockHash)) {
|
|
322
|
+
throw new Error(
|
|
323
|
+
`Block hash ${requestedHash.toString()} not found in world state at block number ${blockNumber} (world state has ${blockHash?.toString() ?? 'no hash'} at that index, genesis header hash is ${this.blockSource.getGenesisBlockHash().toString()}). If the node API has been queried with anchor block hash possibly a reorg has occurred.`,
|
|
324
|
+
);
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
return snapshot;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/** Resolves any {@link BlockParameter} variant to a concrete block number. */
|
|
332
|
+
public async resolveBlockNumber(block: BlockParameter): Promise<BlockNumber> {
|
|
333
|
+
const query = normalizeBlockParameter(block);
|
|
334
|
+
const blockNumber = await this.blockSource.getBlockNumber(query);
|
|
335
|
+
if (blockNumber === undefined) {
|
|
336
|
+
if ('hash' in query) {
|
|
337
|
+
throw new Error(
|
|
338
|
+
`Block hash ${query.hash.toString()} not found when querying world state. If the node API has been queried with anchor block hash possibly a reorg has occurred.`,
|
|
339
|
+
);
|
|
340
|
+
}
|
|
341
|
+
if ('archive' in query) {
|
|
342
|
+
throw new Error(`Block with archive ${query.archive.toString()} not found.`);
|
|
343
|
+
}
|
|
344
|
+
throw new Error(`Block not found for ${inspectBlockParameter(block)}.`);
|
|
345
|
+
}
|
|
346
|
+
return blockNumber;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/**
|
|
350
|
+
* Ensure the world state is synced.
|
|
351
|
+
* @param targetBlockNumber - Block to sync up to. Defaults to the latest block known to the archiver.
|
|
352
|
+
* @param blockHash - If provided, the synchronizer verifies the block at `targetBlockNumber` matches this
|
|
353
|
+
* hash, resyncing (and so detecting reorgs) if it does not yet match or has been reorged away.
|
|
354
|
+
* @returns A promise that fulfils once the world state is synced
|
|
355
|
+
*/
|
|
356
|
+
async #syncWorldState(targetBlockNumber?: BlockNumber, blockHash?: BlockHash): Promise<BlockNumber> {
|
|
357
|
+
const target = targetBlockNumber ?? (await this.blockSource.getBlockNumber());
|
|
358
|
+
return await this.worldStateSynchronizer.syncImmediate(target, blockHash);
|
|
359
|
+
}
|
|
360
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
# Sentinel
|
|
2
|
+
|
|
3
|
+
The Sentinel watches every committee member's behaviour each L2 slot, aggregates it into per-epoch performance once each epoch is fully observed, and emits inactivity slash payloads to the slasher.
|
|
4
|
+
|
|
5
|
+
## Responsibilities
|
|
6
|
+
|
|
7
|
+
- Classify per-slot proposer/attestor behaviour from local node observations.
|
|
8
|
+
- Persist a sliding window of per-slot history per validator.
|
|
9
|
+
- Roll up that history into per-epoch performance after each epoch ends.
|
|
10
|
+
- Decide which validators have been inactive for `slashInactivityConsecutiveEpochThreshold` consecutive epochs and emit `WANT_TO_SLASH_EVENT` with `OffenseType.INACTIVITY`.
|
|
11
|
+
- Expose validator stats to RPC consumers (`getValidatorStats`, `computeStats`).
|
|
12
|
+
|
|
13
|
+
The sentinel is one of several watchers registered with the slasher; it does not vote or publish to L1 itself.
|
|
14
|
+
|
|
15
|
+
## Inputs
|
|
16
|
+
|
|
17
|
+
| Source | What it provides |
|
|
18
|
+
|---|---|
|
|
19
|
+
| `EpochCache` | Slot/epoch helpers, committee + proposer for a slot, escape-hatch state |
|
|
20
|
+
| `L2BlockSource` (archiver) | Synced slot, `getCheckpoint({ slot })`, `getL2Tips()`, block headers |
|
|
21
|
+
| `P2PClient` | `getCheckpointAttestationsForSlot(slot, payloadHash)`, `hasBlockProposalsForSlot(slot)` |
|
|
22
|
+
| `CheckpointReexecutionTracker` | Local re-execution outcome for the proposal at each slot (`valid` / `invalid` / `unvalidated`) — populated by the validator client's `ProposalHandler` |
|
|
23
|
+
| L1-confirmed checkpoints | Fetched on demand per slot via `archiver.getCheckpoint({ slot })`, yielding the canonical attestor set |
|
|
24
|
+
|
|
25
|
+
## Two cadences
|
|
26
|
+
|
|
27
|
+
`Sentinel.work()` runs every quarter L2 slot and drives two pipelines that operate independently:
|
|
28
|
+
|
|
29
|
+
### 1. Per-slot recording (lag = 2 slots)
|
|
30
|
+
|
|
31
|
+
`processSlot(currentSlot - 2)` runs once per slot. The 2-slot lag gives P2P attestations time to settle and lets the archiver catch up. It calls `getSlotActivity(slot, epoch, proposer, committee)` and writes per-validator statuses to `SentinelStore.historyMap`. History is a sliding window of `sentinelHistoryLengthInEpochs * epochDuration` slots (default 24 epochs).
|
|
32
|
+
|
|
33
|
+
If `EpochCache.getCommittee(slot)` reports `isEscapeHatchOpen`, the slot is recorded as processed without writing any per-validator entries.
|
|
34
|
+
|
|
35
|
+
### 2. Per-epoch evaluation (lag = `sentinelEpochEndBufferSlots` past the epoch's last slot)
|
|
36
|
+
|
|
37
|
+
`processEpochEnds(currentSlot)` checks whether any epoch is now fully observable and not yet evaluated. An epoch is eligible once both:
|
|
38
|
+
|
|
39
|
+
- the buffer has elapsed: `currentSlot − sentinelEpochEndBufferSlots ≥ lastSlotOfEpoch`, and
|
|
40
|
+
- per-slot recording has reached the epoch's last slot: `lastProcessedSlot ≥ lastSlotOfEpoch`.
|
|
41
|
+
|
|
42
|
+
When eligible, `handleEpochEnd(epoch)` aggregates the slot-level statuses for that epoch into per-validator `{missed, total}`, persists the result to `SentinelStore.epochMap` (default 2000-epoch window), and runs the inactivity check.
|
|
43
|
+
|
|
44
|
+
The aggregator catches up if multiple epochs become eligible at once (e.g. after a long backoff).
|
|
45
|
+
|
|
46
|
+
## Six-case taxonomy
|
|
47
|
+
|
|
48
|
+
For each slot, the proposer is assigned one of six statuses, ranked highest-confidence first:
|
|
49
|
+
|
|
50
|
+
| # | Status | Trigger | Inactive party |
|
|
51
|
+
|---|---|---|---|
|
|
52
|
+
| 6 | `checkpoint-mined` | `archiver.getCheckpoint({ slot })` returns a checkpoint (one covering this slot has landed on L1) | Attestors who didn't attest |
|
|
53
|
+
| 5 | `checkpoint-valid` | `tracker.getOutcomeForSlot(slot) === 'valid'` | Attestors who didn't attest |
|
|
54
|
+
| 4 | `checkpoint-invalid` | `tracker.getOutcomeForSlot(slot) === 'invalid'` (re-executed and rejected) | Proposer |
|
|
55
|
+
| 3 | `checkpoint-unvalidated` | `tracker.getOutcomeForSlot(slot) === 'unvalidated'` (validation aborted: missing data, timeout, etc.) | Proposer |
|
|
56
|
+
| 2 | `checkpoint-missed` | `p2p.hasBlockProposalsForSlot(slot)` true (blocks seen but no checkpoint proposal observed) | Proposer |
|
|
57
|
+
| 1 | `blocks-missed` | None of the above (no block proposals observed) | Proposer |
|
|
58
|
+
|
|
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.
|
|
60
|
+
|
|
61
|
+
Each non-proposer committee member is tagged:
|
|
62
|
+
|
|
63
|
+
- `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
|
|
65
|
+
- none — otherwise
|
|
66
|
+
|
|
67
|
+
## Inactivity slashing
|
|
68
|
+
|
|
69
|
+
`handleEpochPerformance(epoch, performance)`:
|
|
70
|
+
|
|
71
|
+
1. Filter validators where `missed / total ≥ slashInactivityTargetPercentage`.
|
|
72
|
+
2. For each, call `checkPastInactivity` to require `slashInactivityConsecutiveEpochThreshold − 1` past epochs (from `SentinelStore.epochMap`) over the same threshold. Epochs where the validator was not on a committee are skipped, not counted against the streak.
|
|
73
|
+
3. Emit a single `WANT_TO_SLASH_EVENT` with one `WantToSlashArgs` per qualifying validator.
|
|
74
|
+
|
|
75
|
+
`{missed, total}` only counts slots that had something happen (a proposal, an attestation, or a missed proposal opportunity). Slots where the validator was on the committee but no proposal occurred and they were not the proposer don't show up in either count — that prevents an offline validator from appearing as "5/10 missed" simply because half the epoch had no proposals.
|
|
76
|
+
|
|
77
|
+
## Storage
|
|
78
|
+
|
|
79
|
+
`SentinelStore` is an LMDB-backed KV store with two maps:
|
|
80
|
+
|
|
81
|
+
- `historyMap` — validator address → serialized `[(slot, status)]` rolling window
|
|
82
|
+
- `epochMap` — validator address → serialized `[{epoch, missed, total}]` rolling window
|
|
83
|
+
|
|
84
|
+
`SCHEMA_VERSION` controls on-disk compatibility; bumping it wipes the store on next open. The encoded status numbers live in `SentinelStore.statusToNumber`/`statusFromNumber`.
|
|
85
|
+
|
|
86
|
+
## Configuration
|
|
87
|
+
|
|
88
|
+
| Key | Env var | Default | Purpose |
|
|
89
|
+
|---|---|---|---|
|
|
90
|
+
| `sentinelEnabled` | `SENTINEL_ENABLED` | `false` | Master switch |
|
|
91
|
+
| `sentinelHistoryLengthInEpochs` | `SENTINEL_HISTORY_LENGTH_IN_EPOCHS` | `24` | Slot-history window, in epochs |
|
|
92
|
+
| `sentinelHistoricEpochPerformanceLengthInEpochs` | `SENTINEL_HISTORIC_EPOCH_PERFORMANCE_LENGTH_IN_EPOCHS` | `2000` | Per-epoch performance window |
|
|
93
|
+
| `sentinelEpochEndBufferSlots` | `SENTINEL_EPOCH_END_BUFFER_SLOTS` | `2` | Slots to wait past an epoch's last slot before evaluating it |
|
|
94
|
+
|
|
95
|
+
The sentinel also reads slashing thresholds and L1 chain identifiers from `SentinelRuntimeConfig` (see `sentinel.ts`).
|
|
96
|
+
|
|
97
|
+
## Files
|
|
98
|
+
|
|
99
|
+
- `sentinel.ts` — main class
|
|
100
|
+
- `store.ts` — KV-backed persistence
|
|
101
|
+
- `config.ts` — `SentinelConfig` and env-var mappings
|
|
102
|
+
- `factory.ts` — `createSentinel` factory used by `AztecNodeService`
|
|
103
|
+
- `sentinel.test.ts` / `store.test.ts` — unit tests
|
package/src/sentinel/config.ts
CHANGED
|
@@ -2,8 +2,9 @@ import { type ConfigMappingsType, booleanConfigHelper, numberConfigHelper } from
|
|
|
2
2
|
|
|
3
3
|
export type SentinelConfig = {
|
|
4
4
|
sentinelHistoryLengthInEpochs: number;
|
|
5
|
-
|
|
5
|
+
sentinelHistoricEpochPerformanceLengthInEpochs: number;
|
|
6
6
|
sentinelEnabled: boolean;
|
|
7
|
+
sentinelEpochEndBufferSlots: number;
|
|
7
8
|
};
|
|
8
9
|
|
|
9
10
|
export const sentinelConfigMappings: ConfigMappingsType<SentinelConfig> = {
|
|
@@ -13,8 +14,9 @@ export const sentinelConfigMappings: ConfigMappingsType<SentinelConfig> = {
|
|
|
13
14
|
...numberConfigHelper(24),
|
|
14
15
|
},
|
|
15
16
|
/**
|
|
16
|
-
* The number of L2 epochs kept of
|
|
17
|
-
*
|
|
17
|
+
* The number of L2 epochs kept of per-epoch performance history for each validator. End-of-epoch
|
|
18
|
+
* activity is recorded here and used to decide consecutive-epoch inactivity slashing.
|
|
19
|
+
* This value must be large enough so that we have epoch performance for every validator
|
|
18
20
|
* for at least slashInactivityConsecutiveEpochThreshold. Assuming this value is 3,
|
|
19
21
|
* and the committee size is 48, and we have 10k validators, then we pick 48 out of 10k each draw.
|
|
20
22
|
* For any fixed element, per-draw prob = 48/10000 = 0.0048.
|
|
@@ -24,9 +26,9 @@ export const sentinelConfigMappings: ConfigMappingsType<SentinelConfig> = {
|
|
|
24
26
|
* - 95% chance: n = 1310
|
|
25
27
|
* - 99% chance: n = 1749
|
|
26
28
|
*/
|
|
27
|
-
|
|
28
|
-
description: 'The number of L2 epochs kept of
|
|
29
|
-
env: '
|
|
29
|
+
sentinelHistoricEpochPerformanceLengthInEpochs: {
|
|
30
|
+
description: 'The number of L2 epochs kept of per-epoch performance history for each validator.',
|
|
31
|
+
env: 'SENTINEL_HISTORIC_EPOCH_PERFORMANCE_LENGTH_IN_EPOCHS',
|
|
30
32
|
...numberConfigHelper(2000),
|
|
31
33
|
},
|
|
32
34
|
sentinelEnabled: {
|
|
@@ -34,4 +36,14 @@ export const sentinelConfigMappings: ConfigMappingsType<SentinelConfig> = {
|
|
|
34
36
|
env: 'SENTINEL_ENABLED',
|
|
35
37
|
...booleanConfigHelper(false),
|
|
36
38
|
},
|
|
39
|
+
/**
|
|
40
|
+
* Number of L2 slots to wait after the end of an epoch before computing the epoch's performance.
|
|
41
|
+
* The buffer allows P2P attestations and the local archiver to settle. Higher values reduce the
|
|
42
|
+
* risk of misjudging late-arriving activity at the cost of delayed slashing.
|
|
43
|
+
*/
|
|
44
|
+
sentinelEpochEndBufferSlots: {
|
|
45
|
+
description: 'Number of L2 slots after the end of an epoch before the sentinel evaluates it.',
|
|
46
|
+
env: 'SENTINEL_EPOCH_END_BUFFER_SLOTS',
|
|
47
|
+
...numberConfigHelper(2),
|
|
48
|
+
},
|
|
37
49
|
};
|
package/src/sentinel/factory.ts
CHANGED
|
@@ -3,7 +3,9 @@ import { createLogger } from '@aztec/foundation/log';
|
|
|
3
3
|
import { createStore } from '@aztec/kv-store/lmdb-v2';
|
|
4
4
|
import type { P2PClient } from '@aztec/p2p';
|
|
5
5
|
import type { L2BlockSource } from '@aztec/stdlib/block';
|
|
6
|
-
import type {
|
|
6
|
+
import type { CheckpointReexecutionTracker } from '@aztec/stdlib/checkpoint';
|
|
7
|
+
import type { ChainConfig } from '@aztec/stdlib/config';
|
|
8
|
+
import type { SlasherConfig, ValidatorClientConfig } from '@aztec/stdlib/interfaces/server';
|
|
7
9
|
import type { DataStoreConfig } from '@aztec/stdlib/kv-store';
|
|
8
10
|
|
|
9
11
|
import type { SentinelConfig } from './config.js';
|
|
@@ -14,18 +16,31 @@ export async function createSentinel(
|
|
|
14
16
|
epochCache: EpochCache,
|
|
15
17
|
archiver: L2BlockSource,
|
|
16
18
|
p2p: P2PClient,
|
|
17
|
-
|
|
19
|
+
reexecutionTracker: CheckpointReexecutionTracker,
|
|
20
|
+
config: SentinelConfig &
|
|
21
|
+
DataStoreConfig &
|
|
22
|
+
SlasherConfig &
|
|
23
|
+
Pick<ChainConfig, 'l1ChainId' | 'rollupAddress'> &
|
|
24
|
+
Pick<ValidatorClientConfig, 'disableValidator'>,
|
|
18
25
|
logger = createLogger('node:sentinel'),
|
|
19
26
|
): Promise<Sentinel | undefined> {
|
|
20
|
-
|
|
27
|
+
const runsValidator = !config.disableValidator;
|
|
28
|
+
if (!runsValidator && !config.sentinelEnabled) {
|
|
29
|
+
logger.verbose('Sentinel is disabled');
|
|
21
30
|
return undefined;
|
|
22
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
|
+
|
|
23
38
|
const kvStore = await createStore('sentinel', SentinelStore.SCHEMA_VERSION, config, logger.getBindings());
|
|
24
39
|
const storeHistoryLength = config.sentinelHistoryLengthInEpochs * epochCache.getL1Constants().epochDuration;
|
|
25
|
-
const
|
|
40
|
+
const storeHistoricEpochPerformanceLength = config.sentinelHistoricEpochPerformanceLengthInEpochs;
|
|
26
41
|
const sentinelStore = new SentinelStore(kvStore, {
|
|
27
42
|
historyLength: storeHistoryLength,
|
|
28
|
-
|
|
43
|
+
historicEpochPerformanceLength: storeHistoricEpochPerformanceLength,
|
|
29
44
|
});
|
|
30
|
-
return new Sentinel(epochCache, archiver, p2p, sentinelStore, config, logger);
|
|
45
|
+
return new Sentinel(epochCache, archiver, p2p, sentinelStore, reexecutionTracker, config, logger);
|
|
31
46
|
}
|