Skip to content
Open
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
12 changes: 10 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -306,14 +306,16 @@ COMMANDS:
validator-deposit [options] Make an additional deposit as a validator
validator-exit [options] Exit as a validator by withdrawing shares
validator-claim [options] Claim validator withdrawals after unbonding period
validator-prime [validator] Prime a validator for the next epoch
prime-all [options] Prime all validators that need priming
delegator-join [options] Join as a delegator by staking with a validator
delegator-exit [options] Exit as a delegator by withdrawing shares
delegator-claim [options] Claim delegator withdrawals after unbonding period
validator-info [validator] Get information about a validator
validator-info [validator] Get information about a validator (--debug for raw data)
validator-history [validator] Show slash and reward history for a validator
delegation-info [validator] Get delegation info for a delegator with a validator
epoch-info [options] Get current/previous epoch info (--epoch <n> for specific)
validators [options] Show validator set with stake, status, and weight
validators [options] Show validator set with stake, primed status, and weight
active-validators [options] List all active validators
quarantined-validators List all quarantined validators
banned-validators List all banned validators
Expand Down Expand Up @@ -416,6 +418,12 @@ EXAMPLES:
# Exit and claim (requires validator wallet address)
genlayer staking validator-exit --validator 0x... --shares 100
genlayer staking validator-claim --validator 0x...

# Prime a validator for next epoch
genlayer staking validator-prime 0x...

# Prime all validators that need priming (anyone can call)
genlayer staking prime-all
```

### Running the CLI from the repository
Expand Down
27 changes: 27 additions & 0 deletions docs/validator-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -235,6 +235,23 @@ Output will include:

## Managing Your Validator

### Priming Your Validator

Validators must be "primed" each epoch to participate in consensus. Priming updates the validator's stake record for the upcoming epoch.

```bash
# Prime your validator
genlayer staking validator-prime 0xYourValidator...

# Or prime all validators at once (anyone can do this)
genlayer staking prime-all
```

The `validators` command shows priming status:
- **Green** `e11` - Primed for current epoch
- **Yellow** `e10` - Needs priming before next epoch
- **Red** `e9!` - Urgently needs priming (behind)

### Add More Stake

```bash
Expand All @@ -247,6 +264,16 @@ genlayer staking validator-deposit --validator 0xYourValidatorWallet... --amount
genlayer staking active-validators
```

### View Validator Set

```bash
# Show all validators with stake, primed status, and weight
genlayer staking validators

# Include banned validators
genlayer staking validators --all
```

### Exit as Validator

```bash
Expand Down
75 changes: 73 additions & 2 deletions src/commands/staking/StakingAction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,21 @@ import {BaseAction, BUILT_IN_NETWORKS, resolveNetwork} from "../../lib/actions/B
import {createClient, createAccount, formatStakingAmount, parseStakingAmount, abi} from "genlayer-js";
import type {GenLayerClient, GenLayerChain, Address} from "genlayer-js/types";
import {readFileSync, existsSync} from "fs";
import {ethers} from "ethers";
import {ethers, ZeroAddress} from "ethers";
import {createPublicClient, createWalletClient, http, type PublicClient, type WalletClient, type Chain, type Account} from "viem";
import {privateKeyToAccount} from "viem/accounts";

// Extended ABI for tree traversal (not in SDK)
const STAKING_TREE_ABI = [
{
name: "validatorsRoot",
type: "function",
stateMutability: "view",
inputs: [],
outputs: [{name: "", type: "address"}],
},
] as const;

// Re-export for use by other staking commands
export {BUILT_IN_NETWORKS};

Expand Down Expand Up @@ -132,7 +143,7 @@ export class StakingAction extends BaseAction {
// Stop spinner before prompting for password
this.stopSpinner();
const password = await this.promptPassword(`Enter password to unlock account '${accountName}':`);
this.startSpinner("Continuing...");
this.startSpinner("Unlocking account...");

const wallet = await ethers.Wallet.fromEncryptedJson(keystoreJson, password);
return wallet.privateKey;
Expand Down Expand Up @@ -193,4 +204,64 @@ export class StakingAction extends BaseAction {
signerAddress: account.address as Address,
};
}

