UNPKG

@symbioticfi/relay-stats-ts

Version:

TypeScript library for deriving validator sets from Symbiotic network contracts

1,208 lines (1,207 loc) 53.2 kB
/* eslint-disable @typescript-eslint/no-explicit-any */ import { createPublicClient, http, getContract, hexToString, isHex, } from 'viem'; // bytesToHex retained in public API exports; not used internally here import { buildSimpleExtraData, buildZkExtraData } from './extra_data.js'; import { VALSET_VERSION, AGGREGATOR_MODE, MULTICALL3_ADDRESS, MULTICALL_TARGET_GAS, MULTICALL_VOTING_CALL_GAS, MULTICALL_KEYS_CALL_GAS, MULTICALL_VAULT_COLLATERAL_CALL_GAS, MULTICALL_ERC20_METADATA_CALL_GAS, } from './constants.js'; import { VALSET_DRIVER_ABI, SETTLEMENT_ABI, VOTING_POWER_PROVIDER_ABI, KEY_REGISTRY_ABI, VAULT_ABI, ERC20_METADATA_ABI, } from './abi.js'; import { blockTagFromFinality } from './utils.js'; import { calculateQuorumThreshold, composeValidators, createValidatorSetHeader, encodeValidatorSetHeader, hashValidatorSet, hashValidatorSetHeader, totalActiveVotingPower, } from './validator_set.js'; import { selectDefaultSettlement, getOrCreateValSetEventsState, retrieveValSetEvent as fetchValSetLogEvent, } from './valset_events.js'; const EPOCH_EVENT_BLOCK_BUFFER = 16n; const getSettlementContract = (client, settlement) => getContract({ address: settlement.address, abi: SETTLEMENT_ABI, client, }); const tryFetchSettlementStatusViaMulticall = async (client, settlement, epochNumber, blockTag) => { const tagsToTry = blockTag === 'finalized' ? [blockTag, 'latest'] : [blockTag]; for (const tag of tagsToTry) { try { const results = (await client.multicall({ allowFailure: false, blockTag: tag, multicallAddress: MULTICALL3_ADDRESS, contracts: [ { address: settlement.address, abi: SETTLEMENT_ABI, functionName: 'isValSetHeaderCommittedAt', args: [epochNumber], }, { address: settlement.address, abi: SETTLEMENT_ABI, functionName: 'getValSetHeaderHashAt', args: [epochNumber], }, { address: settlement.address, abi: SETTLEMENT_ABI, functionName: 'getLastCommittedHeaderEpoch', }, ], })); const [isCommittedRaw, headerHashRaw, lastCommittedEpochRaw] = results; let headerHash = null; if (typeof headerHashRaw === 'string') { headerHash = headerHashRaw; } let lastCommittedEpoch = 0n; if (typeof lastCommittedEpochRaw === 'bigint') { lastCommittedEpoch = lastCommittedEpochRaw; } else if (typeof lastCommittedEpochRaw === 'number' || typeof lastCommittedEpochRaw === 'string') { lastCommittedEpoch = BigInt(lastCommittedEpochRaw); } return { isCommitted: Boolean(isCommittedRaw), headerHash, lastCommittedEpoch, }; } catch { continue; } } return null; }; export const determineValSetStatus = async (clientFactory, settlements, epoch, preferFinalized) => { const hashes = new Map(); let allCommitted = true; let lastCommitted = Number.MAX_SAFE_INTEGER; const details = []; const blockTag = blockTagFromFinality(preferFinalized); const epochNumber = Number(epoch); for (const settlement of settlements) { const client = clientFactory(settlement.chainId); const detail = { settlement, committed: false, headerHash: null, lastCommittedEpoch: null, }; const multiResult = await tryFetchSettlementStatusViaMulticall(client, settlement, epochNumber, blockTag); if (multiResult) { const committed = Boolean(multiResult.isCommitted); detail.committed = committed; detail.headerHash = multiResult.headerHash; detail.lastCommittedEpoch = Number(multiResult.lastCommittedEpoch); } else { const settlementContract = getSettlementContract(client, settlement); const isCommitted = await settlementContract.read.isValSetHeaderCommittedAt([epochNumber], { blockTag, }); detail.committed = Boolean(isCommitted); if (detail.committed) { const headerHash = (await settlementContract.read.getValSetHeaderHashAt([epochNumber], { blockTag, })); detail.headerHash = headerHash ?? null; } const lastCommittedEpoch = await settlementContract.read.getLastCommittedHeaderEpoch({ blockTag, }); detail.lastCommittedEpoch = Number(lastCommittedEpoch); } if (detail.committed && detail.headerHash) { hashes.set(`${settlement.chainId}_${settlement.address}`, detail.headerHash); } if (!detail.committed) { allCommitted = false; } if (detail.lastCommittedEpoch !== null && Number.isFinite(detail.lastCommittedEpoch)) { lastCommitted = Math.min(lastCommitted, detail.lastCommittedEpoch); } details.push(detail); } let status; if (allCommitted) { status = 'committed'; } else if (epoch < lastCommitted && lastCommitted !== Number.MAX_SAFE_INTEGER) { status = 'missing'; } else { status = 'pending'; } const uniqueHashes = new Set(hashes.values()); const integrity = uniqueHashes.size <= 1 ? 'valid' : 'invalid'; return { status, integrity, settlements: details }; }; export class ValidatorSetDeriver { constructor(config) { this.clients = new Map(); this.valsetEventsState = new Map(); this.driverAddress = config.driverAddress; this.cache = config.cache === undefined ? null : config.cache; this.maxSavedEpochs = config.maxSavedEpochs || 100; this.rpcUrls = Object.freeze([...config.rpcUrls]); this.initializedPromise = this.initializeClients(); } /** * Derive auxiliary network data used by the Relay system. * - Reads NETWORK and SUBNETWORK from the ValSet Driver * - Reads EIP-712 domain from a given settlement contract */ async getNetworkData(settlement, finalized = true) { await this.ensureInitialized(); const blockTag = blockTagFromFinality(finalized); let networkAddress; let subnetwork; const driverClient = this.getClient(this.driverAddress.chainId); if (await this.multicallExists(this.driverAddress.chainId, blockTag)) { const results = await driverClient.multicall({ allowFailure: false, blockTag, multicallAddress: MULTICALL3_ADDRESS, contracts: [ { address: this.driverAddress.address, abi: VALSET_DRIVER_ABI, functionName: 'NETWORK', }, { address: this.driverAddress.address, abi: VALSET_DRIVER_ABI, functionName: 'SUBNETWORK', }, ], }); networkAddress = results[0]; subnetwork = results[1]; } else { const driver = this.getDriverContract(); const values = await Promise.all([ driver.read.NETWORK({ blockTag }), driver.read.SUBNETWORK({ blockTag }), ]); networkAddress = values[0]; subnetwork = values[1]; } // Resolve settlement: use provided or first from config let targetSettlement = settlement; if (!targetSettlement) { const cfg = await this.getNetworkConfig(undefined, finalized); targetSettlement = cfg.settlements[0]; } if (!targetSettlement) { throw new Error('No settlement available to fetch EIP-712 domain'); } // Read EIP-712 domain from the settlement contract on its chain const settlementClient = this.getClient(targetSettlement.chainId); const settlementContract = getContract({ address: targetSettlement.address, abi: SETTLEMENT_ABI, client: settlementClient, }); const domainTuple = await settlementContract.read.eip712Domain({ blockTag }); const [fields, name, version, chainId, verifyingContract, salt, extensions] = domainTuple; const eip712Data = { fields: fields, name, version, chainId, verifyingContract, salt, extensions: [...extensions], }; return { address: networkAddress, subnetwork: subnetwork, eip712Data, }; } /** * Build key/value style extraData entries like relay Aggregator.GenerateExtraData * - simple: keccak(ValidatorsData) and compressed aggregated G1 key * - zk: totalActiveValidators and MiMC-based validators hash * Note: This is a lightweight TS analog without bn254 ops; it uses available data. */ async getAggregatorsExtraData(mode, keyTags, finalized = true, epoch) { const vset = await this.getValidatorSet(epoch, finalized); // If keyTags not provided, use requiredKeyTags from network config const config = await this.getNetworkConfig(vset.epoch, finalized); const tags = keyTags && keyTags.length > 0 ? keyTags : config.requiredKeyTags; return this.loadAggregatorsExtraData({ mode, tags, validatorSet: vset, finalized, }); } async getEpochData(options) { await this.ensureInitialized(); const finalized = options?.finalized ?? true; const { targetEpoch } = await this.resolveEpoch(options?.epoch, finalized); const { config, epochStart } = await this.loadNetworkConfigData({ targetEpoch, finalized }); const useCache = finalized; const cacheKey = 'valset'; let validatorSet = null; if (useCache) { const cached = await this.getFromCache('valset', targetEpoch, cacheKey); if (cached && this.isValidatorSet(cached) && this.canCacheValidatorSet(cached)) { validatorSet = cached; } else if (cached) { await this.deleteFromCache('valset', targetEpoch, cacheKey); } } if (!validatorSet) { validatorSet = await this.buildValidatorSet({ targetEpoch, finalized, epochStart, config, useCache, }); } let settlementStatuses = []; let valsetStatusData = null; if (config.settlements.length > 0) { valsetStatusData = await this.updateValidatorSetStatus(validatorSet, config.settlements, targetEpoch, finalized); settlementStatuses = valsetStatusData.settlements; if (useCache && this.canCacheValSetStatus(valsetStatusData)) { await this.setToCache('valset', targetEpoch, cacheKey, validatorSet); } } const includeNetworkData = options?.includeNetworkData ?? false; const includeValSetEvent = options?.includeValSetEvent ?? false; const settlement = options?.settlement ?? (config.settlements.length > 0 ? config.settlements[0] : undefined); let networkData; if (includeNetworkData) { networkData = await this.getNetworkData(settlement, finalized); } const tags = options?.aggregatorKeyTags && options.aggregatorKeyTags.length > 0 ? options.aggregatorKeyTags : config.requiredKeyTags; const verificationMode = this.getVerificationMode(config); const aggregatorsExtraData = await this.loadAggregatorsExtraData({ mode: verificationMode, tags, validatorSet, finalized, }); if (finalized && this.canCacheValidatorSet(validatorSet)) { const requiredTagsSorted = [...config.requiredKeyTags].sort((a, b) => a - b); const oppositeMode = verificationMode === AGGREGATOR_MODE.SIMPLE ? AGGREGATOR_MODE.ZK : AGGREGATOR_MODE.SIMPLE; if (oppositeMode !== verificationMode) { await this.loadAggregatorsExtraData({ mode: oppositeMode, tags: requiredTagsSorted, validatorSet, finalized, }); } } let valSetEvents; if (includeValSetEvent) { if (settlementStatuses.length === 0) { valSetEvents = []; } else { const events = []; for (const detail of settlementStatuses) { let event = null; if (detail.committed) { event = await this.retrieveValSetEvent({ epoch: targetEpoch, settlement: detail.settlement, finalized, mode: verificationMode, }, { overall: valsetStatusData, detail }); } events.push({ settlement: detail.settlement, committed: detail.committed, event, }); } valSetEvents = events; } } return { epoch: targetEpoch, finalized, epochStart, config, validatorSet, networkData, settlementStatuses, valSetEvents, aggregatorsExtraData, }; } /** * Static factory method that ensures the deriver is fully initialized before returning */ static async create(config) { const deriver = new ValidatorSetDeriver(config); await deriver.ensureInitialized(); // Validate all required chains are available await deriver.validateRequiredChains(); return deriver; } async initializeClients() { const initPromises = this.rpcUrls.map(async (url) => { const client = createPublicClient({ transport: http(url), }); const chainId = await client.getChainId(); this.clients.set(chainId, client); return chainId; }); await Promise.all(initPromises); if (!this.clients.has(this.driverAddress.chainId)) { throw new Error(`Driver chain ID ${this.driverAddress.chainId} not found in provided RPC URLs`); } } async validateRequiredChains() { try { const currentEpoch = await this.getCurrentEpoch(true); const config = await this.getNetworkConfig(currentEpoch, true); const requiredChainIds = new Set(); requiredChainIds.add(this.driverAddress.chainId); config.votingPowerProviders.forEach((p) => requiredChainIds.add(Number(p.chainId))); requiredChainIds.add(Number(config.keysProvider.chainId)); config.settlements.forEach((s) => requiredChainIds.add(Number(s.chainId))); const missingChains = []; for (const chainId of requiredChainIds) { if (!this.clients.has(chainId)) { missingChains.push(chainId); } } if (missingChains.length > 0) { throw new Error(`Missing RPC clients for required chains: ${missingChains.join(', ')}. ` + `Please ensure RPC URLs are provided for all chains used by voting power providers, keys provider, and settlements.`); } } catch (error) { if (error instanceof Error && error.message.includes('Missing RPC clients')) { throw error; // Re-throw our validation error } // For other errors (like contract not available), just warn console.warn('Warning: Could not validate required chains. This might be expected for test environments.'); console.warn('Error:', error instanceof Error ? error.message : String(error)); } } async resolveEpoch(epoch, finalized) { const currentEpoch = await this.getCurrentEpoch(finalized); const targetEpoch = epoch ?? currentEpoch; if (targetEpoch > currentEpoch) { throw new Error(`Requested epoch ${targetEpoch} is not yet available on-chain (latest is ${currentEpoch}).`); } return { currentEpoch, targetEpoch }; } getVerificationMode(config) { return config.verificationType === 0 ? AGGREGATOR_MODE.ZK : AGGREGATOR_MODE.SIMPLE; } isValSetStatus(value) { if (!value || typeof value !== 'object') return false; const candidate = value; return ((candidate.status === 'committed' || candidate.status === 'pending' || candidate.status === 'missing') && (candidate.integrity === 'valid' || candidate.integrity === 'invalid') && Array.isArray(candidate.settlements)); } isValidatorSet(value) { if (!value || typeof value !== 'object' || value === null) return false; const candidate = value; return (typeof candidate.epoch === 'number' && typeof candidate.status === 'string' && Array.isArray(candidate.validators)); } canCacheValSetStatus(status) { return status.status === 'committed'; } canCacheValidatorSet(validatorSet) { return validatorSet.status === 'committed' && validatorSet.integrity === 'valid'; } isAggregatorExtraCacheEntry(value) { if (!value || typeof value !== 'object') return false; const candidate = value; return typeof candidate.hash === 'string' && Array.isArray(candidate.data); } async updateValidatorSetStatus(validatorSet, settlements, epoch, finalized) { const statusInfo = await this.loadValSetStatus({ settlements, epoch, finalized, }); validatorSet.status = statusInfo.status; validatorSet.integrity = statusInfo.integrity; if (statusInfo.integrity === 'invalid') { throw new Error(`Settlement integrity check failed for epoch ${epoch}. ` + `Header hashes do not match across settlements, indicating a critical issue with the validator set.`); } return statusInfo; } isCachedNetworkConfigEntry(value) { if (!value || typeof value !== 'object' || value === null) return false; const entry = value; return (typeof entry.epochStart === 'number' && entry.config !== undefined && typeof entry.config.requiredHeaderKeyTag === 'number'); } isNetworkConfigStructure(value) { if (!value || typeof value !== 'object' || value === null) return false; const candidate = value; return (typeof candidate.keysProvider === 'object' && candidate.keysProvider !== null && Array.isArray(candidate.settlements)); } mapDriverConfig(config) { return { votingPowerProviders: config.votingPowerProviders.map((p) => ({ chainId: Number(p.chainId), address: p.addr, })), keysProvider: { chainId: Number(config.keysProvider.chainId), address: config.keysProvider.addr, }, settlements: config.settlements.map((s) => ({ chainId: Number(s.chainId), address: s.addr, })), verificationType: Number(config.verificationType), maxVotingPower: config.maxVotingPower, minInclusionVotingPower: config.minInclusionVotingPower, maxValidatorsCount: config.maxValidatorsCount, requiredKeyTags: config.requiredKeyTags.map(Number), requiredHeaderKeyTag: Number(config.requiredHeaderKeyTag), quorumThresholds: config.quorumThresholds.map((q) => ({ keyTag: Number(q.keyTag), quorumThreshold: q.quorumThreshold, })), numCommitters: Number(config.numCommitters), numAggregators: Number(config.numAggregators), }; } async loadNetworkConfigData(params) { const { targetEpoch, finalized } = params; const useCache = finalized; const cacheKey = 'config'; if (useCache) { const cached = await this.getFromCache('config', targetEpoch, cacheKey); if (cached) { if (this.isCachedNetworkConfigEntry(cached)) { return cached; } if (this.isNetworkConfigStructure(cached)) { const epochStart = await this.getEpochStart(targetEpoch, finalized); const entry = { config: cached, epochStart, }; await this.setToCache('config', targetEpoch, cacheKey, entry); return entry; } } } const blockTag = blockTagFromFinality(finalized); const epochStart = await this.getEpochStart(targetEpoch, finalized); const driver = this.getDriverContract(); const rawConfig = await driver.read.getConfigAt([Number(epochStart)], { blockTag, }); const config = this.mapDriverConfig(rawConfig); const entry = { config, epochStart, }; if (useCache) { await this.setToCache('config', targetEpoch, cacheKey, entry); } return entry; } settlementCacheKey(settlement) { return `${settlement.chainId}_${settlement.address.toLowerCase()}`; } settlementsCacheKey(settlements) { return settlements .map((s) => this.settlementCacheKey(s)) .sort() .join('|'); } async loadValSetStatus(params) { const { settlements, epoch, finalized } = params; const useCache = finalized; const key = this.settlementsCacheKey(settlements); if (useCache) { const cached = await this.getFromCache('valset_status', epoch, key); if (cached && this.isValSetStatus(cached) && this.canCacheValSetStatus(cached)) { return cached; } if (cached) { await this.deleteFromCache('valset_status', epoch, key); } } const status = await determineValSetStatus((chainId) => this.getClient(chainId), settlements, epoch, finalized); if (useCache && this.canCacheValSetStatus(status)) { await this.setToCache('valset_status', epoch, key, status); } return status; } async loadAggregatorsExtraData(params) { const { mode, tags, validatorSet, finalized } = params; const sortedTags = [...tags].sort((a, b) => a - b); const cacheKey = `${mode}_${sortedTags.join(',')}`; const useCache = finalized; const cacheable = useCache && this.canCacheValidatorSet(validatorSet); const validatorSetHash = cacheable ? this.getValidatorSetHeaderHash(validatorSet) : null; if (cacheable && validatorSetHash) { const cached = await this.getFromCache('aggregator_extra', validatorSet.epoch, cacheKey); if (cached && this.isAggregatorExtraCacheEntry(cached) && cached.hash === validatorSetHash) { return cached.data; } if (cached) { await this.deleteFromCache('aggregator_extra', validatorSet.epoch, cacheKey); } } const result = mode === AGGREGATOR_MODE.SIMPLE ? buildSimpleExtraData(validatorSet, sortedTags) : await buildZkExtraData(validatorSet, sortedTags); if (cacheable && validatorSetHash) { await this.setToCache('aggregator_extra', validatorSet.epoch, cacheKey, { hash: validatorSetHash, data: result, }); } return result; } async buildValidatorSet(params) { const { targetEpoch, finalized, epochStart, config, useCache } = params; const cacheKey = 'valset'; const timestampNumber = Number(epochStart); const allVotingPowers = []; for (const provider of config.votingPowerProviders) { const votingPowers = await this.getVotingPowers(provider, timestampNumber, finalized); allVotingPowers.push({ chainId: provider.chainId, votingPowers, }); } const keys = await this.getKeys(config.keysProvider, timestampNumber, finalized); const validators = composeValidators(config, allVotingPowers, keys); await this.populateVaultCollateralMetadata(validators, finalized); const totalVotingPower = validators .filter((validator) => validator.isActive) .reduce((sum, validator) => sum + validator.votingPower, 0n); const quorumThreshold = calculateQuorumThreshold(config, totalVotingPower); const sortedRequiredTags = [...config.requiredKeyTags].sort((a, b) => a - b); const baseValset = { version: VALSET_VERSION, requiredKeyTag: config.requiredHeaderKeyTag, epoch: targetEpoch, captureTimestamp: timestampNumber, quorumThreshold, validators, totalVotingPower, status: 'pending', integrity: 'valid', extraData: [], }; const simpleExtra = buildSimpleExtraData(baseValset, sortedRequiredTags); const zkExtra = await buildZkExtraData(baseValset, sortedRequiredTags); const combinedEntries = [...simpleExtra, ...zkExtra]; const deduped = new Map(); for (const entry of combinedEntries) { deduped.set(entry.key.toLowerCase(), entry); } const extraData = Array.from(deduped.values()).sort((left, right) => left.key.toLowerCase().localeCompare(right.key.toLowerCase())); const result = { ...baseValset, extraData, }; if (useCache) { await this.setToCache('valset', targetEpoch, cacheKey, result); } return result; } async ensureInitialized() { await this.initializedPromise; } getClient(chainId) { const client = this.clients.get(chainId); if (!client) { throw new Error(`No client for chain ID ${chainId}`); } return client; } async multicallExists(chainId, blockTag) { const client = this.getClient(chainId); const tagsToTry = blockTag === 'finalized' ? [blockTag, 'latest'] : [blockTag]; for (const tag of tagsToTry) { try { const bytecode = await client.getBytecode({ address: MULTICALL3_ADDRESS, blockTag: tag, }); if (bytecode && bytecode !== '0x') { return true; } if (bytecode !== null) { return false; } } catch { // If the network does not support the requested finality, try the next tag. continue; } } return false; } async executeChunkedMulticall({ client, requests, blockTag, allowFailure = false, }) { if (requests.length === 0) return []; const chunks = []; let currentChunk = []; let currentGas = 0n; for (const request of requests) { const gasEstimate = request.estimatedGas ?? 0n; if (currentChunk.length > 0 && currentGas + gasEstimate > MULTICALL_TARGET_GAS) { chunks.push(currentChunk); currentChunk = []; currentGas = 0n; } currentChunk.push(request); currentGas += gasEstimate; } if (currentChunk.length > 0) { chunks.push(currentChunk); } const results = []; for (const chunk of chunks) { const rawResult = await client.multicall({ allowFailure, blockTag, multicallAddress: MULTICALL3_ADDRESS, contracts: chunk.map((item) => ({ address: item.address, abi: item.abi, functionName: item.functionName, args: item.args, })), }); if (allowFailure) { const chunkResult = rawResult.map((entry) => (entry.status === 'success' ? entry.result : null)); results.push(...chunkResult); } else { const chunkResult = rawResult; results.push(...chunkResult); } } return results; } async populateVaultCollateralMetadata(validators, finalized) { const vaultsByChain = new Map(); for (const validator of validators) { for (const vault of validator.vaults) { let list = vaultsByChain.get(vault.chainId); if (!list) { list = []; vaultsByChain.set(vault.chainId, list); } list.push(vault.vault); } } if (vaultsByChain.size === 0) return; const blockTag = blockTagFromFinality(finalized); const ZERO_ADDRESS = '0x0000000000000000000000000000000000000000'; const vaultCollateralMap = new Map(); const tokensByChain = new Map(); const chainMulticallSupport = new Map(); for (const [chainId, vaultAddresses] of vaultsByChain) { const client = this.getClient(chainId); const uniqueVaults = this.dedupeAddresses(vaultAddresses); if (uniqueVaults.length === 0) continue; const hasMulticall = await this.multicallExists(chainId, blockTag); chainMulticallSupport.set(chainId, hasMulticall); let tokensForChain = tokensByChain.get(chainId); if (!tokensForChain) { tokensForChain = new Map(); tokensByChain.set(chainId, tokensForChain); } let collateralResults = []; if (hasMulticall) { collateralResults = await this.executeChunkedMulticall({ client, requests: uniqueVaults.map((address) => ({ address, abi: VAULT_ABI, functionName: 'collateral', args: [], estimatedGas: MULTICALL_VAULT_COLLATERAL_CALL_GAS, })), blockTag, allowFailure: true, }); } if (collateralResults.length === 0) { collateralResults = Array(uniqueVaults.length).fill(null); } for (let i = 0; i < uniqueVaults.length; i++) { if (!collateralResults[i]) { try { const fallback = (await client.readContract({ address: uniqueVaults[i], abi: VAULT_ABI, functionName: 'collateral', args: [], blockTag, })); collateralResults[i] = fallback; } catch { collateralResults[i] = null; } } const collateral = collateralResults[i]; if (!collateral || collateral.toLowerCase() === ZERO_ADDRESS) continue; vaultCollateralMap.set(`${chainId}_${uniqueVaults[i].toLowerCase()}`, collateral); tokensForChain.set(collateral.toLowerCase(), collateral); } } if (vaultCollateralMap.size === 0) return; const metadataMap = new Map(); for (const [chainId, tokenMap] of tokensByChain) { const client = this.getClient(chainId); const tokens = Array.from(tokenMap.values()); if (tokens.length === 0) continue; const hasMulticall = chainMulticallSupport.get(chainId) ?? false; const metadataCalls = []; const requests = []; for (const token of tokens) { metadataCalls.push({ token, field: 'symbol' }); requests.push({ address: token, abi: ERC20_METADATA_ABI, functionName: 'symbol', args: [], estimatedGas: MULTICALL_ERC20_METADATA_CALL_GAS, }); metadataCalls.push({ token, field: 'name' }); requests.push({ address: token, abi: ERC20_METADATA_ABI, functionName: 'name', args: [], estimatedGas: MULTICALL_ERC20_METADATA_CALL_GAS, }); } let metadataResults; if (hasMulticall && requests.length > 0) { metadataResults = await this.executeChunkedMulticall({ client, requests, blockTag, allowFailure: true, }); } else { metadataResults = Array(requests.length).fill(null); } const fallbackRequests = new Map(); for (let i = 0; i < metadataCalls.length; i++) { const call = metadataCalls[i]; const rawValue = metadataResults[i]; const normalized = this.normalizeTokenMetadataValue(rawValue); if (normalized !== null) { const key = `${chainId}_${call.token.toLowerCase()}`; const existing = metadataMap.get(key) ?? {}; if (call.field === 'symbol') { existing.symbol = normalized; } else { existing.name = normalized; } metadataMap.set(key, existing); } else { const current = fallbackRequests.get(call.token) ?? new Set(); current.add(call.field); fallbackRequests.set(call.token, current); } } if (fallbackRequests.size > 0) { for (const [token, fields] of fallbackRequests) { for (const field of fields) { const fallbackValue = await this.readTokenMetadataField(client, token, field, blockTag); if (!fallbackValue) continue; const key = `${chainId}_${token.toLowerCase()}`; const existing = metadataMap.get(key) ?? {}; if (field === 'symbol') { existing.symbol = fallbackValue; } else { existing.name = fallbackValue; } metadataMap.set(key, existing); } } } } for (const validator of validators) { for (const vault of validator.vaults) { const vaultKey = `${vault.chainId}_${vault.vault.toLowerCase()}`; const collateral = vaultCollateralMap.get(vaultKey); if (!collateral) continue; vault.collateral = collateral; const metadataKey = `${vault.chainId}_${collateral.toLowerCase()}`; const metadata = metadataMap.get(metadataKey); if (metadata?.symbol) { vault.collateralSymbol = metadata.symbol; } if (metadata?.name) { vault.collateralName = metadata.name; } } } } dedupeAddresses(addresses) { const seen = new Set(); const unique = []; for (const address of addresses) { const lower = address.toLowerCase(); if (seen.has(lower)) continue; seen.add(lower); unique.push(address); } return unique; } normalizeTokenMetadataValue(value) { if (typeof value !== 'string' || value.length === 0) { return null; } if (isHex(value)) { if (value === '0x') { return null; } try { const size = value.length === 66 ? 32 : undefined; const decoded = hexToString(value, size ? { size } : undefined); const trimmed = decoded.replace(/\u0000+$/g, ''); return trimmed.length > 0 ? trimmed : null; } catch { return null; } } return value; } async readTokenMetadataField(client, token, field, blockTag) { try { const result = await client.readContract({ address: token, abi: ERC20_METADATA_ABI, functionName: field, args: [], blockTag, }); const normalized = this.normalizeTokenMetadataValue(result); if (normalized !== null) { return normalized; } } catch { // Ignore read errors; fall through to null } return null; } getDriverContract() { const client = this.getClient(this.driverAddress.chainId); return getContract({ address: this.driverAddress.address, abi: VALSET_DRIVER_ABI, client, }); } async readDriverNumber(method, finalized, args) { await this.ensureInitialized(); const driver = this.getDriverContract(); const blockTag = blockTagFromFinality(finalized); const reader = driver.read[method]; const result = args && args.length > 0 ? await reader(args, { blockTag }) : await reader({ blockTag }); return Number(result); } // withPreferredBlockTag removed; use explicit { blockTag: blockTagFromFinality(...) } async getFromCache(namespace, epoch, key) { if (!this.cache) return null; try { return await this.cache.get(epoch, key); } catch { return null; } } async setToCache(namespace, epoch, key, value) { if (!this.cache) return; try { await this.cache.set(epoch, key, value); } catch { // Ignore cache errors } } async deleteFromCache(namespace, epoch, key) { if (!this.cache) return; try { await this.cache.delete(epoch, key); } catch { // Ignore cache errors } } async getCurrentEpoch(finalized = true) { return this.readDriverNumber('getCurrentEpoch', finalized); } async getCurrentEpochDuration(finalized = true) { return this.readDriverNumber('getCurrentEpochDuration', finalized); } async getCurrentEpochStart(finalized = true) { return this.readDriverNumber('getCurrentEpochStart', finalized); } async getNextEpoch(finalized = true) { return this.readDriverNumber('getNextEpoch', finalized); } async getNextEpochDuration(finalized = true) { return this.readDriverNumber('getNextEpochDuration', finalized); } async getNextEpochStart(finalized = true) { return this.readDriverNumber('getNextEpochStart', finalized); } async getEpochStart(epoch, finalized = true) { return this.readDriverNumber('getEpochStart', finalized, [Number(epoch)]); } async getEpochDuration(epoch, finalized = true) { return this.readDriverNumber('getEpochDuration', finalized, [Number(epoch)]); } async getEpochIndex(timestamp, finalized = true) { return this.readDriverNumber('getEpochIndex', finalized, [Number(timestamp)]); } async getNetworkConfig(epoch, finalized = true) { await this.ensureInitialized(); const { targetEpoch } = await this.resolveEpoch(epoch, finalized); const { config } = await this.loadNetworkConfigData({ targetEpoch, finalized }); return config; } async getValidatorSet(epoch, finalized = true) { await this.ensureInitialized(); const { targetEpoch } = await this.resolveEpoch(epoch, finalized); const useCache2 = finalized; const cacheKey = 'valset'; let validatorSet = null; if (useCache2) { const cached = await this.getFromCache('valset', targetEpoch, cacheKey); if (cached && this.isValidatorSet(cached)) { validatorSet = cached; } else if (cached) { await this.deleteFromCache('valset', targetEpoch, cacheKey); } } const { config, epochStart } = await this.loadNetworkConfigData({ targetEpoch, finalized }); if (!validatorSet) { validatorSet = await this.buildValidatorSet({ targetEpoch, finalized, epochStart, config, useCache: useCache2, }); } let statusInfo = null; if (config.settlements.length > 0) { statusInfo = await this.updateValidatorSetStatus(validatorSet, config.settlements, targetEpoch, finalized); if (useCache2 && this.canCacheValSetStatus(statusInfo)) { await this.setToCache('valset', targetEpoch, cacheKey, validatorSet); } } return validatorSet; } /** * Get the current validator set (simplified interface) */ async getCurrentValidatorSet() { return this.getValidatorSet(undefined, true); } /** * Get the current network configuration (simplified interface) */ async getCurrentNetworkConfig() { return this.getNetworkConfig(undefined, true); } getTotalActiveVotingPower(validatorSet) { return totalActiveVotingPower(validatorSet); } getValidatorSetHeader(validatorSet) { return createValidatorSetHeader(validatorSet); } abiEncodeValidatorSetHeader(header) { return encodeValidatorSetHeader(header); } hashValidatorSetHeader(header) { return hashValidatorSetHeader(header); } getValidatorSetHeaderHash(validatorSet) { return hashValidatorSet(validatorSet); } async getVotingPowers(provider, timestamp, preferFinalized) { const client = this.getClient(provider.chainId); const blockTag = blockTagFromFinality(preferFinalized); const timestampBigInt = BigInt(timestamp); const timestampNumber = Number(timestamp); const providerContract = getContract({ address: provider.address, abi: VOTING_POWER_PROVIDER_ABI, client, }); if (await this.multicallExists(provider.chainId, blockTag)) { const operators = await providerContract.read.getOperatorsAt([timestampNumber], { blockTag, }); if (operators.length === 0) return []; const operatorList = Array.from(operators, (operator) => operator); const results = await this.executeChunkedMulticall({ client, requests: operatorList.map((operator) => ({ address: provider.address, abi: VOTING_POWER_PROVIDER_ABI, functionName: 'getOperatorVotingPowersAt', args: [operator, '0x', timestampBigInt], estimatedGas: MULTICALL_VOTING_CALL_GAS, })), blockTag, }); if (results.length !== operatorList.length) { throw new Error(`Multicall result length mismatch for voting powers: expected ${operatorList.length}, got ${results.length}`); } return operatorList.map((operator, index) => { const vaults = (results[index] ?? []); return { operator, vaults: Array.from(vaults, (v) => ({ vault: v.vault, votingPower: v.value, })), }; }); } const votingPowers = await providerContract.read.getVotingPowersAt([[], timestampNumber], { blockTag, }); return votingPowers.map((vp) => ({ operator: vp.operator, vaults: vp.vaults.map((v) => ({ vault: v.vault, votingPower: v.value, })), })); } async getKeys(provider, timestamp, preferFinalized) { const client = this.getClient(provider.chainId); const blockTag = blockTagFromFinality(preferFinalized); const timestampNumber = Number(timestamp); const keyRegistry = getContract({ address: provider.address, abi: KEY_REGISTRY_ABI, client, }); if (await this.multicallExists(provider.chainId, blockTag)) { const operators = await keyRegistry.read.getKeysOperatorsAt([timestampNumber], { blockTag, }); if (operators.length === 0) return []; const operatorList = Array.from(operators, (operator) => operator); const results = await this.executeChunkedMulticall({ client, requests: operatorList.map((operator) => ({ address: provider.address, abi: KEY_REGISTRY_ABI, functionName: 'getKeysAt', args: [operator, timestampNumber], estimatedGas: MULTICALL_KEYS_CALL_GAS, })), blockTag, }); if (results.length !== operatorList.length) { throw new Error(`Multicall result length mismatch for keys: expected ${operatorList.length}, got ${results.length}`); } return operatorList.map((operator, index) => { const operatorKeys = (results[index] ?? []); return { operator, keys: Array.from(operatorKeys, (key) => ({ tag: typeof key.tag === 'bigint' ? Number(key.tag) : key.tag, payload: key.payload, })), }; }); } const keys = (await client.readContract({ address: provider.address, abi: KEY_REGISTRY_ABI, functionName: 'getKeysAt', args: [timestampNumber], blockTag, })); return keys.map((k) => ({ operator: k.operator, keys: Array.from(k.keys, (key) => ({ tag: typeof key.tag === 'bigint' ? Number(key.tag) : key.tag, payload: key.payload, })), })); } async getValsetStatus(settlements, epoch, preferFinalized) { return this.loadValSetStatus({ settlements, epoch, finalized: preferFinalized, }); } getOrCreateValSetEventsState(blockTag, settlement) { return getOrCreateValSetEventsState(this.valsetEventsState, blockTag, settlement); } async selectSettlementForEvent(epoch, finalized, settlement) { if (settlement) return settlement; const config = await this.getNetworkConfig(epoch, finalized); return selectDefaultSettlement(config.settlements); } async getValSetStatus(epoch, finalized = true) { await this.ensureInitialized(); const config = await this.getNetworkConfig(epoch, finalized); return this.getValsetStatus(config.settlements, epoch, finalized); } async getValSetSettlementStatuses(options) { await this.ensureInitialized(); const finalized = options?.finalized ?? true; const { targetEpoch } = await this.resolveEpoch(options?.epoch, finalized); let settlements = options?.settlements; if (!settlements) { const config = await this.getNetworkConfig(targetEpoch, finalized); settlements = config.settlements;