@symbioticfi/relay-stats-ts
Version:
TypeScript library for deriving validator sets from Symbiotic network contracts
202 lines (170 loc) • 6.18 kB
text/typescript
import { encodeAbiParameters, keccak256, type Hex } from 'viem';
import type {
OperatorVotingPower,
OperatorWithKeys,
Validator,
ValidatorSet,
ValidatorSetHeader,
NetworkConfig,
} from './types.js';
import type { Address } from 'viem';
import { sszTreeRoot } from './encoding.js';
import { SSZ_MAX_VALIDATORS, SSZ_MAX_VAULTS } from './constants.js';
export const totalActiveVotingPower = (validatorSet: ValidatorSet): bigint =>
validatorSet.validators.reduce(
(sum, validator) => sum + (validator.isActive ? validator.votingPower : 0n),
0n,
);
export const createValidatorSetHeader = (validatorSet: ValidatorSet): ValidatorSetHeader => ({
version: validatorSet.version,
requiredKeyTag: validatorSet.requiredKeyTag,
epoch: validatorSet.epoch,
captureTimestamp: validatorSet.captureTimestamp,
quorumThreshold: validatorSet.quorumThreshold,
totalVotingPower: totalActiveVotingPower(validatorSet),
validatorsSszMRoot: sszTreeRoot(validatorSet),
});
export const encodeValidatorSetHeader = (header: ValidatorSetHeader): Hex =>
encodeAbiParameters(
[
{ name: 'version', type: 'uint8' },
{ name: 'requiredKeyTag', type: 'uint8' },
{ name: 'epoch', type: 'uint48' },
{ name: 'captureTimestamp', type: 'uint48' },
{ name: 'quorumThreshold', type: 'uint256' },
{ name: 'totalVotingPower', type: 'uint256' },
{ name: 'validatorsSszMRoot', type: 'bytes32' },
],
[
header.version,
header.requiredKeyTag,
header.epoch,
header.captureTimestamp,
header.quorumThreshold,
header.totalVotingPower,
header.validatorsSszMRoot,
],
) as Hex;
export const hashValidatorSetHeader = (header: ValidatorSetHeader): Hex =>
keccak256(encodeValidatorSetHeader(header));
export const hashValidatorSet = (validatorSet: ValidatorSet): Hex =>
hashValidatorSetHeader(createValidatorSetHeader(validatorSet));
type ChainVotingPowers = {
readonly chainId: number;
readonly votingPowers: readonly OperatorVotingPower[];
};
const limitAndSortVaults = (validator: Validator): void => {
if (validator.vaults.length <= SSZ_MAX_VAULTS) {
validator.vaults.sort((a, b) => a.vault.toLowerCase().localeCompare(b.vault.toLowerCase()));
return;
}
validator.vaults.sort((left, right) => {
const diff = right.votingPower - left.votingPower;
if (diff !== 0n) {
return diff > 0n ? 1 : -1;
}
return left.vault.toLowerCase().localeCompare(right.vault.toLowerCase());
});
validator.vaults = validator.vaults.slice(0, SSZ_MAX_VAULTS);
validator.votingPower = validator.vaults.reduce((sum, vault) => sum + vault.votingPower, 0n);
validator.vaults.sort((a, b) => a.vault.toLowerCase().localeCompare(b.vault.toLowerCase()));
};
const applyValidatorKeys = (
validators: Map<string, Validator>,
keys: readonly OperatorWithKeys[],
): void => {
for (const keyRecord of keys) {
const operatorAddr = keyRecord.operator.toLowerCase() as Address;
const validator = validators.get(operatorAddr);
if (validator) {
validator.keys = keyRecord.keys;
}
}
};
const markValidatorsActive = (config: NetworkConfig, validators: Validator[]): void => {
let totalActive = 0;
for (const validator of validators) {
if (validator.votingPower < config.minInclusionVotingPower) {
break;
}
if (validator.keys.length === 0) {
continue;
}
totalActive++;
validator.isActive = true;
if (config.maxVotingPower !== 0n && validator.votingPower > config.maxVotingPower) {
validator.votingPower = config.maxVotingPower;
}
if (config.maxValidatorsCount !== 0n && totalActive >= Number(config.maxValidatorsCount)) {
break;
}
}
};
export const composeValidators = (
config: NetworkConfig,
votingPowers: readonly ChainVotingPowers[],
operatorKeys: readonly OperatorWithKeys[],
): Validator[] => {
const validators = new Map<string, Validator>();
for (const chainVotingPower of votingPowers) {
for (const votingPower of chainVotingPower.votingPowers) {
const operatorAddr = votingPower.operator.toLowerCase() as Address;
if (!validators.has(operatorAddr)) {
validators.set(operatorAddr, {
operator: votingPower.operator,
votingPower: 0n,
isActive: false,
keys: [],
vaults: [],
});
}
const validator = validators.get(operatorAddr)!;
for (const vault of votingPower.vaults) {
validator.votingPower += vault.votingPower;
validator.vaults.push({
vault: vault.vault,
votingPower: vault.votingPower,
chainId: chainVotingPower.chainId,
});
}
}
}
for (const validator of validators.values()) {
limitAndSortVaults(validator);
}
applyValidatorKeys(validators, operatorKeys);
let mappedValidators = Array.from(validators.values());
mappedValidators.sort((left, right) => {
const diff = right.votingPower - left.votingPower;
if (diff !== 0n) {
return diff > 0n ? 1 : -1;
}
return left.operator.toLowerCase().localeCompare(right.operator.toLowerCase());
});
if (mappedValidators.length > SSZ_MAX_VALIDATORS) {
mappedValidators = mappedValidators.slice(0, SSZ_MAX_VALIDATORS);
}
markValidatorsActive(config, mappedValidators);
mappedValidators.sort((left, right) =>
left.operator.toLowerCase().localeCompare(right.operator.toLowerCase()),
);
return mappedValidators;
};
export const calculateQuorumThreshold = (
config: NetworkConfig,
totalVotingPower: bigint,
): bigint => {
const threshold = config.quorumThresholds.find(
(entry) => entry.keyTag === config.requiredHeaderKeyTag,
);
if (!threshold) {
throw new Error(`No quorum threshold for key tag ${config.requiredHeaderKeyTag}`);
}
if (threshold.quorumThreshold === 0n) {
throw new Error(`Quorum threshold for key tag ${config.requiredHeaderKeyTag} is zero`);
}
const maxThreshold = 1_000_000_000_000_000_000n;
const multiplied = totalVotingPower * threshold.quorumThreshold;
const divided = multiplied / maxThreshold;
return divided + 1n;
};