UNPKG

@symbioticfi/relay-stats-ts

Version:

TypeScript library for deriving validator sets from Symbiotic network contracts

400 lines 15.2 kB
import { decodeFunctionData, hexToBytes, bytesToHex } from 'viem'; import { SETTLEMENT_ABI } from './abi.js'; import { blockTagFromFinality } from './utils.js'; export const settlementKey = (settlement) => `${settlement.chainId}_${settlement.address.toLowerCase()}`; export const valsetEventsStateKey = (blockTag, settlement) => `${blockTag}_${settlementKey(settlement)}`; const buildValsetCacheKey = (settlement) => settlementKey(settlement); const bytesToBigint = (bytes) => { if (bytes.length === 0) return 0n; return BigInt(bytesToHex(bytes)); }; const decodeSimpleQuorumProof = (proof) => { const bytes = hexToBytes(proof); if (bytes.length < 224) { return null; } const aggregatedSignature = bytesToHex(bytes.slice(0, 64)); const aggregatedPublicKey = bytesToHex(bytes.slice(64, 192)); 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 = []; for (let i = 0; i < validatorCount; i++) { const key = bytesToHex(bytes.slice(offset, offset + 32)); 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 = []; 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) => { const bytes = hexToBytes(proof); if (bytes.length < 416) { return null; } const proofElements = []; let offset = 0; for (let i = 0; i < 8; i++) { proofElements.push(bytesToHex(bytes.slice(offset, offset + 32))); offset += 32; } const commitments = []; for (let i = 0; i < 2; i++) { commitments.push(bytesToHex(bytes.slice(offset, offset + 32))); offset += 32; } const commitmentPok = []; for (let i = 0; i < 2; i++) { commitmentPok.push(bytesToHex(bytes.slice(offset, offset + 32))); offset += 32; } const votingPower = bytesToBigint(bytes.slice(offset, offset + 32)); return { mode: 'zk', proof: proofElements, commitments, commitmentPok, signersVotingPower: votingPower, rawProof: proof, }; }; const decodeQuorumProof = (proof, mode) => { if (!proof || proof === '0x') return null; if (mode === 'zk') { return decodeZkQuorumProof(proof); } return decodeSimpleQuorumProof(proof); }; const fetchQuorumProofFromTransaction = async (client, transactionHash, mode) => { 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, }); if (decoded.functionName !== 'commitValSetHeader') { return null; } const proofArg = (decoded.args?.[2] ?? null); if (!proofArg) { return null; } return decodeQuorumProof(proofArg, mode); } catch { return null; } }; export const getOrCreateValSetEventsState = (states, blockTag, settlement) => { const key = valsetEventsStateKey(blockTag, settlement); let state = states.get(key); if (!state) { state = { settlement, map: new Map() }; states.set(key, state); } return state; }; export const ingestSettlementEvents = async (client, settlement, state, fromBlock, toBlock, mode) => { const filteredEvents = SETTLEMENT_ABI.filter((item) => 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(); const transactionProofCache = new Map(); for (const log of logs) { const rawHeader = toRawValSetHeader(log.args?.valSetHeader); if (!rawHeader) continue; const extraData = toEventExtraData(log.args?.extraData); const blockNumber = log.blockNumber; let blockTimestamp; 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), logIndex: log.logIndex ?? undefined, }; const kind = log.eventName === 'SetGenesis' ? 'genesis' : 'commit'; let quorumProof = 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, existing) => { 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) => ({ 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, }); const toRawValSetHeader = (value) => { if (!value) return null; const toBigInt = (input) => { 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; if (version === null || requiredKeyTag === null || epoch === null || captureTimestamp === null || quorumThreshold === null || totalVotingPower === null || !validatorsSszMRoot) { return null; } return { version, requiredKeyTag, epoch, captureTimestamp, quorumThreshold, totalVotingPower, validatorsSszMRoot, }; }; const toEventExtraData = (value) => { if (!value || value.length === 0) return []; const entries = []; for (const item of value) { if (!item || typeof item.key !== 'string' || typeof item.value !== 'string') continue; entries.push({ key: item.key, value: item.value, }); } return entries; }; const processValSetEventLog = (store, header, extraData, kind, metadata, quorumProof) => { const parsedHeader = parseValSetHeaderFromEvent(header); const event = { kind, header: parsedHeader, extraData, blockNumber: metadata.blockNumber ?? null, blockTimestamp: metadata.blockTimestamp ?? null, transactionHash: metadata.transactionHash ?? null, quorumProof: quorumProof ?? null, }; const candidate = { 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, state, epoch) => state.map.get(epoch)?.event ?? null; export const selectDefaultSettlement = (settlements) => { const settlement = settlements[0]; if (!settlement) { throw new Error('No settlement configured to retrieve validator set events'); } return settlement; }; export const pruneValSetEventState = async (states, cache, epoch) => { const seen = new Set(); 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, timestamp, highestBlock, highestTimestamp) => { 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, startTimestamp, endTimestamp, buffer, blockTag) => { 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, cache, clientFactory, params) => { 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; 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, stateFactory, cache, statusFetcher, blockMetrics, clientFactory, buffer, statusContext, allowCache = false) => { 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; }; //# sourceMappingURL=valset_events.js.map