diff --git a/e2e/config-default.e2e.ts b/e2e/config-default.e2e.ts index 8de7bbe0..5a2674c0 100644 --- a/e2e/config-default.e2e.ts +++ b/e2e/config-default.e2e.ts @@ -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); @@ -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 diff --git a/e2e/errors.e2e.ts b/e2e/errors.e2e.ts index 63a1aacb..4cf6cab2 100644 --- a/e2e/errors.e2e.ts +++ b/e2e/errors.e2e.ts @@ -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"); @@ -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}, ); diff --git a/e2e/lane-a-staking.e2e.ts b/e2e/lane-a-staking.e2e.ts index e95abb31..7b913e28 100644 --- a/e2e/lane-a-staking.e2e.ts +++ b/e2e/lane-a-staking.e2e.ts @@ -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, ); @@ -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, ); diff --git a/src/commands/staking/index.ts b/src/commands/staking/index.ts index fe49e56d..f1cc249b 100644 --- a/src/commands/staking/index.ts +++ b/src/commands/staking/index.ts @@ -68,6 +68,7 @@ export function initializeStakingCommands(program: Command) { .option("--password ", "Password to unlock account (skips interactive prompt)") .option("--network ", "built-in or custom network alias (see: genlayer network list)") .option("--rpc ", "RPC URL for the network") + .option("--force", "Proceed even if self-stake is below the minimum required to become active") .option("--staking-address
", "Staking contract address (overrides chain config)"), ).action(async (options: ValidatorJoinOptions) => { const action = new ValidatorJoinAction(); @@ -83,7 +84,8 @@ export function initializeStakingCommands(program: Command) { .option("--account ", "Account to use (must be validator owner)") .option("--password ", "Password to unlock account (skips interactive prompt)") .option("--network ", "built-in or custom network alias (see: genlayer network list)") - .option("--rpc ", "RPC URL for the network"), + .option("--rpc ", "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) { @@ -295,6 +297,7 @@ export function initializeStakingCommands(program: Command) { .option("--network ", "built-in or custom network alias (see: genlayer network list)") .option("--rpc ", "RPC URL for the network") .option("--staking-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; diff --git a/src/commands/staking/stakingInfo.ts b/src/commands/staking/stakingInfo.ts index 0d8f92a8..4f4d5a80 100644 --- a/src/commands/staking/stakingInfo.ts +++ b/src/commands/staking/stakingInfo.ts @@ -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 { @@ -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 { + 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, " 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, + 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 { this.startSpinner("Fetching stake info..."); diff --git a/src/commands/staking/validatorDeposit.ts b/src/commands/staking/validatorDeposit.ts index 56568a45..69831849 100644 --- a/src/commands/staking/validatorDeposit.ts +++ b/src/commands/staking/validatorDeposit.ts @@ -1,9 +1,10 @@ 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 { @@ -11,6 +12,55 @@ export class ValidatorDepositAction extends StakingAction { 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, + validatorWallet: Address, + signerAddress: Address, + amount: bigint, + force?: boolean, + ): Promise { + 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 { if (this.isBrowserWallet(options)) { return this.executeWithBrowserWallet(options); @@ -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}...`); @@ -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({ diff --git a/src/commands/staking/validatorJoin.ts b/src/commands/staking/validatorJoin.ts index 943bf409..8318dab4 100644 --- a/src/commands/staking/validatorJoin.ts +++ b/src/commands/staking/validatorJoin.ts @@ -1,9 +1,10 @@ import {StakingAction, StakingConfig} from "./StakingAction"; -import type {Address} from "genlayer-js/types"; +import type {Address, GenLayerClient, GenLayerChain} from "genlayer-js/types"; export interface ValidatorJoinOptions extends StakingConfig { amount: string; operator?: string; + force?: boolean; } export class ValidatorJoinAction extends StakingAction { @@ -11,6 +12,40 @@ export class ValidatorJoinAction extends StakingAction { super(); } + /** + * A fresh join always creates a NEW liquid (wallet-funded) validator wallet, + * so the self-stake source is fixed at creation and the resulting self-stake + * is exactly the join amount. Warn/block if that is below the on-chain + * minimum, and surface the source note. + */ + private async preflight( + client: GenLayerClient, + amount: bigint, + force?: boolean, + ): Promise { + this.logInfo( + "Creating a liquid (wallet-funded) validator. Self-stake source is fixed at creation — " + + "you won't be able to add vesting tokens later.", + ); + // The self-stake minimum is advisory. If the chain can't report it + // (a minimal/stub staking contract without epoch/minStake reads, or a + // transient read failure), skip the check rather than blocking the join. + let epochInfo; + try { + epochInfo = await client.getEpochInfo(); + } catch { + return; + } + this.assertOrWarnSelfStakeMinimum({ + currentEpoch: epochInfo.currentEpoch, + minStakeRaw: epochInfo.validatorMinStakeRaw, + minStakeFormatted: epochInfo.validatorMinStake, + resultingSelfStakeRaw: amount, + resultingSelfStakeFormatted: this.formatAmount(amount), + force, + }); + } + async execute(options: ValidatorJoinOptions): Promise { if (this.isBrowserWallet(options)) { return this.executeWithBrowserWallet(options); @@ -23,6 +58,8 @@ export class ValidatorJoinAction extends StakingAction { const amount = this.parseAmount(options.amount); const signerAddress = await this.getSignerAddress(); + await this.preflight(client, amount, options.force); + this.setSpinnerText(`Creating validator with ${this.formatAmount(amount)} stake...`); this.log(` From: ${signerAddress}`); if (options.operator) { @@ -63,6 +100,8 @@ export class ValidatorJoinAction extends StakingAction { const amount = this.parseAmount(options.amount); const client = this.getBrowserStakingClient(options, session); + await this.preflight(client, amount, options.force); + this.log(` From (browser wallet): ${session.signerAddress}`); if (options.operator) { this.log(` Operator: ${options.operator}`); diff --git a/src/commands/staking/validatorPrime.ts b/src/commands/staking/validatorPrime.ts index 42e26903..7cb96c0a 100644 --- a/src/commands/staking/validatorPrime.ts +++ b/src/commands/staking/validatorPrime.ts @@ -33,6 +33,8 @@ export class ValidatorPrimeAction extends StakingAction { const result = await client.validatorPrime({validator: options.validator as Address}); + await this.maybeNotePrimedBelowMinimum(client, options.validator as Address); + const output = { transactionHash: result.transactionHash, validator: options.validator, @@ -76,6 +78,34 @@ export class ValidatorPrimeAction extends StakingAction { } } + /** + * Best-effort one-line note: priming makes a validator's stake record ready + * for the next epoch, but it still won't become active while self-stake is + * below the on-chain minimum. Purely informational; never fails the prime. + */ + private async maybeNotePrimedBelowMinimum( + client: GenLayerClient, + validator: Address, + ): Promise { + try { + const [info, epochInfo] = await Promise.all([ + client.getValidatorInfo(validator), + client.getEpochInfo(), + ]); + if (epochInfo.currentEpoch === 0n) return; + const effectiveSelfStakeRaw = + info.vStakeRaw + info.pendingDeposits.reduce((sum, d) => sum + d.stakeRaw, 0n); + if (effectiveSelfStakeRaw < epochInfo.validatorMinStakeRaw) { + this.logInfo( + `Primed, but this validator won't become active until its self-stake reaches ` + + `${epochInfo.validatorMinStake} (currently ${info.vStake}).`, + ); + } + } catch { + // Informational only — never block or fail the prime on this note. + } + } + async primeAll(options: StakingConfig): Promise { if (this.isBrowserWallet(options)) { return this.primeAllWithBrowserWallet(options); diff --git a/src/commands/vesting/index.ts b/src/commands/vesting/index.ts index 19b24c9b..73b67a31 100644 --- a/src/commands/vesting/index.ts +++ b/src/commands/vesting/index.ts @@ -150,7 +150,8 @@ export function initializeVestingCommands(program: Command) { .command(`${name} [operator]`) .description("Create a vesting-backed validator") .option("--operator
", "Operator address (deprecated, use positional arg)") - .requiredOption("--amount ", "Amount to self-stake (in wei or with 'eth'/'gen' suffix)"), + .requiredOption("--amount ", "Amount to self-stake (in wei or with 'eth'/'gen' suffix)") + .option("--force", "Proceed even if self-stake is below the minimum required to become active"), ).action(async (operatorArg: string | undefined, options: VestingValidatorCreateOptions) => { const operator = requireOperator(operatorArg, options); const action = new VestingValidatorCreateAction(); @@ -166,7 +167,8 @@ export function initializeVestingCommands(program: Command) { validator .command("deposit [wallet]") .description("Deposit more vesting-held tokens to a validator wallet") - .requiredOption("--amount ", "Amount to deposit (in wei or with 'eth'/'gen' suffix)"), + .requiredOption("--amount ", "Amount to deposit (in wei or with 'eth'/'gen' suffix)") + .option("--force", "Proceed even if self-stake is below the minimum required to become active"), ), ).action(async (walletArg: string | undefined, options: VestingValidatorDepositOptions) => { const wallet = requireWallet(walletArg, options); diff --git a/src/commands/vesting/validatorCreate.ts b/src/commands/vesting/validatorCreate.ts index a62f25dc..a614914e 100644 --- a/src/commands/vesting/validatorCreate.ts +++ b/src/commands/vesting/validatorCreate.ts @@ -1,9 +1,11 @@ import {VestingAction, VestingConfig} from "./VestingAction"; import type {Address} from "genlayer-js/types"; +import type {VestingClient} from "./vestingTypes"; export interface VestingValidatorCreateOptions extends VestingConfig { operator: string; amount: string; + force?: boolean; } export class VestingValidatorCreateAction extends VestingAction { @@ -11,6 +13,34 @@ export class VestingValidatorCreateAction extends VestingAction { super(); } + /** + * A vesting-backed create always spins up a NEW wallet owned by the vesting + * contract, so the self-stake source is fixed and the resulting self-stake is + * exactly the create amount. Warn/block if that is below the on-chain + * minimum, and surface the source note. + */ + private async preflight(client: VestingClient, amount: bigint, force?: boolean): Promise { + this.logInfo( + "Creating a vesting-funded validator. Self-stake source is fixed — you won't be able to add " + + "liquid self-stake later.", + ); + // Advisory min check — skip if the chain can't report the minimum. + let epochInfo; + try { + epochInfo = await client.getEpochInfo(); + } catch { + return; + } + this.assertOrWarnSelfStakeMinimum({ + currentEpoch: epochInfo.currentEpoch, + minStakeRaw: epochInfo.validatorMinStakeRaw, + minStakeFormatted: epochInfo.validatorMinStake, + resultingSelfStakeRaw: amount, + resultingSelfStakeFormatted: this.formatAmount(amount), + force, + }); + } + async execute(options: VestingValidatorCreateOptions): Promise { if (this.isBrowserWallet(options)) { return this.executeWithBrowserWallet(options); @@ -23,6 +53,8 @@ export class VestingValidatorCreateAction extends VestingAction { const amount = this.parseAmount(options.amount); const vesting = await this.resolveBeneficiaryVesting(client, options); + await this.preflight(client, amount, options.force); + this.setSpinnerText(`Creating validator with ${this.formatAmount(amount)} from vesting ${vesting}...`); const result = await client.vestingValidatorJoin({ @@ -75,6 +107,8 @@ export class VestingValidatorCreateAction extends VestingAction { const vesting = await this.resolveBeneficiaryVesting(client, options); const amount = this.parseAmount(options.amount); + await this.preflight(client, amount, options.force); + session.setNextLabel("Create vesting validator"); const result = await client.vestingValidatorJoin({ vesting, diff --git a/src/commands/vesting/validatorDeposit.ts b/src/commands/vesting/validatorDeposit.ts index 09df920d..37e6594a 100644 --- a/src/commands/vesting/validatorDeposit.ts +++ b/src/commands/vesting/validatorDeposit.ts @@ -1,9 +1,11 @@ import {VestingAction, VestingConfig} from "./VestingAction"; import type {Address} from "genlayer-js/types"; +import type {VestingClient} from "./vestingTypes"; export interface VestingValidatorDepositOptions extends VestingConfig { walletAddress: string; amount: string; + force?: boolean; } export class VestingValidatorDepositAction extends VestingAction { @@ -11,6 +13,56 @@ export class VestingValidatorDepositAction extends VestingAction { super(); } + /** + * Pre-submit checks for a vesting-funded top-up deposit: + * 1. Mixing hard-guard: the target wallet must have been created by THIS + * vesting contract (`isValidatorWallet`). A liquid, wallet-funded + * validator's wallet is not owned by the vesting contract, so depositing + * vesting tokens would revert on-chain — fail fast with actionable copy. + * No `--force` override. + * 2. Self-stake minimum: 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: VestingClient, + vesting: Address, + wallet: Address, + amount: bigint, + force?: boolean, + ): Promise { + const isOwned = await client.isValidatorWallet(vesting, wallet); + if (!isOwned) { + throw new Error( + "This wallet was not created by this vesting contract (it's a liquid, wallet-funded " + + "validator). You can't add vesting tokens. Use `genlayer staking validator-deposit` from " + + "the owner wallet instead.", + ); + } + + // Advisory min check (the mixing guard above is already enforced) — skip + // if the chain can't report validator/epoch state. + let info, epochInfo; + try { + [info, epochInfo] = await Promise.all([ + client.getValidatorInfo(wallet), + 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: VestingValidatorDepositOptions): Promise { if (this.isBrowserWallet(options)) { return this.executeWithBrowserWallet(options); @@ -23,6 +75,8 @@ export class VestingValidatorDepositAction extends VestingAction { const amount = this.parseAmount(options.amount); const vesting = await this.resolveBeneficiaryVesting(client, options); + await this.preflight(client, vesting, options.walletAddress as Address, amount, options.force); + this.setSpinnerText(`Depositing ${this.formatAmount(amount)} from vesting ${vesting} to wallet ${options.walletAddress}...`); const result = await client.vestingValidatorDeposit({ @@ -62,6 +116,8 @@ export class VestingValidatorDepositAction extends VestingAction { const vesting = await this.resolveBeneficiaryVesting(client, options); const amount = this.parseAmount(options.amount); + await this.preflight(client, vesting, options.walletAddress as Address, amount, options.force); + session.setNextLabel(`Deposit ${this.formatAmount(amount)} to validator wallet`); const result = await client.vestingValidatorDeposit({ vesting, diff --git a/src/lib/actions/BaseAction.ts b/src/lib/actions/BaseAction.ts index 5361659a..01eeb67c 100644 --- a/src/lib/actions/BaseAction.ts +++ b/src/lib/actions/BaseAction.ts @@ -508,6 +508,73 @@ export class BaseAction extends ConfigFileManager { if (error !== undefined) console.error(chalk.red(this.formatOutput(error))); } + /** + * Self-stake eligibility gate shared by the validator write commands + * (staking validator-join / validator-deposit, vesting validator-create / + * validator-deposit). Both StakingAction and VestingAction extend BaseAction, + * so this is the common home they can both reach. + * + * Eligibility is SELF-stake only — delegated stake never counts. The minimum + * is the on-chain `validatorMinStakeRaw` read from `getEpochInfo()`; never + * hardcode it. Mirrors the wizard's epoch-0 carve-out (staking/wizard.ts): + * at epoch 0 the minimum is not enforced, but the informational note is still + * surfaced. + * + * Behaviour: + * - epoch 0: informational note only, never blocks. + * - resulting self-stake >= min: silent (already eligible). + * - resulting self-stake < min: block by THROWING (the caller's catch turns + * it into a failSpinner) unless `force` is set, in which case it warns and + * proceeds. The thrown/warned message names `--force`. + */ + protected assertOrWarnSelfStakeMinimum(params: { + currentEpoch: bigint; + minStakeRaw: bigint; + minStakeFormatted: string; + resultingSelfStakeRaw: bigint; + resultingSelfStakeFormatted: string; + force?: boolean; + }): void { + const { + currentEpoch, + minStakeRaw, + minStakeFormatted, + resultingSelfStakeRaw, + resultingSelfStakeFormatted, + force, + } = params; + + if (currentEpoch === 0n) { + this.logInfo( + `Epoch 0: minimum self-stake not enforced yet. Note: this validator won't become active ` + + `until its self-stake reaches ${minStakeFormatted}.`, + ); + return; + } + + if (resultingSelfStakeRaw >= minStakeRaw) { + return; + } + + const base = + `Resulting self-stake ${resultingSelfStakeFormatted} is below the ${minStakeFormatted} minimum ` + + `required to become an active validator. Only self-stake counts toward eligibility (delegated ` + + `stake does not).`; + + if (force) { + this.logWarning( + `${base} Proceeding anyway because --force was set; the validator will stay inactive until ` + + `its self-stake reaches the minimum.`, + ); + return; + } + + throw new Error( + `${base} Increase the amount, or pass --force to proceed anyway (the validator will stay ` + + `inactive until its self-stake reaches the minimum).`, + ); + } + protected startSpinner(message: string) { this.spinner.text = chalk.blue(`${message}`); this.spinner.start(); diff --git a/tests/actions/staking.test.ts b/tests/actions/staking.test.ts index fe9a3679..177b1686 100644 --- a/tests/actions/staking.test.ts +++ b/tests/actions/staking.test.ts @@ -17,7 +17,11 @@ vi.mock("genlayer-js", () => ({ formatStakingAmount: vi.fn((val: bigint) => `${Number(val) / 1e18} GEN`), parseStakingAmount: vi.fn((val: string) => { if (val.toLowerCase().endsWith("gen") || val.toLowerCase().endsWith("eth")) { - return BigInt(parseFloat(val.slice(0, -3)) * 1e18); + // Scale via bigint so integer GEN amounts are exact (e.g. "42000gen" == + // 42000e18, not 41999.99e18 from float rounding) — the self-stake minimum + // check compares against exactly that boundary. Keeps sub-GEN precision. + const gen = parseFloat(val.slice(0, -3)); + return BigInt(Math.round(gen * 1e9)) * BigInt(1e9); } return BigInt(val); }), @@ -101,6 +105,9 @@ describe("ValidatorJoinAction", () => { action = new ValidatorJoinAction(); setupActionMocks(action); mockClient.validatorJoin.mockResolvedValue(mockValidatorJoinResult); + // Pre-submit self-stake minimum check reads epochInfo. The 42000gen joins + // used here meet the mocked 42000 GEN minimum, so it passes silently. + mockClient.getEpochInfo.mockResolvedValue(mockEpochInfo); }); afterEach(() => { @@ -151,6 +158,20 @@ describe("ValidatorDepositAction", () => { action = new ValidatorDepositAction(); setupActionMocks(action); mockClient.validatorDeposit.mockResolvedValue(mockTxResult); + // Pre-submit checks: mixing hard-guard (wallet owned by the signing EOA) + // and self-stake minimum. Owner matches the mocked signer and vStake is + // already at the minimum, so both pass silently for the default deposits. + mockClient.getEpochInfo.mockResolvedValue(mockEpochInfo); + mockClient.getValidatorInfo.mockResolvedValue({ + address: "0xValidatorWallet", + owner: "0xMockedSigner", + operator: "0xOperator", + vStake: "42000 GEN", + vStakeRaw: 42000n * BigInt(1e18), + dStakeRaw: 0n, + pendingDeposits: [], + pendingWithdrawals: [], + }); }); afterEach(() => { @@ -476,7 +497,9 @@ describe("StakingInfoAction", () => { await action.getValidatorInfo({validator: "0xValidator", stakingAddress: "0xStaking"}); expect(mockClient.isValidator).toHaveBeenCalledWith("0xValidator"); - expect(action["succeedSpinner"]).toHaveBeenCalledWith("Validator info retrieved", expect.any(Object)); + // The clean grouped view prints via console.log; the success line no longer + // carries the raw result object (that lives behind --json now). + expect(action["succeedSpinner"]).toHaveBeenCalledWith("Validator info retrieved"); }); test("fails if not a validator", async () => { @@ -586,7 +609,10 @@ describe("ValidatorJoinAction --wallet browser", () => { vi.spyOn(action as any, "getBrowserWalletSession").mockResolvedValue(session); // Browser writes run the SAME SDK call as the keystore lane; the client is // built by getBrowserStakingClient (synchronous — Address account + provider). - const mockBrowserClient = {validatorJoin: vi.fn().mockResolvedValue(mockBrowserJoinResult)}; + const mockBrowserClient = { + validatorJoin: vi.fn().mockResolvedValue(mockBrowserJoinResult), + getEpochInfo: vi.fn().mockResolvedValue(mockEpochInfo), + }; vi.spyOn(action as any, "getBrowserStakingClient").mockReturnValue(mockBrowserClient); await action.execute({amount: "42000gen", wallet: "browser", stakingAddress: "0xStaking"}); @@ -622,6 +648,7 @@ describe("ValidatorJoinAction --wallet browser", () => { vi.spyOn(action as any, "getBrowserWalletSession").mockResolvedValue(session); vi.spyOn(action as any, "getBrowserStakingClient").mockReturnValue({ validatorJoin: vi.fn().mockRejectedValue(new Error("Transaction rejected in wallet")), + getEpochInfo: vi.fn().mockResolvedValue(mockEpochInfo), }); await action.execute({amount: "42000gen", wallet: "browser", stakingAddress: "0xStaking"}); @@ -698,6 +725,19 @@ describe("ValidatorDepositAction --wallet browser", () => { vi.spyOn(action as any, "getBrowserWalletSession").mockResolvedValue(session); const mockClient = { validatorDeposit: vi.fn().mockResolvedValue({transactionHash: "0xBH", blockNumber: 5n, gasUsed: 6n}), + getEpochInfo: vi.fn().mockResolvedValue(mockEpochInfo), + // Owner matches the browser session signer (0xBrowserOwner) and vStake is + // at the minimum, so the mixing guard and min check pass silently. + getValidatorInfo: vi.fn().mockResolvedValue({ + address: "0xVW", + owner: "0xBrowserOwner", + operator: "0xOp", + vStake: "42000 GEN", + vStakeRaw: 42000n * BigInt(1e18), + dStakeRaw: 0n, + pendingDeposits: [], + pendingWithdrawals: [], + }), }; vi.spyOn(action as any, "getBrowserStakingClient").mockReturnValue(mockClient); @@ -729,6 +769,17 @@ describe("ValidatorDepositAction --wallet browser", () => { vi.spyOn(action as any, "getBrowserWalletSession").mockResolvedValue(session); vi.spyOn(action as any, "getBrowserStakingClient").mockReturnValue({ validatorDeposit: vi.fn().mockRejectedValue(new Error("Rejected in wallet")), + getEpochInfo: vi.fn().mockResolvedValue(mockEpochInfo), + getValidatorInfo: vi.fn().mockResolvedValue({ + address: "0xVW", + owner: "0xBrowserOwner", + operator: "0xOp", + vStake: "42000 GEN", + vStakeRaw: 42000n * BigInt(1e18), + dStakeRaw: 0n, + pendingDeposits: [], + pendingWithdrawals: [], + }), }); await action.execute({validator: "0xVW", amount: "1gen", wallet: "browser"}); @@ -845,3 +896,231 @@ describe("DelegatorClaimAction --wallet browser", () => { ); }); }); + +// --------------------------------------------------------------------------- +// Self-stake eligibility gate + liquid/vesting mixing guards + clean +// validator-info view. Minimums are always driven from the mocked epochInfo — +// never a hardcoded 42000 — so the gate tracks the on-chain param. +// --------------------------------------------------------------------------- + +// Min set well above the join/deposit amounts used below so the gate trips. +const highMinEpochInfo = { + ...mockEpochInfo, + currentEpoch: 7n, + validatorMinStake: "50000 GEN", + validatorMinStakeRaw: 50000n * BigInt(1e18), +}; +const epoch0EpochInfo = {...highMinEpochInfo, currentEpoch: 0n}; + +function fullValidatorInfo(overrides: Record = {}) { + return { + address: "0xValidatorWallet", + owner: "0xMockedSigner", + operator: "0xOperator", + vStake: "1000 GEN", + vStakeRaw: 1000n * BigInt(1e18), + vShares: 100n, + dStake: "500 GEN", + dStakeRaw: 500n * BigInt(1e18), + dShares: 50n, + vDeposit: "0 GEN", + vDepositRaw: 0n, + vWithdrawal: "0 GEN", + vWithdrawalRaw: 0n, + ePrimed: 5n, + needsPriming: false, + live: true, + banned: false, + bannedEpoch: null, + pendingDeposits: [], + pendingWithdrawals: [], + identity: null, + ...overrides, + }; +} + +describe("ValidatorJoinAction self-stake minimum gate", () => { + let action: ValidatorJoinAction; + + beforeEach(() => { + vi.clearAllMocks(); + action = new ValidatorJoinAction(); + setupActionMocks(action); + mockClient.validatorJoin.mockResolvedValue(mockValidatorJoinResult); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + test("blocks a join below the minimum when --force is not set", async () => { + mockClient.getEpochInfo.mockResolvedValue(highMinEpochInfo); + + await action.execute({amount: "42000gen", stakingAddress: "0xStaking"}); + + expect(mockClient.validatorJoin).not.toHaveBeenCalled(); + const [msg, detail] = (action["failSpinner"] as any).mock.calls[0]; + expect(msg).toBe("Failed to create validator"); + expect(detail).toContain(highMinEpochInfo.validatorMinStake); + expect(detail).toContain("--force"); + }); + + test("proceeds with --force below the minimum and warns", async () => { + mockClient.getEpochInfo.mockResolvedValue(highMinEpochInfo); + const warnSpy = vi.spyOn(action as any, "logWarning").mockImplementation(() => {}); + + await action.execute({amount: "42000gen", stakingAddress: "0xStaking", force: true}); + + expect(mockClient.validatorJoin).toHaveBeenCalledTimes(1); + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining(highMinEpochInfo.validatorMinStake)); + expect(action["failSpinner"]).not.toHaveBeenCalled(); + }); + + test("does not block at epoch 0 even below the minimum", async () => { + mockClient.getEpochInfo.mockResolvedValue(epoch0EpochInfo); + + await action.execute({amount: "42000gen", stakingAddress: "0xStaking"}); + + expect(mockClient.validatorJoin).toHaveBeenCalledTimes(1); + expect(action["failSpinner"]).not.toHaveBeenCalled(); + }); +}); + +describe("ValidatorDepositAction minimum gate + mixing guard", () => { + let action: ValidatorDepositAction; + + beforeEach(() => { + vi.clearAllMocks(); + action = new ValidatorDepositAction(); + setupActionMocks(action); + mockClient.validatorDeposit.mockResolvedValue(mockTxResult); + mockClient.getEpochInfo.mockResolvedValue(highMinEpochInfo); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + test("blocks when resulting self-stake (vStake + pending + amount) is below the minimum", async () => { + // vStake 1000 + pending 0 + 10 = 1010 GEN, far below the 50000 GEN min. + mockClient.getValidatorInfo.mockResolvedValue(fullValidatorInfo()); + + await action.execute({validator: "0xValidatorWallet", amount: "10gen", stakingAddress: "0xStaking"}); + + expect(mockClient.validatorDeposit).not.toHaveBeenCalled(); + const [msg, detail] = (action["failSpinner"] as any).mock.calls[0]; + expect(msg).toBe("Failed to make deposit"); + expect(detail).toContain(highMinEpochInfo.validatorMinStake); + expect(detail).toContain("--force"); + }); + + test("counts still-pending self-stake deposits toward the resulting stake", async () => { + // vStake 1000 + pending 49500 + 10 = 50510 GEN >= 50000 GEN min → proceeds. + mockClient.getValidatorInfo.mockResolvedValue( + fullValidatorInfo({ + pendingDeposits: [{epoch: 6n, stake: "49500 GEN", stakeRaw: 49500n * BigInt(1e18), shares: 1n}], + }), + ); + + await action.execute({validator: "0xValidatorWallet", amount: "10gen", stakingAddress: "0xStaking"}); + + expect(mockClient.validatorDeposit).toHaveBeenCalledTimes(1); + expect(action["failSpinner"]).not.toHaveBeenCalled(); + }); + + test("hard-blocks a liquid deposit into a vesting-owned wallet (no --force override)", async () => { + mockClient.getValidatorInfo.mockResolvedValue(fullValidatorInfo({owner: "0xVestingContract"})); + + // Even with --force the mixing guard blocks (the tx would revert on-chain). + await action.execute({validator: "0xValidatorWallet", amount: "10gen", stakingAddress: "0xStaking", force: true}); + + expect(mockClient.validatorDeposit).not.toHaveBeenCalled(); + const [msg, detail] = (action["failSpinner"] as any).mock.calls[0]; + expect(msg).toBe("Failed to make deposit"); + expect(detail).toContain("vesting"); + expect(detail).toContain("genlayer vesting validator-deposit"); + }); +}); + +describe("StakingInfoAction clean view + eligibility display", () => { + let action: StakingInfoAction; + let logSpy: any; + + beforeEach(() => { + vi.clearAllMocks(); + action = new StakingInfoAction(); + setupActionMocks(action); + mockClient.isValidator.mockResolvedValue(true); + mockClient.getEpochInfo.mockResolvedValue(highMinEpochInfo); + logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + const printed = () => logSpy.mock.calls.map((c: any[]) => String(c[0] ?? "")).join("\n"); + + test("--json prints the raw object and skips the grouped view / success line", async () => { + mockClient.getValidatorInfo.mockResolvedValue(fullValidatorInfo()); + + await action.getValidatorInfo({validator: "0xValidatorWallet", stakingAddress: "0xStaking", json: true}); + + expect(action["succeedSpinner"]).not.toHaveBeenCalled(); + const parsed = JSON.parse(logSpy.mock.calls[0][0]); + expect(parsed.validator).toBe("0xValidatorWallet"); + expect(parsed.live).toBe(true); + expect(parsed.banned).toBe("Not banned"); + }); + + test("clean view keeps load-bearing values as plain substrings (e2e grep safety)", async () => { + mockClient.getValidatorInfo.mockResolvedValue( + fullValidatorInfo({vStake: "60000 GEN", vStakeRaw: 60000n * BigInt(1e18), identity: {moniker: "AcmeNode"}}), + ); + + await action.getValidatorInfo({validator: "0xValidatorWallet", stakingAddress: "0xStaking"}); + + const out = printed(); + expect(action["succeedSpinner"]).toHaveBeenCalledWith("Validator info retrieved"); + expect(out).toContain("0xValidatorWallet"); // validator address + expect(out).toContain("0xMockedSigner"); // owner + expect(out).toContain("0xOperator"); // operator + expect(out).toContain("60000 GEN"); // self-stake amount + expect(out).toContain("500 GEN"); // delegated amount + expect(out).toContain("AcmeNode"); // moniker + expect(out).toContain("Not banned"); + expect(out).toContain("live"); // the live label + expect(out).toContain("true"); // the live boolean value + // Never relabels live as "active". + expect(out).not.toContain("active"); + }); + + test("warns (display only) when effective self-stake is below the minimum, never blocks", async () => { + mockClient.getValidatorInfo.mockResolvedValue(fullValidatorInfo()); + const warnSpy = vi.spyOn(action as any, "logWarning").mockImplementation(() => {}); + + await action.getValidatorInfo({validator: "0xValidatorWallet", stakingAddress: "0xStaking"}); + + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining(highMinEpochInfo.validatorMinStake)); + // Read command still succeeds — the warning never blocks. + expect(action["succeedSpinner"]).toHaveBeenCalledWith("Validator info retrieved"); + expect(action["failSpinner"]).not.toHaveBeenCalled(); + }); + + test("informs when a pending deposit will cross the minimum, with the activation epoch", async () => { + // vStake 1000 (< 50000 min) but pending 60000 crosses it. Deposit epoch 6 + + // 2 activation-delay epochs → activates at epoch 8. + mockClient.getValidatorInfo.mockResolvedValue( + fullValidatorInfo({ + pendingDeposits: [{epoch: 6n, stake: "60000 GEN", stakeRaw: 60000n * BigInt(1e18), shares: 1n}], + }), + ); + const infoSpy = vi.spyOn(action as any, "logInfo").mockImplementation(() => {}); + const warnSpy = vi.spyOn(action as any, "logWarning").mockImplementation(() => {}); + + await action.getValidatorInfo({validator: "0xValidatorWallet", stakingAddress: "0xStaking"}); + + expect(warnSpy).not.toHaveBeenCalled(); + expect(infoSpy).toHaveBeenCalledWith(expect.stringContaining("epoch 8")); + }); +}); diff --git a/tests/actions/vesting.test.ts b/tests/actions/vesting.test.ts index 9dbb048f..e3a9680b 100644 --- a/tests/actions/vesting.test.ts +++ b/tests/actions/vesting.test.ts @@ -1,6 +1,8 @@ import {describe, test, vi, beforeEach, afterEach, expect} from "vitest"; import {VestingDelegateAction} from "../../src/commands/vesting/delegate"; import {VestingWithdrawAction} from "../../src/commands/vesting/withdraw"; +import {VestingValidatorCreateAction} from "../../src/commands/vesting/validatorCreate"; +import {VestingValidatorDepositAction} from "../../src/commands/vesting/validatorDeposit"; // Mock genlayer-js. The browser-wallet path routes writes through the SDK // client built by getBrowserVestingClient, which we spy in each test, so @@ -182,3 +184,122 @@ describe("VestingWithdrawAction --wallet browser", () => { ); }); }); + +// --------------------------------------------------------------------------- +// Keystore-lane self-stake minimum gate + vesting/liquid mixing hard-guard. +// The minimum is driven from the mocked epochInfo, never hardcoded. +// --------------------------------------------------------------------------- + +const vestingEpochInfo = { + currentEpoch: 7n, + validatorMinStake: "50000 GEN", + validatorMinStakeRaw: 50000n * BigInt(1e18), + delegatorMinStake: "42 GEN", + delegatorMinStakeRaw: 42n * BigInt(1e18), +}; + +function setupVestingKeystoreMocks(action: any, clientOverrides: Record = {}) { + vi.spyOn(action, "startSpinner").mockImplementation(() => {}); + vi.spyOn(action, "setSpinnerText").mockImplementation(() => {}); + vi.spyOn(action, "succeedSpinner").mockImplementation(() => {}); + vi.spyOn(action, "failSpinner").mockImplementation(() => {}); + vi.spyOn(action, "log").mockImplementation(() => {}); + vi.spyOn(action, "logInfo").mockImplementation(() => {}); + vi.spyOn(action, "logWarning").mockImplementation(() => {}); + vi.spyOn(action, "resolveBeneficiaryVesting").mockResolvedValue("0xVesting"); + const client = { + vestingValidatorJoin: vi.fn().mockResolvedValue({ + transactionHash: "0xVH", + vesting: "0xVesting", + validatorWallet: "0xWallet", + blockNumber: 1n, + gasUsed: 2n, + }), + vestingValidatorDeposit: vi.fn().mockResolvedValue({transactionHash: "0xVH", blockNumber: 1n, gasUsed: 2n}), + getValidatorWallets: vi.fn().mockResolvedValue(["0xWallet"]), + getEpochInfo: vi.fn().mockResolvedValue(vestingEpochInfo), + getValidatorInfo: vi.fn().mockResolvedValue({ + address: "0xWallet", + owner: "0xVesting", + operator: "0xOperator", + vStake: "1000 GEN", + vStakeRaw: 1000n * BigInt(1e18), + dStakeRaw: 0n, + pendingDeposits: [], + pendingWithdrawals: [], + }), + isValidatorWallet: vi.fn().mockResolvedValue(true), + ...clientOverrides, + }; + vi.spyOn(action as any, "getVestingClient").mockResolvedValue(client); + return client; +} + +describe("VestingValidatorCreateAction self-stake minimum gate", () => { + let action: VestingValidatorCreateAction; + + beforeEach(() => { + vi.clearAllMocks(); + action = new VestingValidatorCreateAction(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + test("blocks a create below the minimum without --force", async () => { + const client = setupVestingKeystoreMocks(action); + + await action.execute({operator: "0xOperator", amount: "100gen"}); + + expect(client.vestingValidatorJoin).not.toHaveBeenCalled(); + const [msg, detail] = (action["failSpinner"] as any).mock.calls[0]; + expect(msg).toBe("Failed to create vesting-backed validator"); + expect(detail).toContain(vestingEpochInfo.validatorMinStake); + expect(detail).toContain("--force"); + }); + + test("proceeds with --force below the minimum", async () => { + const client = setupVestingKeystoreMocks(action); + + await action.execute({operator: "0xOperator", amount: "100gen", force: true}); + + expect(client.vestingValidatorJoin).toHaveBeenCalledTimes(1); + expect(action["failSpinner"]).not.toHaveBeenCalled(); + }); +}); + +describe("VestingValidatorDepositAction mixing guard", () => { + let action: VestingValidatorDepositAction; + + beforeEach(() => { + vi.clearAllMocks(); + action = new VestingValidatorDepositAction(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + test("hard-blocks a vesting deposit into a wallet not created by this vesting contract", async () => { + const client = setupVestingKeystoreMocks(action, {isValidatorWallet: vi.fn().mockResolvedValue(false)}); + + // Even with --force: the mixing guard is not overridable (tx would revert). + await action.execute({walletAddress: "0xLiquidWallet", amount: "100gen", force: true}); + + expect(client.vestingValidatorDeposit).not.toHaveBeenCalled(); + const [msg, detail] = (action["failSpinner"] as any).mock.calls[0]; + expect(msg).toBe("Failed to deposit vesting validator tokens"); + expect(detail).toContain("staking validator-deposit"); + }); + + test("blocks a vesting deposit below the minimum without --force", async () => { + const client = setupVestingKeystoreMocks(action); // isValidatorWallet true, vStake 1000 GEN + await action.execute({walletAddress: "0xWallet", amount: "100gen"}); + + expect(client.vestingValidatorDeposit).not.toHaveBeenCalled(); + const [msg, detail] = (action["failSpinner"] as any).mock.calls[0]; + expect(msg).toBe("Failed to deposit vesting validator tokens"); + expect(detail).toContain(vestingEpochInfo.validatorMinStake); + }); +}); diff --git a/tests/commands/vesting.test.ts b/tests/commands/vesting.test.ts index 497ce67c..7a606529 100644 --- a/tests/commands/vesting.test.ts +++ b/tests/commands/vesting.test.ts @@ -96,6 +96,8 @@ const mockClient = { validatorDeposited: vi.fn(), isValidatorWallet: vi.fn(), getStakeInfo: vi.fn(), + getEpochInfo: vi.fn(), + getValidatorInfo: vi.fn(), }; describe("vesting commands", () => { @@ -144,6 +146,26 @@ describe("vesting commands", () => { mockClient.validatorWalletCount.mockResolvedValue(1n); mockClient.validatorDeposited.mockResolvedValue(42n); mockClient.isValidatorWallet.mockResolvedValue(true); + // Self-stake pre-submit checks: min read from epochInfo (42 GEN here, met by + // the 42gen create / 42gen+10gen deposit) and the vesting-wallet ownership + // guard (isValidatorWallet true above). + mockClient.getEpochInfo.mockResolvedValue({ + currentEpoch: 5n, + validatorMinStake: "42 GEN", + validatorMinStakeRaw: 42n * BigInt(1e18), + delegatorMinStake: "42 GEN", + delegatorMinStakeRaw: 42n * BigInt(1e18), + }); + mockClient.getValidatorInfo.mockResolvedValue({ + address: "0xWallet", + owner: "0xVesting", + operator: "0xOperator", + vStake: "42 GEN", + vStakeRaw: 42n * BigInt(1e18), + dStakeRaw: 0n, + pendingDeposits: [], + pendingWithdrawals: [], + }); mockClient.getStakeInfo.mockResolvedValue({ delegator: "0xVesting", validator: "0xValidator",