Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions e2e/config-default.e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ test.describe.serial("S5 config default walletMode=browser", () => {

test("bare validator-join signs via session (no --wallet flag)", async () => {
const before = await readStubCallCount(anvil);
const res = await runCli(["staking", "validator-join", "--amount", "1"], scratch);
const res = await runCli(["staking", "validator-join", "--force", "--amount", "1"], scratch);
expect(res.all).toContain("Validator created successfully!");
expect(res.all.match(HASH_RE)?.[0]).toBeTruthy();
expect(await readStubCallCount(anvil)).toBe(before + 1);
Expand All @@ -55,7 +55,7 @@ test.describe.serial("S5 config default walletMode=browser", () => {
test("--wallet keystore overrides config, takes keystore path (no enqueue)", async () => {
const before = await readStubCallCount(anvil);
const res = await runCli(
["staking", "validator-join", "--amount", "1", "--wallet", "keystore"],
["staking", "validator-join", "--force", "--amount", "1", "--wallet", "keystore"],
scratch,
);
// Keystore path selected: it fails on the missing account rather than
Expand Down
4 changes: 2 additions & 2 deletions e2e/errors.e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ test.describe.serial("S6a user reject (4001)", () => {

test("reject surfaces the message, exits non-zero, session survives", async () => {
const res = await runCli(
["staking", "validator-join", "--amount", "1", "--wallet", "browser"],
["staking", "validator-join", "--force", "--amount", "1", "--wallet", "browser"],
scratch,
);
expect(res.all).toContain("Transaction rejected in wallet");
Expand Down Expand Up @@ -118,7 +118,7 @@ test.describe.serial("S6b tab closed", () => {
expect(stale, "heartbeat should go stale after tab close").toBe(true);

const res = await runCli(
["staking", "validator-join", "--amount", "1", "--wallet", "browser"],
["staking", "validator-join", "--force", "--amount", "1", "--wallet", "browser"],
scratch,
{timeoutMs: 20_000},
);
Expand Down
6 changes: 3 additions & 3 deletions e2e/lane-a-staking.e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ test.describe.serial("Lane A staking (validator-join)", () => {
test("S2: validator-join --wallet browser signs and mines", async () => {
const before = await readStubCallCount(anvil);
const res = await runCli(
["staking", "validator-join", "--amount", "1", "--wallet", "browser"],
["staking", "validator-join", "--force", "--amount", "1", "--wallet", "browser"],
scratch,
);

Expand All @@ -65,11 +65,11 @@ test.describe.serial("Lane A staking (validator-join)", () => {
const before = await readStubCallCount(anvil);

const first = await runCli(
["staking", "validator-join", "--amount", "1", "--wallet", "browser"],
["staking", "validator-join", "--force", "--amount", "1", "--wallet", "browser"],
scratch,
);
const second = await runCli(
["staking", "validator-join", "--amount", "2", "--wallet", "browser"],
["staking", "validator-join", "--force", "--amount", "2", "--wallet", "browser"],
scratch,
);

Expand Down
5 changes: 4 additions & 1 deletion src/commands/staking/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ export function initializeStakingCommands(program: Command) {
.option("--password <password>", "Password to unlock account (skips interactive prompt)")
.option("--network <network>", "built-in or custom network alias (see: genlayer network list)")
.option("--rpc <rpcUrl>", "RPC URL for the network")
.option("--force", "Proceed even if self-stake is below the minimum required to become active")
.option("--staking-address <address>", "Staking contract address (overrides chain config)"),
).action(async (options: ValidatorJoinOptions) => {
const action = new ValidatorJoinAction();
Expand All @@ -83,7 +84,8 @@ export function initializeStakingCommands(program: Command) {
.option("--account <name>", "Account to use (must be validator owner)")
.option("--password <password>", "Password to unlock account (skips interactive prompt)")
.option("--network <network>", "built-in or custom network alias (see: genlayer network list)")
.option("--rpc <rpcUrl>", "RPC URL for the network"),
.option("--rpc <rpcUrl>", "RPC URL for the network")
.option("--force", "Proceed even if self-stake is below the minimum required to become active"),
).action(async (validatorArg: string | undefined, options: ValidatorDepositOptions) => {
const validator = validatorArg || options.validator;
if (!validator) {
Expand Down Expand Up @@ -295,6 +297,7 @@ export function initializeStakingCommands(program: Command) {
.option("--network <network>", "built-in or custom network alias (see: genlayer network list)")
.option("--rpc <rpcUrl>", "RPC URL for the network")
.option("--staking-address <address>", "Staking contract address (overrides chain config)")
.option("--json", "Output raw validator info as machine-readable JSON")
.option("--debug", "Show raw unfiltered pending deposits/withdrawals")
.action(async (validatorArg: string | undefined, options: StakingInfoOptions) => {
const validator = validatorArg || options.validator;
Expand Down
174 changes: 173 additions & 1 deletion src/commands/staking/stakingInfo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ const UNBONDING_PERIOD_EPOCHS = 7n;
export interface StakingInfoOptions extends StakingConfig {
validator?: string;
debug?: boolean;
json?: boolean;
}

export class StakingInfoAction extends StakingAction {
Expand Down Expand Up @@ -110,12 +111,183 @@ export class StakingInfoAction extends StakingAction {
};
}

this.succeedSpinner("Validator info retrieved", result);
// --json: emit the exact object the code builds today, machine-readable.
// No spinner decoration so the output is a clean JSON document.
if (options.json) {
this.stopSpinner();
console.log(JSON.stringify(result, null, 2));
return;
}

const source = await this.detectSelfStakeSource(client, info.owner as Address);

this.succeedSpinner("Validator info retrieved");
this.renderValidatorInfo(info, result, epochInfo, source);
} catch (error: any) {
this.failSpinner("Failed to get validator info", error.message || error);
}
}

/**
* Best-effort liquid-vs-vesting label. A liquid validator's wallet is owned by
* the signer EOA; a vesting-funded one is owned by the vesting contract. The
* cheap distinguishing signal is whether the owner address has code. If the
* probe isn't available/fails, degrade to "unknown" — the owner address is
* shown regardless, so nothing is lost.
*/
private async detectSelfStakeSource(client: any, owner: Address): Promise<string> {
try {
const getCode = client.getCode ?? client.getBytecode;
if (typeof getCode !== "function") return "unknown";
const code = await getCode.call(client, {address: owner});
return code && code !== "0x" ? "vesting (owner is a contract)" : "liquid (wallet-owned)";
} catch {
return "unknown";
}
}

/**
* Clean grouped validator view. Every VALUE from the raw output is preserved
* verbatim as a plain substring (addresses, "<n> GEN" amounts, moniker,
* "Not banned", epoch numbers, the `live` boolean) so downstream greps keep
* matching — only the layout changes. `live` is shown as an opaque contract
* flag, never relabelled "active".
*/
private renderValidatorInfo(
info: ValidatorInfo,
result: Record<string, any>,
epochInfo: {currentEpoch: bigint; validatorMinStake: string; validatorMinStakeRaw: bigint},
source: string,
): void {
const currentEpoch = epochInfo.currentEpoch;
const label = (s: string) => chalk.gray(s);

console.log("");

// Identity (only when set)
if (info.identity?.moniker) {
console.log(chalk.bold("Identity"));
console.log(` ${label("Moniker:")} ${info.identity.moniker}`);
if (info.identity.website) console.log(` ${label("Website:")} ${info.identity.website}`);
if (info.identity.description) console.log(` ${label("Description:")} ${info.identity.description}`);
if (info.identity.twitter) console.log(` ${label("Twitter:")} ${info.identity.twitter}`);
if (info.identity.telegram) console.log(` ${label("Telegram:")} ${info.identity.telegram}`);
if (info.identity.github) console.log(` ${label("GitHub:")} ${info.identity.github}`);
if (info.identity.email) console.log(` ${label("Email:")} ${info.identity.email}`);
if (info.identity.logoUri) console.log(` ${label("Logo URI:")} ${info.identity.logoUri}`);
console.log("");
}

// Addresses
console.log(chalk.bold("Addresses"));
console.log(` ${label("Validator:")} ${info.address}`);
console.log(` ${label("Owner:")} ${info.owner}`);
console.log(` ${label("Operator:")} ${info.operator}`);
console.log(` ${label("Source:")} ${source}`);
console.log("");

// Stake
console.log(chalk.bold("Stake"));
console.log(` ${label("Self (vStake):")} ${info.vStake} ${chalk.gray("(counts toward eligibility)")}`);
console.log(` ${label("Delegated (dStake):")} ${info.dStake} ${chalk.gray("(does NOT count toward eligibility)")}`);
console.log(` ${label("Deposit (vDeposit):")} ${info.vDeposit}`);
console.log(` ${label("Withdrawal (vWithdrawal):")} ${info.vWithdrawal}`);
console.log("");

// Status. `live` printed as the raw boolean, an opaque contract flag.
console.log(chalk.bold("Status"));
console.log(` ${label("live:")} ${info.live} ${chalk.gray("(contract flag: primed & not exited — not an eligibility signal)")}`);
console.log(` ${label("needsPriming:")} ${info.needsPriming}`);
console.log(` ${label("Banned:")} ${result.banned}`);
console.log(` ${label("Primed epoch (ePrimed):")} ${info.ePrimed}`);
console.log(` ${label("Current epoch:")} ${currentEpoch}`);
console.log("");

// Pending self-stake deposits
console.log(chalk.bold("Pending self-stake deposits"));
const deposits = result.selfStakePendingDeposits;
if (Array.isArray(deposits) && deposits.length > 0) {
for (const d of deposits) {
console.log(
` ${label("epoch")} ${d.epoch}: ${d.stake} ` +
`${chalk.gray(`(activatesAtEpoch ${d.activatesAtEpoch}, ${d.epochsRemaining ?? d.status} epochs remaining)`)}`,
);
}
} else {
console.log(` ${typeof deposits === "string" ? deposits : "None"}`);
}
console.log("");

// Pending self-stake withdrawals
console.log(chalk.bold("Pending self-stake withdrawals"));
const withdrawals = result.selfStakePendingWithdrawals;
if (Array.isArray(withdrawals) && withdrawals.length > 0) {
for (const w of withdrawals) {
console.log(
` ${label("epoch")} ${w.epoch}: ${w.stake} ` +
`${chalk.gray(`(claimableAtEpoch ${w.claimableAtEpoch}, ${w.status})`)}`,
);
}
} else {
console.log(` ${typeof withdrawals === "string" ? withdrawals : "None"}`);
}
console.log("");

// Eligibility warning (display-only — never blocks a read command).
this.renderEligibility(info, epochInfo);
}

/**
* Display-only self-stake eligibility note (decision 2). Counts still-pending
* self-stake deposits toward the effective self-stake:
* effectiveSelfStakeRaw = vStakeRaw + sum(pendingDeposits[].stakeRaw)
* Epoch-0 aware. Reads the minimum from epochInfo — never hardcoded.
*/
private renderEligibility(
info: ValidatorInfo,
epochInfo: {currentEpoch: bigint; validatorMinStake: string; validatorMinStakeRaw: bigint},
): void {
const minRaw = epochInfo.validatorMinStakeRaw;
const minFmt = epochInfo.validatorMinStake;
const pendingSum = info.pendingDeposits.reduce((sum, d) => sum + d.stakeRaw, 0n);
const effectiveSelfStakeRaw = info.vStakeRaw + pendingSum;

if (epochInfo.currentEpoch === 0n) {
this.logInfo(
`Epoch 0: minimum self-stake not enforced yet. This validator won't become active until its ` +
`self-stake reaches ${minFmt}.`,
);
return;
}

if (effectiveSelfStakeRaw < minRaw) {
this.logWarning(
`Self-stake below minimum: ${info.vStake} self-staked vs ${minFmt} required. This validator ` +
`won't become active until its self-stake reaches the minimum. Only self-stake counts toward ` +
`eligibility (delegated stake does not).`,
);
return;
}

if (info.vStakeRaw < minRaw) {
// Active stake alone is below min, but pending deposits will cross it.
let running = info.vStakeRaw;
let activatesAtEpoch = "?";
const sorted = [...info.pendingDeposits].sort((a, b) => (a.epoch < b.epoch ? -1 : a.epoch > b.epoch ? 1 : 0));
for (const d of sorted) {
running += d.stakeRaw;
if (running >= minRaw) {
activatesAtEpoch = (d.epoch + ACTIVATION_DELAY_EPOCHS).toString();
break;
}
}
this.logInfo(
`Self-stake meets the ${minFmt} minimum once the pending deposit activates at epoch ` +
`${activatesAtEpoch}. Currently ${info.vStake} is active.`,
);
}
}

async getStakeInfo(options: StakingInfoOptions & {delegator?: string}): Promise<void> {
this.startSpinner("Fetching stake info...");

Expand Down
57 changes: 56 additions & 1 deletion src/commands/staking/validatorDeposit.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,66 @@
import {StakingAction, StakingConfig} from "./StakingAction";
import type {Address} from "genlayer-js/types";
import type {Address, GenLayerClient, GenLayerChain} from "genlayer-js/types";

export interface ValidatorDepositOptions extends StakingConfig {
amount: string;
validator: string;
force?: boolean;
}

export class ValidatorDepositAction extends StakingAction {
constructor() {
super();
}

/**
* Pre-submit checks for a liquid (wallet-funded) top-up deposit:
* 1. Mixing hard-guard: the target wallet must be owned by the signing EOA.
* A vesting-funded validator's wallet is owned by the vesting contract,
* so a liquid deposit would revert on-chain (OwnableUnauthorizedAccount)
* — fail fast with actionable copy instead. No `--force` override.
* 2. Self-stake minimum: the resulting self-stake is the current committed
* self-stake plus still-pending self-stake deposits plus the new amount.
* Block unless `--force`, epoch-0 aware.
*/
private async preflight(
client: GenLayerClient<GenLayerChain>,
validatorWallet: Address,
signerAddress: Address,
amount: bigint,
force?: boolean,
): Promise<void> {
const info = await client.getValidatorInfo(validatorWallet);

// Mixing guard — a liquid deposit into a vesting-owned wallet reverts
// on-chain; fail fast with guidance. This is a hard guard, always enforced.
if (info.owner.toLowerCase() !== signerAddress.toLowerCase()) {
throw new Error(
"This validator wallet is owned by a vesting contract (vesting-funded self-stake). " +
"Self-stake source is fixed at creation — you can't add liquid (wallet) tokens. " +
"Use `genlayer vesting validator-deposit` instead.",
);
}

// The self-stake minimum is advisory. If the chain can't report it, skip
// the check rather than blocking the deposit.
let epochInfo;
try {
epochInfo = await client.getEpochInfo();
} catch {
return;
}
const pendingSelfStakeRaw = info.pendingDeposits.reduce((sum, d) => sum + d.stakeRaw, 0n);
const resultingSelfStakeRaw = info.vStakeRaw + pendingSelfStakeRaw + amount;
this.assertOrWarnSelfStakeMinimum({
currentEpoch: epochInfo.currentEpoch,
minStakeRaw: epochInfo.validatorMinStakeRaw,
minStakeFormatted: epochInfo.validatorMinStake,
resultingSelfStakeRaw,
resultingSelfStakeFormatted: this.formatAmount(resultingSelfStakeRaw),
force,
});
}

async execute(options: ValidatorDepositOptions): Promise<void> {
if (this.isBrowserWallet(options)) {
return this.executeWithBrowserWallet(options);
Expand All @@ -30,6 +80,9 @@ export class ValidatorDepositAction extends StakingAction {
// ValidatorWallet's own `validatorDeposit`, preserving msg.sender ==
// ValidatorWallet when it re-enters Staking.
const client = await this.getStakingClient(options);
const signerAddress = await this.getSignerAddress();

await this.preflight(client, validatorWallet, signerAddress, amount, options.force);

this.setSpinnerText(`Depositing ${this.formatAmount(amount)} to validator ${validatorWallet}...`);

Expand Down Expand Up @@ -67,6 +120,8 @@ export class ValidatorDepositAction extends StakingAction {
const validatorWallet = options.validator as Address;
const client = this.getBrowserStakingClient(options, session);

await this.preflight(client, validatorWallet, session.signerAddress as Address, amount, options.force);

this.log(` From (browser wallet): ${session.signerAddress}`);
session.setNextLabel(`Deposit ${this.formatAmount(amount)} to validator`);
const result = await client.validatorDeposit({
Expand Down
Loading
Loading