Skip to content

Commit de07326

Browse files
StoneMac650x4r45h
authored andcommitted
Add getValidatorStats RPC method to AztecNode server
Co-authored-by: 0x4r45h <[email protected]>
1 parent bc949c2 commit de07326

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)