11import { parseOffenseSnapshot } from './offenses.mjs' ;
22
3+ const VALIDATOR_STATUSES = new Set ( [
4+ 'checkpoint-mined' ,
5+ 'checkpoint-valid' ,
6+ 'checkpoint-invalid' ,
7+ 'checkpoint-unvalidated' ,
8+ 'checkpoint-missed' ,
9+ 'blocks-missed' ,
10+ 'attestation-sent' ,
11+ 'attestation-missed' ,
12+ ] ) ;
13+
314export class AztecAdminClient {
415 constructor ( {
516 url,
@@ -8,6 +19,7 @@ export class AztecAdminClient {
819 nodeApiKey,
920 timeoutMs = 10_000 ,
1021 maxResponseBytes = 2 * 1024 * 1024 ,
22+ maxSingleValidatorStatsResponseBytes = 2 * 1024 * 1024 ,
1123 maxOffenses = 100_000 ,
1224 fetchImpl = fetch ,
1325 } ) {
@@ -17,6 +29,7 @@ export class AztecAdminClient {
1729 this . nodeApiKey = nodeApiKey ;
1830 this . timeoutMs = timeoutMs ;
1931 this . maxResponseBytes = maxResponseBytes ;
32+ this . maxSingleValidatorStatsResponseBytes = maxSingleValidatorStatsResponseBytes ;
2033 this . maxOffenses = maxOffenses ;
2134 this . fetchImpl = fetchImpl ;
2235 this . nextId = 1 ;
@@ -42,10 +55,42 @@ export class AztecAdminClient {
4255 return parseNodeSyncStatus ( { ready, l1Timestamp, l2Slot, l2Epoch } ) ;
4356 }
4457
58+ async getSentinelSyncStatus ( signal ) {
59+ const [ ready , l2Slot ] = await Promise . all ( [
60+ this . call ( 'aztec_isReady' , [ ] , signal ) ,
61+ this . call ( 'aztec_getSyncedL2SlotNumber' , [ ] , signal ) ,
62+ ] ) ;
63+ const parsed = parseNodeSyncStatus ( {
64+ ready,
65+ l1Timestamp : null ,
66+ l2Slot,
67+ l2Epoch : null ,
68+ } ) ;
69+ return { ready : parsed . ready , l2Slot : parsed . l2Slot } ;
70+ }
71+
72+ async getValidatorStats ( address , fromSlot , toSlot , signal ) {
73+ const sequencer = parseAddress ( address , 'validator stats address' ) ;
74+ const from = parseUnsignedInteger ( fromSlot , 'validator stats fromSlot' ) ;
75+ const to = parseUnsignedInteger ( toSlot , 'validator stats toSlot' ) ;
76+ if ( BigInt ( from ) > BigInt ( to ) ) {
77+ throw new Error ( 'Aztec validator stats fromSlot must not exceed toSlot' ) ;
78+ }
79+ const result = await this . call ( 'aztec_getValidatorStats' , [ sequencer , from , to ] , signal ) ;
80+ return parseSingleValidatorStats ( result , { sequencer, fromSlot : from , toSlot : to } ) ;
81+ }
82+
83+ async getInactivityConfig ( signal ) {
84+ const result = await this . call ( 'aztecAdmin_getConfig' , [ ] , signal ) ;
85+ return parseInactivityConfig ( result ) ;
86+ }
87+
4588 async call ( method , params , signal ) {
4689 let request ;
4790 if ( method === 'aztecAdmin_getSlashOffenses' && isExactParams ( params , [ 'all' ] ) ) {
4891 request = { url : this . url , apiKey : this . apiKey , label : 'Aztec admin' } ;
92+ } else if ( method === 'aztecAdmin_getConfig' && isExactParams ( params , [ ] ) ) {
93+ request = { url : this . url , apiKey : this . apiKey , label : 'Aztec admin' } ;
4994 } else if (
5095 [
5196 'aztec_getNodeInfo' ,
@@ -56,6 +101,8 @@ export class AztecAdminClient {
56101 ] . includes ( method ) && isExactParams ( params , [ ] )
57102 ) {
58103 request = { url : this . nodeUrl , apiKey : this . nodeApiKey , label : 'Aztec node' } ;
104+ } else if ( method === 'aztec_getValidatorStats' && isValidatorStatsParams ( params ) ) {
105+ request = { url : this . nodeUrl , apiKey : this . nodeApiKey , label : 'Aztec node' } ;
59106 } else {
60107 throw new Error ( `Aztec RPC method or parameters are not allowed: ${ method } ` ) ;
61108 }
@@ -84,7 +131,10 @@ export class AztecAdminClient {
84131 throw new Error ( `${ request . label } request failed: ${ error instanceof Error ? error . message : String ( error ) } ` ) ;
85132 }
86133
87- const body = await readLimitedBody ( response , this . maxResponseBytes , request . label ) ;
134+ const responseLimit = method === 'aztec_getValidatorStats'
135+ ? this . maxSingleValidatorStatsResponseBytes
136+ : this . maxResponseBytes ;
137+ const body = await readLimitedBody ( response , responseLimit , request . label ) ;
88138 let payload ;
89139 try {
90140 payload = JSON . parse ( body ) ;
@@ -139,12 +189,136 @@ export function parseNodeSyncStatus(value) {
139189 } ;
140190}
141191
192+ export function parseSingleValidatorStats ( value , {
193+ sequencer : expectedSequencer ,
194+ fromSlot,
195+ toSlot,
196+ } = { } ) {
197+ // The node returns undefined (JSON null) when this address has no slot-level
198+ // history. L1 committee membership still lets the collector persist a 0/0
199+ // epoch row after another committee response proves the epoch was evaluated.
200+ if ( value === undefined || value === null ) return undefined ;
201+ if ( ! isPlainObject ( value ) || ! isPlainObject ( value . validator ) ) {
202+ throw new Error ( 'Aztec single-validator stats must include a validator object' ) ;
203+ }
204+ const expected = parseAddress ( expectedSequencer , 'expected validator stats address' ) ;
205+ const sequencer = parseAddress ( value . validator . address , 'validator stats address' ) ;
206+ if ( sequencer !== expected ) {
207+ throw new Error ( `Aztec validator stats address does not match requested address ${ expected } ` ) ;
208+ }
209+ if ( ! Array . isArray ( value . validator . history ) ) {
210+ throw new Error ( `Aztec validator history for ${ sequencer } must be an array` ) ;
211+ }
212+ const lowerBound = BigInt ( parseUnsignedInteger ( fromSlot , 'validator stats fromSlot' ) ) ;
213+ const upperBound = BigInt ( parseUnsignedInteger ( toSlot , 'validator stats toSlot' ) ) ;
214+ const seenSlots = new Set ( ) ;
215+ let previousSlot ;
216+ const history = value . validator . history . map ( ( observation , index ) => {
217+ if ( ! isPlainObject ( observation ) || ! VALIDATOR_STATUSES . has ( observation . status ) ) {
218+ throw new Error ( `Aztec validator history status at ${ sequencer } [${ index } ] is invalid` ) ;
219+ }
220+ const slot = parseUnsignedInteger ( observation . slot , `validator history slot at ${ sequencer } [${ index } ]` ) ;
221+ const numericSlot = BigInt ( slot ) ;
222+ if ( numericSlot < lowerBound || numericSlot > upperBound ) {
223+ throw new Error ( `Aztec validator history slot ${ slot } is outside the requested range` ) ;
224+ }
225+ if ( seenSlots . has ( slot ) ) {
226+ throw new Error ( `Aztec validator history contains duplicate slot ${ slot } for ${ sequencer } ` ) ;
227+ }
228+ if ( previousSlot !== undefined && numericSlot <= previousSlot ) {
229+ throw new Error ( `Aztec validator history is not strictly ordered for ${ sequencer } ` ) ;
230+ }
231+ seenSlots . add ( slot ) ;
232+ previousSlot = numericSlot ;
233+ return { slot, status : observation . status } ;
234+ } ) ;
235+ if ( ! Array . isArray ( value . allTimeEpochPerformance ) ) {
236+ throw new Error ( `Aztec all-time epoch performance for ${ sequencer } must be an array` ) ;
237+ }
238+ const seenEpochs = new Set ( ) ;
239+ const allTimeEpochPerformance = value . allTimeEpochPerformance . map ( ( performance , index ) => {
240+ if ( ! isPlainObject ( performance ) ) {
241+ throw new Error ( `Aztec epoch performance at ${ sequencer } [${ index } ] is invalid` ) ;
242+ }
243+ const epoch = parseUnsignedInteger ( performance . epoch , `validator epoch at ${ sequencer } [${ index } ]` ) ;
244+ if ( seenEpochs . has ( epoch ) ) {
245+ throw new Error ( `Aztec validator epoch performance contains duplicate epoch ${ epoch } for ${ sequencer } ` ) ;
246+ }
247+ seenEpochs . add ( epoch ) ;
248+ const missed = parseSafeInteger (
249+ performance . missed ,
250+ `validator missed duties at ${ sequencer } [${ index } ]` ,
251+ 0 ,
252+ ) ;
253+ const total = parseSafeInteger (
254+ performance . total ,
255+ `validator total duties at ${ sequencer } [${ index } ]` ,
256+ 0 ,
257+ ) ;
258+ if ( missed > total ) {
259+ throw new Error ( `Aztec validator missed duties exceed total duties at ${ sequencer } [${ index } ]` ) ;
260+ }
261+ return {
262+ epoch,
263+ missed,
264+ total,
265+ } ;
266+ } ) ;
267+ const totalSlots = parseSafeInteger ( value . validator . totalSlots , 'Aztec validator totalSlots' , 0 ) ;
268+ if ( totalSlots !== history . length ) {
269+ throw new Error ( `Aztec validator totalSlots does not match history length for ${ sequencer } ` ) ;
270+ }
271+ return {
272+ sequencer,
273+ history,
274+ allTimeEpochPerformance,
275+ lastProcessedSlot : parseOptionalInteger ( value . lastProcessedSlot , 'validator stats lastProcessedSlot' ) ,
276+ } ;
277+ }
278+
279+ export function parseInactivityConfig ( value ) {
280+ if ( ! isPlainObject ( value ) ) {
281+ throw new Error ( 'Aztec admin config must be an object' ) ;
282+ }
283+ const targetPercentage = value . slashInactivityTargetPercentage ;
284+ if (
285+ typeof targetPercentage !== 'number' ||
286+ ! Number . isFinite ( targetPercentage ) ||
287+ targetPercentage < 0 ||
288+ targetPercentage > 1
289+ ) {
290+ throw new Error ( 'Aztec admin slashInactivityTargetPercentage must be between 0 and 1' ) ;
291+ }
292+ return {
293+ targetPercentage,
294+ consecutiveEpochThreshold : parseSafeInteger (
295+ value . slashInactivityConsecutiveEpochThreshold ,
296+ 'Aztec admin slashInactivityConsecutiveEpochThreshold' ,
297+ 1 ,
298+ ) ,
299+ epochEndBufferSlots : parseSafeInteger (
300+ value . sentinelEpochEndBufferSlots ,
301+ 'Aztec admin sentinelEpochEndBufferSlots' ,
302+ 0 ,
303+ ) ,
304+ } ;
305+ }
306+
142307function isExactParams ( actual , expected ) {
143308 return Array . isArray ( actual )
144309 && actual . length === expected . length
145310 && actual . every ( ( value , index ) => value === expected [ index ] ) ;
146311}
147312
313+ function isValidatorStatsParams ( params ) {
314+ return Array . isArray ( params ) &&
315+ params . length === 3 &&
316+ / ^ 0 x [ 0 - 9 a - f ] { 40 } $ / . test ( params [ 0 ] ) &&
317+ / ^ [ 0 - 9 ] + $ / . test ( params [ 1 ] ) &&
318+ / ^ [ 0 - 9 ] + $ / . test ( params [ 2 ] ) &&
319+ BigInt ( params [ 1 ] ) <= BigInt ( params [ 2 ] ) ;
320+ }
321+
148322function isPlainObject ( value ) {
149323 return Boolean ( value ) && typeof value === 'object' && ! Array . isArray ( value ) ;
150324}
@@ -175,6 +349,36 @@ function parseIdentityAddress(value, name) {
175349 return value . toLowerCase ( ) ;
176350}
177351
352+ function parseAddress ( value , label ) {
353+ if ( typeof value !== 'string' || ! / ^ 0 x [ 0 - 9 a - f A - F ] { 40 } $ / . test ( value ) || / ^ 0 x 0 { 40 } $ / i. test ( value ) ) {
354+ throw new Error ( `Aztec ${ label } must be a nonzero 20-byte hex address` ) ;
355+ }
356+ return value . toLowerCase ( ) ;
357+ }
358+
359+ function parseOptionalInteger ( value , label ) {
360+ if ( value === undefined || value === null ) return undefined ;
361+ return parseUnsignedInteger ( value , label ) ;
362+ }
363+
364+ function parseUnsignedInteger ( value , label ) {
365+ if ( typeof value === 'number' && Number . isSafeInteger ( value ) && value >= 0 ) {
366+ return String ( value ) ;
367+ }
368+ if ( typeof value === 'string' && ( / ^ [ 0 - 9 ] + $ / . test ( value ) || / ^ 0 x [ 0 - 9 a - f ] + $ / i. test ( value ) ) ) {
369+ return BigInt ( value ) . toString ( ) ;
370+ }
371+ throw new Error ( `Aztec ${ label } must be an unsigned integer` ) ;
372+ }
373+
374+ function parseSafeInteger ( value , label , minimum ) {
375+ const parsed = Number ( value ) ;
376+ if ( ! Number . isSafeInteger ( parsed ) || parsed < minimum ) {
377+ throw new Error ( `${ label } must be an integer of at least ${ minimum } ` ) ;
378+ }
379+ return parsed ;
380+ }
381+
178382function parseOptionalUnsignedInteger ( value , label ) {
179383 if ( value === null ) return undefined ;
180384 let parsed ;
0 commit comments