@symbioticfi/relay-stats-ts
Version:
TypeScript library for deriving validator sets from Symbiotic network contracts
400 lines • 15.2 kB
JavaScript
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