genlayer
Version:
GenLayer Command Line Tool
567 lines (488 loc) • 22.4 kB
text/typescript
import {StakingAction, StakingConfig} from "./StakingAction";
import type {Address, ValidatorInfo} from "genlayer-js/types";
import Table from "cli-table3";
import chalk from "chalk";
// Epoch-related constants
const ACTIVATION_DELAY_EPOCHS = 2n;
const UNBONDING_PERIOD_EPOCHS = 7n;
export interface StakingInfoOptions extends StakingConfig {
validator?: string;
}
export class StakingInfoAction extends StakingAction {
constructor() {
super();
}
async getValidatorInfo(options: StakingInfoOptions): Promise<void> {
this.startSpinner("Fetching validator info...");
try {
const client = await this.getReadOnlyStakingClient(options);
const validatorAddress = options.validator || (await this.getSignerAddress());
const isValidator = await client.isValidator(validatorAddress as Address);
if (!isValidator) {
this.failSpinner(`Address ${validatorAddress} is not a validator`);
return;
}
const [info, epochInfo] = await Promise.all([
client.getValidatorInfo(validatorAddress as Address),
client.getEpochInfo(),
]);
const currentEpoch = epochInfo.currentEpoch;
const result: Record<string, any> = {
validator: info.address,
owner: info.owner,
operator: info.operator,
vStake: info.vStake,
vShares: info.vShares.toString(),
dStake: info.dStake,
dShares: info.dShares.toString(),
vDeposit: info.vDeposit,
vWithdrawal: info.vWithdrawal,
ePrimed: info.ePrimed.toString(),
needsPriming: info.needsPriming,
live: info.live,
banned: info.banned ? info.bannedEpoch?.toString() : "Not banned",
selfStakePendingDeposits: (() => {
// Filter to only truly pending deposits (not yet active)
const pending = info.pendingDeposits.filter(d => d.epoch + ACTIVATION_DELAY_EPOCHS > currentEpoch);
return pending.length > 0
? pending.map(d => {
const depositEpoch = d.epoch;
const activationEpoch = depositEpoch + ACTIVATION_DELAY_EPOCHS;
const epochsUntilActive = activationEpoch - currentEpoch;
return {
epoch: depositEpoch.toString(),
stake: d.stake,
shares: d.shares.toString(),
activatesAtEpoch: activationEpoch.toString(),
epochsRemaining: epochsUntilActive.toString(),
};
})
: "None";
})(),
selfStakePendingWithdrawals:
info.pendingWithdrawals.length > 0
? info.pendingWithdrawals.map(w => {
const exitEpoch = w.epoch;
const claimableEpoch = exitEpoch + UNBONDING_PERIOD_EPOCHS;
const epochsUntilClaimable = claimableEpoch - currentEpoch;
return {
epoch: exitEpoch.toString(),
shares: w.shares.toString(),
stake: w.stake,
claimableAtEpoch: claimableEpoch.toString(),
status:
epochsUntilClaimable <= 0n
? "Claimable now"
: `Unbonding (${epochsUntilClaimable} epoch${epochsUntilClaimable > 1n ? "s" : ""} remaining)`,
};
})
: "None",
};
// Add identity if set
if (info.identity?.moniker) {
result.identity = {
moniker: info.identity.moniker,
...(info.identity.website && {website: info.identity.website}),
...(info.identity.description && {description: info.identity.description}),
...(info.identity.twitter && {twitter: info.identity.twitter}),
...(info.identity.telegram && {telegram: info.identity.telegram}),
...(info.identity.github && {github: info.identity.github}),
...(info.identity.email && {email: info.identity.email}),
...(info.identity.logoUri && {logoUri: info.identity.logoUri}),
};
}
this.succeedSpinner("Validator info retrieved", result);
} catch (error: any) {
this.failSpinner("Failed to get validator info", error.message || error);
}
}
async getStakeInfo(options: StakingInfoOptions & {delegator?: string}): Promise<void> {
this.startSpinner("Fetching stake info...");
try {
const client = await this.getReadOnlyStakingClient(options);
const delegatorAddress = options.delegator || (await this.getSignerAddress());
const isOwnDelegation = !options.delegator;
this.setSpinnerText(`Fetching delegation info for ${delegatorAddress}...`);
if (!options.validator) {
this.failSpinner("Validator address is required");
return;
}
const [info, epochInfo] = await Promise.all([
client.getStakeInfo(delegatorAddress as Address, options.validator as Address),
client.getEpochInfo(),
]);
const currentEpoch = epochInfo.currentEpoch;
// Calculate projected rewards
let projectedReward = "N/A";
if (epochInfo.totalWeight > 0n && epochInfo.inflationRaw > 0n && info.stakeRaw > 0n) {
const rewardRaw = (info.stakeRaw * epochInfo.inflationRaw) / epochInfo.totalWeight;
projectedReward = client.formatStakingAmount(rewardRaw) + " per epoch";
} else if (epochInfo.inflationRaw === 0n) {
projectedReward = "0 GEN (no inflation this epoch)";
}
const result = {
delegator: info.delegator,
validator: info.validator,
shares: info.shares.toString(),
stake: info.stake,
projectedReward,
pendingDeposits: (() => {
// Filter to only truly pending deposits (not yet active)
const pending = info.pendingDeposits.filter(d => d.epoch + ACTIVATION_DELAY_EPOCHS > currentEpoch);
return pending.length > 0
? pending.map(d => {
const depositEpoch = d.epoch;
const activationEpoch = depositEpoch + ACTIVATION_DELAY_EPOCHS;
const epochsUntilActive = activationEpoch - currentEpoch;
return {
epoch: depositEpoch.toString(),
stake: d.stake,
shares: d.shares.toString(),
activatesAtEpoch: activationEpoch.toString(),
epochsRemaining: epochsUntilActive.toString(),
};
})
: "None";
})(),
pendingWithdrawals:
info.pendingWithdrawals.length > 0
? info.pendingWithdrawals.map(w => {
const exitEpoch = w.epoch;
const claimableEpoch = exitEpoch + UNBONDING_PERIOD_EPOCHS; // Must wait 7 full epochs
const epochsUntilClaimable = claimableEpoch - currentEpoch;
return {
epoch: exitEpoch.toString(),
shares: w.shares.toString(),
stake: w.stake,
claimableAtEpoch: claimableEpoch.toString(),
status:
epochsUntilClaimable <= 0n
? "Claimable now"
: `Unbonding (${epochsUntilClaimable} epoch${epochsUntilClaimable > 1n ? "s" : ""} remaining)`,
};
})
: "None",
};
const msg = isOwnDelegation ? "Your delegation info" : `Delegation info for ${delegatorAddress}`;
this.succeedSpinner(msg, result);
} catch (error: any) {
this.failSpinner("Failed to get stake info", error.message || error);
}
}
async getEpochInfo(options: StakingConfig & {epoch?: string}): Promise<void> {
this.startSpinner("Fetching epoch info...");
try {
const client = await this.getReadOnlyStakingClient(options);
const info = await client.getEpochInfo();
const formatDuration = (ms: number): string => {
const hours = Math.floor(ms / (1000 * 60 * 60));
const minutes = Math.floor((ms % (1000 * 60 * 60)) / (1000 * 60));
if (hours > 24) {
const days = Math.floor(hours / 24);
const remainingHours = hours % 24;
return `${days}d ${remainingHours}h ${minutes}m`;
}
return `${hours}h ${minutes}m`;
};
const formatAmount = client.formatStakingAmount;
// If specific epoch requested, show just that epoch's data
if (options.epoch !== undefined) {
const epochNum = BigInt(options.epoch);
const epochData = await client.getEpochData(epochNum);
const isFinalized = info.lastFinalizedEpoch >= epochNum;
const startDate = new Date(Number(epochData.start) * 1000);
const endDate = epochData.end > 0n ? new Date(Number(epochData.end) * 1000) : null;
this.succeedSpinner(`Epoch ${epochNum}`);
console.log(`\n Epoch: ${epochNum}`);
console.log(` Started: ${startDate.toISOString()}`);
console.log(` Ended: ${endDate?.toISOString() || "Not yet"}`);
console.log(` Finalized: ${isFinalized ? "Yes" : "No"}`);
console.log(` Validators: ${epochData.vcount}`);
console.log(` Weight: ${epochData.weight}`);
console.log(` Inflation: ${formatAmount(epochData.inflation)}`);
console.log(` Claimed: ${formatAmount(epochData.claimed)}`);
console.log(` Slashed: ${formatAmount(epochData.slashed)}`);
console.log();
return;
}
// Default: show current + previous epoch
const currentEpochData = await client.getEpochData(info.currentEpoch);
const currentStart = new Date(Number(currentEpochData.start) * 1000);
const now = Date.now();
const timeSinceStart = now - currentStart.getTime();
const timeUntilNext = info.nextEpochEstimate ? info.nextEpochEstimate.getTime() - now : null;
this.succeedSpinner("Epoch info");
const nextEstimate = timeUntilNext && timeUntilNext > 0
? `in ${formatDuration(timeUntilNext)}`
: currentEpochData.end > 0n ? "Next epoch started" : "N/A";
console.log(`\n Current Epoch: ${info.currentEpoch} (started ${formatDuration(timeSinceStart)} ago)`);
console.log(` Next Epoch: ${nextEstimate}`);
console.log(` Validators: ${info.activeValidatorsCount}`);
console.log(` Weight: ${currentEpochData.weight}`);
console.log(` Slashed: ${formatAmount(currentEpochData.slashed)}`);
// Previous epoch (has the actual inflation/rewards data)
if (info.currentEpoch > 0n) {
const prevEpoch = info.currentEpoch - 1n;
const prevData = await client.getEpochData(prevEpoch);
const isFinalized = info.lastFinalizedEpoch >= prevEpoch;
const prevEnd = prevData.end > 0n;
let status: string;
if (!prevEnd) {
status = "still active";
} else if (isFinalized) {
status = "finalized";
} else {
status = "finalizing txs...";
}
console.log(`\n Previous Epoch: ${prevEpoch} (${status})`);
console.log(` Inflation: ${formatAmount(prevData.inflation)}`);
console.log(` Claimed: ${formatAmount(prevData.claimed)}`);
console.log(` Unclaimed: ${formatAmount(prevData.inflation - prevData.claimed)}`);
console.log(` Slashed: ${formatAmount(prevData.slashed)}`);
}
console.log(`\n Min Epoch Duration: ${formatDuration(Number(info.epochMinDuration) * 1000)}`);
console.log(` Validator Min Stake: ${info.validatorMinStake}`);
console.log(` Delegator Min Stake: ${info.delegatorMinStake}\n`);
} catch (error: any) {
this.failSpinner("Failed to get epoch info", error.message || error);
}
}
async listActiveValidators(options: StakingConfig): Promise<void> {
this.startSpinner("Fetching active validators...");
try {
const client = await this.getReadOnlyStakingClient(options);
const activeValidators = await client.getActiveValidators();
const result = {
count: activeValidators.length,
validators: activeValidators,
};
this.succeedSpinner("Active validators retrieved", result);
} catch (error: any) {
this.failSpinner("Failed to get active validators", error.message || error);
}
}
async listQuarantinedValidators(options: StakingConfig): Promise<void> {
this.startSpinner("Fetching quarantined validators...");
try {
const client = await this.getReadOnlyStakingClient(options);
const validators = await client.getQuarantinedValidatorsDetailed();
const result = {
count: validators.length,
validators: validators.map(v => ({
validator: v.validator,
untilEpoch: v.untilEpoch.toString(),
permanentlyBanned: v.permanentlyBanned,
})),
};
this.succeedSpinner("Quarantined validators retrieved", result);
} catch (error: any) {
this.failSpinner("Failed to get quarantined validators", error.message || error);
}
}
async listBannedValidators(options: StakingConfig): Promise<void> {
this.startSpinner("Fetching banned validators...");
try {
const client = await this.getReadOnlyStakingClient(options);
const validators = await client.getBannedValidators();
const result = {
count: validators.length,
validators: validators.map(v => ({
validator: v.validator,
untilEpoch: v.permanentlyBanned ? "permanent" : v.untilEpoch.toString(),
permanentlyBanned: v.permanentlyBanned,
})),
};
this.succeedSpinner("Banned validators retrieved", result);
} catch (error: any) {
this.failSpinner("Failed to get banned validators", error.message || error);
}
}
async listValidators(options: StakingConfig & {all?: boolean}): Promise<void> {
this.startSpinner("Fetching validator set...");
try {
const client = await this.getReadOnlyStakingClient(options);
// Get current user's address to mark "mine"
let myAddress: Address | null = null;
try {
myAddress = await this.getSignerAddress();
} catch {
// No account configured, that's fine
}
// Fetch all data in parallel
const [activeAddresses, quarantinedList, bannedList, epochInfo] = await Promise.all([
client.getActiveValidators(),
client.getQuarantinedValidatorsDetailed(),
options.all ? client.getBannedValidators() : Promise.resolve([]),
client.getEpochInfo(),
]);
// Build set of quarantined/banned for status lookup
const quarantinedSet = new Map(quarantinedList.map(v => [v.validator.toLowerCase(), v]));
const bannedSet = new Map(bannedList.map(v => [v.validator.toLowerCase(), v]));
// Combine all validators
const allAddresses = new Set([
...activeAddresses,
...quarantinedList.map(v => v.validator),
...(options.all ? bannedList.map(v => v.validator) : []),
]);
this.setSpinnerText(`Fetching details for ${allAddresses.size} validators...`);
// Fetch detailed info in batches to avoid rate limiting
const BATCH_SIZE = 5;
const addressArray = Array.from(allAddresses);
const validatorInfos: ValidatorInfo[] = [];
for (let i = 0; i < addressArray.length; i += BATCH_SIZE) {
const batch = addressArray.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 < addressArray.length) {
this.setSpinnerText(`Fetching details... ${Math.min(i + BATCH_SIZE, addressArray.length)}/${addressArray.length}`);
}
}
// Build table rows
type ValidatorRow = {
info: ValidatorInfo;
status: string;
isMine: boolean;
totalStakeRaw: bigint;
};
const rows: ValidatorRow[] = validatorInfos.map(info => {
const addrLower = info.address.toLowerCase();
const isQuarantined = quarantinedSet.has(addrLower);
const isBanned = bannedSet.has(addrLower);
const isActive = activeAddresses.some(a => a.toLowerCase() === addrLower);
let status = "";
if (isBanned) {
const banInfo = bannedSet.get(addrLower)!;
status = banInfo.permanentlyBanned ? "BANNED" : `banned(e${banInfo.untilEpoch})`;
} else if (isQuarantined) {
const qInfo = quarantinedSet.get(addrLower)!;
status = `quarant(e${qInfo.untilEpoch})`;
} else if (info.needsPriming) {
status = "prime!";
} else if (isActive) {
status = "active";
} else {
status = "pending";
}
const isMine = myAddress
? info.owner.toLowerCase() === myAddress.toLowerCase() ||
info.operator.toLowerCase() === myAddress.toLowerCase()
: false;
return {
info,
status,
isMine,
totalStakeRaw: info.vStakeRaw + info.dStakeRaw,
};
});
// Calculate validator weight using the contract formula:
// weight = (vStake * alpha + dStake * (1 - alpha)) ^ beta
// Default: alpha = 0.6, beta = 0.5 (square root)
const ALPHA = 0.6;
const BETA = 0.5;
const calcWeight = (vStakeRaw: bigint, dStakeRaw: bigint): number => {
const vStake = Number(vStakeRaw) / 1e18;
const dStake = Number(dStakeRaw) / 1e18;
const util = vStake * ALPHA + dStake * (1 - ALPHA);
return Math.pow(util, BETA);
};
// Add weight to rows and sort by weight descending
const rowsWithWeight = rows.map(r => ({
...r,
weight: calcWeight(r.info.vStakeRaw, r.info.dStakeRaw),
}));
rowsWithWeight.sort((a, b) => b.weight - a.weight);
// Calculate total weight for active validators only (for power %)
const totalWeight = rowsWithWeight
.filter(r => r.status === "active")
.reduce((sum, r) => sum + r.weight, 0);
this.stopSpinner();
// Format stake - shorten large numbers
const formatStake = (s: string) => {
const num = parseFloat(s.replace(" GEN", ""));
if (num >= 1000000) return `${(num / 1000000).toFixed(1)}M`;
if (num >= 1000) return `${(num / 1000).toFixed(1)}K`;
if (num >= 1) return num.toFixed(1);
if (num > 0) return num.toPrecision(2);
return "0";
};
// Create table (no fixed widths - let it auto-size)
const table = new Table({
head: [
chalk.cyan("#"),
chalk.cyan("Validator"),
chalk.cyan("Self"),
chalk.cyan("Deleg"),
chalk.cyan("Pending"),
chalk.cyan("Weight"),
chalk.cyan("Status"),
],
style: {head: [], border: []},
});
rowsWithWeight.forEach((row, idx) => {
const {info, status, isMine, weight} = row;
// Weight percentage (share of active set weight)
const weightPct = totalWeight > 0 ? (weight / totalWeight) * 100 : 0;
const weightStr = status === "active" ? `${weightPct.toFixed(1)}%` : chalk.gray("-");
// Pending deposits/withdrawals - sum amounts (filter to truly pending only)
const currentEpoch = epochInfo.currentEpoch;
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 pendingDepositSum = trulyPendingDeposits.reduce((sum, d) => sum + d.stakeRaw, 0n);
const pendingWithdrawSum = trulyPendingWithdrawals.reduce((sum, w) => sum + w.stakeRaw, 0n);
let pendingStr = "-";
if (pendingDepositSum > 0n && pendingWithdrawSum > 0n) {
pendingStr = chalk.green(`+${formatStake(`${Number(pendingDepositSum) / 1e18} GEN`)}`) +
" " + chalk.red(`-${formatStake(`${Number(pendingWithdrawSum) / 1e18} GEN`)}`);
} else if (pendingDepositSum > 0n) {
pendingStr = chalk.green(`+${formatStake(`${Number(pendingDepositSum) / 1e18} GEN`)}`);
} else if (pendingWithdrawSum > 0n) {
pendingStr = chalk.red(`-${formatStake(`${Number(pendingWithdrawSum) / 1e18} GEN`)}`)
}
// Role indicator (colored)
let roleTag = "";
if (isMine) {
if (myAddress && info.owner.toLowerCase() === myAddress.toLowerCase()) {
roleTag = info.operator.toLowerCase() === myAddress.toLowerCase()
? chalk.cyan(" [own+op]")
: chalk.cyan(" [owner]");
} else {
roleTag = chalk.cyan(" [operator]");
}
}
// Moniker + role + full address on second line
let moniker = info.identity?.moniker || "";
if (moniker.length > 20) moniker = moniker.slice(0, 19) + "…";
const validatorCell = moniker
? `${moniker}${roleTag}\n${chalk.gray(info.address)}`
: `${chalk.gray(info.address)}${roleTag}`;
// Status coloring
let statusStr = status;
if (status === "active") statusStr = chalk.green(status);
else if (status === "BANNED") statusStr = chalk.red(status);
else if (status.startsWith("quarant")) statusStr = chalk.yellow(status);
else if (status.startsWith("banned")) statusStr = chalk.red(status);
else if (status === "prime!") statusStr = chalk.magenta(status);
else if (status === "pending") statusStr = chalk.gray(status);
table.push([
(idx + 1).toString(),
validatorCell,
formatStake(info.vStake),
formatStake(info.dStake),
pendingStr,
weightStr,
statusStr,
]);
});
console.log("");
console.log(table.toString());
console.log("");
const activeCount = rowsWithWeight.filter(r => r.status === "active").length;
console.log(chalk.gray(`Total: ${rowsWithWeight.length} validators (${activeCount} active)`));
console.log("");
} catch (error: any) {
this.failSpinner("Failed to list validators", error.message || error);
}
}
}