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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 9 additions & 5 deletions docs/validator-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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" \
Expand All @@ -209,9 +209,13 @@ genlayer staking set-identity \
```

**Required:**
- `--validator <address>` - Your validator address
- `<validator>` - Your validator wallet address
- `--moniker <name>` - 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 <validator-wallet>` 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 <uri>` - Logo image URL
- `--website <url>` - Website URL
Expand All @@ -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:
Expand Down
20 changes: 20 additions & 0 deletions src/commands/staking/epochTiming.ts
Original file line number Diff line number Diff line change
@@ -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;
}
4 changes: 2 additions & 2 deletions src/commands/staking/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <address>", "Validator wallet address (deprecated, use positional arg)")
.requiredOption("--moniker <name>", "Validator display name")
.option("--logo-uri <uri>", "Logo URI")
Expand All @@ -282,7 +282,7 @@ export function initializeStakingCommands(program: Command) {
.option("--telegram <handle>", "Telegram handle")
.option("--github <handle>", "GitHub handle")
.option("--extra-cid <cid>", "Extra data as IPFS CID or hex bytes (0x...)")
.option("--account <name>", "Account to use (must be validator operator)")
.option("--account <name>", "Account to use (must be validator owner)")
.option("--password <password>", "Password to unlock account (skips interactive prompt)")
.option("--network <network>", "built-in or custom network alias (see: genlayer network list)")
.option("--rpc <rpcUrl>", "RPC URL for the network"),
Expand Down
37 changes: 25 additions & 12 deletions src/commands/staking/stakingInfo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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}.`,
Expand All @@ -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) {
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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";
}

Expand Down Expand Up @@ -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);
Expand Down
6 changes: 5 additions & 1 deletion src/commands/staking/validatorDeposit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
10 changes: 6 additions & 4 deletions src/commands/staking/validators.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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) {
Expand All @@ -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);

Expand Down
70 changes: 55 additions & 15 deletions src/commands/staking/wizard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
},
]);

Expand Down Expand Up @@ -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) {
Expand All @@ -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([
Expand All @@ -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;
}
Expand Down Expand Up @@ -981,6 +999,12 @@ export class ValidatorWizardAction extends StakingAction {
options: WizardOptions,
): Promise<void> {
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 <addr> or --create-operator <name>.",
);
}
state.operatorAddress = ensureHexPrefix(state.accountAddress!);
state.operatorAccountName = state.accountName;
console.log("Operator will be the same as owner address.\n");
Expand All @@ -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 <name>.",
);
}
state.operatorAddress = ensureHexPrefix(options.operator);
console.log(`Operator: ${state.operatorAddress}\n`);
state.operatorAccountName = operatorAccountName;
console.log(`Operator: ${state.operatorAddress} (${operatorAccountName})\n`);
return;
}

Expand Down Expand Up @@ -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 <keystore> --passphrase <keystore password> --password <node 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`);
}
Expand Down
6 changes: 5 additions & 1 deletion src/commands/vesting/validatorDeposit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
15 changes: 15 additions & 0 deletions tests/actions/epochTiming.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
Loading
Loading