Skip to content

Commit 9579fcc

Browse files
authored
feat: Add Single Validator RPC Method for Historical Data Retrieval (#16446)
This is a squash of #16177 by @StoneMac65, since the external PR and the squash check seem to be causing issues with CI. Until we fix the underlying issue, let's see if we can get this merged via this PR.
2 parents bc949c2 + de07326 commit 9579fcc

File tree

8 files changed

+312
-6
lines changed

8 files changed

+312
-6
lines changed

yarn-project/aztec-node/src/aztec-node/server.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -95,7 +95,7 @@ import {
9595
type TxValidationResult,
9696
} from '@aztec/stdlib/tx';
9797
import { getPackageVersion } from '@aztec/stdlib/update-checker';
98-
import type { ValidatorsStats } from '@aztec/stdlib/validators';
98+
import type { SingleValidatorStats, ValidatorsStats } from '@aztec/stdlib/validators';
9999
import {
100100
Attributes,
101101
type TelemetryClient,
@@ -1125,6 +1125,14 @@ export class AztecNodeService implements AztecNode, AztecNodeAdmin, Traceable {
11251125
return this.validatorsSentinel?.computeStats() ?? Promise.resolve({ stats: {}, slotWindow: 0 });
11261126
}
11271127

1128+
public getValidatorStats(
1129+
validatorAddress: EthAddress,
1130+
fromSlot?: bigint,
1131+
toSlot?: bigint,
1132+
): Promise<SingleValidatorStats | undefined> {
1133+
return this.validatorsSentinel?.getValidatorStats(validatorAddress, fromSlot, toSlot) ?? Promise.resolve(undefined);
1134+
}
1135+
11281136
public async startSnapshotUpload(location: string): Promise<void> {
11291137
// Note that we are forcefully casting the blocksource as an archiver
11301138
// We break support for archiver running remotely to the node

yarn-project/aztec-node/src/sentinel/sentinel.test.ts

Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -234,6 +234,149 @@ describe('sentinel', () => {
234234
});
235235
});
236236

237+
describe('slot range validation', () => {
238+
let validator: EthAddress;
239+
240+
beforeEach(() => {
241+
validator = EthAddress.random();
242+
jest.spyOn(store, 'getHistoryLength').mockReturnValue(10);
243+
jest.spyOn(store, 'getHistory').mockResolvedValue([
244+
{ slot: 1n, status: 'block-mined' },
245+
{ slot: 2n, status: 'attestation-sent' },
246+
]);
247+
jest.spyOn(store, 'getHistories').mockResolvedValue({
248+
[validator.toString()]: [
249+
{ slot: 1n, status: 'block-mined' },
250+
{ slot: 2n, status: 'attestation-sent' },
251+
],
252+
});
253+
});
254+
255+
describe('getValidatorStats', () => {
256+
it('should throw when slot range exceeds history length', async () => {
257+
await expect(sentinel.getValidatorStats(validator, 1n, 16n)).rejects.toThrow(
258+
'Slot range (15) exceeds history length (10). Requested range: 1 to 16.',
259+
);
260+
});
261+
262+
it('should not throw when slot range equals history length', async () => {
263+
await expect(sentinel.getValidatorStats(validator, 1n, 11n)).resolves.toBeDefined();
264+
});
265+
266+
it('should not throw when slot range is less than history length', async () => {
267+
await expect(sentinel.getValidatorStats(validator, 1n, 6n)).resolves.toBeDefined();
268+
});
269+
270+
it('should return undefined when validator has no history', async () => {
271+
jest.spyOn(store, 'getHistory').mockResolvedValue(undefined);
272+
const result = await sentinel.getValidatorStats(validator, 1n, 6n);
273+
expect(result).toBeUndefined();
274+
});
275+
276+
it('should return undefined when validator has empty history', async () => {
277+
jest.spyOn(store, 'getHistory').mockResolvedValue([]);
278+
const result = await sentinel.getValidatorStats(validator, 1n, 6n);
279+
expect(result).toBeUndefined();
280+
});
281+
282+
it('should return expected mocked data structure', async () => {
283+
const mockHistory: ValidatorStatusHistory = [
284+
{ slot: 1n, status: 'block-mined' },
285+
{ slot: 2n, status: 'attestation-sent' },
286+
];
287+
const mockProvenPerformance = [
288+
{ epoch: 1n, missed: 2, total: 10 },
289+
{ epoch: 2n, missed: 1, total: 8 },
290+
];
291+
292+
jest.spyOn(store, 'getHistory').mockResolvedValue(mockHistory);
293+
jest.spyOn(store, 'getProvenPerformance').mockResolvedValue(mockProvenPerformance);
294+
jest.spyOn(sentinel, 'computeStatsForValidator').mockReturnValue({
295+
address: validator,
296+
totalSlots: 2,
297+
missedProposals: { count: 0, currentStreak: 0, rate: 0 },
298+
missedAttestations: { count: 0, currentStreak: 0, rate: 0 },
299+
history: mockHistory,
300+
});
301+
302+
const result = await sentinel.getValidatorStats(validator, 1n, 6n);
303+
304+
expect(result).toEqual({
305+
validator: {
306+
address: validator,
307+
totalSlots: 2,
308+
missedProposals: { count: 0, currentStreak: 0, rate: 0 },
309+
missedAttestations: { count: 0, currentStreak: 0, rate: 0 },
310+
history: mockHistory,
311+
},
312+
allTimeProvenPerformance: mockProvenPerformance,
313+
lastProcessedSlot: sentinel.getLastProcessedSlot(),
314+
initialSlot: sentinel.getInitialSlot(),
315+
slotWindow: 10,
316+
});
317+
});
318+
319+
it('should call computeStatsForValidator with correct parameters', async () => {
320+
const mockHistory: ValidatorStatusHistory = [{ slot: 5n, status: 'block-mined' }];
321+
jest.spyOn(store, 'getHistory').mockResolvedValue(mockHistory);
322+
jest.spyOn(store, 'getProvenPerformance').mockResolvedValue([]);
323+
const computeStatsSpy = jest.spyOn(sentinel, 'computeStatsForValidator').mockReturnValue({
324+
address: validator,
325+
totalSlots: 1,
326+
missedProposals: { count: 0, currentStreak: 0, rate: 0 },
327+
missedAttestations: { count: 0, currentStreak: 0, rate: 0 },
328+
history: mockHistory,
329+
});
330+
331+
await sentinel.getValidatorStats(validator, 3n, 8n);
332+
333+
expect(computeStatsSpy).toHaveBeenCalledWith(validator.toString(), mockHistory, 3n, 8n);
334+
});
335+
336+
it('should use default slot range when not provided', async () => {
337+
const mockHistory: ValidatorStatusHistory = [{ slot: 5n, status: 'block-mined' }];
338+
jest.spyOn(store, 'getHistory').mockResolvedValue(mockHistory);
339+
jest.spyOn(store, 'getProvenPerformance').mockResolvedValue([]);
340+
const computeStatsSpy = jest.spyOn(sentinel, 'computeStatsForValidator').mockReturnValue({
341+
address: validator,
342+
totalSlots: 1,
343+
missedProposals: { count: 0, currentStreak: 0, rate: 0 },
344+
missedAttestations: { count: 0, currentStreak: 0, rate: 0 },
345+
history: mockHistory,
346+
});
347+
348+
await sentinel.getValidatorStats(validator);
349+
350+
expect(computeStatsSpy).toHaveBeenCalledWith(validator.toString(), mockHistory, slot - BigInt(10), slot);
351+
});
352+
353+
it('should return proven performance data from store', async () => {
354+
const mockHistory: ValidatorStatusHistory = [{ slot: 1n, status: 'block-mined' }];
355+
const mockProvenPerformance = [
356+
{ epoch: 5n, missed: 3, total: 12 },
357+
{ epoch: 6n, missed: 0, total: 15 },
358+
];
359+
360+
jest.spyOn(store, 'getHistory').mockResolvedValue(mockHistory);
361+
const getProvenPerformanceSpy = jest
362+
.spyOn(store, 'getProvenPerformance')
363+
.mockResolvedValue(mockProvenPerformance);
364+
jest.spyOn(sentinel, 'computeStatsForValidator').mockReturnValue({
365+
address: validator,
366+
totalSlots: 1,
367+
missedProposals: { count: 0, currentStreak: 0, rate: 0 },
368+
missedAttestations: { count: 0, currentStreak: 0, rate: 0 },
369+
history: mockHistory,
370+
});
371+
372+
const result = await sentinel.getValidatorStats(validator);
373+
374+
expect(getProvenPerformanceSpy).toHaveBeenCalledWith(validator);
375+
expect(result?.allTimeProvenPerformance).toEqual(mockProvenPerformance);
376+
});
377+
});
378+
});
379+
237380
describe('handleChainProven', () => {
238381
it('calls inactivity watcher with performance data', async () => {
239382
const blockNumber = 15;
@@ -423,4 +566,16 @@ class TestSentinel extends Sentinel {
423566
public override updateProvenPerformance(epoch: bigint, performance: ValidatorsEpochPerformance) {
424567
return super.updateProvenPerformance(epoch, performance);
425568
}
569+
570+
public override getValidatorStats(validatorAddress: EthAddress, fromSlot?: bigint, toSlot?: bigint) {
571+
return super.getValidatorStats(validatorAddress, fromSlot, toSlot);
572+
}
573+
574+
public getLastProcessedSlot() {
575+
return this.lastProcessedSlot;
576+
}
577+
578+
public getInitialSlot() {
579+
return this.initialSlot;
580+
}
426581
}

yarn-project/aztec-node/src/sentinel/sentinel.ts

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import {
1717
} from '@aztec/stdlib/block';
1818
import { getEpochAtSlot, getTimestampForSlot } from '@aztec/stdlib/epoch-helpers';
1919
import type {
20+
SingleValidatorStats,
2021
ValidatorStats,
2122
ValidatorStatusHistory,
2223
ValidatorStatusInSlot,
@@ -379,6 +380,47 @@ export class Sentinel extends (EventEmitter as new () => WatcherEmitter) impleme
379380
};
380381
}
381382

383+
/** Computes stats for a single validator. */
384+
public async getValidatorStats(
385+
validatorAddress: EthAddress,
386+
fromSlot?: bigint,
387+
toSlot?: bigint,
388+
): Promise<SingleValidatorStats | undefined> {
389+
const history = await this.store.getHistory(validatorAddress);
390+
391+
if (!history || history.length === 0) {
392+
return undefined;
393+
}
394+
395+
const slotNow = this.epochCache.getEpochAndSlotNow().slot;
396+
const effectiveFromSlot = fromSlot ?? (this.lastProcessedSlot ?? slotNow) - BigInt(this.store.getHistoryLength());
397+
const effectiveToSlot = toSlot ?? this.lastProcessedSlot ?? slotNow;
398+
399+
const historyLength = BigInt(this.store.getHistoryLength());
400+
if (effectiveToSlot - effectiveFromSlot > historyLength) {
401+
throw new Error(
402+
`Slot range (${effectiveToSlot - effectiveFromSlot}) exceeds history length (${historyLength}). ` +
403+
`Requested range: ${effectiveFromSlot} to ${effectiveToSlot}.`,
404+
);
405+
}
406+
407+
const validator = this.computeStatsForValidator(
408+
validatorAddress.toString(),
409+
history,
410+
effectiveFromSlot,
411+
effectiveToSlot,
412+
);
413+
const allTimeProvenPerformance = await this.store.getProvenPerformance(validatorAddress);
414+
415+
return {
416+
validator,
417+
allTimeProvenPerformance,
418+
lastProcessedSlot: this.lastProcessedSlot,
419+
initialSlot: this.initialSlot,
420+
slotWindow: this.store.getHistoryLength(),
421+
};
422+
}
423+
382424
protected computeStatsForValidator(
383425
address: `0x${string}`,
384426
allHistory: ValidatorStatusHistory,

yarn-project/aztec-node/src/sentinel/store.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -101,7 +101,7 @@ export class SentinelStore {
101101
return histories;
102102
}
103103

104-
private async getHistory(address: EthAddress): Promise<ValidatorStatusHistory | undefined> {
104+
public async getHistory(address: EthAddress): Promise<ValidatorStatusHistory | undefined> {
105105
const data = await this.historyMap.getAsync(address.toString());
106106
return data && this.deserializeHistory(data);
107107
}

yarn-project/stdlib/src/interfaces/aztec-node.test.ts

Lines changed: 61 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@ import { TxEffect } from '../tx/tx_effect.js';
5151
import { TxHash } from '../tx/tx_hash.js';
5252
import { TxReceipt } from '../tx/tx_receipt.js';
5353
import type { TxValidationResult } from '../tx/validator/tx_validator.js';
54-
import type { ValidatorsStats } from '../validators/types.js';
54+
import type { SingleValidatorStats, ValidatorsStats } from '../validators/types.js';
5555
import { MAX_RPC_LEN } from './api_limit.js';
5656
import { type AztecNode, AztecNodeApiSchema } from './aztec-node.js';
5757
import type { SequencerConfig } from './configs.js';
@@ -367,6 +367,51 @@ describe('AztecNodeApiSchema', () => {
367367
expect(response).toEqual(handler.validatorStats);
368368
});
369369

370+
it('getValidatorStats', async () => {
371+
const validatorAddress = EthAddress.random();
372+
handler.singleValidatorStats = {
373+
validator: {
374+
address: validatorAddress,
375+
totalSlots: 5,
376+
missedAttestations: { currentStreak: 0, count: 0 },
377+
missedProposals: { currentStreak: 0, count: 0 },
378+
history: [{ slot: 1n, status: 'block-mined' }],
379+
},
380+
allTimeProvenPerformance: [],
381+
lastProcessedSlot: 10n,
382+
initialSlot: 1n,
383+
slotWindow: 100,
384+
};
385+
386+
const response = await context.client.getValidatorStats(validatorAddress);
387+
expect(response).toEqual(handler.singleValidatorStats);
388+
});
389+
390+
it('getValidatorStats(non-existent)', async () => {
391+
const response = await context.client.getValidatorStats(EthAddress.random());
392+
expect(response).toBeUndefined();
393+
});
394+
395+
it('getValidatorStats(with-time-range)', async () => {
396+
const validatorAddress = EthAddress.random();
397+
handler.singleValidatorStats = {
398+
validator: {
399+
address: validatorAddress,
400+
totalSlots: 3,
401+
missedAttestations: { currentStreak: 0, count: 0 },
402+
missedProposals: { currentStreak: 0, count: 0 },
403+
history: [{ slot: 5n, status: 'attestation-sent' }],
404+
},
405+
allTimeProvenPerformance: [],
406+
lastProcessedSlot: 10n,
407+
initialSlot: 5n,
408+
slotWindow: 5,
409+
};
410+
411+
const response = await context.client.getValidatorStats(validatorAddress, 5n, 10n);
412+
expect(response).toEqual(handler.singleValidatorStats);
413+
});
414+
370415
it('simulatePublicCalls', async () => {
371416
const response = await context.client.simulatePublicCalls(Tx.random());
372417
expect(response).toBeInstanceOf(PublicSimulationOutput);
@@ -419,6 +464,7 @@ describe('AztecNodeApiSchema', () => {
419464

420465
class MockAztecNode implements AztecNode {
421466
public validatorStats: ValidatorsStats | undefined;
467+
public singleValidatorStats: SingleValidatorStats | undefined;
422468

423469
constructor(private artifact: ContractArtifact) {}
424470

@@ -655,6 +701,20 @@ class MockAztecNode implements AztecNode {
655701
getValidatorsStats(): Promise<ValidatorsStats> {
656702
return Promise.resolve(this.validatorStats!);
657703
}
704+
getValidatorStats(
705+
validatorAddress: EthAddress,
706+
fromSlot?: bigint,
707+
toSlot?: bigint,
708+
): Promise<SingleValidatorStats | undefined> {
709+
expect(validatorAddress).toBeInstanceOf(EthAddress);
710+
if (fromSlot !== undefined) {
711+
expect(typeof fromSlot).toBe('bigint');
712+
}
713+
if (toSlot !== undefined) {
714+
expect(typeof toSlot).toBe('bigint');
715+
}
716+
return Promise.resolve(this.singleValidatorStats);
717+
}
658718
simulatePublicCalls(tx: Tx, _enforceFeePayment = false): Promise<PublicSimulationOutput> {
659719
expect(tx).toBeInstanceOf(Tx);
660720
return Promise.resolve(PublicSimulationOutput.random());

yarn-project/stdlib/src/interfaces/aztec-node.ts

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {
77
PUBLIC_DATA_TREE_HEIGHT,
88
} from '@aztec/constants';
99
import { type L1ContractAddresses, L1ContractAddressesSchema } from '@aztec/ethereum/l1-contract-addresses';
10+
import type { EthAddress } from '@aztec/foundation/eth-address';
1011
import type { Fr } from '@aztec/foundation/fields';
1112
import { createSafeJsonRpcClient, makeFetch } from '@aztec/foundation/json-rpc/client';
1213
import { MembershipWitness, SiblingPath } from '@aztec/foundation/trees';
@@ -48,8 +49,8 @@ import {
4849
TxValidationResultSchema,
4950
indexedTxSchema,
5051
} from '../tx/index.js';
51-
import { ValidatorsStatsSchema } from '../validators/schemas.js';
52-
import type { ValidatorsStats } from '../validators/types.js';
52+
import { SingleValidatorStatsSchema, ValidatorsStatsSchema } from '../validators/schemas.js';
53+
import type { SingleValidatorStats, ValidatorsStats } from '../validators/types.js';
5354
import { type ComponentsVersions, getVersioningResponseHandler } from '../versioning/index.js';
5455
import { MAX_RPC_BLOCKS_LEN, MAX_RPC_LEN, MAX_RPC_TXS_LEN } from './api_limit.js';
5556
import {
@@ -397,6 +398,13 @@ export interface AztecNode
397398
/** Returns stats for validators if enabled. */
398399
getValidatorsStats(): Promise<ValidatorsStats>;
399400

401+
/** Returns stats for a single validator if enabled. */
402+
getValidatorStats(
403+
validatorAddress: EthAddress,
404+
fromSlot?: bigint,
405+
toSlot?: bigint,
406+
): Promise<SingleValidatorStats | undefined>;
407+
400408
/**
401409
* Simulates the public part of a transaction with the current state.
402410
* This currently just checks that the transaction execution succeeds.
@@ -577,6 +585,11 @@ export const AztecNodeApiSchema: ApiSchemaFor<AztecNode> = {
577585

578586
getValidatorsStats: z.function().returns(ValidatorsStatsSchema),
579587

588+
getValidatorStats: z
589+
.function()
590+
.args(schemas.EthAddress, optional(schemas.BigInt), optional(schemas.BigInt))
591+
.returns(SingleValidatorStatsSchema.optional()),
592+
580593
simulatePublicCalls: z.function().args(Tx.schema, optional(z.boolean())).returns(PublicSimulationOutput.schema),
581594

582595
isValidTx: z

0 commit comments

Comments
 (0)