@symbioticfi/relay-stats-ts
Version:
TypeScript library for deriving validator sets from Symbiotic network contracts
1,601 lines (1,400 loc) • 55.2 kB
text/typescript
/* eslint-disable @typescript-eslint/no-explicit-any */
import {
createPublicClient,
http,
PublicClient,
Address,
Hex,
getContract,
hexToString,
isHex,
} from 'viem';
import type { Abi } from 'viem';
import {
CrossChainAddress,
NetworkConfig,
ValidatorSet,
Validator,
OperatorVotingPower,
OperatorWithKeys,
CacheInterface,
ValidatorSetHeader,
NetworkData,
Eip712Domain,
AggregatorExtraDataEntry,
EpochData,
ValSetStatus,
ValSetLogEvent,
SettlementValSetStatus,
SettlementValSetLog,
} from './types.js';
// bytesToHex retained in public API exports; not used internally here
import { buildSimpleExtraData, buildZkExtraData } from './extra_data.js';
import {
VALSET_VERSION,
AGGREGATOR_MODE,
AggregatorMode,
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, type BlockTagPreference } from './utils.js';
import {
calculateQuorumThreshold,
composeValidators,
createValidatorSetHeader,
encodeValidatorSetHeader,
hashValidatorSet,
hashValidatorSetHeader,
totalActiveVotingPower,
} from './validator_set.js';
import {
selectDefaultSettlement,
getOrCreateValSetEventsState,
ValSetEventsState,
retrieveValSetEvent as fetchValSetLogEvent,
} from './valset_events.js';
const EPOCH_EVENT_BLOCK_BUFFER = 16n;
const getSettlementContract = (client: PublicClient, settlement: CrossChainAddress) =>
getContract({
address: settlement.address,
abi: SETTLEMENT_ABI,
client,
});
type MulticallSettlementStatus = {
isCommitted: boolean;
headerHash: Hex | null;
lastCommittedEpoch: bigint;
};
type MulticallRequest = {
address: Address;
abi: Abi;
functionName: string;
args: readonly unknown[];
estimatedGas?: bigint;
};
const tryFetchSettlementStatusViaMulticall = async (
client: PublicClient,
settlement: CrossChainAddress,
epochNumber: number,
blockTag: BlockTagPreference,
): Promise<MulticallSettlementStatus | null> => {
const tagsToTry: BlockTagPreference[] =
blockTag === 'finalized' ? [blockTag, 'latest'] : [blockTag];
for (const tag of tagsToTry) {
try {
const results = (await client.multicall({
allowFailure: false,
blockTag: tag,
multicallAddress: MULTICALL3_ADDRESS as Address,
contracts: [
{
address: settlement.address,
abi: SETTLEMENT_ABI,
functionName: 'isValSetHeaderCommittedAt',
args: [epochNumber] as const,
},
{
address: settlement.address,
abi: SETTLEMENT_ABI,
functionName: 'getValSetHeaderHashAt',
args: [epochNumber] as const,
},
{
address: settlement.address,
abi: SETTLEMENT_ABI,
functionName: 'getLastCommittedHeaderEpoch',
},
],
})) as readonly unknown[];
const [isCommittedRaw, headerHashRaw, lastCommittedEpochRaw] = results;
let headerHash: Hex | null = null;
if (typeof headerHashRaw === 'string') {
headerHash = headerHashRaw as Hex;
}
let lastCommittedEpoch: bigint = 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: (chainId: number) => PublicClient,
settlements: readonly CrossChainAddress[],
epoch: number,
preferFinalized: boolean,
): Promise<ValSetStatus> => {
const hashes: Map<string, string> = new Map();
let allCommitted = true;
let lastCommitted: number = Number.MAX_SAFE_INTEGER;
const details: SettlementValSetStatus[] = [];
const blockTag = blockTagFromFinality(preferFinalized);
const epochNumber = Number(epoch);
for (const settlement of settlements) {
const client = clientFactory(settlement.chainId);
const detail: SettlementValSetStatus = {
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,
})) as Hex;
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: ValSetStatus['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: ValSetStatus['integrity'] = uniqueHashes.size <= 1 ? 'valid' : 'invalid';
return { status, integrity, settlements: details };
};
type DriverReadMethod =
| 'getCurrentEpoch'
| 'getCurrentEpochDuration'
| 'getCurrentEpochStart'
| 'getNextEpoch'
| 'getNextEpochDuration'
| 'getNextEpochStart'
| 'getEpochStart'
| 'getEpochDuration'
| 'getEpochIndex';
type DriverReadArgsMap = {
getCurrentEpoch: [];
getCurrentEpochDuration: [];
getCurrentEpochStart: [];
getNextEpoch: [];
getNextEpochDuration: [];
getNextEpochStart: [];
getEpochStart: [number];
getEpochDuration: [number];
getEpochIndex: [number];
};
type CachedNetworkConfigEntry = {
config: NetworkConfig;
epochStart: number;
};
export interface ValidatorSetDeriverConfig {
rpcUrls: string[];
driverAddress: CrossChainAddress;
cache?: CacheInterface | null;
maxSavedEpochs?: number;
}
export class ValidatorSetDeriver {
private readonly clients = new Map<number, PublicClient>();
private readonly driverAddress: CrossChainAddress;
private readonly cache: CacheInterface | null;
private readonly maxSavedEpochs: number;
private readonly initializedPromise: Promise<void>;
private readonly rpcUrls: readonly string[];
private readonly valsetEventsState = new Map<string, ValSetEventsState>();
constructor(config: ValidatorSetDeriverConfig) {
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?: CrossChainAddress,
finalized: boolean = true,
): Promise<NetworkData> {
await this.ensureInitialized();
const blockTag = blockTagFromFinality(finalized);
let networkAddress: Address;
let subnetwork: Hex;
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 as 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] as Address;
subnetwork = results[1] as Hex;
} else {
const driver = this.getDriverContract();
const values = await Promise.all([
driver.read.NETWORK({ blockTag }),
driver.read.SUBNETWORK({ blockTag }),
]);
networkAddress = values[0] as Address;
subnetwork = values[1] as Hex;
}
// Resolve settlement: use provided or first from config
let targetSettlement: CrossChainAddress | undefined = 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 as readonly [Hex, string, string, bigint, Address, Hex, readonly bigint[]];
const eip712Data: Eip712Domain = {
fields: fields as string,
name,
version,
chainId,
verifyingContract,
salt,
extensions: [...extensions],
};
return {
address: networkAddress as Address,
subnetwork: subnetwork as Hex,
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.
*/
public async getAggregatorsExtraData(
mode: AggregatorMode,
keyTags?: number[],
finalized: boolean = true,
epoch?: number,
): Promise<AggregatorExtraDataEntry[]> {
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,
});
}
public async getEpochData(options?: {
epoch?: number;
finalized?: boolean;
includeNetworkData?: boolean;
includeValSetEvent?: boolean;
settlement?: CrossChainAddress;
aggregatorKeyTags?: number[];
}): Promise<EpochData> {
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: ValidatorSet | null = 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: SettlementValSetStatus[] = [];
let valsetStatusData: ValSetStatus | null = 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: NetworkData | undefined;
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: SettlementValSetLog[] | undefined;
if (includeValSetEvent) {
if (settlementStatuses.length === 0) {
valSetEvents = [];
} else {
const events: SettlementValSetLog[] = [];
for (const detail of settlementStatuses) {
let event: ValSetLogEvent | null = 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: ValidatorSetDeriverConfig): Promise<ValidatorSetDeriver> {
const deriver = new ValidatorSetDeriver(config);
await deriver.ensureInitialized();
// Validate all required chains are available
await deriver.validateRequiredChains();
return deriver;
}
private async initializeClients(): Promise<void> {
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`,
);
}
}
private async validateRequiredChains(): Promise<void> {
try {
const currentEpoch = await this.getCurrentEpoch(true);
const config = await this.getNetworkConfig(currentEpoch, true);
const requiredChainIds = new Set<number>();
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: number[] = [];
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));
}
}
private async resolveEpoch(
epoch: number | undefined,
finalized: boolean,
): Promise<{ currentEpoch: number; targetEpoch: number }> {
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 };
}
private getVerificationMode(config: NetworkConfig): AggregatorMode {
return config.verificationType === 0 ? AGGREGATOR_MODE.ZK : AGGREGATOR_MODE.SIMPLE;
}
private isValSetStatus(value: unknown): value is ValSetStatus {
if (!value || typeof value !== 'object') return false;
const candidate = value as ValSetStatus;
return (
(candidate.status === 'committed' ||
candidate.status === 'pending' ||
candidate.status === 'missing') &&
(candidate.integrity === 'valid' || candidate.integrity === 'invalid') &&
Array.isArray(candidate.settlements)
);
}
private isValidatorSet(value: unknown): value is ValidatorSet {
if (!value || typeof value !== 'object' || value === null) return false;
const candidate = value as ValidatorSet;
return (
typeof candidate.epoch === 'number' &&
typeof candidate.status === 'string' &&
Array.isArray(candidate.validators)
);
}
private canCacheValSetStatus(status: ValSetStatus): boolean {
return status.status === 'committed';
}
private canCacheValidatorSet(validatorSet: ValidatorSet): boolean {
return validatorSet.status === 'committed' && validatorSet.integrity === 'valid';
}
private isAggregatorExtraCacheEntry(
value: unknown,
): value is { hash: Hex; data: AggregatorExtraDataEntry[] } {
if (!value || typeof value !== 'object') return false;
const candidate = value as { hash?: unknown; data?: unknown };
return typeof candidate.hash === 'string' && Array.isArray(candidate.data);
}
private async updateValidatorSetStatus(
validatorSet: ValidatorSet,
settlements: readonly CrossChainAddress[],
epoch: number,
finalized: boolean,
): Promise<ValSetStatus> {
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;
}
private isCachedNetworkConfigEntry(value: unknown): value is CachedNetworkConfigEntry {
if (!value || typeof value !== 'object' || value === null) return false;
const entry = value as CachedNetworkConfigEntry & { epochStart?: unknown };
return (
typeof entry.epochStart === 'number' &&
entry.config !== undefined &&
typeof (entry.config as NetworkConfig).requiredHeaderKeyTag === 'number'
);
}
private isNetworkConfigStructure(value: unknown): value is NetworkConfig {
if (!value || typeof value !== 'object' || value === null) return false;
const candidate = value as NetworkConfig & { keysProvider?: unknown; settlements?: unknown };
return (
typeof candidate.keysProvider === 'object' &&
candidate.keysProvider !== null &&
Array.isArray(candidate.settlements)
);
}
private mapDriverConfig(config: any): NetworkConfig {
return {
votingPowerProviders: config.votingPowerProviders.map((p: any) => ({
chainId: Number(p.chainId),
address: p.addr as Address,
})),
keysProvider: {
chainId: Number(config.keysProvider.chainId),
address: config.keysProvider.addr as Address,
},
settlements: config.settlements.map((s: any) => ({
chainId: Number(s.chainId),
address: s.addr as Address,
})),
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: any) => ({
keyTag: Number(q.keyTag),
quorumThreshold: q.quorumThreshold,
})),
numCommitters: Number(config.numCommitters),
numAggregators: Number(config.numAggregators),
};
}
private async loadNetworkConfigData(params: {
targetEpoch: number;
finalized: boolean;
}): Promise<CachedNetworkConfigEntry> {
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: CachedNetworkConfigEntry = {
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: CachedNetworkConfigEntry = {
config,
epochStart,
};
if (useCache) {
await this.setToCache('config', targetEpoch, cacheKey, entry);
}
return entry;
}
private settlementCacheKey(settlement: CrossChainAddress): string {
return `${settlement.chainId}_${settlement.address.toLowerCase()}`;
}
private settlementsCacheKey(settlements: readonly CrossChainAddress[]): string {
return settlements
.map((s) => this.settlementCacheKey(s))
.sort()
.join('|');
}
private async loadValSetStatus(params: {
settlements: readonly CrossChainAddress[];
epoch: number;
finalized: boolean;
}): Promise<ValSetStatus> {
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;
}
private async loadAggregatorsExtraData(params: {
mode: AggregatorMode;
tags: readonly number[];
validatorSet: ValidatorSet;
finalized: boolean;
}): Promise<AggregatorExtraDataEntry[]> {
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;
}
private async buildValidatorSet(params: {
targetEpoch: number;
finalized: boolean;
epochStart: number;
config: NetworkConfig;
useCache: boolean;
}): Promise<ValidatorSet> {
const { targetEpoch, finalized, epochStart, config, useCache } = params;
const cacheKey = 'valset';
const timestampNumber = Number(epochStart);
const allVotingPowers: { chainId: number; votingPowers: OperatorVotingPower[] }[] = [];
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: ValidatorSet = {
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<string, AggregatorExtraDataEntry>();
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: ValidatorSet = {
...baseValset,
extraData,
};
if (useCache) {
await this.setToCache('valset', targetEpoch, cacheKey, result);
}
return result;
}
private async ensureInitialized(): Promise<void> {
await this.initializedPromise;
}
private getClient(chainId: number): PublicClient {
const client = this.clients.get(chainId);
if (!client) {
throw new Error(`No client for chain ID ${chainId}`);
}
return client;
}
private async multicallExists(chainId: number, blockTag: BlockTagPreference): Promise<boolean> {
const client = this.getClient(chainId);
const tagsToTry: BlockTagPreference[] =
blockTag === 'finalized' ? [blockTag, 'latest'] : [blockTag];
for (const tag of tagsToTry) {
try {
const bytecode = await client.getBytecode({
address: MULTICALL3_ADDRESS as 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;
}
private async executeChunkedMulticall<T>({
client,
requests,
blockTag,
allowFailure = false,
}: {
client: PublicClient;
requests: readonly MulticallRequest[];
blockTag: BlockTagPreference;
allowFailure?: boolean;
}): Promise<T[]> {
if (requests.length === 0) return [];
const chunks: MulticallRequest[][] = [];
let currentChunk: MulticallRequest[] = [];
let currentGas: bigint = 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: T[] = [];
for (const chunk of chunks) {
const rawResult = await client.multicall({
allowFailure,
blockTag,
multicallAddress: MULTICALL3_ADDRESS as Address,
contracts: chunk.map((item) => ({
address: item.address,
abi: item.abi,
functionName: item.functionName as never,
args: item.args,
})),
});
if (allowFailure) {
const chunkResult = (
rawResult as readonly { status: 'success' | 'failure'; result: unknown }[]
).map((entry) => (entry.status === 'success' ? (entry.result as T) : (null as T)));
results.push(...chunkResult);
} else {
const chunkResult = rawResult as unknown as T[];
results.push(...chunkResult);
}
}
return results;
}
private async populateVaultCollateralMetadata(
validators: Validator[],
finalized: boolean,
): Promise<void> {
const vaultsByChain = new Map<number, Address[]>();
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<string, Address>();
const tokensByChain = new Map<number, Map<string, Address>>();
const chainMulticallSupport = new Map<number, boolean>();
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<string, Address>();
tokensByChain.set(chainId, tokensForChain);
}
let collateralResults: (Address | null)[] = [];
if (hasMulticall) {
collateralResults = await this.executeChunkedMulticall<Address | null>({
client,
requests: uniqueVaults.map((address) => ({
address,
abi: VAULT_ABI as 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,
})) as Address;
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 as Address,
);
tokensForChain.set(collateral.toLowerCase(), collateral as Address);
}
}
if (vaultCollateralMap.size === 0) return;
const metadataMap = new Map<string, { symbol?: string; name?: string }>();
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: { token: Address; field: 'symbol' | 'name' }[] = [];
const requests: MulticallRequest[] = [];
for (const token of tokens) {
metadataCalls.push({ token, field: 'symbol' });
requests.push({
address: token,
abi: ERC20_METADATA_ABI as Abi,
functionName: 'symbol',
args: [],
estimatedGas: MULTICALL_ERC20_METADATA_CALL_GAS,
});
metadataCalls.push({ token, field: 'name' });
requests.push({
address: token,
abi: ERC20_METADATA_ABI as Abi,
functionName: 'name',
args: [],
estimatedGas: MULTICALL_ERC20_METADATA_CALL_GAS,
});
}
let metadataResults: (string | null)[];
if (hasMulticall && requests.length > 0) {
metadataResults = await this.executeChunkedMulticall<string | null>({
client,
requests,
blockTag,
allowFailure: true,
});
} else {
metadataResults = Array(requests.length).fill(null);
}
const fallbackRequests = new Map<Address, Set<'symbol' | 'name'>>();
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) ?? ({} as { symbol?: string; name?: string });
if (call.field === 'symbol') {
existing.symbol = normalized;
} else {
existing.name = normalized;
}
metadataMap.set(key, existing);
} else {
const current = fallbackRequests.get(call.token) ?? new Set<'symbol' | 'name'>();
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) ?? ({} as { symbol?: string; name?: string });
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;
}
}
}
}
private dedupeAddresses(addresses: readonly Address[]): Address[] {
const seen = new Set<string>();
const unique: Address[] = [];
for (const address of addresses) {
const lower = address.toLowerCase();
if (seen.has(lower)) continue;
seen.add(lower);
unique.push(address);
}
return unique;
}
private normalizeTokenMetadataValue(value: unknown): string | null {
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 as Hex, size ? { size } : undefined);
const trimmed = decoded.replace(/\u0000+$/g, '');
return trimmed.length > 0 ? trimmed : null;
} catch {
return null;
}
}
return value;
}
private async readTokenMetadataField(
client: PublicClient,
token: Address,
field: 'symbol' | 'name',
blockTag: BlockTagPreference,
): Promise<string | null> {
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;
}
private getDriverContract() {
const client = this.getClient(this.driverAddress.chainId);
return getContract({
address: this.driverAddress.address,
abi: VALSET_DRIVER_ABI,
client,
});
}
private async readDriverNumber<M extends DriverReadMethod>(
method: M,
finalized: boolean,
args?: DriverReadArgsMap[M],
): Promise<number> {
await this.ensureInitialized();
const driver = this.getDriverContract();
const blockTag = blockTagFromFinality(finalized);
const reader = (
driver.read as unknown as Record<
DriverReadMethod,
(...params: any[]) => Promise<bigint | number>
>
)[method];
const result =
args && args.length > 0 ? await reader(args, { blockTag }) : await reader({ blockTag });
return Number(result);
}
// withPreferredBlockTag removed; use explicit { blockTag: blockTagFromFinality(...) }
private async getFromCache(namespace: string, epoch: number, key: string): Promise<any | null> {
if (!this.cache) return null;
try {
return await this.cache.get(epoch, key);
} catch {
return null;
}
}
private async setToCache(
namespace: string,
epoch: number,
key: string,
value: any,
): Promise<void> {
if (!this.cache) return;
try {
await this.cache.set(epoch, key, value);
} catch {
// Ignore cache errors
}
}
private async deleteFromCache(namespace: string, epoch: number, key: string): Promise<void> {
if (!this.cache) return;
try {
await this.cache.delete(epoch, key);
} catch {
// Ignore cache errors
}
}
async getCurrentEpoch(finalized: boolean = true): Promise<number> {
return this.readDriverNumber('getCurrentEpoch', finalized);
}
async getCurrentEpochDuration(finalized: boolean = true): Promise<number> {
return this.readDriverNumber('getCurrentEpochDuration', finalized);
}
async getCurrentEpochStart(finalized: boolean = true): Promise<number> {
return this.readDriverNumber('getCurrentEpochStart', finalized);
}
async getNextEpoch(finalized: boolean = true): Promise<number> {
return this.readDriverNumber('getNextEpoch', finalized);
}
async getNextEpochDuration(finalized: boolean = true): Promise<number> {
return this.readDriverNumber('getNextEpochDuration', finalized);
}
async getNextEpochStart(finalized: boolean = true): Promise<number> {
return this.readDriverNumber('getNextEpochStart', finalized);
}
async getEpochStart(epoch: number, finalized: boolean = true): Promise<number> {
return this.readDriverNumber('getEpochStart', finalized, [Number(epoch)]);
}
async getEpochDuration(epoch: number, finalized: boolean = true): Promise<number> {
return this.readDriverNumber('getEpochDuration', finalized, [Number(epoch)]);
}
async getEpochIndex(timestamp: number, finalized: boolean = true): Promise<number> {
return this.readDriverNumber('getEpochIndex', finalized, [Number(timestamp)]);
}
async getNetworkConfig(epoch?: number, finalized: boolean = true): Promise<NetworkConfig> {
await this.ensureInitialized();
const { targetEpoch } = await this.resolveEpoch(epoch, finalized);
const { config } = await this.loadNetworkConfigData({ targetEpoch, finalized });
return config;
}
async getValidatorSet(epoch?: number, finalized: boolean = true): Promise<ValidatorSet> {
await this.ensureInitialized();
const { targetEpoch } = await this.resolveEpoch(epoch, finalized);
const useCache2 = finalized;
const cacheKey = 'valset';
let validatorSet: ValidatorSet | null = 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: ValSetStatus | null = 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(): Promise<ValidatorSet> {
return this.getValidatorSet(undefined, true);
}
/**
* Get the current network configuration (simplified interface)
*/
async getCurrentNetworkConfig(): Promise<NetworkConfig> {
return this.getNetworkConfig(undefined, true);
}
public getTotalActiveVotingPower(validatorSet: ValidatorSet): bigint {
return totalActiveVotingPower(validatorSet);
}
public getValidatorSetHeader(validatorSet: ValidatorSet): ValidatorSetHeader {
return createValidatorSetHeader(validatorSet);
}
public abiEncodeValidatorSetHeader(header: ValidatorSetHeader): Hex {
return encodeValidatorSetHeader(header);
}
public hashValidatorSetHeader(header: ValidatorSetHeader): Hex {
return hashValidatorSetHeader(header);
}
public getValidatorSetHeaderHash(validatorSet: ValidatorSet): Hex {
return hashValidatorSet(validatorSet);
}
private async getVotingPowers(
provider: CrossChainAddress,
timestamp: number,
preferFinalized: boolean,
): Promise<OperatorVotingPower[]> {
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 as Address);
const results = await this.executeChunkedMulticall<
readonly { vault: Address; value: bigint }[]
>({
client,
requests: operatorList.map((operator) => ({
address: provider.address,
abi: VOTING_POWER_PROVIDER_ABI,
functionName: 'getOperatorVotingPowersAt',
args: [operator, '0x' as Hex, 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] ?? []) as readonly { vault: Address; value: bigint }[];
return {
operator,
vaults: Array.from(vaults, (v) => ({
vault: v.vault as Address,
votingPower: v.value,
})),
};
});
}
const votingPowers = await providerContract.read.getVotingPowersAt([[], timestampNumber], {
blockTag,
});
return votingPowers.map((vp: any) => ({
operator: vp.operator as Address,
vaults: vp.vaults.map((v: any) => ({
vault: v.vault as Address,
votingPower: v.value,
})),
}));
}
private async getKeys(
provider: CrossChainAddress,
timestamp: number,
preferFinalized: boolean,
): Promise<OperatorWithKeys[]> {
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 as Address);
const results = await this.executeChunkedMulticall<
readonly { tag: number | bigint; payload: Hex }[]
>({
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] ?? []) as readonly {
tag: number | bigint;
payload: Hex;
}[];
return {
operator,
keys: Array.from(operatorKeys, (key) => ({
tag: typeof key.tag === 'bigint' ? Number(key.tag) : key.tag,
payload: key.payload as Hex,
})),
};
});
}
const keys = (await client.readContract({
address: provider.address,
abi: KEY_REGISTRY_ABI,
functionName: 'getKeysAt',
args: [timestampNumber] as const,
blockTag,
})) as readonly {
operator: Address;
keys: readonly { tag: number | bigint; payload: Hex }[];