diff --git a/docs/validator-guide.md b/docs/validator-guide.md index 419bfe07..fc9aac18 100644 --- a/docs/validator-guide.md +++ b/docs/validator-guide.md @@ -143,7 +143,7 @@ Options: This way, if your operator server is compromised, your staked funds remain safe. -If you already have an operator wallet (e.g., from geth, foundry, or another tool), you can use its address directly. Otherwise, create one: +The operator must be an account in the CLI: the join includes a proof of possession that the CLI signs with the operator key. If you already have an operator key (e.g., from geth, foundry, or another tool), import it first with `genlayer account import --name operator`. Otherwise, create one: ```bash # Create operator account (skip if you already have one) @@ -199,8 +199,8 @@ Output: Set your validator's public identity so delegators can find you: ```bash -genlayer staking set-identity \ - --validator 0x86D0...7E \ +genlayer staking set-identity 0x86D0...7E \ + --account owner \ --moniker "My Validator" \ --website "https://myvalidator.com" \ --description "Reliable validator with 99.9% uptime" \ @@ -209,9 +209,13 @@ genlayer staking set-identity \ ``` **Required:** -- `--validator
` - Your validator address +- `` - Your validator wallet address - `--moniker ` - Display name for your validator +Sign with the **owner** account. The operator can't set identity. For a validator funded from a vesting contract, use `genlayer vesting validator set-identity ` as the beneficiary. + +Each call replaces the whole identity: any field you leave out is cleared. To change one field, pass the others again with their current values. + **Optional:** - `--logo-uri ` - Logo image URL - `--website ` - Website URL @@ -225,7 +229,7 @@ genlayer staking set-identity \ Your identity will show in `validator-info`: ```bash -genlayer staking validator-info +genlayer staking validator-info 0x86D0...7E ``` Output will include: diff --git a/src/commands/staking/epochTiming.ts b/src/commands/staking/epochTiming.ts new file mode 100644 index 00000000..61526245 --- /dev/null +++ b/src/commands/staking/epochTiming.ts @@ -0,0 +1,20 @@ +/** + * Epoch timing rules shared by the staking read commands. + * + * After epoch 0, a deposit becomes active ACTIVATION_DELAY_EPOCHS after the + * epoch it was made in, and a withdrawal is claimable UNBONDING_PERIOD_EPOCHS + * after its exit epoch. + * + * Epoch 0 is different: the Staking contract writes epoch-0 deposits straight + * into the active stake record (ValidatorUtils.validatorDepositEpoch0 / + * DelegatorUtils.delegatorJoinEpoch0), so they are already counted in vStake / + * stake and must never be reported as pending. + */ +export const ACTIVATION_DELAY_EPOCHS = 2n; +export const UNBONDING_PERIOD_EPOCHS = 7n; + +/** True when a deposit made in `depositEpoch` is not active yet. */ +export function isDepositPending(depositEpoch: bigint, currentEpoch: bigint): boolean { + if (depositEpoch === 0n) return false; + return depositEpoch + ACTIVATION_DELAY_EPOCHS > currentEpoch; +} diff --git a/src/commands/staking/index.ts b/src/commands/staking/index.ts index f728f1ce..3c45c742 100644 --- a/src/commands/staking/index.ts +++ b/src/commands/staking/index.ts @@ -271,7 +271,7 @@ export function initializeStakingCommands(program: Command) { addWalletModeOption( staking .command("set-identity [validator]") - .description("Set validator identity metadata (moniker, website, socials, etc.)") + .description("Set validator identity metadata (moniker, website, socials, etc.). Replaces the whole identity: omitted fields are cleared") .option("--validator
", "Validator wallet address (deprecated, use positional arg)") .requiredOption("--moniker ", "Validator display name") .option("--logo-uri ", "Logo URI") @@ -282,7 +282,7 @@ export function initializeStakingCommands(program: Command) { .option("--telegram ", "Telegram handle") .option("--github ", "GitHub handle") .option("--extra-cid ", "Extra data as IPFS CID or hex bytes (0x...)") - .option("--account ", "Account to use (must be validator operator)") + .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"), diff --git a/src/commands/staking/stakingInfo.ts b/src/commands/staking/stakingInfo.ts index 086a23e8..99c32ccd 100644 --- a/src/commands/staking/stakingInfo.ts +++ b/src/commands/staking/stakingInfo.ts @@ -3,9 +3,7 @@ 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; +import {ACTIVATION_DELAY_EPOCHS, UNBONDING_PERIOD_EPOCHS, isDepositPending} from "./epochTiming"; export interface StakingInfoOptions extends StakingConfig { validator?: string; @@ -58,13 +56,14 @@ export class StakingInfoAction extends StakingAction { // In debug mode, show all deposits; otherwise filter to truly pending only const deposits = options.debug ? info.pendingDeposits - : info.pendingDeposits.filter(d => d.epoch + ACTIVATION_DELAY_EPOCHS > currentEpoch); + : info.pendingDeposits.filter(d => isDepositPending(d.epoch, currentEpoch)); return deposits.length > 0 ? deposits.map(d => { const depositEpoch = d.epoch; - const activationEpoch = depositEpoch + ACTIVATION_DELAY_EPOCHS; - const epochsUntilActive = activationEpoch - currentEpoch; - const isActivated = epochsUntilActive <= 0n; + // Epoch-0 deposits are counted at once (see epochTiming.ts). + const activationEpoch = depositEpoch === 0n ? 0n : depositEpoch + ACTIVATION_DELAY_EPOCHS; + const epochsUntilActive = activationEpoch > currentEpoch ? activationEpoch - currentEpoch : 0n; + const isActivated = !isDepositPending(depositEpoch, currentEpoch); return { epoch: depositEpoch.toString(), stake: d.stake, @@ -249,10 +248,20 @@ export class StakingInfoAction extends StakingAction { ): void { const minRaw = epochInfo.validatorMinStakeRaw; const minFmt = epochInfo.validatorMinStake; - const pendingSum = info.pendingDeposits.reduce((sum, d) => sum + d.stakeRaw, 0n); + // Epoch-0 deposits are already part of vStake, so only count deposits that + // are still activating, or they would be added twice. + const pendingDeposits = info.pendingDeposits.filter(d => isDepositPending(d.epoch, epochInfo.currentEpoch)); + const pendingSum = pendingDeposits.reduce((sum, d) => sum + d.stakeRaw, 0n); const effectiveSelfStakeRaw = info.vStakeRaw + pendingSum; if (epochInfo.currentEpoch === 0n) { + if (info.vStakeRaw >= minRaw) { + this.logInfo( + `Epoch 0: self-stake ${info.vStake} meets the ${minFmt} minimum. Epoch-0 deposits count ` + + `toward stake immediately; no validator is active until epoch 0 ends.`, + ); + return; + } this.logInfo( `Epoch 0: minimum self-stake not enforced yet. This validator won't become active until its ` + `self-stake reaches ${minFmt}.`, @@ -273,7 +282,7 @@ export class StakingInfoAction extends StakingAction { // 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)); + const sorted = [...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) { @@ -327,7 +336,7 @@ export class StakingInfoAction extends StakingAction { projectedReward, pendingDeposits: (() => { // Filter to only truly pending deposits (not yet active) - const pending = info.pendingDeposits.filter(d => d.epoch + ACTIVATION_DELAY_EPOCHS > currentEpoch); + const pending = info.pendingDeposits.filter(d => isDepositPending(d.epoch, currentEpoch)); return pending.length > 0 ? pending.map(d => { const depositEpoch = d.epoch; @@ -625,7 +634,11 @@ export class StakingInfoAction extends StakingAction { } else if (info.needsPriming) { status = "needs-priming"; } else { - const activating = info.pendingDeposits.some(d => d.epoch + ACTIVATION_DELAY_EPOCHS > epochInfo.currentEpoch); + // In epoch 0 a validator's stake is counted at once, but nobody is + // active until the epoch advances, so a funded validator is pending. + const activating = + info.pendingDeposits.some(d => isDepositPending(d.epoch, epochInfo.currentEpoch)) || + (epochInfo.currentEpoch === 0n && info.vStakeRaw > 0n && info.vStakeRaw >= epochInfo.validatorMinStakeRaw); status = activating ? "pending" : "inactive"; } @@ -702,7 +715,7 @@ export class StakingInfoAction extends StakingAction { // 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 trulyPendingDeposits = info.pendingDeposits.filter(d => isDepositPending(d.epoch, 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); diff --git a/src/commands/staking/validatorDeposit.ts b/src/commands/staking/validatorDeposit.ts index 69831849..5f6117ad 100644 --- a/src/commands/staking/validatorDeposit.ts +++ b/src/commands/staking/validatorDeposit.ts @@ -49,7 +49,11 @@ export class ValidatorDepositAction extends StakingAction { } catch { return; } - const pendingSelfStakeRaw = info.pendingDeposits.reduce((sum, d) => sum + d.stakeRaw, 0n); + // Epoch-0 deposits are already part of vStake (see staking/epochTiming.ts); + // adding them again would overstate the resulting self-stake. + const pendingSelfStakeRaw = info.pendingDeposits + .filter(d => d.epoch !== 0n) + .reduce((sum, d) => sum + d.stakeRaw, 0n); const resultingSelfStakeRaw = info.vStakeRaw + pendingSelfStakeRaw + amount; this.assertOrWarnSelfStakeMinimum({ currentEpoch: epochInfo.currentEpoch, diff --git a/src/commands/staking/validators.ts b/src/commands/staking/validators.ts index 80f9e7e8..3ecbfec1 100644 --- a/src/commands/staking/validators.ts +++ b/src/commands/staking/validators.ts @@ -3,9 +3,8 @@ import {StakingAction, StakingConfig} from "./StakingAction"; import type {Address, GenLayerChain, ValidatorInfo} from "genlayer-js/types"; import Table from "cli-table3"; import chalk from "chalk"; +import {ACTIVATION_DELAY_EPOCHS, UNBONDING_PERIOD_EPOCHS, isDepositPending} from "./epochTiming"; -const ACTIVATION_DELAY_EPOCHS = 2n; -const UNBONDING_PERIOD_EPOCHS = 7n; const EXPLORER_PAGE_SIZE = 100; const EXPLORER_TIMEOUT_MS = 5000; @@ -230,7 +229,7 @@ export class ValidatorsAction extends StakingAction { let bannedUntilEpoch: string | undefined; const belowMin = info.vStakeRaw < validatorMinStakeRaw; const totalStakeRaw = info.vStakeRaw + info.dStakeRaw; - const trulyPendingDeposits = info.pendingDeposits.filter(d => d.epoch + ACTIVATION_DELAY_EPOCHS > currentEpoch); + const trulyPendingDeposits = info.pendingDeposits.filter(d => isDepositPending(d.epoch, currentEpoch)); if (isBanned) { if (bannedInfo?.permanentlyBanned) { @@ -253,7 +252,10 @@ export class ValidatorsAction extends StakingAction { } else if (info.needsPriming) { status = "needs-priming"; } else { - status = trulyPendingDeposits.length > 0 ? "pending" : "inactive"; + // In epoch 0 stake is counted at once, but nobody is active until the + // epoch advances, so a funded validator is still pending. + const fundedAtEpoch0 = currentEpoch === 0n && info.vStakeRaw > 0n; + status = trulyPendingDeposits.length > 0 || fundedAtEpoch0 ? "pending" : "inactive"; } const trulyPendingWithdrawals = info.pendingWithdrawals.filter(w => w.epoch + UNBONDING_PERIOD_EPOCHS > currentEpoch); diff --git a/src/commands/staking/wizard.ts b/src/commands/staking/wizard.ts index 753963a8..ae73c57c 100644 --- a/src/commands/staking/wizard.ts +++ b/src/commands/staking/wizard.ts @@ -345,13 +345,17 @@ export class ValidatorWizardAction extends StakingAction { value: alias, })); + const choices = [...networks, ...customChoices]; + // Default to the configured network when it can stake, else the live testnet. + const defaultNetwork = choices.some(c => c.value === currentNetwork) ? currentNetwork : "testnet-bradbury"; + const {selectedNetwork} = await inquirer.prompt([ { type: "list", name: "selectedNetwork", message: "Select network:", - choices: [...networks, ...customChoices], - default: currentNetwork || "testnet-asimov", + choices, + default: defaultNetwork, }, ]); @@ -714,14 +718,22 @@ export class ValidatorWizardAction extends StakingAction { console.log("- Owner account: holds staked funds (keep secure)"); console.log("- Operator account: signs blocks (hot wallet on validator server)\n"); - const {useOperator} = await inquirer.prompt([ - { - type: "confirm", - name: "useOperator", - message: "Do you want to use a separate operator address?", - default: true, - }, - ]); + // The join needs a proof of possession signed with the operator's private + // key. A browser-wallet owner's key stays in the wallet, so it can't be the + // operator: go straight to choosing a separate CLI operator account. + let useOperator = true; + if (state.ownerIsBrowserWallet) { + console.log("Your owner key is in your browser wallet, so the operator must be a separate CLI account.\n"); + } else { + ({useOperator} = await inquirer.prompt([ + { + type: "confirm", + name: "useOperator", + message: "Do you want to use a separate operator address?", + default: true, + }, + ])); + } if (!useOperator) { if (!state.accountAddress) { @@ -739,7 +751,7 @@ export class ValidatorWizardAction extends StakingAction { const choices = [ {name: "Create new operator account", value: "create"}, ...(otherAccounts.length > 0 ? [{name: "Select from my accounts", value: "select"}] : []), - {name: "Enter existing operator address", value: "existing"}, + {name: "Enter the address of an imported CLI account", value: "existing"}, ]; const {operatorChoice} = await inquirer.prompt([ @@ -761,12 +773,18 @@ export class ValidatorWizardAction extends StakingAction { if (!input.match(/^0x[a-fA-F0-9]{40}$/)) { return "Invalid address format. Expected 0x followed by 40 hex characters."; } + if (!this.findLocalAccountByAddress(input)) { + return ( + "This address is not a CLI account. The operator key must be in the CLI to sign its " + + "proof of possession: import it with 'genlayer account import', or create a new operator." + ); + } return true; }, }, ]); state.operatorAddress = ensureHexPrefix(operatorAddress); - // No operatorAccountName - external address + state.operatorAccountName = this.findLocalAccountByAddress(operatorAddress); console.log(""); return; } @@ -981,6 +999,12 @@ export class ValidatorWizardAction extends StakingAction { options: WizardOptions, ): Promise { if (options.operatorSame) { + if (state.ownerIsBrowserWallet) { + throw new Error( + "--operator-same can't be used with a browser-wallet owner: the operator's proof of possession " + + "must be signed with a CLI account key. Use --operator or --create-operator .", + ); + } state.operatorAddress = ensureHexPrefix(state.accountAddress!); state.operatorAccountName = state.accountName; console.log("Operator will be the same as owner address.\n"); @@ -993,8 +1017,16 @@ export class ValidatorWizardAction extends StakingAction { `Invalid --operator '${options.operator}'. Expected 0x followed by 40 hex characters.`, ); } + const operatorAccountName = this.findLocalAccountByAddress(options.operator); + if (!operatorAccountName) { + throw new Error( + `Operator ${options.operator} is not a CLI account. The operator key must be in the CLI to sign ` + + "its proof of possession: import it with 'genlayer account import', or use --create-operator .", + ); + } state.operatorAddress = ensureHexPrefix(options.operator); - console.log(`Operator: ${state.operatorAddress}\n`); + state.operatorAccountName = operatorAccountName; + console.log(`Operator: ${state.operatorAddress} (${operatorAccountName})\n`); return; } @@ -1686,10 +1718,18 @@ export class ValidatorWizardAction extends StakingAction { if (state.operatorKeystorePath) { console.log(` ${step++}. Transfer operator keystore to your validator server:`); console.log(` ${state.operatorKeystorePath}`); - console.log(` ${step++}. Import it into your validator node software`); + console.log(` ${step++}. Import it into your node and point the node at this validator:`); + console.log(` genlayernode account import --path --passphrase --password `); + console.log(` Then set validatorWalletAddress and operatorAddress in the node's config.yaml.`); + console.log(` Guide: https://docs.genlayer.com/validators/setup-guide#import-the-operator-key`); + } else if (state.operatorAccountName && state.operatorAccountName !== state.accountName) { + // Operator entered by address: no keystore was exported during the wizard. + console.log(` ${step++}. Export the operator keystore and import it into your node:`); + console.log(` genlayer account export --account ${state.operatorAccountName} --output ./${state.operatorAccountName}-keystore.json`); + console.log(` Guide: https://docs.genlayer.com/validators/setup-guide#import-the-operator-key`); } console.log(` ${step++}. Monitor your validator:`); - console.log(` genlayer staking validator-info --validator ${validatorWallet}`); + console.log(` genlayer staking validator-info ${validatorWallet} --network ${state.networkAlias}`); if (!state.ownerIsBrowserWallet) { console.log(` ${step++}. Lock your account when done: genlayer account lock`); } diff --git a/src/commands/vesting/validatorDeposit.ts b/src/commands/vesting/validatorDeposit.ts index 37e6594a..b7197dd4 100644 --- a/src/commands/vesting/validatorDeposit.ts +++ b/src/commands/vesting/validatorDeposit.ts @@ -51,7 +51,11 @@ export class VestingValidatorDepositAction extends VestingAction { } catch { return; } - const pendingSelfStakeRaw = info.pendingDeposits.reduce((sum, d) => sum + d.stakeRaw, 0n); + // Epoch-0 deposits are already part of vStake (see staking/epochTiming.ts); + // adding them again would overstate the resulting self-stake. + const pendingSelfStakeRaw = info.pendingDeposits + .filter(d => d.epoch !== 0n) + .reduce((sum, d) => sum + d.stakeRaw, 0n); const resultingSelfStakeRaw = info.vStakeRaw + pendingSelfStakeRaw + amount; this.assertOrWarnSelfStakeMinimum({ currentEpoch: epochInfo.currentEpoch, diff --git a/tests/actions/epochTiming.test.ts b/tests/actions/epochTiming.test.ts new file mode 100644 index 00000000..222d1023 --- /dev/null +++ b/tests/actions/epochTiming.test.ts @@ -0,0 +1,15 @@ +import {describe, test, expect} from "vitest"; +import {isDepositPending} from "../../src/commands/staking/epochTiming"; + +describe("isDepositPending", () => { + test("epoch-0 deposits are never pending (the contract counts them at once)", () => { + expect(isDepositPending(0n, 0n)).toBe(false); + expect(isDepositPending(0n, 1n)).toBe(false); + }); + + test("later deposits are pending for two epochs", () => { + expect(isDepositPending(5n, 5n)).toBe(true); + expect(isDepositPending(5n, 6n)).toBe(true); + expect(isDepositPending(5n, 7n)).toBe(false); + }); +}); diff --git a/tests/actions/staking.test.ts b/tests/actions/staking.test.ts index 1109a692..6b193394 100644 --- a/tests/actions/staking.test.ts +++ b/tests/actions/staking.test.ts @@ -1171,6 +1171,25 @@ describe("ValidatorDepositAction minimum gate + mixing guard", () => { expect(action["failSpinner"]).not.toHaveBeenCalled(); }); + test("does not count an epoch-0 deposit twice (it is already part of vStake)", async () => { + // The epoch-0 deposit is already in vStake: 30000 + 10 = 30010 GEN < 50000 GEN min. + // Adding it again would give 60010 GEN and wrongly let the deposit through. + mockClient.getValidatorInfo.mockResolvedValue( + fullValidatorInfo({ + vStake: "30000 GEN", + vStakeRaw: 30000n * BigInt(1e18), + pendingDeposits: [{epoch: 0n, stake: "30000 GEN", stakeRaw: 30000n * BigInt(1e18), shares: 1n}], + }), + ); + + 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); + }); + test("hard-blocks a liquid deposit into a vesting-owned wallet (no --force override)", async () => { mockClient.getValidatorInfo.mockResolvedValue(fullValidatorInfo({owner: "0xVestingContract"})); @@ -1260,6 +1279,50 @@ describe("StakingInfoAction clean view + eligibility display", () => { expect(action["failSpinner"]).not.toHaveBeenCalled(); }); + test("epoch 0: an epoch-0 deposit is counted at once, not listed as pending", async () => { + // Bradbury epoch 0: the contract writes epoch-0 deposits straight into vStake. + mockClient.getEpochInfo.mockResolvedValue(epoch0EpochInfo); + mockClient.getValidatorInfo.mockResolvedValue( + fullValidatorInfo({ + vStake: "60000 GEN", + vStakeRaw: 60000n * BigInt(1e18), + pendingDeposits: [{epoch: 0n, stake: "60000 GEN", stakeRaw: 60000n * BigInt(1e18), shares: 1n}], + }), + ); + const infoSpy = vi.spyOn(action as any, "logInfo").mockImplementation(() => {}); + + await action.getValidatorInfo({validator: "0xValidatorWallet", stakingAddress: "0xStaking"}); + + const out = printed(); + expect(out).not.toContain("activatesAtEpoch"); + expect(infoSpy).toHaveBeenCalledWith(expect.stringContaining("meets the 50000 GEN minimum")); + expect(infoSpy).not.toHaveBeenCalledWith(expect.stringContaining("won't become active")); + }); + + test("epoch 0: still notes the minimum when self-stake is below it", async () => { + mockClient.getEpochInfo.mockResolvedValue(epoch0EpochInfo); + mockClient.getValidatorInfo.mockResolvedValue(fullValidatorInfo()); + const infoSpy = vi.spyOn(action as any, "logInfo").mockImplementation(() => {}); + + await action.getValidatorInfo({validator: "0xValidatorWallet", stakingAddress: "0xStaking"}); + + expect(infoSpy).toHaveBeenCalledWith(expect.stringContaining("won't become active until its self-stake reaches 50000 GEN")); + }); + + test("--json: an epoch-0 deposit is not pending after epoch 0 either", async () => { + mockClient.getEpochInfo.mockResolvedValue({...highMinEpochInfo, currentEpoch: 1n}); + mockClient.getValidatorInfo.mockResolvedValue( + fullValidatorInfo({ + pendingDeposits: [{epoch: 0n, stake: "1000 GEN", stakeRaw: 1000n * BigInt(1e18), shares: 1n}], + }), + ); + + await action.getValidatorInfo({validator: "0xValidatorWallet", stakingAddress: "0xStaking", json: true}); + + const parsed = JSON.parse(logSpy.mock.calls[0][0]); + expect(parsed.selfStakePendingDeposits).toBe("None"); + }); + 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. diff --git a/tests/actions/stakingWizard.test.ts b/tests/actions/stakingWizard.test.ts index f526b3e8..4363a275 100644 --- a/tests/actions/stakingWizard.test.ts +++ b/tests/actions/stakingWizard.test.ts @@ -86,6 +86,74 @@ describe("ValidatorWizardAction Studio network filtering", () => { expect(aliases).not.toContain("studio-dev"); expect(aliases).toContain("testnet-asimov"); expect(aliases).toContain("testnet-bradbury"); + // The configured network can't stake, so the default is the live testnet. + expect(questions[0].default).toBe("testnet-bradbury"); + }); +}); + +describe("ValidatorWizardAction operator checks and summary", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + test("an entered operator address must be a CLI account", async () => { + const action = new ValidatorWizardAction(); + vi.spyOn(action as any, "listAccounts").mockReturnValue([]); + const findSpy = vi.spyOn(action as any, "findLocalAccountByAddress").mockReturnValue(undefined); + vi.mocked(inquirer.prompt) + .mockResolvedValueOnce({useOperator: true}) + .mockResolvedValueOnce({operatorChoice: "existing"}) + .mockResolvedValueOnce({operatorAddress: "0x1111111111111111111111111111111111111111"}); + vi.spyOn(console, "log").mockImplementation(() => {}); + + await (action as any).stepOperatorSetup({accountName: "owner", accountAddress: "0xOwner"}, {}); + + const addressQuestion = (vi.mocked(inquirer.prompt).mock.calls[2][0] as any)[0]; + const external = "0x1111111111111111111111111111111111111111"; + expect(addressQuestion.validate(external)).toMatch(/not a CLI account/); + findSpy.mockReturnValue("operator"); + expect(addressQuestion.validate(external)).toBe(true); + }); + + test("a browser-wallet owner is not offered the owner as operator", async () => { + const action = new ValidatorWizardAction(); + vi.spyOn(action as any, "listAccounts").mockReturnValue([]); + vi.spyOn(console, "log").mockImplementation(() => {}); + vi.mocked(inquirer.prompt).mockClear(); + vi.mocked(inquirer.prompt) + .mockResolvedValueOnce({operatorChoice: "existing"}) + .mockResolvedValueOnce({operatorAddress: "0x1111111111111111111111111111111111111111"}); + vi.spyOn(action as any, "findLocalAccountByAddress").mockReturnValue("operator"); + + const state: any = {accountName: "browser wallet", accountAddress: "0xBrowserOwner", ownerIsBrowserWallet: true}; + await (action as any).stepOperatorSetup(state, {}); + + const names = vi.mocked(inquirer.prompt).mock.calls.map((c: any) => c[0][0].name); + expect(names).not.toContain("useOperator"); + expect(state.operatorAccountName).toBe("operator"); + }); + + test("summary points at validator-info with the network and the node import step", () => { + const action = new ValidatorWizardAction(); + vi.spyOn(action as any, "getCustomNetworks").mockReturnValue({}); + const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + + (action as any).showSummary({ + accountName: "owner", + accountAddress: "0xOwner", + networkAlias: "testnet-bradbury", + stakeSource: "wallet", + operatorAddress: "0xOp", + operatorAccountName: "operator", + operatorKeystorePath: "/tmp/operator-keystore.json", + stakeAmount: "42gen", + validatorWalletAddress: "0xWallet", + }); + + const out = logSpy.mock.calls.map(c => String(c[0])).join("\n"); + expect(out).toContain("genlayer staking validator-info 0xWallet --network testnet-bradbury"); + expect(out).not.toContain("--validator"); + expect(out).toContain("genlayernode account import"); }); }); @@ -202,13 +270,12 @@ describe("ValidatorWizardAction --wallet browser (owner)", () => { test("routes join through the browser SDK client, keeps operator export, never uses keystore", async () => { // Prompt sequence: // funding source -> wallet (default; original flow unchanged) - // step4 useOperator -> true; operatorChoice -> create; operatorName -> "op"; + // step4 (browser owner: no separate-operator question) operatorChoice -> create; operatorName -> "op"; // export filename -> default; export password x2 // step5 stakeAmount -> "42gen"; confirm -> true // step7 setupIdentity -> false (skip identity prompts) vi.mocked(inquirer.prompt) .mockResolvedValueOnce({stakeSource: "wallet"}) // funding source - .mockResolvedValueOnce({useOperator: true}) // step4 .mockResolvedValueOnce({operatorChoice: "create"}) .mockResolvedValueOnce({operatorName: "op"}) .mockResolvedValueOnce({outputFilename: "op-keystore.json"}) @@ -251,12 +318,11 @@ describe("ValidatorWizardAction --wallet browser (owner)", () => { mockGlClient.getBeneficiaryVestings.mockResolvedValue(["0xVesting"]); // funding source -> vesting (one contract, no pick prompt) - // step4 useOperator -> true; operatorChoice -> existing; operatorAddress + // step4 (browser owner: separate operator is required, no question) operatorChoice -> existing; operatorAddress // step5 stakeAmount -> "42gen"; confirm -> true // step7 setupIdentity -> false (this test focuses on join) vi.mocked(inquirer.prompt) .mockResolvedValueOnce({stakeSource: "vesting"}) - .mockResolvedValueOnce({useOperator: true}) .mockResolvedValueOnce({operatorChoice: "existing"}) .mockResolvedValueOnce({operatorAddress: "0xOperatorAddr"}) .mockResolvedValueOnce({stakeAmount: "42gen"}) @@ -294,7 +360,6 @@ describe("ValidatorWizardAction --wallet browser (owner)", () => { vi.mocked(inquirer.prompt) .mockResolvedValueOnce({stakeSource: "vesting"}) - .mockResolvedValueOnce({useOperator: true}) .mockResolvedValueOnce({operatorChoice: "existing"}) .mockResolvedValueOnce({operatorAddress: "0xOperatorAddr"}) .mockResolvedValueOnce({stakeAmount: "42gen"}) @@ -830,6 +895,18 @@ describe("ValidatorWizardAction --non-interactive (keystore owner)", () => { ); }); + test("--operator that is not a CLI account fails before the stake prompts", async () => { + vi.spyOn(action as any, "findLocalAccountByAddress").mockReturnValue(undefined); + + await run({operator: EXTERNAL_OP, amount: "50gen"}); + + expect(validatorJoin).not.toHaveBeenCalled(); + expect(action["failSpinner"]).toHaveBeenCalledWith( + "Wizard failed", + expect.stringMatching(/not a CLI account/), + ); + }); + test("missing operator choice fails clearly", async () => { await run({amount: "50gen"}); @@ -932,6 +1009,23 @@ describe("ValidatorWizardAction --non-interactive (browser owner)", () => { vi.restoreAllMocks(); }); + test("browser owner can't use --operator-same", async () => { + await action.execute({ + wallet: "browser", + network: "testnet-bradbury", + nonInteractive: true, + operatorSame: true, + amount: "50gen", + skipIdentity: true, + } as any); + + expect(validatorJoin).not.toHaveBeenCalled(); + expect(action["failSpinner"]).toHaveBeenCalledWith( + "Wizard failed", + expect.stringMatching(/--operator-same can't be used with a browser-wallet owner/), + ); + }); + test("browser owner runs end-to-end through the browser SDK client with ZERO prompts", async () => { await action.execute({ wallet: "browser", diff --git a/tests/commands/stakingValidators.test.ts b/tests/commands/stakingValidators.test.ts index 3a0674fa..d4663902 100644 --- a/tests/commands/stakingValidators.test.ts +++ b/tests/commands/stakingValidators.test.ts @@ -205,6 +205,23 @@ describe("staking validators action", () => { expect(output).not.toContain("inactive/below-min"); }); + test("epoch 0: a funded validator with an epoch-0 deposit is pending, not inactive", async () => { + // Beta meets the minimum and its epoch-0 deposit is already part of vStake. + const client = createMockClient({ + currentEpoch: 0n, + betaSelfStake: 100, + betaNeedsPriming: false, + betaPendingDeposits: [{epoch: 0n, stake: "100 GEN", stakeRaw: rawGen(100), shares: 1n}], + }); + const action = setupAction(client); + + await action.execute({json: true}); + + const output = JSON.parse(logSpy.mock.calls.at(-1)?.[0] as string); + const beta = output.validators.find((row: any) => row.address === B); + expect(beta.status).toBe("pending"); + }); + test("renders epoch 2 below-min validators as inactive below-min", async () => { const action = setupAction(createMockClient({currentEpoch: 2n, betaHasUnclaimedRewards: false})); await action.execute({});