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({});