UNPKG

@symbioticfi/relay-stats-ts

Version:

TypeScript library for deriving validator sets from Symbiotic network contracts

630 lines (548 loc) 18.3 kB
import type { Hex, PublicClient } from 'viem'; import { decodeFunctionData, hexToBytes, bytesToHex } from 'viem'; import type { CrossChainAddress, ValSetLogEvent, ValSetEventKind, ValSetStatus, ValidatorSetHeader, CacheInterface, ValSetExtraData, SettlementValSetStatus, ValSetQuorumProof, ValSetQuorumProofSimple, ValSetQuorumProofSimpleSigner, ValSetQuorumProofZk, } from './types.js'; import { SETTLEMENT_ABI } from './abi.js'; import { blockTagFromFinality, type BlockTagPreference } from './utils.js'; export const settlementKey = (settlement: CrossChainAddress): string => `${settlement.chainId}_${settlement.address.toLowerCase()}`; export const valsetEventsStateKey = ( blockTag: BlockTagPreference, settlement: CrossChainAddress, ): string => `${blockTag}_${settlementKey(settlement)}`; type VerificationMode = 'simple' | 'zk'; const buildValsetCacheKey = (settlement: CrossChainAddress): string => settlementKey(settlement); const bytesToBigint = (bytes: Uint8Array): bigint => { if (bytes.length === 0) return 0n; return BigInt(bytesToHex(bytes)); }; const decodeSimpleQuorumProof = (proof: Hex): ValSetQuorumProofSimple | null => { const bytes = hexToBytes(proof); if (bytes.length < 224) { return null; } const aggregatedSignature = bytesToHex(bytes.slice(0, 64)) as Hex; const aggregatedPublicKey = bytesToHex(bytes.slice(64, 192)) as Hex; const validatorCountBigint = bytesToBigint(bytes.slice(192, 224)); if (validatorCountBigint < 0n) return null; const validatorCount = Number(validatorCountBigint); if (!Number.isSafeInteger(validatorCount) || validatorCount < 0) return null; let offset = 224; const expectedLength = offset + validatorCount * 64; if (bytes.length < expectedLength) return null; const signers: ValSetQuorumProofSimpleSigner[] = []; for (let i = 0; i < validatorCount; i++) { const key = bytesToHex(bytes.slice(offset, offset + 32)) as Hex; const votingPower = bytesToBigint(bytes.slice(offset + 32, offset + 64)); signers.push({ key, votingPower, }); offset += 64; } const remaining = bytes.length - offset; if (remaining < 0 || remaining % 2 !== 0) { return null; } const nonSignerIndices: number[] = []; for (let i = offset; i < bytes.length; i += 2) { const value = (bytes[i] << 8) | bytes[i + 1]; nonSignerIndices.push(value); } return { mode: 'simple', aggregatedSignature, aggregatedPublicKey, signers, nonSignerIndices, rawProof: proof, }; }; const decodeZkQuorumProof = (proof: Hex): ValSetQuorumProofZk | null => { const bytes = hexToBytes(proof); if (bytes.length < 416) { return null; } const proofElements: Hex[] = []; let offset = 0; for (let i = 0; i < 8; i++) { proofElements.push(bytesToHex(bytes.slice(offset, offset + 32)) as Hex); offset += 32; } const commitments: Hex[] = []; for (let i = 0; i < 2; i++) { commitments.push(bytesToHex(bytes.slice(offset, offset + 32)) as Hex); offset += 32; } const commitmentPok: Hex[] = []; for (let i = 0; i < 2; i++) { commitmentPok.push(bytesToHex(bytes.slice(offset, offset + 32)) as Hex); offset += 32; } const votingPower = bytesToBigint(bytes.slice(offset, offset + 32)); return { mode: 'zk', proof: proofElements, commitments, commitmentPok, signersVotingPower: votingPower, rawProof: proof, }; }; const decodeQuorumProof = (proof: Hex, mode: VerificationMode): ValSetQuorumProof | null => { if (!proof || proof === '0x') return null; if (mode === 'zk') { return decodeZkQuorumProof(proof); } return decodeSimpleQuorumProof(proof); }; const fetchQuorumProofFromTransaction = async ( client: PublicClient, transactionHash: Hex, mode: VerificationMode, ): Promise<ValSetQuorumProof | null> => { try { const tx = await client.getTransaction({ hash: transactionHash }); if (!tx || tx.input === undefined || tx.input === '0x') { return null; } const decoded = decodeFunctionData({ abi: SETTLEMENT_ABI, data: tx.input as Hex, }); if (decoded.functionName !== 'commitValSetHeader') { return null; } const proofArg = (decoded.args?.[2] ?? null) as Hex | null; if (!proofArg) { return null; } return decodeQuorumProof(proofArg, mode); } catch { return null; } }; export const getOrCreateValSetEventsState = ( states: Map<string, ValSetEventsState>, blockTag: BlockTagPreference, settlement: CrossChainAddress, ): ValSetEventsState => { const key = valsetEventsStateKey(blockTag, settlement); let state = states.get(key); if (!state) { state = { settlement, map: new Map() }; states.set(key, state); } return state; }; type StoredValSetEvent = { event: ValSetLogEvent; logIndex: number | null; }; export interface ValSetEventsState { settlement: CrossChainAddress; map: Map<number, StoredValSetEvent>; } type SettlementEventHeaderLike = { version: bigint | number | string; requiredKeyTag: bigint | number | string; epoch: bigint | number | string; captureTimestamp: bigint | number | string; quorumThreshold: bigint | number | string; totalVotingPower: bigint | number | string; validatorsSszMRoot: Hex; }; type SettlementEventArgs = { valSetHeader?: SettlementEventHeaderLike; extraData?: readonly { key: Hex; value: Hex; }[]; }; export const ingestSettlementEvents = async ( client: PublicClient, settlement: CrossChainAddress, state: ValSetEventsState, fromBlock: bigint, toBlock: bigint, mode: VerificationMode, ): Promise<void> => { const filteredEvents = SETTLEMENT_ABI.filter( (item): item is Extract<(typeof SETTLEMENT_ABI)[number], { type: 'event' }> => item.type === 'event' && (item.name === 'SetGenesis' || item.name === 'CommitValSetHeader'), ); const logs = await client.getLogs({ address: settlement.address, events: filteredEvents, fromBlock, toBlock, }); const blockTimestampCache = new Map<bigint, number>(); const transactionProofCache = new Map<string, ValSetQuorumProof | null>(); for (const log of logs) { const rawHeader = toRawValSetHeader( (log.args as SettlementEventArgs | undefined)?.valSetHeader, ); if (!rawHeader) continue; const extraData = toEventExtraData((log.args as SettlementEventArgs | undefined)?.extraData); const blockNumber = log.blockNumber; let blockTimestamp: number | undefined; if (typeof blockNumber === 'bigint') { const cachedTimestamp = blockTimestampCache.get(blockNumber); if (cachedTimestamp !== undefined) { blockTimestamp = cachedTimestamp; } else { const block = await client.getBlock({ blockNumber }); const timestampNumber = Number(block.timestamp); blockTimestampCache.set(blockNumber, timestampNumber); blockTimestamp = timestampNumber; } } const metadata = { blockNumber, blockTimestamp, transactionHash: (log.transactionHash ?? undefined) as Hex | undefined, logIndex: log.logIndex ?? undefined, }; const kind: ValSetEventKind = log.eventName === 'SetGenesis' ? 'genesis' : 'commit'; let quorumProof: ValSetQuorumProof | null = null; if (metadata.transactionHash && kind === 'commit') { const key = metadata.transactionHash.toLowerCase(); if (transactionProofCache.has(key)) { quorumProof = transactionProofCache.get(key) ?? null; } else { quorumProof = await fetchQuorumProofFromTransaction(client, metadata.transactionHash, mode); transactionProofCache.set(key, quorumProof ?? null); } } processValSetEventLog(state.map, rawHeader, extraData, kind, metadata, quorumProof); } }; const isNewerValSetEvent = (candidate: StoredValSetEvent, existing: StoredValSetEvent): boolean => { const candidateBlock = candidate.event.blockNumber ?? -1n; const existingBlock = existing.event.blockNumber ?? -1n; if (candidateBlock !== existingBlock) { return candidateBlock > existingBlock; } const candidateIndex = candidate.logIndex ?? -1; const existingIndex = existing.logIndex ?? -1; return candidateIndex > existingIndex; }; const parseValSetHeaderFromEvent = (raw: RawValSetHeader): ValidatorSetHeader => ({ version: Number(raw.version), requiredKeyTag: Number(raw.requiredKeyTag), epoch: Number(raw.epoch), captureTimestamp: Number(raw.captureTimestamp), quorumThreshold: BigInt(raw.quorumThreshold), totalVotingPower: BigInt(raw.totalVotingPower), validatorsSszMRoot: raw.validatorsSszMRoot as Hex, }); type RawValSetHeader = { version: bigint; requiredKeyTag: bigint; epoch: bigint; captureTimestamp: bigint; quorumThreshold: bigint; totalVotingPower: bigint; validatorsSszMRoot: Hex; }; const toRawValSetHeader = ( value: SettlementEventHeaderLike | null | undefined, ): RawValSetHeader | null => { if (!value) return null; const toBigInt = (input: bigint | number | string | undefined): bigint | null => { if (typeof input === 'bigint') return input; if (typeof input === 'number') return BigInt(input); if (typeof input === 'string' && input.trim() !== '') return BigInt(input); return null; }; const version = toBigInt(value.version); const requiredKeyTag = toBigInt(value.requiredKeyTag); const epoch = toBigInt(value.epoch); const captureTimestamp = toBigInt(value.captureTimestamp); const quorumThreshold = toBigInt(value.quorumThreshold); const totalVotingPower = toBigInt(value.totalVotingPower); const validatorsSszMRoot = value.validatorsSszMRoot as Hex | undefined; if ( version === null || requiredKeyTag === null || epoch === null || captureTimestamp === null || quorumThreshold === null || totalVotingPower === null || !validatorsSszMRoot ) { return null; } return { version, requiredKeyTag, epoch, captureTimestamp, quorumThreshold, totalVotingPower, validatorsSszMRoot, } satisfies RawValSetHeader; }; const toEventExtraData = (value: SettlementEventArgs['extraData']): ValSetExtraData[] => { if (!value || value.length === 0) return []; const entries: ValSetExtraData[] = []; for (const item of value) { if (!item || typeof item.key !== 'string' || typeof item.value !== 'string') continue; entries.push({ key: item.key as Hex, value: item.value as Hex, }); } return entries; }; const processValSetEventLog = ( store: Map<number, StoredValSetEvent>, header: RawValSetHeader, extraData: ValSetExtraData[], kind: ValSetEventKind, metadata: { blockNumber?: bigint; blockTimestamp?: number; transactionHash?: Hex; logIndex?: number; }, quorumProof: ValSetQuorumProof | null, ): void => { const parsedHeader = parseValSetHeaderFromEvent(header); const event: ValSetLogEvent = { kind, header: parsedHeader, extraData, blockNumber: metadata.blockNumber ?? null, blockTimestamp: metadata.blockTimestamp ?? null, transactionHash: metadata.transactionHash ?? null, quorumProof: quorumProof ?? null, }; const candidate: StoredValSetEvent = { event, logIndex: metadata.logIndex ?? null, }; const existing = store.get(parsedHeader.epoch); if (!existing || isNewerValSetEvent(candidate, existing)) { store.set(parsedHeader.epoch, candidate); } }; export const getValSetLogEventFromSet = ( settlement: CrossChainAddress, state: ValSetEventsState, epoch: number, ): ValSetLogEvent | null => state.map.get(epoch)?.event ?? null; export const selectDefaultSettlement = ( settlements: readonly CrossChainAddress[], ): CrossChainAddress => { const settlement = settlements[0]; if (!settlement) { throw new Error('No settlement configured to retrieve validator set events'); } return settlement; }; export const pruneValSetEventState = async ( states: Map<string, ValSetEventsState>, cache: CacheInterface | null, epoch: number, ): Promise<void> => { const seen = new Set<string>(); for (const state of states.values()) { state.map.delete(epoch); if (!cache) continue; const key = settlementKey(state.settlement); if (seen.has(key)) continue; seen.add(key); await cache.delete(epoch, key); } }; export const findBlockNumberForTimestamp = async ( client: PublicClient, timestamp: number, highestBlock: bigint, highestTimestamp: number, ): Promise<bigint> => { if (timestamp >= highestTimestamp) return highestBlock; let low = 0n; let high = highestBlock; let best = 0n; while (low <= high) { const mid = (low + high) >> 1n; const block = await client.getBlock({ blockNumber: mid }); const blockTimestamp = Number(block.timestamp); if (blockTimestamp <= timestamp) { best = mid; low = mid + 1n; } else { if (mid === 0n) return 0n; high = mid - 1n; } } return best; }; export const estimateEpochBlockRange = async ( client: PublicClient, startTimestamp: number, endTimestamp: number, buffer: bigint, blockTag: BlockTagPreference, ): Promise<{ fromBlock: bigint; toBlock: bigint }> => { const latestBlock = await client.getBlock({ blockTag }); const latestNumber = latestBlock.number ?? 0n; const latestTimestamp = Number(latestBlock.timestamp); const fromEstimate = await findBlockNumberForTimestamp( client, startTimestamp, latestNumber, latestTimestamp, ); const toEstimate = await findBlockNumberForTimestamp( client, endTimestamp, latestNumber, latestTimestamp, ); const bufferedFrom = fromEstimate > buffer ? fromEstimate - buffer : 0n; let bufferedTo = toEstimate + buffer; if (bufferedTo > latestNumber) bufferedTo = latestNumber; if (bufferedTo < bufferedFrom) bufferedTo = bufferedFrom; return { fromBlock: bufferedFrom, toBlock: bufferedTo }; }; export const loadValSetEvent = async ( state: ValSetEventsState, cache: CacheInterface | null, clientFactory: (chainId: number) => PublicClient, params: { epoch: number; settlement: CrossChainAddress; finalized: boolean; startTimestamp: number; endTimestamp: number; buffer: bigint; mode: VerificationMode; allowCache: boolean; }, ): Promise<ValSetLogEvent | null> => { const { epoch, settlement, finalized, startTimestamp, endTimestamp, buffer, mode, allowCache } = params; const blockTag = blockTagFromFinality(finalized); const existing = state.map.get(epoch) ?? null; if (!finalized) return existing?.event ?? null; if (existing) return existing.event; const cacheKey = buildValsetCacheKey(settlement); if (cache) { const cached = await cache.get(epoch, cacheKey); if (cached) { const event = cached as ValSetLogEvent; state.map.set(epoch, { event, logIndex: null }); return event; } } const client = clientFactory(settlement.chainId); const { fromBlock, toBlock } = await estimateEpochBlockRange( client, startTimestamp, endTimestamp, buffer, blockTag, ); if (toBlock >= fromBlock) { await ingestSettlementEvents(client, settlement, state, fromBlock, toBlock, mode); } const stored = state.map.get(epoch) ?? null; const event = stored?.event ?? null; if (event && cache && allowCache) { await cache.set(epoch, cacheKey, event); } return event; }; export const retrieveValSetEvent = async ( params: { epoch: number; settlement: CrossChainAddress; finalized: boolean; mode: VerificationMode; }, stateFactory: (blockTag: BlockTagPreference, settlement: CrossChainAddress) => ValSetEventsState, cache: CacheInterface | null, statusFetcher: (epoch: number, finalized: boolean) => Promise<ValSetStatus>, blockMetrics: { getStart: (epoch: number, finalized: boolean) => Promise<number>; getEnd: (epoch: number, finalized: boolean, fallbackStart: number) => Promise<number>; }, clientFactory: (chainId: number) => PublicClient, buffer: bigint, statusContext?: { overall?: ValSetStatus | null; detail?: SettlementValSetStatus | null; }, allowCache: boolean = false, ): Promise<ValSetLogEvent | null> => { const { epoch, settlement, finalized, mode } = params; const blockTag = blockTagFromFinality(finalized); const state = stateFactory(blockTag, settlement); const existing = state.map.get(epoch) ?? null; if (!finalized) return existing?.event ?? null; const startTimestamp = await blockMetrics.getStart(epoch, finalized); const endTimestamp = await blockMetrics.getEnd(epoch + 1, finalized, startTimestamp); const event = existing?.event ?? (await loadValSetEvent(state, cache, clientFactory, { epoch, settlement, finalized, startTimestamp, endTimestamp, buffer, mode, allowCache, })); if (!event) { let overallStatus = statusContext?.overall ?? null; let settlementStatus = statusContext?.detail ?? null; if (!overallStatus) { overallStatus = await statusFetcher(epoch, finalized); } if (!settlementStatus && overallStatus) { settlementStatus = overallStatus.settlements.find( (item) => item.settlement.chainId === settlement.chainId && item.settlement.address.toLowerCase() === settlement.address.toLowerCase(), ) ?? null; } if (settlementStatus && settlementStatus.committed) { throw new Error( `Validator set epoch ${epoch} is committed for settlement ${settlement.address} but no events were found using ${blockTag} data.`, ); } if (overallStatus && overallStatus.status !== 'committed') { throw new Error( `Validator set epoch ${epoch} is not committed yet (status: ${overallStatus.status}).`, ); } const status = overallStatus ?? (await statusFetcher(epoch, finalized)); if (status.status !== 'committed') { throw new Error( `Validator set epoch ${epoch} is not committed yet (status: ${status.status}).`, ); } throw new Error( `Validator set epoch ${epoch} is committed but no settlement events were found using ${blockTag} data.`, ); } return event; };