From b6d2433e4bce46fe4322be64c1050ad795a17951 Mon Sep 17 00:00:00 2001 From: Edgars Date: Thu, 2 Jul 2026 19:16:59 +0100 Subject: [PATCH 1/2] feat: add staking validators discovery --- src/commands/staking/index.ts | 13 +- src/commands/staking/validators.ts | 608 +++++++++++++++++++++++ tests/commands/staking.test.ts | 31 ++ tests/commands/stakingValidators.test.ts | 151 ++++++ 4 files changed, 799 insertions(+), 4 deletions(-) create mode 100644 src/commands/staking/validators.ts create mode 100644 tests/commands/stakingValidators.test.ts diff --git a/src/commands/staking/index.ts b/src/commands/staking/index.ts index 41a1d535..c2252f30 100644 --- a/src/commands/staking/index.ts +++ b/src/commands/staking/index.ts @@ -1,4 +1,5 @@ import {Command} from "commander"; +import type {StakingConfig} from "./StakingAction"; import {ValidatorJoinAction, ValidatorJoinOptions} from "./validatorJoin"; import {ValidatorDepositAction, ValidatorDepositOptions} from "./validatorDeposit"; import {ValidatorExitAction, ValidatorExitOptions} from "./validatorExit"; @@ -11,6 +12,7 @@ import {DelegatorExitAction, DelegatorExitOptions} from "./delegatorExit"; import {DelegatorClaimAction, DelegatorClaimOptions} from "./delegatorClaim"; import {StakingInfoAction, StakingInfoOptions} from "./stakingInfo"; import {ValidatorHistoryAction, ValidatorHistoryOptions} from "./validatorHistory"; +import {ValidatorsAction, ValidatorsOptions} from "./validators"; import {ValidatorWizardAction, WizardOptions} from "./wizard"; export function initializeStakingCommands(program: Command) { @@ -324,14 +326,17 @@ export function initializeStakingCommands(program: Command) { staking .command("validators") - .description("Show validator set with stake, status, and voting power") + .description("List validators with stake, status, and optional explorer performance") .option("--all", "Include banned validators") + .option("--json", "Output machine-readable JSON") + .option("--sort-by ", "Sort validators by stake or uptime (default: stake)", "stake") + .option("--explorer-url ", "Explorer backend or explorer base URL for optional performance enrichment") .option("--network ", "Network to use (localnet, testnet-asimov)") .option("--rpc ", "RPC URL for the network") .option("--staking-address
", "Staking contract address (overrides chain config)") - .action(async (options: StakingInfoOptions & {all?: boolean}) => { - const action = new StakingInfoAction(); - await action.listValidators(options); + .action(async (options: ValidatorsOptions) => { + const action = new ValidatorsAction(); + await action.execute(options); }); staking diff --git a/src/commands/staking/validators.ts b/src/commands/staking/validators.ts new file mode 100644 index 00000000..13e16b04 --- /dev/null +++ b/src/commands/staking/validators.ts @@ -0,0 +1,608 @@ +import {resolveNetwork} from "../../lib/actions/BaseAction"; +import {StakingAction, StakingConfig, BUILT_IN_NETWORKS} from "./StakingAction"; +import type {Address, GenLayerChain, ValidatorInfo} from "genlayer-js/types"; +import Table from "cli-table3"; +import chalk from "chalk"; + +const ACTIVATION_DELAY_EPOCHS = 2n; +const UNBONDING_PERIOD_EPOCHS = 7n; +const EXPLORER_PAGE_SIZE = 100; +const EXPLORER_TIMEOUT_MS = 5000; + +export interface ValidatorsOptions extends StakingConfig { + all?: boolean; + json?: boolean; + explorerUrl?: string; + sortBy?: "stake" | "uptime" | string; +} + +interface ExplorerValidatorSummary { + validator_address?: string; + validatorAddress?: string; + status?: string | null; + is_active?: boolean; + isActive?: boolean; + apy?: string | null; + idle_pct_7d?: number | null; + idlePct7d?: number | null; + rotation_pct_7d?: number | null; + rotationPct7d?: number | null; + minority_pct_7d?: number | null; + minorityPct7d?: number | null; + transaction_count?: number; + transactionCount?: number | null; +} + +interface ExplorerAddressValidator { + delegators?: unknown[]; + total_votes_7d?: number; + totalVotes7d?: number | null; + minority_votes_7d?: number; + minorityVotes7d?: number | null; + successful_appeals_7d?: number; + successfulAppeals7d?: number | null; + idle_pct_7d?: number | null; + idlePct7d?: number | null; + rotation_pct_7d?: number | null; + rotationPct7d?: number | null; + minority_pct_7d?: number | null; + minorityPct7d?: number | null; + apy?: string | null; +} + +interface ExplorerPerformance { + apy?: string | null; + uptimePct?: number | null; + idlePct7d?: number | null; + rotationPct7d?: number | null; + minorityPct7d?: number | null; + totalVotes7d?: number | null; + minorityVotes7d?: number | null; + successfulAppeals7d?: number | null; + transactionCount?: number | null; +} + +interface ExplorerData { + endpoint: string; + validators: Map; +} + +interface ValidatorRow { + address: Address; + owner: Address; + operator: Address; + moniker?: string; + active: boolean; + live: boolean; + banned: boolean; + bannedUntilEpoch?: string; + status: string; + selfStake: string; + selfStakeRaw: bigint; + delegatedStake: string; + delegatedStakeRaw: bigint; + totalStake: string; + totalStakeRaw: bigint; + pendingDepositRaw: bigint; + pendingWithdrawalRaw: bigint; + primedEpoch: string; + needsPriming: boolean; + delegatorCount: number | null; + epochsActive: number | null; + isMine: boolean; + performance?: ExplorerPerformance; +} + +export class ValidatorsAction extends StakingAction { + async execute(options: ValidatorsOptions): Promise { + this.startSpinner("Fetching validator set..."); + + try { + const client: any = await this.getReadOnlyStakingClient(options); + + let myAddress: Address | null = null; + try { + myAddress = await this.getSignerAddress(); + } catch { + // Listing validators should not require a local account. + } + + const [allTreeAddresses, activeAddresses, quarantinedList, bannedList, epochInfo] = await Promise.all([ + this.getAllValidatorsFromTree(options), + client.getActiveValidators(), + client.getQuarantinedValidatorsDetailed(), + client.getBannedValidators(), + client.getEpochInfo(), + ]); + + const quarantinedSet = new Map(quarantinedList.map((v: any) => [v.validator.toLowerCase(), v])); + const bannedSet = new Map(bannedList.map((v: any) => [v.validator.toLowerCase(), v])); + const activeSet = new Set(activeAddresses.map((a: string) => a.toLowerCase())); + + const allAddresses: Address[] = options.all + ? allTreeAddresses + : allTreeAddresses.filter((addr: Address) => !bannedSet.has(addr.toLowerCase())); + + this.setSpinnerText(`Fetching details for ${allAddresses.length} validators...`); + + const validatorInfos = await this.fetchValidatorInfos(client, allAddresses); + const explorerUrl = options.explorerUrl || this.getDefaultExplorerUrl(options); + const explorerData = explorerUrl + ? await this.fetchExplorerData(explorerUrl, validatorInfos.map(info => info.address)) + : null; + + const rows = validatorInfos.map(info => { + const addrLower = info.address.toLowerCase(); + const isQuarantined = quarantinedSet.has(addrLower); + const isBanned = info.banned || bannedSet.has(addrLower); + const isActive = activeSet.has(addrLower); + const bannedInfo = bannedSet.get(addrLower); + const quarantinedInfo = quarantinedSet.get(addrLower); + const performance = explorerData?.validators.get(addrLower); + + return this.buildRow({ + info, + currentEpoch: epochInfo.currentEpoch, + isActive, + isQuarantined, + isBanned, + bannedInfo, + quarantinedInfo, + myAddress, + performance, + }); + }); + + const sortedRows = this.sortRows(rows, options.sortBy || "stake"); + + this.stopSpinner(); + + if (options.json) { + const output = { + count: sortedRows.length, + activeCount: sortedRows.filter(row => row.active).length, + sortBy: this.normalizeSortBy(options.sortBy || "stake"), + explorer: explorerData + ? {enabled: true, url: explorerUrl, endpoint: explorerData.endpoint} + : {enabled: false, url: explorerUrl || null}, + validators: sortedRows.map(row => this.toJsonRow(row)), + }; + console.log(JSON.stringify(output, null, 2)); + return; + } + + this.printTable(sortedRows); + } catch (error: any) { + this.failSpinner("Failed to list validators", error.message || error); + } + } + + private async fetchValidatorInfos(client: any, addresses: Address[]): Promise { + const BATCH_SIZE = 5; + const validatorInfos: ValidatorInfo[] = []; + + for (let i = 0; i < addresses.length; i += BATCH_SIZE) { + const batch = addresses.slice(i, i + BATCH_SIZE); + const batchResults = await Promise.all( + batch.map(addr => client.getValidatorInfo(addr as Address)), + ); + validatorInfos.push(...batchResults); + if (i + BATCH_SIZE < addresses.length) { + this.setSpinnerText(`Fetching details... ${Math.min(i + BATCH_SIZE, addresses.length)}/${addresses.length}`); + } + } + + return validatorInfos; + } + + private buildRow({ + info, + currentEpoch, + isActive, + isQuarantined, + isBanned, + bannedInfo, + quarantinedInfo, + myAddress, + performance, + }: { + info: ValidatorInfo; + currentEpoch: bigint; + isActive: boolean; + isQuarantined: boolean; + isBanned: boolean; + bannedInfo: any; + quarantinedInfo: any; + myAddress: Address | null; + performance?: ExplorerPerformance & {delegatorCount?: number}; + }): ValidatorRow { + let status: string; + let bannedUntilEpoch: string | undefined; + + if (isBanned) { + if (bannedInfo?.permanentlyBanned) { + status = "banned"; + bannedUntilEpoch = "permanent"; + } else { + const epoch = bannedInfo?.untilEpoch ?? info.bannedEpoch; + status = epoch !== undefined ? `banned(e${epoch})` : "banned"; + bannedUntilEpoch = epoch !== undefined ? epoch.toString() : undefined; + } + } else if (isQuarantined) { + const untilEpoch = quarantinedInfo?.untilEpoch; + status = untilEpoch !== undefined ? `quarantined(e${untilEpoch})` : "quarantined"; + } else if (isActive) { + status = "active"; + } else { + status = info.live ? "pending" : "inactive"; + } + + const trulyPendingDeposits = info.pendingDeposits.filter(d => d.epoch + ACTIVATION_DELAY_EPOCHS > currentEpoch); + const trulyPendingWithdrawals = info.pendingWithdrawals.filter(w => w.epoch + UNBONDING_PERIOD_EPOCHS > currentEpoch); + + const isMine = myAddress + ? info.owner.toLowerCase() === myAddress.toLowerCase() || + info.operator.toLowerCase() === myAddress.toLowerCase() + : false; + + const totalStakeRaw = info.vStakeRaw + info.dStakeRaw; + + return { + address: info.address, + owner: info.owner, + operator: info.operator, + moniker: info.identity?.moniker || undefined, + active: isActive, + live: info.live, + banned: isBanned, + bannedUntilEpoch, + status, + selfStake: info.vStake, + selfStakeRaw: info.vStakeRaw, + delegatedStake: info.dStake, + delegatedStakeRaw: info.dStakeRaw, + totalStake: this.formatAmount(totalStakeRaw), + totalStakeRaw, + pendingDepositRaw: trulyPendingDeposits.reduce((sum, d) => sum + d.stakeRaw, 0n), + pendingWithdrawalRaw: trulyPendingWithdrawals.reduce((sum, w) => sum + w.stakeRaw, 0n), + primedEpoch: info.ePrimed.toString(), + needsPriming: info.needsPriming, + delegatorCount: performance?.delegatorCount ?? null, + epochsActive: null, + isMine, + performance, + }; + } + + private sortRows(rows: ValidatorRow[], sortBy: string): ValidatorRow[] { + const normalized = this.normalizeSortBy(sortBy); + const sorted = [...rows]; + + if (normalized === "uptime") { + sorted.sort((a, b) => { + const au = a.performance?.uptimePct; + const bu = b.performance?.uptimePct; + if (au === undefined || au === null) return bu === undefined || bu === null ? this.compareStakeDescending(a, b) : 1; + if (bu === undefined || bu === null) return -1; + if (bu !== au) return bu - au; + return this.compareStakeDescending(a, b); + }); + return sorted; + } + + sorted.sort((a, b) => this.compareStakeDescending(a, b)); + return sorted; + } + + private compareStakeDescending(a: ValidatorRow, b: ValidatorRow): number { + if (a.totalStakeRaw > b.totalStakeRaw) return -1; + if (a.totalStakeRaw < b.totalStakeRaw) return 1; + return a.address.localeCompare(b.address); + } + + private normalizeSortBy(sortBy: string): "stake" | "uptime" { + return sortBy === "uptime" ? "uptime" : "stake"; + } + + private resolveExplorerNetwork(config: StakingConfig): GenLayerChain { + if (config.network) { + const network = BUILT_IN_NETWORKS[config.network]; + if (!network) { + throw new Error(`Unknown network: ${config.network}. Available: ${Object.keys(BUILT_IN_NETWORKS).join(", ")}`); + } + return network; + } + + return resolveNetwork(this.getConfig().network); + } + + private getDefaultExplorerUrl(options: ValidatorsOptions): string | undefined { + const network = this.resolveExplorerNetwork(options); + if ((network as any).isStudio) { + return undefined; + } + + return network.blockExplorers?.default?.url; + } + + private async fetchExplorerData(explorerUrl: string, addresses: Address[]): Promise { + const validatorsResult = await this.fetchExplorerValidators(explorerUrl); + if (!validatorsResult) { + return null; + } + + await this.enrichDelegatorCounts(validatorsResult, addresses); + return validatorsResult; + } + + private async fetchExplorerValidators(explorerUrl: string): Promise { + for (const endpoint of this.getValidatorEndpointCandidates(explorerUrl)) { + try { + const validators = new Map(); + let page = 1; + let total = 0; + + do { + const url = new URL(endpoint); + url.searchParams.set("page", page.toString()); + url.searchParams.set("page_size", EXPLORER_PAGE_SIZE.toString()); + + const response = await this.fetchJson(url.toString()); + if (!response || !Array.isArray(response.validators)) { + throw new Error("Unexpected validators response"); + } + + total = typeof response.total === "number" ? response.total : response.validators.length; + + for (const item of response.validators as ExplorerValidatorSummary[]) { + const address = item.validator_address || item.validatorAddress; + if (!address) continue; + validators.set(address.toLowerCase(), this.toExplorerPerformance(item)); + } + + page += 1; + } while (validators.size < total && page <= 50); + + return {endpoint, validators}; + } catch { + // Try the next plausible deployment path; explorer enrichment is optional. + } + } + + return null; + } + + private async enrichDelegatorCounts(explorerData: ExplorerData, addresses: Address[]): Promise { + const apiBase = explorerData.endpoint.replace(/\/validators\/?$/, ""); + const BATCH_SIZE = 5; + + for (let i = 0; i < addresses.length; i += BATCH_SIZE) { + const batch = addresses.slice(i, i + BATCH_SIZE); + await Promise.all(batch.map(async address => { + try { + const response = await this.fetchJson(`${apiBase}/address/${address}`); + const validator = response?.validator as ExplorerAddressValidator | undefined; + if (!validator) return; + + const lower = address.toLowerCase(); + const existing = explorerData.validators.get(lower) || {}; + const detailPerformance = this.toExplorerPerformance(validator); + const delegatorCount = Array.isArray(validator.delegators) ? validator.delegators.length : existing.delegatorCount; + explorerData.validators.set(lower, this.mergeExplorerPerformance(existing, detailPerformance, delegatorCount)); + } catch { + // Delegator counts are enrichment-only and should never break chain output. + } + })); + } + } + + private mergeExplorerPerformance( + existing: ExplorerPerformance & {delegatorCount?: number}, + incoming: ExplorerPerformance, + delegatorCount?: number, + ): ExplorerPerformance & {delegatorCount?: number} { + return { + apy: incoming.apy ?? existing.apy, + uptimePct: incoming.uptimePct ?? existing.uptimePct, + idlePct7d: incoming.idlePct7d ?? existing.idlePct7d, + rotationPct7d: incoming.rotationPct7d ?? existing.rotationPct7d, + minorityPct7d: incoming.minorityPct7d ?? existing.minorityPct7d, + totalVotes7d: incoming.totalVotes7d ?? existing.totalVotes7d, + minorityVotes7d: incoming.minorityVotes7d ?? existing.minorityVotes7d, + successfulAppeals7d: incoming.successfulAppeals7d ?? existing.successfulAppeals7d, + transactionCount: incoming.transactionCount ?? existing.transactionCount, + delegatorCount, + }; + } + + private toExplorerPerformance(item: ExplorerValidatorSummary | ExplorerAddressValidator): ExplorerPerformance { + const idlePct7d = this.pickNumber((item as any).idle_pct_7d, (item as any).idlePct7d); + + return { + apy: (item as any).apy ?? undefined, + uptimePct: idlePct7d === undefined || idlePct7d === null ? null : Math.max(0, 100 - idlePct7d), + idlePct7d, + rotationPct7d: this.pickNumber((item as any).rotation_pct_7d, (item as any).rotationPct7d), + minorityPct7d: this.pickNumber((item as any).minority_pct_7d, (item as any).minorityPct7d), + totalVotes7d: this.pickNumber((item as any).total_votes_7d, (item as any).totalVotes7d), + minorityVotes7d: this.pickNumber((item as any).minority_votes_7d, (item as any).minorityVotes7d), + successfulAppeals7d: this.pickNumber((item as any).successful_appeals_7d, (item as any).successfulAppeals7d), + transactionCount: this.pickNumber((item as any).transaction_count, (item as any).transactionCount), + }; + } + + private pickNumber(...values: unknown[]): number | null | undefined { + for (const value of values) { + if (typeof value === "number") return value; + if (typeof value === "string" && value.trim() !== "" && !Number.isNaN(Number(value))) { + return Number(value); + } + if (value === null) return null; + } + return undefined; + } + + private async fetchJson(url: string): Promise { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), EXPLORER_TIMEOUT_MS); + + try { + const response = await fetch(url, { + headers: {accept: "application/json"}, + signal: controller.signal, + }); + + if (!response.ok) { + return null; + } + + return await response.json(); + } finally { + clearTimeout(timeout); + } + } + + private getValidatorEndpointCandidates(explorerUrl: string): string[] { + const base = this.normalizeUrl(explorerUrl); + const url = new URL(base); + const path = url.pathname.replace(/\/+$/, ""); + const originWithPath = `${url.origin}${path}`; + const candidates = new Set(); + + if (path.endsWith("/api/v1")) { + candidates.add(`${originWithPath}/validators`); + } else if (path.endsWith("/api")) { + candidates.add(`${originWithPath}/v1/validators`); + } else { + candidates.add(`${originWithPath}/api/v1/validators`); + candidates.add(`${originWithPath}/explorer/api/v1/validators`); + candidates.add(`${originWithPath}/validators`); + } + + return [...candidates]; + } + + private normalizeUrl(url: string): string { + if (/^https?:\/\//i.test(url)) { + return url; + } + return `https://${url}`; + } + + private toJsonRow(row: ValidatorRow) { + return { + address: row.address, + owner: row.owner, + operator: row.operator, + moniker: row.moniker || null, + active: row.active, + live: row.live, + banned: row.banned, + bannedUntilEpoch: row.bannedUntilEpoch || null, + status: row.status, + stake: { + total: row.totalStake, + totalRaw: row.totalStakeRaw.toString(), + self: row.selfStake, + selfRaw: row.selfStakeRaw.toString(), + delegated: row.delegatedStake, + delegatedRaw: row.delegatedStakeRaw.toString(), + }, + delegatorCount: row.delegatorCount, + epochsActive: row.epochsActive, + primedEpoch: row.primedEpoch, + needsPriming: row.needsPriming, + pending: { + depositRaw: row.pendingDepositRaw.toString(), + withdrawalRaw: row.pendingWithdrawalRaw.toString(), + }, + performance: row.performance + ? { + apy: row.performance.apy ?? null, + uptimePct: row.performance.uptimePct ?? null, + idlePct7d: row.performance.idlePct7d ?? null, + rotationPct7d: row.performance.rotationPct7d ?? null, + minorityPct7d: row.performance.minorityPct7d ?? null, + totalVotes7d: row.performance.totalVotes7d ?? null, + minorityVotes7d: row.performance.minorityVotes7d ?? null, + successfulAppeals7d: row.performance.successfulAppeals7d ?? null, + transactionCount: row.performance.transactionCount ?? null, + } + : null, + }; + } + + private printTable(rows: ValidatorRow[]): void { + const table = new Table({ + head: [ + chalk.cyan("#"), + chalk.cyan("Validator"), + chalk.cyan("Total Stake"), + chalk.cyan("Self"), + chalk.cyan("Deleg Stake"), + chalk.cyan("Delegators"), + chalk.cyan("Active"), + chalk.cyan("Status"), + chalk.cyan("Uptime"), + chalk.cyan("Epochs"), + ], + style: {head: [], border: []}, + }); + + rows.forEach((row, idx) => { + table.push([ + (idx + 1).toString(), + this.formatValidatorCell(row), + this.formatCompactStake(row.totalStakeRaw), + this.formatCompactStake(row.selfStakeRaw), + this.formatCompactStake(row.delegatedStakeRaw), + row.delegatorCount === null ? "-" : row.delegatorCount.toString(), + row.active ? chalk.green("yes") : chalk.gray("no"), + this.colorStatus(row.status), + row.performance?.uptimePct === null || row.performance?.uptimePct === undefined + ? "-" + : `${row.performance.uptimePct.toFixed(1)}%`, + row.epochsActive === null ? "-" : row.epochsActive.toString(), + ]); + }); + + console.log(""); + console.log(table.toString()); + console.log(""); + const activeCount = rows.filter(row => row.active).length; + console.log(chalk.gray(`Total: ${rows.length} validators (${activeCount} active)`)); + console.log(""); + } + + private formatValidatorCell(row: ValidatorRow): string { + let roleTag = ""; + if (row.isMine) { + roleTag = chalk.cyan(" [mine]"); + } + + const moniker = row.moniker && row.moniker.length > 20 + ? row.moniker.slice(0, 19) + "..." + : row.moniker; + + return moniker + ? `${moniker}${roleTag}\n${chalk.gray(row.address)}` + : `${chalk.gray(row.address)}${roleTag}`; + } + + private colorStatus(status: string): string { + if (status === "active") return chalk.green(status); + if (status.startsWith("banned")) return chalk.red(status); + if (status.startsWith("quarantined")) return chalk.yellow(status); + if (status === "pending") return chalk.gray(status); + return status; + } + + private formatCompactStake(raw: bigint): string { + const value = Number(raw) / 1e18; + if (value >= 1000000) return `${(value / 1000000).toFixed(1)}M`; + if (value >= 1000) return `${(value / 1000).toFixed(1)}K`; + if (value >= 1) return value.toFixed(1); + if (value > 0) return value.toPrecision(2); + return "0"; + } +} diff --git a/tests/commands/staking.test.ts b/tests/commands/staking.test.ts index abcad7ef..3866529e 100644 --- a/tests/commands/staking.test.ts +++ b/tests/commands/staking.test.ts @@ -12,6 +12,7 @@ import {DelegatorJoinAction} from "../../src/commands/staking/delegatorJoin"; import {DelegatorExitAction} from "../../src/commands/staking/delegatorExit"; import {DelegatorClaimAction} from "../../src/commands/staking/delegatorClaim"; import {StakingInfoAction} from "../../src/commands/staking/stakingInfo"; +import {ValidatorsAction} from "../../src/commands/staking/validators"; vi.mock("../../src/commands/staking/validatorJoin"); vi.mock("../../src/commands/staking/validatorDeposit"); @@ -24,6 +25,7 @@ vi.mock("../../src/commands/staking/delegatorJoin"); vi.mock("../../src/commands/staking/delegatorExit"); vi.mock("../../src/commands/staking/delegatorClaim"); vi.mock("../../src/commands/staking/stakingInfo"); +vi.mock("../../src/commands/staking/validators"); describe("staking commands", () => { let program: Command; @@ -211,6 +213,35 @@ describe("staking commands", () => { }); }); + describe("validators", () => { + test("calls ValidatorsAction.execute with discovery options", async () => { + program.parse([ + "node", + "test", + "staking", + "validators", + "--json", + "--sort-by", + "uptime", + "--explorer-url", + "https://explorer.example.com", + "--network", + "testnet-asimov", + "--staking-address", + "0xStaking", + ]); + + expect(ValidatorsAction).toHaveBeenCalledTimes(1); + expect(ValidatorsAction.prototype.execute).toHaveBeenCalledWith({ + json: true, + sortBy: "uptime", + explorerUrl: "https://explorer.example.com", + network: "testnet-asimov", + stakingAddress: "0xStaking", + }); + }); + }); + describe("validator-prime", () => { test("calls ValidatorPrimeAction.execute", async () => { program.parse(["node", "test", "staking", "validator-prime", "--validator", "0xValidator"]); diff --git a/tests/commands/stakingValidators.test.ts b/tests/commands/stakingValidators.test.ts new file mode 100644 index 00000000..80f918b2 --- /dev/null +++ b/tests/commands/stakingValidators.test.ts @@ -0,0 +1,151 @@ +import {afterEach, beforeEach, describe, expect, test, vi} from "vitest"; +import {ValidatorsAction} from "../../src/commands/staking/validators"; + +const A = "0x1111111111111111111111111111111111111111"; +const B = "0x2222222222222222222222222222222222222222"; +const OWNER = "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; +const OPERATOR = "0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; +const GEN = 10n ** 18n; + +function rawGen(amount: number): bigint { + return BigInt(amount) * GEN; +} + +function validatorInfo(address: string, selfStake: number, delegatedStake: number, moniker: string) { + return { + address, + owner: OWNER, + operator: OPERATOR, + vStake: `${selfStake} GEN`, + vStakeRaw: rawGen(selfStake), + vShares: 0n, + dStake: `${delegatedStake} GEN`, + dStakeRaw: rawGen(delegatedStake), + dShares: 0n, + vDeposit: "0 GEN", + vDepositRaw: 0n, + vWithdrawal: "0 GEN", + vWithdrawalRaw: 0n, + ePrimed: 5n, + live: true, + banned: false, + needsPriming: false, + identity: {moniker}, + pendingDeposits: [], + pendingWithdrawals: [], + } as any; +} + +function jsonResponse(body: unknown) { + return { + ok: true, + json: vi.fn().mockResolvedValue(body), + } as any; +} + +function createMockClient() { + const infos = new Map([ + [A.toLowerCase(), validatorInfo(A, 100, 20, "Alpha")], + [B.toLowerCase(), validatorInfo(B, 50, 10, "Beta")], + ]); + + return { + getActiveValidators: vi.fn().mockResolvedValue([A]), + getQuarantinedValidatorsDetailed: vi.fn().mockResolvedValue([]), + getBannedValidators: vi.fn().mockResolvedValue([]), + getEpochInfo: vi.fn().mockResolvedValue({currentEpoch: 6n}), + getValidatorInfo: vi.fn((address: string) => Promise.resolve(infos.get(address.toLowerCase()))), + }; +} + +function setupAction(mockClient = createMockClient()) { + const action = new ValidatorsAction(); + + vi.spyOn(action as any, "startSpinner").mockImplementation(() => undefined); + vi.spyOn(action as any, "setSpinnerText").mockImplementation(() => undefined); + vi.spyOn(action as any, "stopSpinner").mockImplementation(() => undefined); + vi.spyOn(action as any, "failSpinner").mockImplementation((message: unknown, error?: unknown) => { + throw new Error(`${message}: ${String(error)}`); + }); + vi.spyOn(action as any, "getReadOnlyStakingClient").mockResolvedValue(mockClient); + vi.spyOn(action as any, "getAllValidatorsFromTree").mockResolvedValue([A, B]); + vi.spyOn(action as any, "getSignerAddress").mockRejectedValue(new Error("no account")); + vi.spyOn(action as any, "getConfig").mockReturnValue({network: "localnet"}); + vi.spyOn(action as any, "formatAmount").mockImplementation((amount: unknown) => String((amount as bigint) / GEN) + " GEN"); + + return action; +} + +describe("staking validators action", () => { + let logSpy: ReturnType; + + beforeEach(() => { + logSpy = vi.spyOn(console, "log").mockImplementation(() => undefined); + }); + + afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + }); + + test("emits chain-only JSON without requiring explorer", async () => { + const fetchMock = vi.fn(); + vi.stubGlobal("fetch", fetchMock); + + const action = setupAction(); + await action.execute({json: true}); + + const output = JSON.parse(logSpy.mock.calls.at(-1)?.[0] as string); + + expect(fetchMock).not.toHaveBeenCalled(); + expect(output.explorer).toEqual({enabled: false, url: null}); + expect(output.validators).toHaveLength(2); + expect(output.validators[0].address).toBe(A); + expect(output.validators[0].active).toBe(true); + expect(output.validators[0].stake.totalRaw).toBe(rawGen(120).toString()); + expect(output.validators[0].delegatorCount).toBeNull(); + expect(output.validators[0].performance).toBeNull(); + }); + + test("merges explorer performance and sorts by uptime", async () => { + const fetchMock = vi.fn(async (url: string) => { + if (url.startsWith("https://explorer.example.com/api/v1/validators")) { + return jsonResponse({ + total: 2, + validators: [ + {validator_address: A, idle_pct_7d: 10, rotation_pct_7d: 2, minority_pct_7d: 1, apy: "5.00%", transaction_count: 7}, + {validator_address: B, idle_pct_7d: 1, rotation_pct_7d: 0, minority_pct_7d: 0, apy: "4.00%", transaction_count: 9}, + ], + }); + } + + if (url === `https://explorer.example.com/api/v1/address/${A}`) { + return jsonResponse({validator: {delegators: [{}], total_votes_7d: 11, minority_votes_7d: 1, successful_appeals_7d: 0}}); + } + + if (url === `https://explorer.example.com/api/v1/address/${B}`) { + return jsonResponse({validator: {delegators: [{}, {}], total_votes_7d: 21, minority_votes_7d: 0, successful_appeals_7d: 1}}); + } + + return {ok: false, json: vi.fn()} as any; + }); + vi.stubGlobal("fetch", fetchMock); + + const action = setupAction(); + await action.execute({json: true, explorerUrl: "https://explorer.example.com", sortBy: "uptime"}); + + const output = JSON.parse(logSpy.mock.calls.at(-1)?.[0] as string); + + expect(output.explorer).toEqual({ + enabled: true, + url: "https://explorer.example.com", + endpoint: "https://explorer.example.com/api/v1/validators", + }); + expect(output.sortBy).toBe("uptime"); + expect(output.validators[0].address).toBe(B); + expect(output.validators[0].delegatorCount).toBe(2); + expect(output.validators[0].performance.uptimePct).toBe(99); + expect(output.validators[0].performance.totalVotes7d).toBe(21); + expect(output.validators[1].address).toBe(A); + }); +}); From 157a3077923867a5d0a0fd631dd1647e81be90b8 Mon Sep 17 00:00:00 2001 From: Edgars Date: Thu, 2 Jul 2026 23:38:57 +0100 Subject: [PATCH 2/2] feat: epoch-aware validator listing with below-min indicator --- src/commands/staking/validators.ts | 27 +++++++++--- tests/commands/stakingValidators.test.ts | 56 +++++++++++++++++++++--- 2 files changed, 72 insertions(+), 11 deletions(-) diff --git a/src/commands/staking/validators.ts b/src/commands/staking/validators.ts index 13e16b04..cfd8617e 100644 --- a/src/commands/staking/validators.ts +++ b/src/commands/staking/validators.ts @@ -77,6 +77,7 @@ interface ValidatorRow { banned: boolean; bannedUntilEpoch?: string; status: string; + belowMin: boolean; selfStake: string; selfStakeRaw: bigint; delegatedStake: string; @@ -118,6 +119,8 @@ export class ValidatorsAction extends StakingAction { const quarantinedSet = new Map(quarantinedList.map((v: any) => [v.validator.toLowerCase(), v])); const bannedSet = new Map(bannedList.map((v: any) => [v.validator.toLowerCase(), v])); const activeSet = new Set(activeAddresses.map((a: string) => a.toLowerCase())); + const currentEpoch = BigInt(epochInfo.currentEpoch); + const validatorMinStakeRaw = BigInt(epochInfo.validatorMinStakeRaw ?? 0n); const allAddresses: Address[] = options.all ? allTreeAddresses @@ -142,7 +145,8 @@ export class ValidatorsAction extends StakingAction { return this.buildRow({ info, - currentEpoch: epochInfo.currentEpoch, + currentEpoch, + validatorMinStakeRaw, isActive, isQuarantined, isBanned, @@ -161,6 +165,7 @@ export class ValidatorsAction extends StakingAction { const output = { count: sortedRows.length, activeCount: sortedRows.filter(row => row.active).length, + current_epoch: currentEpoch.toString(), sortBy: this.normalizeSortBy(options.sortBy || "stake"), explorer: explorerData ? {enabled: true, url: explorerUrl, endpoint: explorerData.endpoint} @@ -171,7 +176,7 @@ export class ValidatorsAction extends StakingAction { return; } - this.printTable(sortedRows); + this.printTable(sortedRows, currentEpoch); } catch (error: any) { this.failSpinner("Failed to list validators", error.message || error); } @@ -198,6 +203,7 @@ export class ValidatorsAction extends StakingAction { private buildRow({ info, currentEpoch, + validatorMinStakeRaw, isActive, isQuarantined, isBanned, @@ -208,6 +214,7 @@ export class ValidatorsAction extends StakingAction { }: { info: ValidatorInfo; currentEpoch: bigint; + validatorMinStakeRaw: bigint; isActive: boolean; isQuarantined: boolean; isBanned: boolean; @@ -218,6 +225,8 @@ export class ValidatorsAction extends StakingAction { }): ValidatorRow { let status: string; let bannedUntilEpoch: string | undefined; + const belowMin = info.vStakeRaw < validatorMinStakeRaw; + const totalStakeRaw = info.vStakeRaw + info.dStakeRaw; if (isBanned) { if (bannedInfo?.permanentlyBanned) { @@ -231,6 +240,10 @@ export class ValidatorsAction extends StakingAction { } else if (isQuarantined) { const untilEpoch = quarantinedInfo?.untilEpoch; status = untilEpoch !== undefined ? `quarantined(e${untilEpoch})` : "quarantined"; + } else if (belowMin && currentEpoch < ACTIVATION_DELAY_EPOCHS) { + status = "pending-activation"; + } else if (belowMin && currentEpoch >= ACTIVATION_DELAY_EPOCHS) { + status = "inactive/below-min"; } else if (isActive) { status = "active"; } else { @@ -245,8 +258,6 @@ export class ValidatorsAction extends StakingAction { info.operator.toLowerCase() === myAddress.toLowerCase() : false; - const totalStakeRaw = info.vStakeRaw + info.dStakeRaw; - return { address: info.address, owner: info.owner, @@ -257,6 +268,7 @@ export class ValidatorsAction extends StakingAction { banned: isBanned, bannedUntilEpoch, status, + belowMin, selfStake: info.vStake, selfStakeRaw: info.vStakeRaw, delegatedStake: info.dStake, @@ -500,6 +512,7 @@ export class ValidatorsAction extends StakingAction { banned: row.banned, bannedUntilEpoch: row.bannedUntilEpoch || null, status: row.status, + below_min: row.belowMin, stake: { total: row.totalStake, totalRaw: row.totalStakeRaw.toString(), @@ -532,7 +545,7 @@ export class ValidatorsAction extends StakingAction { }; } - private printTable(rows: ValidatorRow[]): void { + private printTable(rows: ValidatorRow[], currentEpoch: bigint): void { const table = new Table({ head: [ chalk.cyan("#"), @@ -567,6 +580,7 @@ export class ValidatorsAction extends StakingAction { }); console.log(""); + console.log(chalk.gray(`Current epoch: ${currentEpoch}`)); console.log(table.toString()); console.log(""); const activeCount = rows.filter(row => row.active).length; @@ -593,7 +607,8 @@ export class ValidatorsAction extends StakingAction { if (status === "active") return chalk.green(status); if (status.startsWith("banned")) return chalk.red(status); if (status.startsWith("quarantined")) return chalk.yellow(status); - if (status === "pending") return chalk.gray(status); + if (status === "inactive/below-min") return chalk.yellow(status); + if (status === "pending" || status === "pending-activation") return chalk.gray(status); return status; } diff --git a/tests/commands/stakingValidators.test.ts b/tests/commands/stakingValidators.test.ts index 80f918b2..6ce6beb5 100644 --- a/tests/commands/stakingValidators.test.ts +++ b/tests/commands/stakingValidators.test.ts @@ -11,7 +11,13 @@ function rawGen(amount: number): bigint { return BigInt(amount) * GEN; } -function validatorInfo(address: string, selfStake: number, delegatedStake: number, moniker: string) { +function validatorInfo( + address: string, + selfStake: number, + delegatedStake: number, + moniker: string, + options: {live?: boolean} = {}, +) { return { address, owner: OWNER, @@ -27,7 +33,7 @@ function validatorInfo(address: string, selfStake: number, delegatedStake: numbe vWithdrawal: "0 GEN", vWithdrawalRaw: 0n, ePrimed: 5n, - live: true, + live: options.live ?? true, banned: false, needsPriming: false, identity: {moniker}, @@ -43,17 +49,29 @@ function jsonResponse(body: unknown) { } as any; } -function createMockClient() { +function createMockClient({ + currentEpoch = 6n, + validatorMinStakeRaw = rawGen(75), + betaLive = true, +}: { + currentEpoch?: bigint; + validatorMinStakeRaw?: bigint; + betaLive?: boolean; +} = {}) { const infos = new Map([ [A.toLowerCase(), validatorInfo(A, 100, 20, "Alpha")], - [B.toLowerCase(), validatorInfo(B, 50, 10, "Beta")], + [B.toLowerCase(), validatorInfo(B, 50, 10, "Beta", {live: betaLive})], ]); return { getActiveValidators: vi.fn().mockResolvedValue([A]), getQuarantinedValidatorsDetailed: vi.fn().mockResolvedValue([]), getBannedValidators: vi.fn().mockResolvedValue([]), - getEpochInfo: vi.fn().mockResolvedValue({currentEpoch: 6n}), + getEpochInfo: vi.fn().mockResolvedValue({ + currentEpoch, + validatorMinStakeRaw, + validatorMinStake: `${validatorMinStakeRaw / GEN} GEN`, + }), getValidatorInfo: vi.fn((address: string) => Promise.resolve(infos.get(address.toLowerCase()))), }; } @@ -78,6 +96,7 @@ function setupAction(mockClient = createMockClient()) { describe("staking validators action", () => { let logSpy: ReturnType; + const loggedOutput = () => logSpy.mock.calls.map(call => String(call[0])).join("\n"); beforeEach(() => { logSpy = vi.spyOn(console, "log").mockImplementation(() => undefined); @@ -98,13 +117,40 @@ describe("staking validators action", () => { const output = JSON.parse(logSpy.mock.calls.at(-1)?.[0] as string); expect(fetchMock).not.toHaveBeenCalled(); + expect(output.current_epoch).toBe("6"); expect(output.explorer).toEqual({enabled: false, url: null}); expect(output.validators).toHaveLength(2); expect(output.validators[0].address).toBe(A); expect(output.validators[0].active).toBe(true); + expect(output.validators[0].below_min).toBe(false); + expect(output.validators[0].status).toBe("active"); expect(output.validators[0].stake.totalRaw).toBe(rawGen(120).toString()); expect(output.validators[0].delegatorCount).toBeNull(); expect(output.validators[0].performance).toBeNull(); + expect(output.validators[1].below_min).toBe(true); + expect(output.validators[1].status).toBe("inactive/below-min"); + }); + + test("renders epoch 0 below-min validators as pending activation", async () => { + const action = setupAction(createMockClient({currentEpoch: 0n, betaLive: false})); + await action.execute({}); + + const output = loggedOutput(); + + expect(output).toContain("Current epoch: 0"); + expect(output).toContain("pending-activation"); + expect(output).not.toContain("inactive/below-min"); + }); + + test("renders epoch 2 below-min validators as inactive below-min", async () => { + const action = setupAction(createMockClient({currentEpoch: 2n, betaLive: false})); + await action.execute({}); + + const output = loggedOutput(); + + expect(output).toContain("Current epoch: 2"); + expect(output).toContain("inactive/below-min"); + expect(output).not.toContain("pending-activation"); }); test("merges explorer performance and sorts by uptime", async () => {