/**
* Get all validators by traversing the validator tree.
* This finds ALL validators including those not yet active/primed.
*/
protected async getAllValidatorsFromTree(config: StakingConfig): Promise<Address[]> {
const network = this.getNetwork(config);
const rpcUrl = config.rpc || network.rpcUrls.default.http[0];
const stakingAddress = config.stakingAddress || network.stakingContract?.address;

if (!stakingAddress) {
throw new Error("Staking contract address not configured");
}

const publicClient = createPublicClient({
chain: network,
transport: http(rpcUrl),
});

// Get the root of the validator tree
const root = await publicClient.readContract({
address: stakingAddress as `0x${string}`,
abi: STAKING_TREE_ABI,
functionName: "validatorsRoot",
});

if (root === ZeroAddress) {
return [];
}

const validators: Address[] = [];
const stack: string[] = [root as string];
const visited = new Set<string>();

// Use validatorView from SDK's ABI (has left/right fields)
while (stack.length > 0) {
const addr = stack.pop()!;

if (addr === ZeroAddress || visited.has(addr.toLowerCase())) continue;
visited.add(addr.toLowerCase());

validators.push(addr as Address);

const info = await publicClient.readContract({
address: stakingAddress as `0x${string}`,
abi: abi.STAKING_ABI,
functionName: "validatorView",
args: [addr as `0x${string}`],
}) as {left: string; right: string};

if (info.left !== ZeroAddress) {
stack.push(info.left);
}
if (info.right !== ZeroAddress) {
stack.push(info.right);
}
}

return validators;
}
}
13 changes: 13 additions & 0 deletions src/commands/staking/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,18 @@ export function initializeStakingCommands(program: Command) {
await action.execute({...options, validator});
});

staking
.command("prime-all")
.description("Prime all validators that need priming")
.option("--account <name>", "Account to use (pays gas)")
.option("--network <network>", "Network to use (localnet, testnet-asimov)")
.option("--rpc <rpcUrl>", "RPC URL for the network")
.option("--staking-address <address>", "Staking contract address (overrides chain config)")
.action(async (options: StakingConfig) => {
const action = new ValidatorPrimeAction();
await action.primeAll(options);
});
Comment on lines +119 to +129
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Check the imports and content of the staking index.ts file
echo "=== Checking imports in src/commands/staking/index.ts ==="
head -30 src/commands/staking/index.ts

echo -e "\n=== Checking lines 119-129 for prime-all action ==="
sed -n '119,129p' src/commands/staking/index.ts

echo -e "\n=== Checking if StakingConfig exists ==="
rg "StakingConfig" src/commands/staking/ -A 2 -B 2

Repository: genlayerlabs/genlayer-cli

Length of output: 14314


Add missing import for StakingConfig type.

The prime-all action uses StakingConfig type annotation (line 126), but this type is not imported at the top of the file. Add the import from ./StakingAction:

 import {Command} from "commander";
 import {ValidatorJoinAction, ValidatorJoinOptions} from "./validatorJoin";
 import {ValidatorDepositAction, ValidatorDepositOptions} from "./validatorDeposit";
 import {ValidatorExitAction, ValidatorExitOptions} from "./validatorExit";
 import {ValidatorClaimAction, ValidatorClaimOptions} from "./validatorClaim";
 import {ValidatorPrimeAction, ValidatorPrimeOptions} from "./validatorPrime";
+import {StakingConfig} from "./StakingAction";
 import {SetOperatorAction, SetOperatorOptions} from "./setOperator";

Committable suggestion skipped: line range outside the PR's diff.

🤖 Prompt for AI Agents
In src/commands/staking/index.ts around lines 119 to 129, the StakingConfig type
used to type the options parameter in the prime-all action is not imported; add
an import for StakingConfig from ./StakingAction at the top of the file (e.g.
import { StakingConfig } from "./StakingAction") so the type reference is
resolved and TypeScript compiles cleanly.


staking
.command("set-operator [validator] [operator]")
.description("Change the operator address for a validator wallet")
Expand Down Expand Up @@ -228,6 +240,7 @@ export function initializeStakingCommands(program: Command) {
.option("--network <network>", "Network to use (localnet, testnet-asimov)")
.option("--rpc <rpcUrl>", "RPC URL for the network")
.option("--staking-address <address>", "Staking contract address (overrides chain config)")
.option("--debug", "Show raw unfiltered pending deposits/withdrawals")
.action(async (validatorArg: string | undefined, options: StakingInfoOptions) => {
const validator = validatorArg || options.validator;
const action = new StakingInfoAction();
Expand Down
54 changes: 36 additions & 18 deletions src/commands/staking/stakingInfo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ const UNBONDING_PERIOD_EPOCHS = 7n;

export interface StakingInfoOptions extends StakingConfig {
validator?: string;
debug?: boolean;
}

export class StakingInfoAction extends StakingAction {
Expand Down Expand Up @@ -38,6 +39,7 @@ export class StakingInfoAction extends StakingAction {
const currentEpoch = epochInfo.currentEpoch;

const result: Record<string, any> = {
...(options.debug && {currentEpoch: currentEpoch.toString()}),
validator: info.address,
owner: info.owner,
operator: info.operator,
Expand All @@ -52,22 +54,27 @@ export class StakingInfoAction extends StakingAction {
live: info.live,
banned: info.banned ? info.bannedEpoch?.toString() : "Not banned",
selfStakePendingDeposits: (() => {
// Filter to only truly pending deposits (not yet active)
const pending = info.pendingDeposits.filter(d => d.epoch + ACTIVATION_DELAY_EPOCHS > currentEpoch);
return pending.length > 0
? pending.map(d => {
// 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);
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;
return {
epoch: depositEpoch.toString(),
stake: d.stake,
shares: d.shares.toString(),
activatesAtEpoch: activationEpoch.toString(),
epochsRemaining: epochsUntilActive.toString(),
...(options.debug
? {status: isActivated ? "ACTIVATED" : `pending (${epochsUntilActive} epochs)`}
: {epochsRemaining: epochsUntilActive.toString()}),
};
})
: "None";
: options.debug ? `None (raw count: ${info.pendingDeposits.length})` : "None";
})(),
selfStakePendingWithdrawals:
info.pendingWithdrawals.length > 0
Expand Down Expand Up @@ -362,7 +369,10 @@ export class StakingInfoAction extends StakingAction {
// No account configured, that's fine
}

// Fetch all data in parallel
// Use tree traversal to get ALL validators (including not-yet-primed)
const allTreeAddresses = await this.getAllValidatorsFromTree(options);

// Also fetch status lists in parallel
const [activeAddresses, quarantinedList, bannedList, epochInfo] = await Promise.all([
client.getActiveValidators(),
client.getQuarantinedValidatorsDetailed(),
Expand All @@ -373,15 +383,14 @@ export class StakingInfoAction extends StakingAction {
// Build set of quarantined/banned for status lookup
const quarantinedSet = new Map(quarantinedList.map(v => [v.validator.toLowerCase(), v]));
const bannedSet = new Map(bannedList.map(v => [v.validator.toLowerCase(), v]));
const activeSet = new Set(activeAddresses.map(a => a.toLowerCase()));

// Combine all validators
const allAddresses = new Set([
...activeAddresses,
...quarantinedList.map(v => v.validator),
...(options.all ? bannedList.map(v => v.validator) : []),
]);
// Filter out banned if not --all
const allAddresses = options.all
? allTreeAddresses
: allTreeAddresses.filter(addr => !bannedSet.has(addr.toLowerCase()));

this.setSpinnerText(`Fetching details for ${allAddresses.size} validators...`);
this.setSpinnerText(`Fetching details for ${allAddresses.length} validators...`);

// Fetch detailed info in batches to avoid rate limiting
const BATCH_SIZE = 5;
Expand Down Expand Up @@ -411,7 +420,7 @@ export class StakingInfoAction extends StakingAction {
const addrLower = info.address.toLowerCase();
const isQuarantined = quarantinedSet.has(addrLower);
const isBanned = bannedSet.has(addrLower);
const isActive = activeAddresses.some(a => a.toLowerCase() === addrLower);
const isActive = activeSet.has(addrLower);

let status = "";
if (isBanned) {
Expand All @@ -420,8 +429,6 @@ export class StakingInfoAction extends StakingAction {
} else if (isQuarantined) {
const qInfo = quarantinedSet.get(addrLower)!;
status = `quarant(e${qInfo.untilEpoch})`;
} else if (info.needsPriming) {
status = "prime!";
} else if (isActive) {
status = "active";
} else {
Expand Down Expand Up @@ -485,6 +492,7 @@ export class StakingInfoAction extends StakingAction {
chalk.cyan("Self"),
chalk.cyan("Deleg"),
chalk.cyan("Pending"),
chalk.cyan("Primed"),
chalk.cyan("Weight"),
chalk.cyan("Status"),
],
Expand Down Expand Up @@ -533,13 +541,22 @@ export class StakingInfoAction extends StakingAction {
? `${moniker}${roleTag}\n${chalk.gray(info.address)}`
: `${chalk.gray(info.address)}${roleTag}`;

// Primed status - color based on how current it is
let primedStr: string;
if (info.ePrimed >= currentEpoch) {
primedStr = chalk.green(`e${info.ePrimed}`);
} else if (info.ePrimed === currentEpoch - 1n) {
primedStr = chalk.yellow(`e${info.ePrimed}`);
} else {
primedStr = chalk.red(`e${info.ePrimed}!`);
}

// Status coloring
let statusStr = status;
if (status === "active") statusStr = chalk.green(status);
else if (status === "BANNED") statusStr = chalk.red(status);
else if (status.startsWith("quarant")) statusStr = chalk.yellow(status);
else if (status.startsWith("banned")) statusStr = chalk.red(status);
else if (status === "prime!") statusStr = chalk.magenta(status);
else if (status === "pending") statusStr = chalk.gray(status);

table.push([
Expand All @@ -548,6 +565,7 @@ export class StakingInfoAction extends StakingAction {
formatStake(info.vStake),
formatStake(info.dStake),
pendingStr,
primedStr,
weightStr,
statusStr,
]);
Expand Down
38 changes: 38 additions & 0 deletions src/commands/staking/validatorPrime.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import {StakingAction, StakingConfig} from "./StakingAction";
import type {Address} from "genlayer-js/types";
import chalk from "chalk";

export interface ValidatorPrimeOptions extends StakingConfig {
validator: string;
Expand Down Expand Up @@ -32,4 +33,41 @@ export class ValidatorPrimeAction extends StakingAction {
this.failSpinner("Failed to prime validator", error.message || error);
}
}

async primeAll(options: StakingConfig): Promise<void> {
this.startSpinner("Fetching validators...");

try {
const client = await this.getStakingClient(options);

// Get all validators from tree
this.setSpinnerText("Fetching validators...");
const allValidators = await this.getAllValidatorsFromTree(options);

this.stopSpinner();
console.log(`\nPriming ${allValidators.length} validators:\n`);

let succeeded = 0;
let skipped = 0;

for (const addr of allValidators) {
process.stdout.write(` ${addr} ... `);

try {
const result = await client.validatorPrime({validator: addr});
console.log(chalk.green(`primed ${result.transactionHash}`));
succeeded++;
} catch (error: any) {
const msg = error.message || String(error);
const shortErr = msg.length > 60 ? msg.slice(0, 57) + "..." : msg;
console.log(chalk.gray(`skipped: ${shortErr}`));
skipped++;
}
}

console.log(`\n${chalk.green(`${succeeded} primed`)}, ${chalk.gray(`${skipped} skipped`)}\n`);
} catch (error: any) {
this.failSpinner("Failed to prime validators", error.message || error);
}
}
}