diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000..0666f390 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,55 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Build & Development + +```bash +npm install # Install dependencies +npm run dev # Watch mode development build (uses esbuild) +npm run build # Production build +node dist/index.js # Run CLI from source +``` + +## Testing + +```bash +npm test # Run all tests (vitest) +npm run test:watch # Watch mode +npm run test:coverage # Coverage report +npx vitest tests/commands/deploy.test.ts # Single test file +npx vitest -t "test name pattern" # Run specific test by name +``` + +## Architecture + +### Entry Point & Command Structure +- `src/index.ts` - Main entry, initializes Commander program and registers all command groups +- Commands organized in `src/commands//index.ts` - each exports `initialize*Commands(program)` function +- Command domains: general (init/up/stop), account, contracts, config, localnet, update, scaffold, network, transactions, staking + +### Core Classes +- `BaseAction` (`src/lib/actions/BaseAction.ts`) - Base class for all CLI actions. Provides: + - GenLayer client initialization via `genlayer-js` SDK + - Keystore management (encrypted wallet with password) + - Spinner/logging utilities (ora, chalk) + - User prompts (inquirer) +- `ConfigFileManager` (`src/lib/config/ConfigFileManager.ts`) - Manages `~/.genlayer/genlayer-config.json` +- `KeychainManager` (`src/lib/config/KeychainManager.ts`) - System keychain integration via keytar + +### Pattern for Adding Commands +1. Create action class extending `BaseAction` in `src/commands//.ts` +2. Export action options interface +3. Register in domain's `index.ts` via Commander +4. Add tests in `tests/commands/.test.ts` and `tests/actions/.test.ts` + +### External Dependencies +- `commander` - CLI framework +- `genlayer-js` - GenLayer SDK for blockchain interactions +- `ethers` - Wallet/keystore encryption +- `dockerode` - Docker management for localnet +- `viem` - Ethereum utilities + +### Path Aliases +- `@/*` → `./src/*` +- `@@/tests/*` → `./tests/*` diff --git a/README.md b/README.md index 8eea0cb7..e4ce221f 100644 --- a/README.md +++ b/README.md @@ -203,21 +203,30 @@ EXAMPLES: ##### Schema - `schema` - Retrieves the contract schema -#### Keypair Management +#### Account Management -Generate and manage keypairs. +View and manage your account. ```bash USAGE: - genlayer keygen create [options] + genlayer account Show account info (address, balance, network, status) + genlayer account create [options] Create a new account + genlayer account send Send GEN to an address + genlayer account unlock Unlock account (cache key in OS keychain) + genlayer account lock Lock account (remove key from OS keychain) -OPTIONS: - --output Path to save the keypair (default: "./keypair.json") - --overwrite Overwrite the existing file if it already exists (default: false) +OPTIONS (create): + --output Path to save the keystore (default: "./keypair.json") + --overwrite Overwrite existing file (default: false) EXAMPLES: - genlayer keygen create - genlayer keygen create --output ./my_key.json --overwrite + genlayer account + genlayer account create + genlayer account create --output ./my_key.json --overwrite + genlayer account send 0x123...abc 10gen + genlayer account send 0x123...abc 0.5gen + genlayer account unlock + genlayer account lock ``` #### Update Resources @@ -284,6 +293,105 @@ EXAMPLES: genlayer localnet validators create-random --count 3 --providers openai --models gpt-4 gpt-4o ``` +#### Staking Operations + +Manage staking for validators and delegators on testnet-asimov. Staking is not available on localnet/studio. + +```bash +USAGE: + genlayer staking [options] + +COMMANDS: + validator-join [options] Join as a validator by staking tokens + 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 + 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 [options] Get information about a validator + stake-info [options] Get stake info for a delegator with a validator + epoch-info [options] Get epoch info with timing estimates + active-validators [options] List all active validators + +COMMON OPTIONS (all commands): + --network Network to use (localnet, testnet-asimov) + --rpc RPC URL override + --staking-address
Staking contract address override + +OPTIONS (validator-join): + --amount Amount to stake (in wei or with 'gen' suffix) + --operator
Operator address (defaults to signer) + +OPTIONS (delegator-join): + --validator
Validator address to delegate to + --amount Amount to stake (in wei or with 'gen' suffix) + +OPTIONS (exit commands): + --shares Number of shares to withdraw + --validator
Validator address (for delegator commands) + +EXAMPLES: + # Get epoch info (uses --network to specify testnet-asimov) + genlayer staking epoch-info --network testnet-asimov + + # Or set network globally first + genlayer network testnet-asimov + + # Join as validator with 42000 GEN + genlayer staking validator-join --amount 42000gen + + # Join as delegator with 42 GEN + genlayer staking delegator-join --validator 0x... --amount 42gen + + # Check validator info + genlayer staking validator-info --validator 0x... + # Output: + # { + # validator: '0xa8f1BF1e5e709593b4468d7ac5DC315Ea3CAe130', + # vStake: '0.01 GEN', + # vShares: '10000000000000000', + # dStake: '0 GEN', + # dShares: '0', + # vDeposit: '0 GEN', + # vWithdrawal: '0 GEN', + # epoch: '0', + # live: true, + # banned: 'Not banned' + # } + + # Get current epoch info (includes timing estimates) + genlayer staking epoch-info + # Output: + # { + # currentEpoch: '2', + # epochStarted: '2025-11-28T09:57:49.000Z', + # epochEnded: 'Not ended', + # nextEpochEstimate: '2025-11-29T09:57:49.000Z', + # timeUntilNextEpoch: '19h 56m', + # minEpochDuration: '24h 0m', + # validatorMinStake: '0.01 GEN', + # delegatorMinStake: '42 GEN', + # activeValidatorsCount: '6' + # } + + # List active validators + genlayer staking active-validators + # Output: + # { + # count: 6, + # validators: [ + # '0xa8f1BF1e5e709593b4468d7ac5DC315Ea3CAe130', + # '0xe9246A020cbb4fC6C46e60677981879c9219e8B9', + # ... + # ] + # } + + # Exit and claim + genlayer staking validator-exit --shares 100 + genlayer staking validator-claim +``` + ### Running the CLI from the repository First, install the dependencies and start the build process @@ -301,6 +409,11 @@ Then in another window execute the CLI commands like so: node dist/index.js init ``` +## Guides + +- [Validator Guide](docs/validator-guide.md) - How to become a validator on GenLayer testnet +- [Delegator Guide](docs/delegator-guide.md) - How to delegate GEN to a validator + ## Documentation For detailed information on how to use GenLayer CLI, please refer to our [documentation](https://docs.genlayer.com/). diff --git a/docs/delegator-guide.md b/docs/delegator-guide.md new file mode 100644 index 00000000..47cf9a9f --- /dev/null +++ b/docs/delegator-guide.md @@ -0,0 +1,203 @@ +# Delegator Guide + +This guide walks you through delegating GEN tokens to a validator on the GenLayer testnet. + +## What is Delegation? + +Delegation allows you to stake your GEN tokens with an existing validator without running validator infrastructure yourself. You earn staking rewards proportional to your stake. + +## Prerequisites + +- Node.js installed +- GenLayer CLI installed (`npm install -g genlayer`) +- GEN tokens for staking + +## Step 1: Create an Account + +```bash +genlayer account create +``` + +You'll be prompted to set a password. This creates an encrypted keystore file. + +## Step 2: Set Network to Testnet + +```bash +genlayer network testnet-asimov +``` + +## Step 3: Fund Your Account + +Transfer GEN tokens to your address. Check your balance: + +```bash +genlayer account +``` + +## Step 4: Check Minimum Delegation + +```bash +genlayer staking epoch-info +``` + +Note the `delegatorMinStake` - you need at least this amount. + +## Step 5: Find a Validator + +List all active validators: + +```bash +genlayer staking active-validators +``` + +Output: +``` +{ + count: 6, + validators: [ + '0xa8f1BF1e5e709593b4468d7ac5DC315Ea3CAe130', + '0xe9246A020cbb4fC6C46e60677981879c9219e8B9', + ... + ] +} +``` + +Get details about a specific validator: + +```bash +genlayer staking validator-info --validator 0xa8f1BF1e5e709593b4468d7ac5DC315Ea3CAe130 +``` + +Look for: +- `live: true` - Validator is active +- `banned: 'Not banned'` - Validator is in good standing +- `identity` - Validator's metadata (moniker, website, etc.) + +## Step 6: Unlock Your Account (Optional) + +For convenience: + +```bash +genlayer account unlock +``` + +## Step 7: Delegate to a Validator + +```bash +genlayer staking delegator-join --validator 0xa8f1...130 --amount 100gen +``` + +Options: +- `--validator
` - Validator address to delegate to (required) +- `--amount ` - Amount to stake (e.g., `100gen`) + +## Step 8: Verify Your Delegation + +```bash +genlayer staking stake-info --validator 0xa8f1...130 +``` + +Output: +``` +{ + delegator: '0x86D0d159483CBf01E920ECfF8bB7F0Cd7E964E7E', + validator: '0xa8f1BF1e5e709593b4468d7ac5DC315Ea3CAe130', + shares: '100000000000000000000', + stake: '100 GEN', + projectedReward: '0.2 GEN per epoch', + pendingDeposits: 'None', + pendingWithdrawals: 'None' +} +``` + +The `projectedReward` shows your estimated earnings per epoch based on current inflation and your stake weight. + +## Managing Your Delegation + +### Check Your Stake + +```bash +genlayer staking stake-info --validator 0xa8f1...130 +``` + +### Withdraw (Exit) Delegation + +To withdraw your stake: + +```bash +genlayer staking delegator-exit --validator 0xa8f1...130 --shares 50 +``` + +Options: +- `--validator
` - Validator you delegated to +- `--shares ` - Number of shares to withdraw + +This initiates a withdrawal. Your tokens enter an **unbonding period of 7 epochs** before they can be claimed. + +Check your pending withdrawals with `stake-info`: +``` +pendingWithdrawals: [ + { + epoch: '5', + shares: '50', + stake: '50 GEN', + claimableAtEpoch: '12', + status: 'Unbonding (4 epochs remaining)' + } +] +``` + +### Claim Withdrawals + +After the 7-epoch unbonding period, claim your tokens: + +```bash +genlayer staking delegator-claim --validator 0xa8f1...130 +``` + +## Choosing a Validator + +Consider these factors when choosing a validator: + +1. **Uptime** - Validators with high uptime earn more rewards +2. **Reputation** - Check their identity metadata and community presence +3. **Stake** - Higher stake may indicate trust from the community +4. **Not banned/quarantined** - Avoid validators with issues + +Check quarantined validators: +```bash +genlayer staking quarantined-validators +``` + +Check banned validators: +```bash +genlayer staking banned-validators +``` + +## Troubleshooting + +### "No account found" +Run `genlayer account create` first. + +### "Insufficient balance" +Ensure you have enough GEN. Check with `genlayer account`. + +### "Below minimum stake" +Check minimum with `genlayer staking epoch-info` and increase your amount. + +### "Validator not found" +Verify the validator address is correct and they are still active. + +### Transaction Stuck +Check the transaction status: +```bash +genlayer receipt +``` + +## Lock Your Account + +When done, lock your account: + +```bash +genlayer account lock +``` diff --git a/docs/validator-guide.md b/docs/validator-guide.md new file mode 100644 index 00000000..767affe6 --- /dev/null +++ b/docs/validator-guide.md @@ -0,0 +1,260 @@ +# Validator Guide + +This guide walks you through becoming a validator on the GenLayer testnet using the CLI. + +## Prerequisites + +- Node.js installed +- GenLayer CLI installed (`npm install -g genlayer`) +- GEN tokens for staking (minimum stake required) + +## Step 1: Create an Account + +```bash +genlayer account create +``` + +You'll be prompted to set a password. This creates an encrypted keystore file. + +## Step 2: View Your Account + +```bash +genlayer account +``` + +Output: +``` +{ + address: '0x86D0d159483CBf01E920ECfF8bB7F0Cd7E964E7E', + balance: '0 GEN', + network: 'localnet', + status: 'locked' +} +``` + +## Step 3: Set Network to Testnet + +```bash +genlayer network testnet-asimov +``` + +Verify with: +```bash +genlayer account +``` + +You should see `network: 'Asimov Testnet'`. + +## Step 4: Fund Your Account + +Transfer GEN tokens to your address. You can: +- Use the faucet (if available) +- Transfer from another account: + ```bash + genlayer account send 50000gen + ``` + +## Step 5: Check Staking Requirements + +```bash +genlayer staking epoch-info +``` + +Output: +``` +{ + currentEpoch: '2', + epochStarted: '2025-01-15T00:00:00.000Z', + nextEpochEstimate: '2025-01-16T00:00:00.000Z', + timeUntilNextEpoch: '12h 30m', + minEpochDuration: '24h 0m', + validatorMinStake: '42000 GEN', + delegatorMinStake: '42 GEN', + activeValidatorsCount: '6', + epochInflation: '1000 GEN', + totalWeight: '500000000000000000000000', + totalClaimed: '500 GEN' +} +``` + +Note the `validatorMinStake` - you need at least this amount. + +## Step 6: Unlock Your Account (Optional) + +For convenience, unlock your account to avoid entering password repeatedly: + +```bash +genlayer account unlock +``` + +This caches your private key in the OS keychain. + +## Step 7: Join as Validator + +```bash +genlayer staking validator-join --amount 42000gen --operator 0xOperator... +``` + +Options: +- `--amount ` - Stake amount (e.g., `42000gen` or `42000`) +- `--operator
` - Operator address (recommended, see below) + +### Why Use an Operator Address? + +**Recommended:** Use a separate operator address for security. + +- **Validator wallet** - Holds your staked funds (keep offline/cold) +- **Operator wallet** - Signs blocks and performs validator duties (hot wallet on server) + +This way, if your operator server is compromised, your staked funds remain safe. + +```bash +# Create operator account on your validator server +genlayer account create --output ./operator.json + +# Get operator address +cat ./operator.json | jq -r .address +# 0xOperator123... + +# Join with separate operator (run from your main wallet) +genlayer staking validator-join --amount 42000gen --operator 0xOperator123... +``` + +You can change the operator later: + +```bash +genlayer staking set-operator --validator 0xYourValidator... --operator 0xNewOperator... +``` + +## Step 8: Verify Your Validator Status + +```bash +genlayer staking validator-info +``` + +Output: +``` +{ + validator: '0x86D0d159483CBf01E920ECfF8bB7F0Cd7E964E7E', + vStake: '42000 GEN', + vShares: '42000000000000000000000', + live: true, + banned: 'Not banned', + ... +} +``` + +## Step 9: Set Validator Identity (Metadata) + +Set your validator's public identity so delegators can find you: + +```bash +genlayer staking set-identity \ + --validator 0x86D0...7E \ + --moniker "My Validator" \ + --website "https://myvalidator.com" \ + --description "Reliable validator with 99.9% uptime" \ + --twitter "myvalidator" \ + --github "myvalidator" +``` + +**Required:** +- `--validator
` - Your validator address +- `--moniker ` - Display name for your validator + +**Optional:** +- `--logo-uri ` - Logo image URL +- `--website ` - Website URL +- `--description ` - Description of your validator +- `--email ` - Contact email +- `--twitter ` - Twitter handle +- `--telegram ` - Telegram handle +- `--github ` - GitHub handle +- `--extra-cid ` - Additional data as IPFS CID + +Your identity will show in `validator-info`: + +```bash +genlayer staking validator-info +``` + +Output will include: +``` +{ + ... + identity: { + moniker: 'My Validator', + website: 'https://myvalidator.com', + twitter: 'myvalidator', + github: 'myvalidator' + } +} +``` + +## Managing Your Validator + +### Add More Stake + +```bash +genlayer staking validator-deposit --amount 1000gen +``` + +### Check Active Validators + +```bash +genlayer staking active-validators +``` + +### Exit as Validator + +```bash +genlayer staking validator-exit --shares 100 +``` + +This initiates a withdrawal. Your tokens enter an **unbonding period of 7 epochs** before they can be claimed. + +Check your pending withdrawals with `validator-info`: +``` +selfStakePendingWithdrawals: [ + { + epoch: '5', + shares: '100', + stake: '100 GEN', + claimableAtEpoch: '12', + status: 'Unbonding (4 epochs remaining)' + } +] +``` + +### Claim Withdrawals + +After the 7-epoch unbonding period: + +```bash +genlayer staking validator-claim +``` + +## Troubleshooting + +### "No account found" +Run `genlayer account create` first. + +### "Insufficient balance" +Ensure you have enough GEN. Check with `genlayer account`. + +### "Below minimum stake" +Check minimum with `genlayer staking epoch-info` and increase your stake amount. + +### Transaction Stuck +Check the transaction status: +```bash +genlayer receipt +``` + +## Lock Your Account + +When done, lock your account to remove the cached private key: + +```bash +genlayer account lock +``` diff --git a/package-lock.json b/package-lock.json index ab73b9f7..3dc0f697 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,7 +16,7 @@ "dotenv": "^17.0.0", "ethers": "^6.13.4", "fs-extra": "^11.3.0", - "genlayer-js": "^0.16.0", + "genlayer-js": "^0.18.5", "inquirer": "^12.0.0", "keytar": "^7.9.0", "node-fetch": "^3.0.0", @@ -5514,9 +5514,9 @@ } }, "node_modules/genlayer-js": { - "version": "0.16.0", - "resolved": "https://registry.npmjs.org/genlayer-js/-/genlayer-js-0.16.0.tgz", - "integrity": "sha512-5sXzPva32aAvceJ3C2LtnwP0pX6I1vnVeFEef5MD/2rTZK5I19JljXcGgvRk5eTcdDWv5+7GWHia9S1Viyn4ow==", + "version": "0.18.5", + "resolved": "https://registry.npmjs.org/genlayer-js/-/genlayer-js-0.18.5.tgz", + "integrity": "sha512-wYp0oygho3FucNWLu/QFUl4hPAdFKNqggDJHP4SiRGPGTVgObPPF7txicNArKtXVFhAvu/y0JBbNFRXdpenkwA==", "license": "MIT", "dependencies": { "eslint-plugin-import": "^2.30.0", diff --git a/package.json b/package.json index cbc4e6c1..f46fd270 100644 --- a/package.json +++ b/package.json @@ -65,7 +65,7 @@ "dotenv": "^17.0.0", "ethers": "^6.13.4", "fs-extra": "^11.3.0", - "genlayer-js": "^0.16.0", + "genlayer-js": "^0.18.5", "inquirer": "^12.0.0", "keytar": "^7.9.0", "node-fetch": "^3.0.0", diff --git a/src/commands/account/create.ts b/src/commands/account/create.ts new file mode 100644 index 00000000..1b60e33d --- /dev/null +++ b/src/commands/account/create.ts @@ -0,0 +1,23 @@ +import {BaseAction} from "../../lib/actions/BaseAction"; + +export interface CreateAccountOptions { + output: string; + overwrite: boolean; +} + +export class CreateAccountAction extends BaseAction { + constructor() { + super(); + } + + async execute(options: CreateAccountOptions): Promise { + try { + this.startSpinner("Creating encrypted keystore..."); + await this.createKeypair(options.output, options.overwrite); + + this.succeedSpinner(`Account created and saved to: ${options.output}`); + } catch (error) { + this.failSpinner("Failed to create account", error); + } + } +} diff --git a/src/commands/account/import.ts b/src/commands/account/import.ts new file mode 100644 index 00000000..aadac336 --- /dev/null +++ b/src/commands/account/import.ts @@ -0,0 +1,81 @@ +import {BaseAction} from "../../lib/actions/BaseAction"; +import {ethers} from "ethers"; +import {writeFileSync, existsSync} from "fs"; +import {KeystoreData} from "../../lib/interfaces/KeystoreData"; + +export interface ImportAccountOptions { + privateKey?: string; + output: string; + overwrite: boolean; +} + +export class ImportAccountAction extends BaseAction { + private static readonly MIN_PASSWORD_LENGTH = 8; + + constructor() { + super(); + } + + async execute(options: ImportAccountOptions): Promise { + try { + const privateKey = options.privateKey || await this.promptPrivateKey(); + + const normalizedKey = this.normalizePrivateKey(privateKey); + this.validatePrivateKey(normalizedKey); + + const finalOutputPath = this.getFilePath(options.output); + + if (existsSync(finalOutputPath) && !options.overwrite) { + this.failSpinner(`File at ${finalOutputPath} already exists. Use '--overwrite' to replace.`); + } + + const wallet = new ethers.Wallet(normalizedKey); + + const password = await this.promptPassword("Enter a password to encrypt your keystore (minimum 8 characters):"); + const confirmPassword = await this.promptPassword("Confirm password:"); + + if (password !== confirmPassword) { + this.failSpinner("Passwords do not match"); + } + + if (password.length < ImportAccountAction.MIN_PASSWORD_LENGTH) { + this.failSpinner(`Password must be at least ${ImportAccountAction.MIN_PASSWORD_LENGTH} characters long`); + } + + this.startSpinner("Encrypting and saving keystore..."); + + const encryptedJson = await wallet.encrypt(password); + + const keystoreData: KeystoreData = { + version: 1, + encrypted: encryptedJson, + address: wallet.address, + }; + + writeFileSync(finalOutputPath, JSON.stringify(keystoreData, null, 2)); + this.writeConfig("keyPairPath", finalOutputPath); + + await this.keychainManager.removePrivateKey(); + + this.succeedSpinner(`Account imported and saved to: ${finalOutputPath}`); + this.logInfo(`Address: ${wallet.address}`); + } catch (error) { + this.failSpinner("Failed to import account", error); + } + } + + private async promptPrivateKey(): Promise { + return this.promptPassword("Enter private key to import:"); + } + + private normalizePrivateKey(key: string): string { + const trimmed = key.trim(); + return trimmed.startsWith("0x") ? trimmed : `0x${trimmed}`; + } + + private validatePrivateKey(key: string): void { + if (!/^0x[0-9a-fA-F]{64}$/.test(key)) { + this.failSpinner("Invalid private key format. Expected 64 hex characters (with or without 0x prefix)."); + } + } +} diff --git a/src/commands/account/index.ts b/src/commands/account/index.ts new file mode 100644 index 00000000..13fb19b0 --- /dev/null +++ b/src/commands/account/index.ts @@ -0,0 +1,67 @@ +import {Command} from "commander"; +import {ShowAccountAction} from "./show"; +import {CreateAccountAction, CreateAccountOptions} from "./create"; +import {ImportAccountAction, ImportAccountOptions} from "./import"; +import {UnlockAccountAction} from "./unlock"; +import {LockAccountAction} from "./lock"; +import {SendAction, SendOptions} from "./send"; + +export function initializeAccountCommands(program: Command) { + const accountCommand = program + .command("account") + .description("Manage your account (address, balance, keys)") + .action(async () => { + // Default action: show account info + const showAction = new ShowAccountAction(); + await showAction.execute(); + }); + + accountCommand + .command("create") + .description("Create a new account with encrypted keystore") + .option("--output ", "Path to save the keystore", "./keypair.json") + .option("--overwrite", "Overwrite existing file", false) + .action(async (options: CreateAccountOptions) => { + const createAction = new CreateAccountAction(); + await createAction.execute(options); + }); + + accountCommand + .command("import") + .description("Import an account from a private key") + .option("--private-key ", "Private key to import (will prompt if not provided)") + .option("--output ", "Path to save the keystore", "./keypair.json") + .option("--overwrite", "Overwrite existing file", false) + .action(async (options: ImportAccountOptions) => { + const importAction = new ImportAccountAction(); + await importAction.execute(options); + }); + + accountCommand + .command("send ") + .description("Send GEN to an address") + .option("--rpc ", "RPC URL for the network") + .option("--network ", "Network to use (localnet, testnet-asimov)") + .action(async (to: string, amount: string, options: {rpc?: string; network?: string}) => { + const sendAction = new SendAction(); + await sendAction.execute({to, amount, rpc: options.rpc, network: options.network}); + }); + + accountCommand + .command("unlock") + .description("Unlock account by caching private key in OS keychain") + .action(async () => { + const unlockAction = new UnlockAccountAction(); + await unlockAction.execute(); + }); + + accountCommand + .command("lock") + .description("Lock account by removing private key from OS keychain") + .action(async () => { + const lockAction = new LockAccountAction(); + await lockAction.execute(); + }); + + return program; +} diff --git a/src/commands/keygen/lock.ts b/src/commands/account/lock.ts similarity index 66% rename from src/commands/keygen/lock.ts rename to src/commands/account/lock.ts index 6e5d8b77..58748e9a 100644 --- a/src/commands/keygen/lock.ts +++ b/src/commands/account/lock.ts @@ -1,6 +1,6 @@ -import { BaseAction } from "../../lib/actions/BaseAction"; +import {BaseAction} from "../../lib/actions/BaseAction"; -export class LockAction extends BaseAction { +export class LockAccountAction extends BaseAction { async execute(): Promise { this.startSpinner("Checking keychain availability..."); @@ -14,7 +14,7 @@ export class LockAction extends BaseAction { const hasCachedKey = await this.keychainManager.getPrivateKey(); if (!hasCachedKey) { - this.succeedSpinner("Wallet is already locked (no cached key found in OS keychain)."); + this.succeedSpinner("Account is already locked."); return; } @@ -22,10 +22,9 @@ export class LockAction extends BaseAction { try { await this.keychainManager.removePrivateKey(); - - this.succeedSpinner("Wallet locked successfully! Your private key has been removed from the OS keychain."); + this.succeedSpinner("Account locked! Private key removed from OS keychain."); } catch (error) { - this.failSpinner("Failed to lock wallet.", error); + this.failSpinner("Failed to lock account.", error); } } -} \ No newline at end of file +} diff --git a/src/commands/account/send.ts b/src/commands/account/send.ts new file mode 100644 index 00000000..acd7d1f3 --- /dev/null +++ b/src/commands/account/send.ts @@ -0,0 +1,150 @@ +import {BaseAction, BUILT_IN_NETWORKS, resolveNetwork} from "../../lib/actions/BaseAction"; +import {parseEther, formatEther} from "viem"; +import {createClient, createAccount} from "genlayer-js"; +import type {GenLayerChain, Address, Hash} from "genlayer-js/types"; +import {readFileSync, existsSync} from "fs"; +import {ethers} from "ethers"; +import {KeystoreData} from "../../lib/interfaces/KeystoreData"; + +export interface SendOptions { + to: string; + amount: string; + rpc?: string; + network?: string; +} + +export class SendAction extends BaseAction { + constructor() { + super(); + } + + private getNetwork(networkOption?: string): GenLayerChain { + if (networkOption) { + const network = BUILT_IN_NETWORKS[networkOption]; + if (!network) { + throw new Error(`Unknown network: ${networkOption}. Available: ${Object.keys(BUILT_IN_NETWORKS).join(", ")}`); + } + return network; + } + return resolveNetwork(this.getConfig().network); + } + + private parseAmount(amount: string): bigint { + // Support "10gen" or "10" (assumes gen) or wei values + const lowerAmount = amount.toLowerCase(); + if (lowerAmount.endsWith("gen")) { + const value = lowerAmount.slice(0, -3); + return parseEther(value); + } + // If it's a large number (likely wei), use as-is + if (BigInt(amount) > 1_000_000_000_000n) { + return BigInt(amount); + } + // Otherwise assume it's in GEN + return parseEther(amount); + } + + async execute(options: SendOptions): Promise { + this.startSpinner("Preparing transfer..."); + + try { + const keypairPath = this.getConfigByKey("keyPairPath"); + + if (!keypairPath || !existsSync(keypairPath)) { + this.failSpinner("No account found. Run 'genlayer account create' first."); + return; + } + + const keystoreData: KeystoreData = JSON.parse(readFileSync(keypairPath, "utf-8")); + + if (!this.isValidKeystoreFormat(keystoreData)) { + this.failSpinner("Invalid keystore format."); + return; + } + + // Get private key + const cachedKey = await this.keychainManager.getPrivateKey(); + let privateKey: string; + + if (cachedKey) { + privateKey = cachedKey; + } else { + this.stopSpinner(); + const password = await this.promptPassword("Enter password to unlock account:"); + this.startSpinner("Preparing transfer..."); + const wallet = await ethers.Wallet.fromEncryptedJson(keystoreData.encrypted, password); + privateKey = wallet.privateKey; + } + + const network = this.getNetwork(options.network); + const account = createAccount(privateKey as Hash); + const amount = this.parseAmount(options.amount); + + const client = createClient({ + chain: network, + account, + endpoint: options.rpc, + }); + + this.setSpinnerText(`Sending ${formatEther(amount)} GEN to ${options.to}...`); + + // Get nonce + const nonce = await client.getCurrentNonce({address: account.address}); + + // Prepare and sign transaction (let prepareTransactionRequest estimate gas) + const transactionRequest = await client.prepareTransactionRequest({ + account, + to: options.to as Address, + value: amount, + type: "legacy", + nonce: Number(nonce), + }); + + const serializedTransaction = await account.signTransaction(transactionRequest); + const txHash = await client.sendRawTransaction({serializedTransaction}); + + this.setSpinnerText(`Transaction submitted: ${txHash}\nWaiting for confirmation...`); + + // Poll for receipt (standard ETH transfer, not GenVM tx) + let receipt = null; + for (let i = 0; i < 60; i++) { + try { + receipt = await client.getTransactionReceipt({hash: txHash}); + if (receipt) break; + } catch { + // Receipt not available yet, continue polling + } + await new Promise((r) => setTimeout(r, 2000)); + } + + if (!receipt) { + // Tx submitted but receipt not found yet - still success + this.succeedSpinner("Transfer submitted (pending confirmation)", { + transactionHash: txHash, + from: account.address, + to: options.to, + amount: `${formatEther(amount)} GEN`, + }); + return; + } + + if (receipt.status === "reverted") { + this.failSpinner("Transaction reverted"); + return; + } + + const result = { + transactionHash: txHash, + from: account.address, + to: options.to, + amount: `${formatEther(amount)} GEN`, + blockNumber: receipt.blockNumber.toString(), + gasUsed: receipt.gasUsed.toString(), + }; + + this.succeedSpinner("Transfer successful!", result); + } catch (error: any) { + this.failSpinner("Transfer failed", error.message || error); + } + } +} diff --git a/src/commands/account/show.ts b/src/commands/account/show.ts new file mode 100644 index 00000000..8bf99c5d --- /dev/null +++ b/src/commands/account/show.ts @@ -0,0 +1,60 @@ +import {BaseAction, resolveNetwork} from "../../lib/actions/BaseAction"; +import {formatEther} from "viem"; +import {createClient} from "genlayer-js"; +import type {GenLayerChain, Address} from "genlayer-js/types"; +import {readFileSync, existsSync} from "fs"; +import {KeystoreData} from "../../lib/interfaces/KeystoreData"; + +export class ShowAccountAction extends BaseAction { + constructor() { + super(); + } + + private getNetwork(): GenLayerChain { + return resolveNetwork(this.getConfig().network); + } + + async execute(): Promise { + this.startSpinner("Fetching account info..."); + + try { + const keypairPath = this.getConfigByKey("keyPairPath"); + + if (!keypairPath || !existsSync(keypairPath)) { + this.failSpinner("No account found. Run 'genlayer account create' first."); + return; + } + + const keystoreData: KeystoreData = JSON.parse(readFileSync(keypairPath, "utf-8")); + + if (!this.isValidKeystoreFormat(keystoreData)) { + this.failSpinner("Invalid keystore format."); + return; + } + + const address = keystoreData.address as Address; + const network = this.getNetwork(); + + const client = createClient({ + chain: network, + account: address, + }); + + const balance = await client.getBalance({address}); + const formattedBalance = formatEther(balance); + + const isUnlocked = await this.keychainManager.getPrivateKey(); + + const result = { + address, + balance: `${formattedBalance} GEN`, + network: network.name || "localnet", + status: isUnlocked ? "unlocked" : "locked", + }; + + this.succeedSpinner("Account info", result); + } catch (error: any) { + this.failSpinner("Failed to get account info", error.message || error); + } + } +} diff --git a/src/commands/keygen/unlock.ts b/src/commands/account/unlock.ts similarity index 59% rename from src/commands/keygen/unlock.ts rename to src/commands/account/unlock.ts index 7754cb39..a523fe95 100644 --- a/src/commands/keygen/unlock.ts +++ b/src/commands/account/unlock.ts @@ -1,8 +1,8 @@ -import { BaseAction } from "../../lib/actions/BaseAction"; -import { readFileSync, existsSync } from "fs"; -import { ethers } from "ethers"; +import {BaseAction} from "../../lib/actions/BaseAction"; +import {readFileSync, existsSync} from "fs"; +import {ethers} from "ethers"; -export class UnlockAction extends BaseAction { +export class UnlockAccountAction extends BaseAction { async execute(): Promise { this.startSpinner("Checking keychain availability..."); @@ -12,30 +12,30 @@ export class UnlockAction extends BaseAction { return; } - this.setSpinnerText("Checking for existing keystore..."); + this.setSpinnerText("Checking for existing account..."); const keypairPath = this.getConfigByKey("keyPairPath"); if (!keypairPath || !existsSync(keypairPath)) { - this.failSpinner("No keystore file found. Please create a keypair first using 'genlayer keygen create'."); + this.failSpinner("No account found. Run 'genlayer account create' first."); return; } const keystoreData = JSON.parse(readFileSync(keypairPath, "utf-8")); if (!this.isValidKeystoreFormat(keystoreData)) { - this.failSpinner("Invalid keystore format. Expected encrypted keystore file."); + this.failSpinner("Invalid keystore format."); return; } this.stopSpinner(); try { - const password = await this.promptPassword("Enter password to decrypt keystore:"); + const password = await this.promptPassword("Enter password to unlock account:"); const wallet = await ethers.Wallet.fromEncryptedJson(keystoreData.encrypted, password); - + await this.keychainManager.storePrivateKey(wallet.privateKey); - this.succeedSpinner("Wallet unlocked successfully! Your private key is now stored securely in the OS keychain."); + this.succeedSpinner("Account unlocked! Private key cached in OS keychain."); } catch (error) { - this.failSpinner("Failed to unlock wallet.", error); + this.failSpinner("Failed to unlock account.", error); } } -} \ No newline at end of file +} diff --git a/src/commands/keygen/create.ts b/src/commands/keygen/create.ts deleted file mode 100644 index 57f56b93..00000000 --- a/src/commands/keygen/create.ts +++ /dev/null @@ -1,23 +0,0 @@ -import {BaseAction} from "../../lib/actions/BaseAction"; - -export interface CreateKeypairOptions { - output: string; - overwrite: boolean; -} - -export class KeypairCreator extends BaseAction { - constructor() { - super(); - } - - async createKeypairAction(options: CreateKeypairOptions) { - try { - this.startSpinner(`Creating encrypted keystore...`); - await this.createKeypair(options.output, options.overwrite); - - this.succeedSpinner(`Encrypted keystore successfully created and saved to: ${options.output}`); - } catch (error) { - this.failSpinner("Failed to generate keystore", error); - } - } -} diff --git a/src/commands/keygen/index.ts b/src/commands/keygen/index.ts deleted file mode 100644 index 82a185c4..00000000 --- a/src/commands/keygen/index.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { Command } from "commander"; -import { CreateKeypairOptions, KeypairCreator } from "./create"; -import { UnlockAction } from "./unlock"; -import { LockAction } from "./lock"; - -export function initializeKeygenCommands(program: Command) { - - const keygenCommand = program - .command("keygen") - .description("Manage keypair generation"); - - keygenCommand - .command("create") - .description("Generates a new encrypted keystore and saves it to a file") - .option("--output ", "Path to save the keystore", "./keypair.json") - .option("--overwrite", "Overwrite the existing file if it already exists", false) - .action(async (options: CreateKeypairOptions) => { - const keypairCreator = new KeypairCreator(); - await keypairCreator.createKeypairAction(options); - }); - - keygenCommand - .command("unlock") - .description("Unlock your wallet by storing the decrypted private key in OS keychain") - .action(async () => { - const unlockAction = new UnlockAction(); - await unlockAction.execute(); - }); - - keygenCommand - .command("lock") - .description("Lock your wallet by removing the decrypted private key from OS keychain") - .action(async () => { - const lockAction = new LockAction(); - await lockAction.execute(); - }); - - return program; -} diff --git a/src/commands/network/setNetwork.ts b/src/commands/network/setNetwork.ts index 7cf120d7..1366d00a 100644 --- a/src/commands/network/setNetwork.ts +++ b/src/commands/network/setNetwork.ts @@ -38,7 +38,7 @@ export class NetworkActions extends BaseAction { this.failSpinner(`Network ${networkName} not found`); return; } - this.writeConfig("network", JSON.stringify(selectedNetwork.value)); + this.writeConfig("network", selectedNetwork.alias); this.succeedSpinner(`Network successfully set to ${selectedNetwork.name}`); return; } @@ -48,13 +48,14 @@ export class NetworkActions extends BaseAction { type: "list", name: "selectedNetwork", message: "Select which network do you want to use:", - choices: networks, + choices: networks.map(n => ({name: n.name, value: n.alias})), }, ]; const networkAnswer = await inquirer.prompt(networkQuestions); - const selectedNetwork = networkAnswer.selectedNetwork; + const selectedAlias = networkAnswer.selectedNetwork; + const selectedNetwork = networks.find(n => n.alias === selectedAlias)!; - this.writeConfig("network", JSON.stringify(selectedNetwork)); + this.writeConfig("network", selectedAlias); this.succeedSpinner(`Network successfully set to ${selectedNetwork.name}`); } } diff --git a/src/commands/staking/StakingAction.ts b/src/commands/staking/StakingAction.ts new file mode 100644 index 00000000..24605a03 --- /dev/null +++ b/src/commands/staking/StakingAction.ts @@ -0,0 +1,125 @@ +import {BaseAction, BUILT_IN_NETWORKS, resolveNetwork} from "../../lib/actions/BaseAction"; +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 {KeystoreData} from "../../lib/interfaces/KeystoreData"; + +export interface StakingConfig { + rpc?: string; + stakingAddress?: string; + network?: string; +} + +export class StakingAction extends BaseAction { + private _stakingClient: GenLayerClient | null = null; + + constructor() { + super(); + } + + private getNetwork(config: StakingConfig): GenLayerChain { + // Priority: --network option > global config > localnet default + if (config.network) { + const network = BUILT_IN_NETWORKS[config.network]; + if (!network) { + throw new Error(`Unknown network: ${config.network}. Available: ${Object.keys(BUILT_IN_NETWORKS).join(", ")}`); + } + return {...network}; + } + + return resolveNetwork(this.getConfig().network); + } + + protected async getStakingClient(config: StakingConfig): Promise> { + if (!this._stakingClient) { + const network = this.getNetwork(config); + + // Override staking address if provided + if (config.stakingAddress) { + network.stakingContract = { + address: config.stakingAddress, + abi: abi.STAKING_ABI, + }; + } + + const privateKey = await this.getPrivateKeyForStaking(); + const account = createAccount(privateKey as `0x${string}`); + + this._stakingClient = createClient({ + chain: network, + endpoint: config.rpc, + account, + }); + } + return this._stakingClient; + } + + protected async getReadOnlyStakingClient(config: StakingConfig): Promise> { + const network = this.getNetwork(config); + + if (config.stakingAddress) { + network.stakingContract = { + address: config.stakingAddress, + abi: abi.STAKING_ABI, + }; + } + + const keypairPath = this.getConfigByKey("keyPairPath"); + if (!keypairPath || !existsSync(keypairPath)) { + throw new Error("No account found. Run 'genlayer account create' first."); + } + + const keystoreData: KeystoreData = JSON.parse(readFileSync(keypairPath, "utf-8")); + + return createClient({ + chain: network, + endpoint: config.rpc, + account: keystoreData.address as Address, + }); + } + + private async getPrivateKeyForStaking(): Promise { + const keypairPath = this.getConfigByKey("keyPairPath"); + + if (!keypairPath || !existsSync(keypairPath)) { + throw new Error("No account found. Run 'genlayer account create' first."); + } + + const keystoreData: KeystoreData = JSON.parse(readFileSync(keypairPath, "utf-8")); + + if (!this.isValidKeystoreFormat(keystoreData)) { + throw new Error("Invalid keystore format."); + } + + const cachedKey = await this.keychainManager.getPrivateKey(); + if (cachedKey) { + return cachedKey; + } + + // Stop spinner before prompting for password + this.stopSpinner(); + const password = await this.promptPassword("Enter password to unlock account:"); + this.startSpinner("Continuing..."); + + const wallet = await ethers.Wallet.fromEncryptedJson(keystoreData.encrypted, password); + return wallet.privateKey; + } + + protected parseAmount(amount: string): bigint { + return parseStakingAmount(amount); + } + + protected formatAmount(amount: bigint): string { + return formatStakingAmount(amount); + } + + protected async getSignerAddress(): Promise
{ + const keypairPath = this.getConfigByKey("keyPairPath"); + if (!keypairPath || !existsSync(keypairPath)) { + throw new Error("Keypair file not found."); + } + const keystoreData: KeystoreData = JSON.parse(readFileSync(keypairPath, "utf-8")); + return keystoreData.address as Address; + } +} diff --git a/src/commands/staking/delegatorClaim.ts b/src/commands/staking/delegatorClaim.ts new file mode 100644 index 00000000..6f1b2b56 --- /dev/null +++ b/src/commands/staking/delegatorClaim.ts @@ -0,0 +1,41 @@ +import {StakingAction, StakingConfig} from "./StakingAction"; +import type {Address} from "genlayer-js/types"; + +export interface DelegatorClaimOptions extends StakingConfig { + validator: string; + delegator?: string; +} + +export class DelegatorClaimAction extends StakingAction { + constructor() { + super(); + } + + async execute(options: DelegatorClaimOptions): Promise { + this.startSpinner("Claiming delegator withdrawals..."); + + try { + const client = await this.getStakingClient(options); + const delegatorAddress = options.delegator || (await this.getSignerAddress()); + + this.setSpinnerText(`Claiming for delegator ${delegatorAddress} from validator ${options.validator}...`); + + const result = await client.delegatorClaim({ + validator: options.validator as Address, + delegator: delegatorAddress as Address, + }); + + const output = { + transactionHash: result.transactionHash, + delegator: delegatorAddress, + validator: options.validator, + blockNumber: result.blockNumber.toString(), + gasUsed: result.gasUsed.toString(), + }; + + this.succeedSpinner("Claim successful!", output); + } catch (error: any) { + this.failSpinner("Failed to claim", error.message || error); + } + } +} diff --git a/src/commands/staking/delegatorExit.ts b/src/commands/staking/delegatorExit.ts new file mode 100644 index 00000000..303ea8da --- /dev/null +++ b/src/commands/staking/delegatorExit.ts @@ -0,0 +1,50 @@ +import {StakingAction, StakingConfig} from "./StakingAction"; +import type {Address} from "genlayer-js/types"; + +export interface DelegatorExitOptions extends StakingConfig { + validator: string; + shares: string; +} + +export class DelegatorExitAction extends StakingAction { + constructor() { + super(); + } + + async execute(options: DelegatorExitOptions): Promise { + this.startSpinner("Initiating delegator exit..."); + + try { + let shares: bigint; + try { + shares = BigInt(options.shares); + if (shares <= 0n) throw new Error("must be positive"); + } catch { + this.failSpinner(`Invalid shares value: "${options.shares}". Must be a positive whole number.`); + return; + } + + const client = await this.getStakingClient(options); + + this.setSpinnerText(`Exiting ${shares} shares from validator ${options.validator}...`); + + const result = await client.delegatorExit({ + validator: options.validator as Address, + shares, + }); + + const output = { + transactionHash: result.transactionHash, + validator: options.validator, + sharesWithdrawn: shares.toString(), + blockNumber: result.blockNumber.toString(), + gasUsed: result.gasUsed.toString(), + note: "Withdrawal will be claimable after the unbonding period", + }; + + this.succeedSpinner("Exit initiated successfully!", output); + } catch (error: any) { + this.failSpinner("Failed to exit", error.message || error); + } + } +} diff --git a/src/commands/staking/delegatorJoin.ts b/src/commands/staking/delegatorJoin.ts new file mode 100644 index 00000000..00bacfb8 --- /dev/null +++ b/src/commands/staking/delegatorJoin.ts @@ -0,0 +1,42 @@ +import {StakingAction, StakingConfig} from "./StakingAction"; +import type {Address} from "genlayer-js/types"; + +export interface DelegatorJoinOptions extends StakingConfig { + validator: string; + amount: string; +} + +export class DelegatorJoinAction extends StakingAction { + constructor() { + super(); + } + + async execute(options: DelegatorJoinOptions): Promise { + this.startSpinner("Joining as delegator..."); + + try { + const client = await this.getStakingClient(options); + const amount = this.parseAmount(options.amount); + + this.setSpinnerText(`Delegating ${this.formatAmount(amount)} to validator ${options.validator}...`); + + const result = await client.delegatorJoin({ + validator: options.validator as Address, + amount, + }); + + const output = { + transactionHash: result.transactionHash, + validator: result.validator, + amount: result.amount, + delegator: result.delegator, + blockNumber: result.blockNumber.toString(), + gasUsed: result.gasUsed.toString(), + }; + + this.succeedSpinner("Successfully joined as delegator!", output); + } catch (error: any) { + this.failSpinner("Failed to join as delegator", error.message || error); + } + } +} diff --git a/src/commands/staking/index.ts b/src/commands/staking/index.ts new file mode 100644 index 00000000..fd00425c --- /dev/null +++ b/src/commands/staking/index.ts @@ -0,0 +1,224 @@ +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 {SetOperatorAction, SetOperatorOptions} from "./setOperator"; +import {SetIdentityAction, SetIdentityOptions} from "./setIdentity"; +import {DelegatorJoinAction, DelegatorJoinOptions} from "./delegatorJoin"; +import {DelegatorExitAction, DelegatorExitOptions} from "./delegatorExit"; +import {DelegatorClaimAction, DelegatorClaimOptions} from "./delegatorClaim"; +import {StakingInfoAction, StakingInfoOptions} from "./stakingInfo"; + +export function initializeStakingCommands(program: Command) { + const staking = program.command("staking").description("Staking operations for validators and delegators"); + + // Validator commands + staking + .command("validator-join") + .description("Join as a validator by staking tokens") + .requiredOption("--amount ", "Amount to stake (in wei or with 'eth'/'gen' suffix, e.g., '42000gen')") + .option("--operator
", "Operator address (defaults to signer)") + .option("--network ", "Network to use (localnet, testnet-asimov)") + .option("--rpc ", "RPC URL for the network") + .option("--staking-address
", "Staking contract address (overrides chain config)") + .action(async (options: ValidatorJoinOptions) => { + const action = new ValidatorJoinAction(); + await action.execute(options); + }); + + staking + .command("validator-deposit") + .description("Make an additional deposit as a validator") + .requiredOption("--amount ", "Amount to deposit (in wei or with 'eth'/'gen' suffix)") + .option("--network ", "Network to use (localnet, testnet-asimov)") + .option("--rpc ", "RPC URL for the network") + .option("--staking-address
", "Staking contract address (overrides chain config)") + .action(async (options: ValidatorDepositOptions) => { + const action = new ValidatorDepositAction(); + await action.execute(options); + }); + + staking + .command("validator-exit") + .description("Exit as a validator by withdrawing shares") + .requiredOption("--shares ", "Number of shares to withdraw") + .option("--network ", "Network to use (localnet, testnet-asimov)") + .option("--rpc ", "RPC URL for the network") + .option("--staking-address
", "Staking contract address (overrides chain config)") + .action(async (options: ValidatorExitOptions) => { + const action = new ValidatorExitAction(); + await action.execute(options); + }); + + staking + .command("validator-claim") + .description("Claim validator withdrawals after unbonding period") + .option("--validator
", "Validator address (defaults to signer)") + .option("--network ", "Network to use (localnet, testnet-asimov)") + .option("--rpc ", "RPC URL for the network") + .option("--staking-address
", "Staking contract address (overrides chain config)") + .action(async (options: ValidatorClaimOptions) => { + const action = new ValidatorClaimAction(); + await action.execute(options); + }); + + staking + .command("validator-prime") + .description("Prime a validator to prepare their stake record for the next epoch") + .requiredOption("--validator
", "Validator address to prime") + .option("--network ", "Network to use (localnet, testnet-asimov)") + .option("--rpc ", "RPC URL for the network") + .option("--staking-address
", "Staking contract address (overrides chain config)") + .action(async (options: ValidatorPrimeOptions) => { + const action = new ValidatorPrimeAction(); + await action.execute(options); + }); + + staking + .command("set-operator") + .description("Change the operator address for a validator wallet") + .requiredOption("--validator
", "Validator wallet address") + .requiredOption("--operator
", "New operator address") + .option("--network ", "Network to use (localnet, testnet-asimov)") + .option("--rpc ", "RPC URL for the network") + .option("--staking-address
", "Staking contract address (overrides chain config)") + .action(async (options: SetOperatorOptions) => { + const action = new SetOperatorAction(); + await action.execute(options); + }); + + staking + .command("set-identity") + .description("Set validator identity metadata (moniker, website, socials, etc.)") + .requiredOption("--validator
", "Validator wallet address") + .requiredOption("--moniker ", "Validator display name") + .option("--logo-uri ", "Logo URI") + .option("--website ", "Website URL") + .option("--description ", "Description") + .option("--email ", "Contact email") + .option("--twitter ", "Twitter handle") + .option("--telegram ", "Telegram handle") + .option("--github ", "GitHub handle") + .option("--extra-cid ", "Extra data as IPFS CID or hex bytes (0x...)") + .option("--network ", "Network to use (localnet, testnet-asimov)") + .option("--rpc ", "RPC URL for the network") + .option("--staking-address
", "Staking contract address (overrides chain config)") + .action(async (options: SetIdentityOptions) => { + const action = new SetIdentityAction(); + await action.execute(options); + }); + + // Delegator commands + staking + .command("delegator-join") + .description("Join as a delegator by staking with a validator") + .requiredOption("--validator
", "Validator address to delegate to") + .requiredOption("--amount ", "Amount to stake (in wei or with 'eth'/'gen' suffix)") + .option("--network ", "Network to use (localnet, testnet-asimov)") + .option("--rpc ", "RPC URL for the network") + .option("--staking-address
", "Staking contract address (overrides chain config)") + .action(async (options: DelegatorJoinOptions) => { + const action = new DelegatorJoinAction(); + await action.execute(options); + }); + + staking + .command("delegator-exit") + .description("Exit as a delegator by withdrawing shares from a validator") + .requiredOption("--validator
", "Validator address to exit from") + .requiredOption("--shares ", "Number of shares to withdraw") + .option("--network ", "Network to use (localnet, testnet-asimov)") + .option("--rpc ", "RPC URL for the network") + .option("--staking-address
", "Staking contract address (overrides chain config)") + .action(async (options: DelegatorExitOptions) => { + const action = new DelegatorExitAction(); + await action.execute(options); + }); + + staking + .command("delegator-claim") + .description("Claim delegator withdrawals after unbonding period") + .requiredOption("--validator
", "Validator address") + .option("--delegator
", "Delegator address (defaults to signer)") + .option("--network ", "Network to use (localnet, testnet-asimov)") + .option("--rpc ", "RPC URL for the network") + .option("--staking-address
", "Staking contract address (overrides chain config)") + .action(async (options: DelegatorClaimOptions) => { + const action = new DelegatorClaimAction(); + await action.execute(options); + }); + + // Info commands + staking + .command("validator-info") + .description("Get information about a validator") + .option("--validator
", "Validator address (defaults to signer)") + .option("--network ", "Network to use (localnet, testnet-asimov)") + .option("--rpc ", "RPC URL for the network") + .option("--staking-address
", "Staking contract address (overrides chain config)") + .action(async (options: StakingInfoOptions) => { + const action = new StakingInfoAction(); + await action.getValidatorInfo(options); + }); + + staking + .command("stake-info") + .description("Get stake info for a delegator with a validator") + .requiredOption("--validator
", "Validator address") + .option("--delegator
", "Delegator address (defaults to signer)") + .option("--network ", "Network to use (localnet, testnet-asimov)") + .option("--rpc ", "RPC URL for the network") + .option("--staking-address
", "Staking contract address (overrides chain config)") + .action(async (options: StakingInfoOptions & {delegator?: string}) => { + const action = new StakingInfoAction(); + await action.getStakeInfo(options); + }); + + staking + .command("epoch-info") + .description("Get current epoch and staking parameters") + .option("--network ", "Network to use (localnet, testnet-asimov)") + .option("--rpc ", "RPC URL for the network") + .option("--staking-address
", "Staking contract address (overrides chain config)") + .action(async (options: StakingInfoOptions) => { + const action = new StakingInfoAction(); + await action.getEpochInfo(options); + }); + + staking + .command("active-validators") + .description("List all active validators") + .option("--network ", "Network to use (localnet, testnet-asimov)") + .option("--rpc ", "RPC URL for the network") + .option("--staking-address
", "Staking contract address (overrides chain config)") + .action(async (options: StakingInfoOptions) => { + const action = new StakingInfoAction(); + await action.listActiveValidators(options); + }); + + staking + .command("quarantined-validators") + .description("List all quarantined validators") + .option("--network ", "Network to use (localnet, testnet-asimov)") + .option("--rpc ", "RPC URL for the network") + .option("--staking-address
", "Staking contract address (overrides chain config)") + .action(async (options: StakingInfoOptions) => { + const action = new StakingInfoAction(); + await action.listQuarantinedValidators(options); + }); + + staking + .command("banned-validators") + .description("List all banned validators") + .option("--network ", "Network to use (localnet, testnet-asimov)") + .option("--rpc ", "RPC URL for the network") + .option("--staking-address
", "Staking contract address (overrides chain config)") + .action(async (options: StakingInfoOptions) => { + const action = new StakingInfoAction(); + await action.listBannedValidators(options); + }); + + return program; +} diff --git a/src/commands/staking/setIdentity.ts b/src/commands/staking/setIdentity.ts new file mode 100644 index 00000000..e192da4b --- /dev/null +++ b/src/commands/staking/setIdentity.ts @@ -0,0 +1,61 @@ +import {StakingAction, StakingConfig} from "./StakingAction"; +import type {Address} from "genlayer-js/types"; + +export interface SetIdentityOptions extends StakingConfig { + validator: string; + moniker: string; + logoUri?: string; + website?: string; + description?: string; + email?: string; + twitter?: string; + telegram?: string; + github?: string; + extraCid?: string; +} + +export class SetIdentityAction extends StakingAction { + constructor() { + super(); + } + + async execute(options: SetIdentityOptions): Promise { + this.startSpinner("Setting validator identity..."); + + try { + const client = await this.getStakingClient(options); + + this.setSpinnerText(`Setting identity for ${options.validator}...`); + + const result = await client.setIdentity({ + validator: options.validator as Address, + moniker: options.moniker, + logoUri: options.logoUri, + website: options.website, + description: options.description, + email: options.email, + twitter: options.twitter, + telegram: options.telegram, + github: options.github, + extraCid: options.extraCid, + }); + + const output: Record = { + transactionHash: result.transactionHash, + validator: options.validator, + moniker: options.moniker, + blockNumber: result.blockNumber.toString(), + gasUsed: result.gasUsed.toString(), + }; + + // Add optional fields that were set + if (options.website) output.website = options.website; + if (options.twitter) output.twitter = options.twitter; + if (options.github) output.github = options.github; + + this.succeedSpinner("Validator identity set!", output); + } catch (error: any) { + this.failSpinner("Failed to set identity", error.message || error); + } + } +} diff --git a/src/commands/staking/setOperator.ts b/src/commands/staking/setOperator.ts new file mode 100644 index 00000000..5d1c0c3d --- /dev/null +++ b/src/commands/staking/setOperator.ts @@ -0,0 +1,40 @@ +import {StakingAction, StakingConfig} from "./StakingAction"; +import type {Address} from "genlayer-js/types"; + +export interface SetOperatorOptions extends StakingConfig { + validator: string; + operator: string; +} + +export class SetOperatorAction extends StakingAction { + constructor() { + super(); + } + + async execute(options: SetOperatorOptions): Promise { + this.startSpinner("Setting operator..."); + + try { + const client = await this.getStakingClient(options); + + this.setSpinnerText(`Setting operator to ${options.operator}...`); + + const result = await client.setOperator({ + validator: options.validator as Address, + operator: options.operator as Address, + }); + + const output = { + transactionHash: result.transactionHash, + validator: options.validator, + newOperator: options.operator, + blockNumber: result.blockNumber.toString(), + gasUsed: result.gasUsed.toString(), + }; + + this.succeedSpinner("Operator updated!", output); + } catch (error: any) { + this.failSpinner("Failed to set operator", error.message || error); + } + } +} diff --git a/src/commands/staking/stakingInfo.ts b/src/commands/staking/stakingInfo.ts new file mode 100644 index 00000000..67f23671 --- /dev/null +++ b/src/commands/staking/stakingInfo.ts @@ -0,0 +1,292 @@ +import {StakingAction, StakingConfig} from "./StakingAction"; +import type {Address} from "genlayer-js/types"; + +export interface StakingInfoOptions extends StakingConfig { + validator?: string; +} + +export class StakingInfoAction extends StakingAction { + constructor() { + super(); + } + + async getValidatorInfo(options: StakingInfoOptions): Promise { + this.startSpinner("Fetching validator info..."); + + try { + const client = await this.getReadOnlyStakingClient(options); + const validatorAddress = options.validator || (await this.getSignerAddress()); + + const isValidator = await client.isValidator(validatorAddress as Address); + + if (!isValidator) { + this.failSpinner(`Address ${validatorAddress} is not a validator`); + return; + } + + const [info, epochInfo] = await Promise.all([ + client.getValidatorInfo(validatorAddress as Address), + client.getEpochInfo(), + ]); + + const currentEpoch = epochInfo.currentEpoch; + + const result: Record = { + validator: info.address, + owner: info.owner, + operator: info.operator, + vStake: info.vStake, + vShares: info.vShares.toString(), + dStake: info.dStake, + dShares: info.dShares.toString(), + vDeposit: info.vDeposit, + vWithdrawal: info.vWithdrawal, + ePrimed: info.ePrimed.toString(), + needsPriming: info.needsPriming, + live: info.live, + banned: info.banned ? info.bannedEpoch?.toString() : "Not banned", + selfStakePendingDeposits: + info.pendingDeposits.length > 0 + ? info.pendingDeposits.map(d => { + const depositEpoch = d.epoch; + const activationEpoch = depositEpoch + 2n; + const epochsUntilActive = activationEpoch - currentEpoch; + return { + epoch: depositEpoch.toString(), + stake: d.stake, + shares: d.shares.toString(), + activatesAtEpoch: activationEpoch.toString(), + status: + epochsUntilActive <= 0n + ? "Active" + : `Pending (${epochsUntilActive} epoch${epochsUntilActive > 1n ? "s" : ""} remaining)`, + }; + }) + : "None", + selfStakePendingWithdrawals: + info.pendingWithdrawals.length > 0 + ? info.pendingWithdrawals.map(w => { + const exitEpoch = w.epoch; + const claimableEpoch = exitEpoch + 7n; // Must wait 7 full epochs + const epochsUntilClaimable = claimableEpoch - currentEpoch; + return { + epoch: exitEpoch.toString(), + shares: w.shares.toString(), + stake: w.stake, + claimableAtEpoch: claimableEpoch.toString(), + status: + epochsUntilClaimable <= 0n + ? "Claimable now" + : `Unbonding (${epochsUntilClaimable} epoch${epochsUntilClaimable > 1n ? "s" : ""} remaining)`, + }; + }) + : "None", + }; + + // Add identity if set + if (info.identity?.moniker) { + result.identity = { + moniker: info.identity.moniker, + ...(info.identity.website && {website: info.identity.website}), + ...(info.identity.description && {description: info.identity.description}), + ...(info.identity.twitter && {twitter: info.identity.twitter}), + ...(info.identity.telegram && {telegram: info.identity.telegram}), + ...(info.identity.github && {github: info.identity.github}), + ...(info.identity.email && {email: info.identity.email}), + ...(info.identity.logoUri && {logoUri: info.identity.logoUri}), + }; + } + + this.succeedSpinner("Validator info retrieved", result); + } catch (error: any) { + this.failSpinner("Failed to get validator info", error.message || error); + } + } + + async getStakeInfo(options: StakingInfoOptions & {delegator?: string}): Promise { + this.startSpinner("Fetching stake info..."); + + try { + const client = await this.getReadOnlyStakingClient(options); + const delegatorAddress = options.delegator || (await this.getSignerAddress()); + + if (!options.validator) { + this.failSpinner("Validator address is required"); + return; + } + + const [info, epochInfo] = await Promise.all([ + client.getStakeInfo(delegatorAddress as Address, options.validator as Address), + client.getEpochInfo(), + ]); + + const currentEpoch = epochInfo.currentEpoch; + + // Calculate projected rewards + let projectedReward = "N/A"; + if (epochInfo.totalWeight > 0n && epochInfo.inflationRaw > 0n && info.stakeRaw > 0n) { + const rewardRaw = (info.stakeRaw * epochInfo.inflationRaw) / epochInfo.totalWeight; + projectedReward = client.formatStakingAmount(rewardRaw) + " per epoch"; + } else if (epochInfo.inflationRaw === 0n) { + projectedReward = "0 GEN (no inflation this epoch)"; + } + + const result = { + delegator: info.delegator, + validator: info.validator, + shares: info.shares.toString(), + stake: info.stake, + projectedReward, + pendingDeposits: + info.pendingDeposits.length > 0 + ? info.pendingDeposits.map(d => { + const depositEpoch = d.epoch; + const activationEpoch = depositEpoch + 2n; + const epochsUntilActive = activationEpoch - currentEpoch; + return { + epoch: depositEpoch.toString(), + stake: d.stake, + shares: d.shares.toString(), + activatesAtEpoch: activationEpoch.toString(), + status: + epochsUntilActive <= 0n + ? "Active" + : `Pending (${epochsUntilActive} epoch${epochsUntilActive > 1n ? "s" : ""} remaining)`, + }; + }) + : "None", + pendingWithdrawals: + info.pendingWithdrawals.length > 0 + ? info.pendingWithdrawals.map(w => { + const exitEpoch = w.epoch; + const claimableEpoch = exitEpoch + 7n; // Must wait 7 full epochs + const epochsUntilClaimable = claimableEpoch - currentEpoch; + return { + epoch: exitEpoch.toString(), + shares: w.shares.toString(), + stake: w.stake, + claimableAtEpoch: claimableEpoch.toString(), + status: + epochsUntilClaimable <= 0n + ? "Claimable now" + : `Unbonding (${epochsUntilClaimable} epoch${epochsUntilClaimable > 1n ? "s" : ""} remaining)`, + }; + }) + : "None", + }; + + this.succeedSpinner("Stake info retrieved", result); + } catch (error: any) { + this.failSpinner("Failed to get stake info", error.message || error); + } + } + + async getEpochInfo(options: StakingConfig): Promise { + this.startSpinner("Fetching epoch info..."); + + try { + const client = await this.getReadOnlyStakingClient(options); + + const info = await client.getEpochInfo(); + + const formatDuration = (ms: number): string => { + const hours = Math.floor(ms / (1000 * 60 * 60)); + const minutes = Math.floor((ms % (1000 * 60 * 60)) / (1000 * 60)); + if (hours > 24) { + const days = Math.floor(hours / 24); + const remainingHours = hours % 24; + return `${days}d ${remainingHours}h ${minutes}m`; + } + return `${hours}h ${minutes}m`; + }; + + const now = Date.now(); + const timeUntilNext = info.nextEpochEstimate ? info.nextEpochEstimate.getTime() - now : null; + + const result = { + currentEpoch: info.currentEpoch.toString(), + epochStarted: info.currentEpochStart.toISOString(), + epochEnded: info.currentEpochEnd?.toISOString() || "Not ended", + nextEpochEstimate: info.nextEpochEstimate?.toISOString() || "N/A", + timeUntilNextEpoch: timeUntilNext && timeUntilNext > 0 ? formatDuration(timeUntilNext) : "N/A", + minEpochDuration: formatDuration(Number(info.epochMinDuration) * 1000), + validatorMinStake: info.validatorMinStake, + delegatorMinStake: info.delegatorMinStake, + activeValidatorsCount: info.activeValidatorsCount.toString(), + // Inflation/rewards + epochInflation: info.inflation, + totalWeight: info.totalWeight.toString(), + totalClaimed: info.totalClaimed, + }; + + this.succeedSpinner("Epoch info retrieved", result); + } catch (error: any) { + this.failSpinner("Failed to get epoch info", error.message || error); + } + } + + async listActiveValidators(options: StakingConfig): Promise { + this.startSpinner("Fetching active validators..."); + + try { + const client = await this.getReadOnlyStakingClient(options); + + const activeValidators = await client.getActiveValidators(); + + const result = { + count: activeValidators.length, + validators: activeValidators, + }; + + this.succeedSpinner("Active validators retrieved", result); + } catch (error: any) { + this.failSpinner("Failed to get active validators", error.message || error); + } + } + + async listQuarantinedValidators(options: StakingConfig): Promise { + this.startSpinner("Fetching quarantined validators..."); + + try { + const client = await this.getReadOnlyStakingClient(options); + + const validators = await client.getQuarantinedValidatorsDetailed(); + + const result = { + count: validators.length, + validators: validators.map(v => ({ + validator: v.validator, + untilEpoch: v.untilEpoch.toString(), + permanentlyBanned: v.permanentlyBanned, + })), + }; + + this.succeedSpinner("Quarantined validators retrieved", result); + } catch (error: any) { + this.failSpinner("Failed to get quarantined validators", error.message || error); + } + } + + async listBannedValidators(options: StakingConfig): Promise { + this.startSpinner("Fetching banned validators..."); + + try { + const client = await this.getReadOnlyStakingClient(options); + + const validators = await client.getBannedValidators(); + + const result = { + count: validators.length, + validators: validators.map(v => ({ + validator: v.validator, + untilEpoch: v.permanentlyBanned ? "permanent" : v.untilEpoch.toString(), + permanentlyBanned: v.permanentlyBanned, + })), + }; + + this.succeedSpinner("Banned validators retrieved", result); + } catch (error: any) { + this.failSpinner("Failed to get banned validators", error.message || error); + } + } +} diff --git a/src/commands/staking/validatorClaim.ts b/src/commands/staking/validatorClaim.ts new file mode 100644 index 00000000..18b0be5b --- /dev/null +++ b/src/commands/staking/validatorClaim.ts @@ -0,0 +1,38 @@ +import {StakingAction, StakingConfig} from "./StakingAction"; +import type {Address} from "genlayer-js/types"; + +export interface ValidatorClaimOptions extends StakingConfig { + validator?: string; +} + +export class ValidatorClaimAction extends StakingAction { + constructor() { + super(); + } + + async execute(options: ValidatorClaimOptions): Promise { + this.startSpinner("Claiming validator withdrawals..."); + + try { + const client = await this.getStakingClient(options); + const validatorAddress = options.validator || (await this.getSignerAddress()); + + this.setSpinnerText(`Claiming for validator ${validatorAddress}...`); + + const result = await client.validatorClaim({ + validator: validatorAddress as Address, + }); + + const output = { + transactionHash: result.transactionHash, + validator: validatorAddress, + blockNumber: result.blockNumber.toString(), + gasUsed: result.gasUsed.toString(), + }; + + this.succeedSpinner("Claim successful!", output); + } catch (error: any) { + this.failSpinner("Failed to claim", error.message || error); + } + } +} diff --git a/src/commands/staking/validatorDeposit.ts b/src/commands/staking/validatorDeposit.ts new file mode 100644 index 00000000..c0be6b0c --- /dev/null +++ b/src/commands/staking/validatorDeposit.ts @@ -0,0 +1,35 @@ +import {StakingAction, StakingConfig} from "./StakingAction"; + +export interface ValidatorDepositOptions extends StakingConfig { + amount: string; +} + +export class ValidatorDepositAction extends StakingAction { + constructor() { + super(); + } + + async execute(options: ValidatorDepositOptions): Promise { + this.startSpinner("Making validator deposit..."); + + try { + const client = await this.getStakingClient(options); + const amount = this.parseAmount(options.amount); + + this.setSpinnerText(`Depositing ${this.formatAmount(amount)} to validator stake...`); + + const result = await client.validatorDeposit({amount}); + + const output = { + transactionHash: result.transactionHash, + amount: this.formatAmount(amount), + blockNumber: result.blockNumber.toString(), + gasUsed: result.gasUsed.toString(), + }; + + this.succeedSpinner("Deposit successful!", output); + } catch (error: any) { + this.failSpinner("Failed to make deposit", error.message || error); + } + } +} diff --git a/src/commands/staking/validatorExit.ts b/src/commands/staking/validatorExit.ts new file mode 100644 index 00000000..4b785cff --- /dev/null +++ b/src/commands/staking/validatorExit.ts @@ -0,0 +1,44 @@ +import {StakingAction, StakingConfig} from "./StakingAction"; + +export interface ValidatorExitOptions extends StakingConfig { + shares: string; +} + +export class ValidatorExitAction extends StakingAction { + constructor() { + super(); + } + + async execute(options: ValidatorExitOptions): Promise { + this.startSpinner("Initiating validator exit..."); + + try { + let shares: bigint; + try { + shares = BigInt(options.shares); + if (shares <= 0n) throw new Error("must be positive"); + } catch { + this.failSpinner(`Invalid shares value: "${options.shares}". Must be a positive whole number.`); + return; + } + + const client = await this.getStakingClient(options); + + this.setSpinnerText(`Exiting with ${shares} shares...`); + + const result = await client.validatorExit({shares}); + + const output = { + transactionHash: result.transactionHash, + sharesWithdrawn: shares.toString(), + blockNumber: result.blockNumber.toString(), + gasUsed: result.gasUsed.toString(), + note: "Withdrawal will be claimable after the unbonding period", + }; + + this.succeedSpinner("Exit initiated successfully!", output); + } catch (error: any) { + this.failSpinner("Failed to exit", error.message || error); + } + } +} diff --git a/src/commands/staking/validatorJoin.ts b/src/commands/staking/validatorJoin.ts new file mode 100644 index 00000000..8185fd91 --- /dev/null +++ b/src/commands/staking/validatorJoin.ts @@ -0,0 +1,47 @@ +import {StakingAction, StakingConfig} from "./StakingAction"; +import type {Address} from "genlayer-js/types"; + +export interface ValidatorJoinOptions extends StakingConfig { + amount: string; + operator?: string; +} + +export class ValidatorJoinAction extends StakingAction { + constructor() { + super(); + } + + async execute(options: ValidatorJoinOptions): Promise { + this.startSpinner("Creating a new validator..."); + + try { + const client = await this.getStakingClient(options); + const amount = this.parseAmount(options.amount); + const signerAddress = await this.getSignerAddress(); + + this.setSpinnerText(`Creating validator with ${this.formatAmount(amount)} stake...`); + this.log(` From: ${signerAddress}`); + if (options.operator) { + this.log(` Operator: ${options.operator}`); + } + + const result = await client.validatorJoin({ + amount, + operator: options.operator as Address | undefined, + }); + + const output = { + transactionHash: result.transactionHash, + validatorWallet: result.validatorWallet, + amount: result.amount, + operator: result.operator, + blockNumber: result.blockNumber.toString(), + gasUsed: result.gasUsed.toString(), + }; + + this.succeedSpinner("Validator created successfully!", output); + } catch (error: any) { + this.failSpinner("Failed to create validator", error.message || error); + } + } +} diff --git a/src/commands/staking/validatorPrime.ts b/src/commands/staking/validatorPrime.ts new file mode 100644 index 00000000..9a8c32c6 --- /dev/null +++ b/src/commands/staking/validatorPrime.ts @@ -0,0 +1,35 @@ +import {StakingAction, StakingConfig} from "./StakingAction"; +import type {Address} from "genlayer-js/types"; + +export interface ValidatorPrimeOptions extends StakingConfig { + validator: string; +} + +export class ValidatorPrimeAction extends StakingAction { + constructor() { + super(); + } + + async execute(options: ValidatorPrimeOptions): Promise { + this.startSpinner("Priming validator..."); + + try { + const client = await this.getStakingClient(options); + + this.setSpinnerText(`Priming validator ${options.validator}...`); + + const result = await client.validatorPrime({validator: options.validator as Address}); + + const output = { + transactionHash: result.transactionHash, + validator: options.validator, + blockNumber: result.blockNumber.toString(), + gasUsed: result.gasUsed.toString(), + }; + + this.succeedSpinner("Validator primed for next epoch!", output); + } catch (error: any) { + this.failSpinner("Failed to prime validator", error.message || error); + } + } +} diff --git a/src/index.ts b/src/index.ts index fcf4fa9e..c78a5cd0 100644 --- a/src/index.ts +++ b/src/index.ts @@ -3,7 +3,7 @@ import {program} from "commander"; import {version} from "../package.json"; import {CLI_DESCRIPTION} from "../src/lib/config/text"; import {initializeGeneralCommands} from "../src/commands/general"; -import {initializeKeygenCommands} from "../src/commands/keygen"; +import {initializeAccountCommands} from "../src/commands/account"; import {initializeContractsCommands} from "../src/commands/contracts"; import {initializeConfigCommands} from "../src/commands/config"; import {initializeValidatorCommands} from "../src/commands/localnet"; @@ -11,11 +11,12 @@ import {initializeUpdateCommands} from "../src/commands/update"; import {initializeScaffoldCommands} from "../src/commands/scaffold"; import {initializeNetworkCommands} from "../src/commands/network"; import {initializeTransactionsCommands} from "../src/commands/transactions"; +import {initializeStakingCommands} from "../src/commands/staking"; export function initializeCLI() { program.version(version).description(CLI_DESCRIPTION); initializeGeneralCommands(program); - initializeKeygenCommands(program); + initializeAccountCommands(program); initializeContractsCommands(program); initializeConfigCommands(program); initializeUpdateCommands(program); @@ -23,6 +24,7 @@ export function initializeCLI() { initializeScaffoldCommands(program); initializeNetworkCommands(program); initializeTransactionsCommands(program); + initializeStakingCommands(program); program.parse(process.argv); } diff --git a/src/lib/actions/BaseAction.ts b/src/lib/actions/BaseAction.ts index af603681..0001a148 100644 --- a/src/lib/actions/BaseAction.ts +++ b/src/lib/actions/BaseAction.ts @@ -5,8 +5,43 @@ import chalk from "chalk"; import inquirer from "inquirer"; import { inspect } from "util"; import {createClient, createAccount} from "genlayer-js"; -import {localnet} from "genlayer-js/chains"; +import {localnet, studionet, testnetAsimov} from "genlayer-js/chains"; import type {GenLayerClient, GenLayerChain, Hash, Address, Account} from "genlayer-js/types"; + +// Built-in networks - always resolve fresh from genlayer-js +export const BUILT_IN_NETWORKS: Record = { + "localnet": localnet, + "studionet": studionet, + "testnet-asimov": testnetAsimov, +}; + +/** + * Resolves a stored network config to a fresh chain object. + * Handles both new format (alias string) and old format (JSON object) for backwards compat. + */ +export function resolveNetwork(stored: string | undefined): GenLayerChain { + if (!stored) return localnet; + + // Try as alias first (new format) + if (BUILT_IN_NETWORKS[stored]) { + return BUILT_IN_NETWORKS[stored]; + } + + // Backwards compat: try parsing as JSON (old format) + try { + const parsed = JSON.parse(stored); + // If it has a known name, use fresh version instead + const alias = Object.entries(BUILT_IN_NETWORKS) + .find(([_, chain]) => chain.name === parsed.name)?.[0]; + if (alias) { + return BUILT_IN_NETWORKS[alias]; + } + // Custom network - use as-is + return parsed; + } catch { + throw new Error(`Unknown network: ${stored}`); + } +} import { ethers } from "ethers"; import { writeFileSync, existsSync, readFileSync } from "fs"; import { KeystoreData } from "../interfaces/KeystoreData"; @@ -38,7 +73,6 @@ export class BaseAction extends ConfigFileManager { } catch (error) { if (attempt >= BaseAction.MAX_PASSWORD_ATTEMPTS) { this.failSpinner(`Maximum password attempts exceeded (${BaseAction.MAX_PASSWORD_ATTEMPTS}/${BaseAction.MAX_PASSWORD_ATTEMPTS}).`); - process.exit(1); } return await this.decryptKeystore(keystoreData, attempt + 1); } @@ -62,8 +96,7 @@ export class BaseAction extends ConfigFileManager { protected async getClient(rpcUrl?: string, readOnly: boolean = false): Promise> { if (!this._genlayerClient) { - const networkConfig = this.getConfig().network; - const network = networkConfig ? JSON.parse(networkConfig) : localnet; + const network = resolveNetwork(this.getConfig().network); const account = await this.getAccount(readOnly); this._genlayerClient = createClient({ chain: network, @@ -88,7 +121,7 @@ export class BaseAction extends ConfigFileManager { keystoreData = JSON.parse(readFileSync(keypairPath, "utf-8")); if (!this.isValidKeystoreFormat(keystoreData)) { - this.failSpinner("Invalid keystore format. Expected encrypted keystore file."); + this.failSpinner("Invalid keystore format. Expected encrypted keystore file.", undefined, false); await this.confirmPrompt("Would you like to create a new keypair?"); decryptedPrivateKey = await this.createKeypair(BaseAction.DEFAULT_KEYSTORE_PATH, true); keypairPath = this.getConfigByKey("keyPairPath")!; @@ -116,7 +149,6 @@ export class BaseAction extends ConfigFileManager { if (existsSync(finalOutputPath) && !overwrite) { this.failSpinner(`The file at ${finalOutputPath} already exists. Use the '--overwrite' option to replace it.`); - process.exit(1); } const wallet = ethers.Wallet.createRandom(); @@ -126,12 +158,10 @@ export class BaseAction extends ConfigFileManager { if (password !== confirmPassword) { this.failSpinner("Passwords do not match"); - process.exit(1); } if (password.length < BaseAction.MIN_PASSWORD_LENGTH) { this.failSpinner(`Password must be at least ${BaseAction.MIN_PASSWORD_LENGTH} characters long`); - process.exit(1); } const encryptedJson = await wallet.encrypt(password); @@ -220,10 +250,13 @@ export class BaseAction extends ConfigFileManager { this.spinner.succeed(chalk.green(message)); } - protected failSpinner(message: string, error?:any): void { + protected failSpinner(message: string, error?: any, shouldExit = true): void { if (error) this.log("Error:", error); - console.log(''); + console.log(""); this.spinner.fail(chalk.red(message)); + if (shouldExit) { + process.exit(1); + } } protected stopSpinner(): void { diff --git a/tests/actions/create.test.ts b/tests/actions/create.test.ts index 5a744f0d..40d5d6f9 100644 --- a/tests/actions/create.test.ts +++ b/tests/actions/create.test.ts @@ -1,18 +1,18 @@ import {describe, test, vi, beforeEach, afterEach, expect} from "vitest"; -import {KeypairCreator} from "../../src/commands/keygen/create"; +import {CreateAccountAction} from "../../src/commands/account/create"; -describe("KeypairCreator", () => { - let keypairCreator: KeypairCreator; +describe("CreateAccountAction", () => { + let createAction: CreateAccountAction; beforeEach(() => { vi.clearAllMocks(); - keypairCreator = new KeypairCreator(); - + createAction = new CreateAccountAction(); + // Mock the BaseAction methods - vi.spyOn(keypairCreator as any, "startSpinner").mockImplementation(() => {}); - vi.spyOn(keypairCreator as any, "succeedSpinner").mockImplementation(() => {}); - vi.spyOn(keypairCreator as any, "failSpinner").mockImplementation(() => {}); - vi.spyOn(keypairCreator as any, "createKeypair").mockResolvedValue("0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef"); + vi.spyOn(createAction as any, "startSpinner").mockImplementation(() => {}); + vi.spyOn(createAction as any, "succeedSpinner").mockImplementation(() => {}); + vi.spyOn(createAction as any, "failSpinner").mockImplementation(() => {}); + vi.spyOn(createAction as any, "createKeypair").mockResolvedValue("0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef"); }); afterEach(() => { @@ -22,24 +22,24 @@ describe("KeypairCreator", () => { test("successfully creates and saves an encrypted keystore", async () => { const options = {output: "keypair.json", overwrite: false}; - await keypairCreator.createKeypairAction(options); + await createAction.execute(options); - expect(keypairCreator["startSpinner"]).toHaveBeenCalledWith("Creating encrypted keystore..."); - expect(keypairCreator["createKeypair"]).toHaveBeenCalledWith( - options.output, + expect(createAction["startSpinner"]).toHaveBeenCalledWith("Creating encrypted keystore..."); + expect(createAction["createKeypair"]).toHaveBeenCalledWith( + options.output, options.overwrite ); - expect(keypairCreator["succeedSpinner"]).toHaveBeenCalledWith( - "Encrypted keystore successfully created and saved to: keypair.json", + expect(createAction["succeedSpinner"]).toHaveBeenCalledWith( + "Account created and saved to: keypair.json", ); }); test("handles errors during keystore creation", async () => { const mockError = new Error("Mocked creation error"); - vi.spyOn(keypairCreator as any, "createKeypair").mockRejectedValue(mockError); + vi.spyOn(createAction as any, "createKeypair").mockRejectedValue(mockError); - await keypairCreator.createKeypairAction({output: "keypair.json", overwrite: true}); + await createAction.execute({output: "keypair.json", overwrite: true}); - expect(keypairCreator["failSpinner"]).toHaveBeenCalledWith("Failed to generate keystore", mockError); + expect(createAction["failSpinner"]).toHaveBeenCalledWith("Failed to create account", mockError); }); }); diff --git a/tests/actions/lock.test.ts b/tests/actions/lock.test.ts index 555cd01c..b2065a9f 100644 --- a/tests/actions/lock.test.ts +++ b/tests/actions/lock.test.ts @@ -1,12 +1,12 @@ import {describe, test, vi, beforeEach, afterEach, expect} from "vitest"; -import {LockAction} from "../../src/commands/keygen/lock"; +import {LockAccountAction} from "../../src/commands/account/lock"; -describe("LockAction", () => { - let lockAction: LockAction; +describe("LockAccountAction", () => { + let lockAction: LockAccountAction; beforeEach(() => { vi.clearAllMocks(); - lockAction = new LockAction(); + lockAction = new LockAccountAction(); // Mock the BaseAction methods vi.spyOn(lockAction as any, "startSpinner").mockImplementation(() => {}); @@ -33,7 +33,7 @@ describe("LockAction", () => { expect(lockAction["keychainManager"].getPrivateKey).toHaveBeenCalled(); expect(lockAction["setSpinnerText"]).toHaveBeenCalledWith("Removing private key from OS keychain..."); expect(lockAction["keychainManager"].removePrivateKey).toHaveBeenCalled(); - expect(lockAction["succeedSpinner"]).toHaveBeenCalledWith("Wallet locked successfully! Your private key has been removed from the OS keychain."); + expect(lockAction["succeedSpinner"]).toHaveBeenCalledWith("Account locked! Private key removed from OS keychain."); }); test("fails when keychain is not available", async () => { @@ -51,7 +51,7 @@ describe("LockAction", () => { await lockAction.execute(); - expect(lockAction["succeedSpinner"]).toHaveBeenCalledWith("Wallet is already locked (no cached key found in OS keychain)."); + expect(lockAction["succeedSpinner"]).toHaveBeenCalledWith("Account is already locked."); expect(lockAction["keychainManager"].removePrivateKey).not.toHaveBeenCalled(); }); @@ -61,6 +61,6 @@ describe("LockAction", () => { await lockAction.execute(); - expect(lockAction["failSpinner"]).toHaveBeenCalledWith("Failed to lock wallet.", mockError); + expect(lockAction["failSpinner"]).toHaveBeenCalledWith("Failed to lock account.", mockError); }); }); \ No newline at end of file diff --git a/tests/actions/setNetwork.test.ts b/tests/actions/setNetwork.test.ts index 6ed479e6..0b185030 100644 --- a/tests/actions/setNetwork.test.ts +++ b/tests/actions/setNetwork.test.ts @@ -26,7 +26,7 @@ describe("NetworkActions", () => { test("setNetwork method sets network by valid name", async () => { await networkActions.setNetwork(localnet.name); - expect(networkActions["writeConfig"]).toHaveBeenCalledWith("network", JSON.stringify(localnet)); + expect(networkActions["writeConfig"]).toHaveBeenCalledWith("network", "localnet"); expect(networkActions["succeedSpinner"]).toHaveBeenCalledWith( `Network successfully set to ${localnet.name}`, ); @@ -35,7 +35,7 @@ describe("NetworkActions", () => { test("setNetwork method sets network by valid alias", async () => { await networkActions.setNetwork("localnet"); - expect(networkActions["writeConfig"]).toHaveBeenCalledWith("network", JSON.stringify(localnet)); + expect(networkActions["writeConfig"]).toHaveBeenCalledWith("network", "localnet"); expect(networkActions["succeedSpinner"]).toHaveBeenCalledWith( `Network successfully set to ${localnet.name}`, ); @@ -44,7 +44,7 @@ describe("NetworkActions", () => { test("setNetwork method sets studionet by name", async () => { await networkActions.setNetwork(studionet.name); - expect(networkActions["writeConfig"]).toHaveBeenCalledWith("network", JSON.stringify(studionet)); + expect(networkActions["writeConfig"]).toHaveBeenCalledWith("network", "studionet"); expect(networkActions["succeedSpinner"]).toHaveBeenCalledWith( `Network successfully set to ${studionet.name}`, ); @@ -53,7 +53,7 @@ describe("NetworkActions", () => { test("setNetwork method sets studionet by alias", async () => { await networkActions.setNetwork("studionet"); - expect(networkActions["writeConfig"]).toHaveBeenCalledWith("network", JSON.stringify(studionet)); + expect(networkActions["writeConfig"]).toHaveBeenCalledWith("network", "studionet"); expect(networkActions["succeedSpinner"]).toHaveBeenCalledWith( `Network successfully set to ${studionet.name}`, ); @@ -62,7 +62,7 @@ describe("NetworkActions", () => { test("setNetwork method sets testnet-asimov by name", async () => { await networkActions.setNetwork(testnetAsimov.name); - expect(networkActions["writeConfig"]).toHaveBeenCalledWith("network", JSON.stringify(testnetAsimov)); + expect(networkActions["writeConfig"]).toHaveBeenCalledWith("network", "testnet-asimov"); expect(networkActions["succeedSpinner"]).toHaveBeenCalledWith( `Network successfully set to ${testnetAsimov.name}`, ); @@ -71,7 +71,7 @@ describe("NetworkActions", () => { test("setNetwork method sets testnet-asimov by alias", async () => { await networkActions.setNetwork("testnet-asimov"); - expect(networkActions["writeConfig"]).toHaveBeenCalledWith("network", JSON.stringify(testnetAsimov)); + expect(networkActions["writeConfig"]).toHaveBeenCalledWith("network", "testnet-asimov"); expect(networkActions["succeedSpinner"]).toHaveBeenCalledWith( `Network successfully set to ${testnetAsimov.name}`, ); @@ -94,14 +94,8 @@ describe("NetworkActions", () => { }); test("setNetwork method prompts user when no network name provided", async () => { - const mockSelectedNetwork = { - name: localnet.name, - alias: "localnet", - value: localnet, - }; - vi.mocked(inquirer.prompt).mockResolvedValue({ - selectedNetwork: mockSelectedNetwork, + selectedNetwork: "localnet", }); await networkActions.setNetwork(); @@ -112,74 +106,41 @@ describe("NetworkActions", () => { name: "selectedNetwork", message: "Select which network do you want to use:", choices: [ - { - name: localnet.name, - alias: "localnet", - value: localnet, - }, - { - name: studionet.name, - alias: "studionet", - value: studionet, - }, - { - name: testnetAsimov.name, - alias: "testnet-asimov", - value: testnetAsimov, - }, + {name: localnet.name, value: "localnet"}, + {name: studionet.name, value: "studionet"}, + {name: testnetAsimov.name, value: "testnet-asimov"}, ], }, ]); - expect(networkActions["writeConfig"]).toHaveBeenCalledWith( - "network", - JSON.stringify(mockSelectedNetwork), - ); + expect(networkActions["writeConfig"]).toHaveBeenCalledWith("network", "localnet"); expect(networkActions["succeedSpinner"]).toHaveBeenCalledWith( - `Network successfully set to ${mockSelectedNetwork.name}`, + `Network successfully set to ${localnet.name}`, ); }); test("setNetwork method handles interactive selection of studionet", async () => { - const mockSelectedNetwork = { - name: studionet.name, - alias: "studionet", - value: studionet, - }; - vi.mocked(inquirer.prompt).mockResolvedValue({ - selectedNetwork: mockSelectedNetwork, + selectedNetwork: "studionet", }); await networkActions.setNetwork(); - expect(networkActions["writeConfig"]).toHaveBeenCalledWith( - "network", - JSON.stringify(mockSelectedNetwork), - ); + expect(networkActions["writeConfig"]).toHaveBeenCalledWith("network", "studionet"); expect(networkActions["succeedSpinner"]).toHaveBeenCalledWith( - `Network successfully set to ${mockSelectedNetwork.name}`, + `Network successfully set to ${studionet.name}`, ); }); test("setNetwork method handles interactive selection of testnet-asimov", async () => { - const mockSelectedNetwork = { - name: testnetAsimov.name, - alias: "testnet-asimov", - value: testnetAsimov, - }; - vi.mocked(inquirer.prompt).mockResolvedValue({ - selectedNetwork: mockSelectedNetwork, + selectedNetwork: "testnet-asimov", }); await networkActions.setNetwork(); - expect(networkActions["writeConfig"]).toHaveBeenCalledWith( - "network", - JSON.stringify(mockSelectedNetwork), - ); + expect(networkActions["writeConfig"]).toHaveBeenCalledWith("network", "testnet-asimov"); expect(networkActions["succeedSpinner"]).toHaveBeenCalledWith( - `Network successfully set to ${mockSelectedNetwork.name}`, + `Network successfully set to ${testnetAsimov.name}`, ); }); diff --git a/tests/actions/staking.test.ts b/tests/actions/staking.test.ts new file mode 100644 index 00000000..dea54b2c --- /dev/null +++ b/tests/actions/staking.test.ts @@ -0,0 +1,323 @@ +import {describe, test, vi, beforeEach, afterEach, expect} from "vitest"; +import {ValidatorJoinAction} from "../../src/commands/staking/validatorJoin"; +import {ValidatorDepositAction} from "../../src/commands/staking/validatorDeposit"; +import {ValidatorExitAction} from "../../src/commands/staking/validatorExit"; +import {ValidatorClaimAction} from "../../src/commands/staking/validatorClaim"; +import {DelegatorJoinAction} from "../../src/commands/staking/delegatorJoin"; +import {DelegatorExitAction} from "../../src/commands/staking/delegatorExit"; +import {DelegatorClaimAction} from "../../src/commands/staking/delegatorClaim"; +import {StakingInfoAction} from "../../src/commands/staking/stakingInfo"; + +// Mock genlayer-js +vi.mock("genlayer-js", () => ({ + createClient: vi.fn(), + createAccount: vi.fn(() => ({address: "0xMockedAddress"})), + formatStakingAmount: vi.fn((val: bigint) => `${Number(val) / 1e18} GEN`), + parseStakingAmount: vi.fn((val: string) => { + if (val.toLowerCase().endsWith("gen") || val.toLowerCase().endsWith("eth")) { + return BigInt(parseFloat(val.slice(0, -3)) * 1e18); + } + return BigInt(val); + }), + abi: { + STAKING_ABI: [], + }, +})); + +vi.mock("genlayer-js/chains", () => ({ + localnet: {id: 1, name: "localnet", rpcUrls: {default: {http: ["http://localhost:8545"]}}}, + studionet: {id: 2, name: "studionet", rpcUrls: {default: {http: ["https://studionet.genlayer.com"]}}}, + testnetAsimov: {id: 3, name: "testnet-asimov", rpcUrls: {default: {http: ["https://testnet.genlayer.com"]}}}, +})); + +const mockTxResult = { + transactionHash: "0xMockedHash" as `0x${string}`, + blockNumber: 123n, + gasUsed: 21000n, +}; + +const mockValidatorJoinResult = { + ...mockTxResult, + validatorWallet: "0xValidatorWallet", + operator: "0xOperator", + amount: "42000 GEN", + amountRaw: 42000n * BigInt(1e18), +}; + +const mockDelegatorJoinResult = { + ...mockTxResult, + validator: "0xValidator", + delegator: "0xDelegator", + amount: "42 GEN", + amountRaw: 42n * BigInt(1e18), +}; + +const mockClient = { + validatorJoin: vi.fn(), + validatorDeposit: vi.fn(), + validatorExit: vi.fn(), + validatorClaim: vi.fn(), + delegatorJoin: vi.fn(), + delegatorExit: vi.fn(), + delegatorClaim: vi.fn(), + isValidator: vi.fn(), + getValidatorInfo: vi.fn(), + getStakeInfo: vi.fn(), + getEpochInfo: vi.fn(), + getActiveValidators: vi.fn(), +}; + +function setupActionMocks(action: any) { + vi.spyOn(action as any, "getStakingClient").mockResolvedValue(mockClient); + vi.spyOn(action as any, "getReadOnlyStakingClient").mockResolvedValue(mockClient); + vi.spyOn(action as any, "getSignerAddress").mockResolvedValue("0xMockedSigner"); + vi.spyOn(action as any, "startSpinner").mockImplementation(() => {}); + vi.spyOn(action as any, "setSpinnerText").mockImplementation(() => {}); + vi.spyOn(action as any, "succeedSpinner").mockImplementation(() => {}); + vi.spyOn(action as any, "failSpinner").mockImplementation(() => {}); +} + +describe("ValidatorJoinAction", () => { + let action: ValidatorJoinAction; + + beforeEach(() => { + vi.clearAllMocks(); + action = new ValidatorJoinAction(); + setupActionMocks(action); + mockClient.validatorJoin.mockResolvedValue(mockValidatorJoinResult); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + test("joins as validator without operator", async () => { + await action.execute({amount: "42000gen", stakingAddress: "0xStaking"}); + + expect(mockClient.validatorJoin).toHaveBeenCalledWith({ + amount: expect.any(BigInt), + operator: undefined, + }); + expect(action["succeedSpinner"]).toHaveBeenCalledWith("Validator created successfully!", expect.any(Object)); + }); + + test("joins as validator with operator", async () => { + await action.execute({amount: "42000gen", operator: "0xOperator", stakingAddress: "0xStaking"}); + + expect(mockClient.validatorJoin).toHaveBeenCalledWith({ + amount: expect.any(BigInt), + operator: "0xOperator", + }); + }); + + test("handles errors", async () => { + mockClient.validatorJoin.mockRejectedValue(new Error("Join failed")); + + await action.execute({amount: "42000gen", stakingAddress: "0xStaking"}); + + expect(action["failSpinner"]).toHaveBeenCalledWith("Failed to create validator", "Join failed"); + }); +}); + +describe("ValidatorDepositAction", () => { + let action: ValidatorDepositAction; + + beforeEach(() => { + vi.clearAllMocks(); + action = new ValidatorDepositAction(); + setupActionMocks(action); + mockClient.validatorDeposit.mockResolvedValue(mockTxResult); + }); + + test("deposits successfully", async () => { + await action.execute({amount: "1000gen", stakingAddress: "0xStaking"}); + + expect(mockClient.validatorDeposit).toHaveBeenCalledWith({amount: expect.any(BigInt)}); + expect(action["succeedSpinner"]).toHaveBeenCalledWith("Deposit successful!", expect.any(Object)); + }); +}); + +describe("ValidatorExitAction", () => { + let action: ValidatorExitAction; + + beforeEach(() => { + vi.clearAllMocks(); + action = new ValidatorExitAction(); + setupActionMocks(action); + mockClient.validatorExit.mockResolvedValue(mockTxResult); + }); + + test("exits successfully", async () => { + await action.execute({shares: "100", stakingAddress: "0xStaking"}); + + expect(mockClient.validatorExit).toHaveBeenCalledWith({shares: 100n}); + expect(action["succeedSpinner"]).toHaveBeenCalledWith("Exit initiated successfully!", expect.any(Object)); + }); +}); + +describe("ValidatorClaimAction", () => { + let action: ValidatorClaimAction; + + beforeEach(() => { + vi.clearAllMocks(); + action = new ValidatorClaimAction(); + setupActionMocks(action); + mockClient.validatorClaim.mockResolvedValue({...mockTxResult, claimedAmount: 0n}); + }); + + test("claims successfully", async () => { + await action.execute({validator: "0xValidator", stakingAddress: "0xStaking"}); + + expect(mockClient.validatorClaim).toHaveBeenCalledWith({validator: "0xValidator"}); + expect(action["succeedSpinner"]).toHaveBeenCalledWith("Claim successful!", expect.any(Object)); + }); + + test("uses signer address if no validator specified", async () => { + await action.execute({stakingAddress: "0xStaking"}); + + expect(mockClient.validatorClaim).toHaveBeenCalledWith({validator: "0xMockedSigner"}); + }); +}); + +describe("DelegatorJoinAction", () => { + let action: DelegatorJoinAction; + + beforeEach(() => { + vi.clearAllMocks(); + action = new DelegatorJoinAction(); + setupActionMocks(action); + mockClient.delegatorJoin.mockResolvedValue(mockDelegatorJoinResult); + }); + + test("joins as delegator successfully", async () => { + await action.execute({validator: "0xValidator", amount: "42gen", stakingAddress: "0xStaking"}); + + expect(mockClient.delegatorJoin).toHaveBeenCalledWith({ + validator: "0xValidator", + amount: expect.any(BigInt), + }); + expect(action["succeedSpinner"]).toHaveBeenCalledWith("Successfully joined as delegator!", expect.any(Object)); + }); +}); + +describe("DelegatorExitAction", () => { + let action: DelegatorExitAction; + + beforeEach(() => { + vi.clearAllMocks(); + action = new DelegatorExitAction(); + setupActionMocks(action); + mockClient.delegatorExit.mockResolvedValue(mockTxResult); + }); + + test("exits successfully", async () => { + await action.execute({validator: "0xValidator", shares: "50", stakingAddress: "0xStaking"}); + + expect(mockClient.delegatorExit).toHaveBeenCalledWith({validator: "0xValidator", shares: 50n}); + expect(action["succeedSpinner"]).toHaveBeenCalledWith("Exit initiated successfully!", expect.any(Object)); + }); +}); + +describe("DelegatorClaimAction", () => { + let action: DelegatorClaimAction; + + beforeEach(() => { + vi.clearAllMocks(); + action = new DelegatorClaimAction(); + setupActionMocks(action); + mockClient.delegatorClaim.mockResolvedValue(mockTxResult); + }); + + test("claims successfully", async () => { + await action.execute({validator: "0xValidator", delegator: "0xDelegator", stakingAddress: "0xStaking"}); + + expect(mockClient.delegatorClaim).toHaveBeenCalledWith({validator: "0xValidator", delegator: "0xDelegator"}); + expect(action["succeedSpinner"]).toHaveBeenCalledWith("Claim successful!", expect.any(Object)); + }); +}); + +const mockEpochInfo = { + currentEpoch: 10n, + currentEpochStart: new Date("2024-01-01T00:00:00Z"), + currentEpochEnd: new Date("2024-01-01T01:00:00Z"), + nextEpochEstimate: new Date("2024-01-01T02:00:00Z"), + epochMinDuration: 3600n, + validatorMinStake: "42000 GEN", + validatorMinStakeRaw: 42000n * BigInt(1e18), + delegatorMinStake: "42 GEN", + delegatorMinStakeRaw: 42n * BigInt(1e18), + activeValidatorsCount: 5n, + inflation: "1000 GEN", + inflationRaw: 1000n * BigInt(1e18), + totalWeight: 100000n * BigInt(1e18), + totalClaimed: "500 GEN", + totalClaimedRaw: 500n * BigInt(1e18), +}; + +describe("StakingInfoAction", () => { + let action: StakingInfoAction; + + beforeEach(() => { + vi.clearAllMocks(); + action = new StakingInfoAction(); + setupActionMocks(action); + mockClient.getEpochInfo.mockResolvedValue(mockEpochInfo); + }); + + test("gets validator info", async () => { + mockClient.isValidator.mockResolvedValue(true); + mockClient.getValidatorInfo.mockResolvedValue({ + address: "0xValidator", + owner: "0xOwner", + operator: "0xOperator", + vStake: "1000 GEN", + vStakeRaw: 1000n, + vShares: 100n, + dStake: "500 GEN", + dStakeRaw: 500n, + dShares: 50n, + vDeposit: "0 GEN", + vDepositRaw: 0n, + vWithdrawal: "0 GEN", + vWithdrawalRaw: 0n, + ePrimed: 5n, + needsPriming: false, + live: true, + banned: false, + bannedEpoch: null, + pendingDeposits: [], + pendingWithdrawals: [], + identity: null, + }); + + await action.getValidatorInfo({validator: "0xValidator", stakingAddress: "0xStaking"}); + + expect(mockClient.isValidator).toHaveBeenCalledWith("0xValidator"); + expect(action["succeedSpinner"]).toHaveBeenCalledWith("Validator info retrieved", expect.any(Object)); + }); + + test("fails if not a validator", async () => { + mockClient.isValidator.mockResolvedValue(false); + + await action.getValidatorInfo({validator: "0xNotValidator", stakingAddress: "0xStaking"}); + + expect(action["failSpinner"]).toHaveBeenCalledWith("Address 0xNotValidator is not a validator"); + }); + + test("gets epoch info", async () => { + await action.getEpochInfo({stakingAddress: "0xStaking"}); + + expect(action["succeedSpinner"]).toHaveBeenCalledWith("Epoch info retrieved", expect.any(Object)); + }); + + test("lists active validators", async () => { + mockClient.getActiveValidators.mockResolvedValue(["0xV1", "0xV2", "0xV3"]); + + await action.listActiveValidators({stakingAddress: "0xStaking"}); + + expect(action["succeedSpinner"]).toHaveBeenCalledWith("Active validators retrieved", { + count: 3, + validators: ["0xV1", "0xV2", "0xV3"], + }); + }); +}); diff --git a/tests/actions/unlock.test.ts b/tests/actions/unlock.test.ts index d9a4aa36..408000e6 100644 --- a/tests/actions/unlock.test.ts +++ b/tests/actions/unlock.test.ts @@ -1,5 +1,5 @@ import {describe, test, vi, beforeEach, afterEach, expect} from "vitest"; -import {UnlockAction} from "../../src/commands/keygen/unlock"; +import {UnlockAccountAction} from "../../src/commands/account/unlock"; import {readFileSync, existsSync} from "fs"; import {ethers} from "ethers"; import inquirer from "inquirer"; @@ -8,8 +8,8 @@ vi.mock("fs"); vi.mock("ethers"); vi.mock("inquirer"); -describe("UnlockAction", () => { - let unlockAction: UnlockAction; +describe("UnlockAccountAction", () => { + let unlockAction: UnlockAccountAction; const mockKeystoreData = { version: 1, encrypted: '{"address":"test","crypto":{"cipher":"aes-128-ctr"}}', @@ -21,7 +21,7 @@ describe("UnlockAction", () => { beforeEach(() => { vi.clearAllMocks(); - unlockAction = new UnlockAction(); + unlockAction = new UnlockAccountAction(); // Mock the BaseAction methods vi.spyOn(unlockAction as any, "startSpinner").mockImplementation(() => {}); @@ -52,15 +52,15 @@ describe("UnlockAction", () => { expect(unlockAction["startSpinner"]).toHaveBeenCalledWith("Checking keychain availability..."); expect(unlockAction["keychainManager"].isKeychainAvailable).toHaveBeenCalled(); - expect(unlockAction["setSpinnerText"]).toHaveBeenCalledWith("Checking for existing keystore..."); + expect(unlockAction["setSpinnerText"]).toHaveBeenCalledWith("Checking for existing account..."); expect(unlockAction["getConfigByKey"]).toHaveBeenCalledWith("keyPairPath"); expect(existsSync).toHaveBeenCalledWith("./test-keypair.json"); expect(unlockAction["stopSpinner"]).toHaveBeenCalled(); - expect(unlockAction["promptPassword"]).toHaveBeenCalledWith("Enter password to decrypt keystore:"); + expect(unlockAction["promptPassword"]).toHaveBeenCalledWith("Enter password to unlock account:"); expect(readFileSync).toHaveBeenCalledWith("./test-keypair.json", "utf-8"); expect(ethers.Wallet.fromEncryptedJson).toHaveBeenCalledWith(mockKeystoreData.encrypted, "test-password"); expect(unlockAction["keychainManager"].storePrivateKey).toHaveBeenCalledWith(mockWallet.privateKey); - expect(unlockAction["succeedSpinner"]).toHaveBeenCalledWith("Wallet unlocked successfully! Your private key is now stored securely in the OS keychain."); + expect(unlockAction["succeedSpinner"]).toHaveBeenCalledWith("Account unlocked! Private key cached in OS keychain."); }); test("fails when keychain is not available", async () => { @@ -78,7 +78,7 @@ describe("UnlockAction", () => { await unlockAction.execute(); - expect(unlockAction["failSpinner"]).toHaveBeenCalledWith("No keystore file found. Please create a keypair first using 'genlayer keygen create'."); + expect(unlockAction["failSpinner"]).toHaveBeenCalledWith("No account found. Run 'genlayer account create' first."); expect(unlockAction["promptPassword"]).not.toHaveBeenCalled(); }); @@ -87,7 +87,7 @@ describe("UnlockAction", () => { await unlockAction.execute(); - expect(unlockAction["failSpinner"]).toHaveBeenCalledWith("No keystore file found. Please create a keypair first using 'genlayer keygen create'."); + expect(unlockAction["failSpinner"]).toHaveBeenCalledWith("No account found. Run 'genlayer account create' first."); expect(unlockAction["promptPassword"]).not.toHaveBeenCalled(); }); @@ -96,7 +96,7 @@ describe("UnlockAction", () => { await unlockAction.execute(); - expect(unlockAction["failSpinner"]).toHaveBeenCalledWith("Invalid keystore format. Expected encrypted keystore file."); + expect(unlockAction["failSpinner"]).toHaveBeenCalledWith("Invalid keystore format."); expect(unlockAction["promptPassword"]).not.toHaveBeenCalled(); }); @@ -106,7 +106,7 @@ describe("UnlockAction", () => { await unlockAction.execute(); - expect(unlockAction["failSpinner"]).toHaveBeenCalledWith("Failed to unlock wallet.", mockError); + expect(unlockAction["failSpinner"]).toHaveBeenCalledWith("Failed to unlock account.", mockError); expect(unlockAction["keychainManager"].storePrivateKey).not.toHaveBeenCalled(); }); @@ -116,6 +116,6 @@ describe("UnlockAction", () => { await unlockAction.execute(); - expect(unlockAction["failSpinner"]).toHaveBeenCalledWith("Failed to unlock wallet.", mockError); + expect(unlockAction["failSpinner"]).toHaveBeenCalledWith("Failed to unlock account.", mockError); }); }); \ No newline at end of file diff --git a/tests/commands/account.test.ts b/tests/commands/account.test.ts new file mode 100644 index 00000000..f5bc5d25 --- /dev/null +++ b/tests/commands/account.test.ts @@ -0,0 +1,121 @@ +import { Command } from "commander"; +import { vi, describe, beforeEach, afterEach, test, expect } from "vitest"; +import { initializeAccountCommands } from "../../src/commands/account"; +import { CreateAccountAction } from "../../src/commands/account/create"; +import { UnlockAccountAction } from "../../src/commands/account/unlock"; +import { LockAccountAction } from "../../src/commands/account/lock"; + +vi.mock("../../src/commands/account/create"); +vi.mock("../../src/commands/account/unlock"); +vi.mock("../../src/commands/account/lock"); + +describe("account create command", () => { + let program: Command; + + beforeEach(() => { + program = new Command(); + initializeAccountCommands(program); + vi.clearAllMocks(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + test("CreateAccountAction.execute is called with default options", async () => { + program.parse(["node", "test", "account", "create"]); + expect(CreateAccountAction).toHaveBeenCalledTimes(1); + expect(CreateAccountAction.prototype.execute).toHaveBeenCalledWith({ + output: "./keypair.json", + overwrite: false, + }); + }); + + test("CreateAccountAction.execute is called with custom output option", async () => { + program.parse(["node", "test", "account", "create", "--output", "./custom.json"]); + expect(CreateAccountAction).toHaveBeenCalledTimes(1); + expect(CreateAccountAction.prototype.execute).toHaveBeenCalledWith({ + output: "./custom.json", + overwrite: false, + }); + }); + + test("CreateAccountAction.execute is called with overwrite enabled", async () => { + program.parse(["node", "test", "account", "create", "--overwrite"]); + expect(CreateAccountAction).toHaveBeenCalledTimes(1); + expect(CreateAccountAction.prototype.execute).toHaveBeenCalledWith({ + output: "./keypair.json", + overwrite: true, + }); + }); + + test("CreateAccountAction.execute is called with custom output and overwrite enabled", async () => { + program.parse(["node", "test", "account", "create", "--output", "./custom.json", "--overwrite"]); + expect(CreateAccountAction).toHaveBeenCalledTimes(1); + expect(CreateAccountAction.prototype.execute).toHaveBeenCalledWith({ + output: "./custom.json", + overwrite: true, + }); + }); + + test("CreateAccountAction is instantiated when the command is executed", async () => { + program.parse(["node", "test", "account", "create"]); + expect(CreateAccountAction).toHaveBeenCalledTimes(1); + }); + + test("CreateAccountAction.execute is called without throwing errors for default options", async () => { + program.parse(["node", "test", "account", "create"]); + vi.mocked(CreateAccountAction.prototype.execute).mockReturnValue(Promise.resolve()); + expect(() => program.parse(["node", "test", "account", "create"])).not.toThrow(); + }); +}); + +describe("account unlock command", () => { + let program: Command; + + beforeEach(() => { + program = new Command(); + initializeAccountCommands(program); + vi.clearAllMocks(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + test("UnlockAccountAction is instantiated and execute is called", async () => { + program.parse(["node", "test", "account", "unlock"]); + expect(UnlockAccountAction).toHaveBeenCalledTimes(1); + expect(UnlockAccountAction.prototype.execute).toHaveBeenCalled(); + }); + + test("UnlockAccountAction.execute is called without throwing errors", async () => { + vi.mocked(UnlockAccountAction.prototype.execute).mockResolvedValue(); + expect(() => program.parse(["node", "test", "account", "unlock"])).not.toThrow(); + }); +}); + +describe("account lock command", () => { + let program: Command; + + beforeEach(() => { + program = new Command(); + initializeAccountCommands(program); + vi.clearAllMocks(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + test("LockAccountAction is instantiated and execute is called", async () => { + program.parse(["node", "test", "account", "lock"]); + expect(LockAccountAction).toHaveBeenCalledTimes(1); + expect(LockAccountAction.prototype.execute).toHaveBeenCalled(); + }); + + test("LockAccountAction.execute is called without throwing errors", async () => { + vi.mocked(LockAccountAction.prototype.execute).mockResolvedValue(); + expect(() => program.parse(["node", "test", "account", "lock"])).not.toThrow(); + }); +}); diff --git a/tests/commands/keygen.test.ts b/tests/commands/keygen.test.ts deleted file mode 100644 index f83c8c5a..00000000 --- a/tests/commands/keygen.test.ts +++ /dev/null @@ -1,123 +0,0 @@ -import { Command } from "commander"; -import { vi, describe, beforeEach, afterEach, test, expect } from "vitest"; -import { initializeKeygenCommands } from "../../src/commands/keygen"; -import { KeypairCreator } from "../../src/commands/keygen/create"; -import { UnlockAction } from "../../src/commands/keygen/unlock"; -import { LockAction } from "../../src/commands/keygen/lock"; - -vi.mock("../../src/commands/keygen/create"); -vi.mock("../../src/commands/keygen/unlock"); -vi.mock("../../src/commands/keygen/lock"); - -describe("keygen create command", () => { - let program: Command; - - beforeEach(() => { - program = new Command(); - initializeKeygenCommands(program); - vi.clearAllMocks(); - }); - - afterEach(() => { - vi.restoreAllMocks(); - }); - - test("keypairCreator.createKeypairAction is called with default options", async () => { - program.parse(["node", "test", "keygen", "create"]); - expect(KeypairCreator).toHaveBeenCalledTimes(1); - expect(KeypairCreator.prototype.createKeypairAction).toHaveBeenCalledWith({ - output: "./keypair.json", - overwrite: false, - }); - }); - - test("keypairCreator.createKeypairAction is called with custom output option", async () => { - program.parse(["node", "test", "keygen", "create", "--output", "./custom.json"]); - expect(KeypairCreator).toHaveBeenCalledTimes(1); - expect(KeypairCreator.prototype.createKeypairAction).toHaveBeenCalledWith({ - output: "./custom.json", - overwrite: false, - }); - }); - - test("keypairCreator.createKeypairAction is called with overwrite enabled", async () => { - program.parse(["node", "test", "keygen", "create", "--overwrite"]); - expect(KeypairCreator).toHaveBeenCalledTimes(1); - expect(KeypairCreator.prototype.createKeypairAction).toHaveBeenCalledWith({ - output: "./keypair.json", - overwrite: true, - }); - }); - - test("keypairCreator.createKeypairAction is called with custom output and overwrite enabled", async () => { - program.parse(["node", "test", "keygen", "create", "--output", "./custom.json", "--overwrite"]); - expect(KeypairCreator).toHaveBeenCalledTimes(1); - expect(KeypairCreator.prototype.createKeypairAction).toHaveBeenCalledWith({ - output: "./custom.json", - overwrite: true, - }); - }); - - test("KeypairCreator is instantiated when the command is executed", async () => { - program.parse(["node", "test", "keygen", "create"]); - expect(KeypairCreator).toHaveBeenCalledTimes(1); - }); - - - - test("keypairCreator.createKeypairAction is called without throwing errors for default options", async () => { - program.parse(["node", "test", "keygen", "create"]); - vi.mocked(KeypairCreator.prototype.createKeypairAction).mockReturnValue(); - expect(() => program.parse(["node", "test", "keygen", "create"])).not.toThrow(); - }); -}); - -describe("keygen unlock command", () => { - let program: Command; - - beforeEach(() => { - program = new Command(); - initializeKeygenCommands(program); - vi.clearAllMocks(); - }); - - afterEach(() => { - vi.restoreAllMocks(); - }); - - test("UnlockAction is instantiated and execute is called", async () => { - program.parse(["node", "test", "keygen", "unlock"]); - expect(UnlockAction).toHaveBeenCalledTimes(1); - expect(UnlockAction.prototype.execute).toHaveBeenCalled(); - }); - - test("UnlockAction.execute is called without throwing errors", async () => { - vi.mocked(UnlockAction.prototype.execute).mockResolvedValue(); - expect(() => program.parse(["node", "test", "keygen", "unlock"])).not.toThrow(); - }); -}); - -describe("keygen lock command", () => { - let program: Command; - - beforeEach(() => { - program = new Command(); - initializeKeygenCommands(program); - vi.clearAllMocks(); - }); - - afterEach(() => { - vi.restoreAllMocks(); - }); - - test("LockAction is instantiated and execute is called", async () => { - program.parse(["node", "test", "keygen", "lock"]); - expect(LockAction).toHaveBeenCalledTimes(1); - expect(LockAction.prototype.execute).toHaveBeenCalled(); - }); - - test("LockAction.execute is called without throwing errors", async () => { - vi.mocked(LockAction.prototype.execute).mockResolvedValue(); - expect(() => program.parse(["node", "test", "keygen", "lock"])).not.toThrow(); - }); -}); diff --git a/tests/commands/staking.test.ts b/tests/commands/staking.test.ts new file mode 100644 index 00000000..4f8bbfa9 --- /dev/null +++ b/tests/commands/staking.test.ts @@ -0,0 +1,211 @@ +import {Command} from "commander"; +import {vi, describe, beforeEach, afterEach, test, expect} from "vitest"; +import {initializeStakingCommands} from "../../src/commands/staking"; +import {ValidatorJoinAction} from "../../src/commands/staking/validatorJoin"; +import {ValidatorDepositAction} from "../../src/commands/staking/validatorDeposit"; +import {ValidatorExitAction} from "../../src/commands/staking/validatorExit"; +import {ValidatorClaimAction} from "../../src/commands/staking/validatorClaim"; +import {DelegatorJoinAction} from "../../src/commands/staking/delegatorJoin"; +import {DelegatorExitAction} from "../../src/commands/staking/delegatorExit"; +import {DelegatorClaimAction} from "../../src/commands/staking/delegatorClaim"; +import {StakingInfoAction} from "../../src/commands/staking/stakingInfo"; + +vi.mock("../../src/commands/staking/validatorJoin"); +vi.mock("../../src/commands/staking/validatorDeposit"); +vi.mock("../../src/commands/staking/validatorExit"); +vi.mock("../../src/commands/staking/validatorClaim"); +vi.mock("../../src/commands/staking/delegatorJoin"); +vi.mock("../../src/commands/staking/delegatorExit"); +vi.mock("../../src/commands/staking/delegatorClaim"); +vi.mock("../../src/commands/staking/stakingInfo"); + +describe("staking commands", () => { + let program: Command; + + beforeEach(() => { + program = new Command(); + initializeStakingCommands(program); + vi.clearAllMocks(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe("validator-join", () => { + test("calls ValidatorJoinAction.execute with amount", async () => { + program.parse(["node", "test", "staking", "validator-join", "--amount", "42000gen"]); + + expect(ValidatorJoinAction).toHaveBeenCalledTimes(1); + expect(ValidatorJoinAction.prototype.execute).toHaveBeenCalledWith({ + amount: "42000gen", + }); + }); + + test("calls ValidatorJoinAction.execute with operator", async () => { + program.parse([ + "node", + "test", + "staking", + "validator-join", + "--amount", + "42000gen", + "--operator", + "0xOperator", + ]); + + expect(ValidatorJoinAction.prototype.execute).toHaveBeenCalledWith({ + amount: "42000gen", + operator: "0xOperator", + }); + }); + + test("accepts staking-address option", async () => { + program.parse([ + "node", + "test", + "staking", + "validator-join", + "--amount", + "42000", + "--staking-address", + "0xStaking", + ]); + + expect(ValidatorJoinAction.prototype.execute).toHaveBeenCalledWith( + expect.objectContaining({stakingAddress: "0xStaking"}), + ); + }); + }); + + describe("validator-deposit", () => { + test("calls ValidatorDepositAction.execute", async () => { + program.parse(["node", "test", "staking", "validator-deposit", "--amount", "1000gen"]); + + expect(ValidatorDepositAction).toHaveBeenCalledTimes(1); + expect(ValidatorDepositAction.prototype.execute).toHaveBeenCalledWith({ + amount: "1000gen", + }); + }); + }); + + describe("validator-exit", () => { + test("calls ValidatorExitAction.execute", async () => { + program.parse(["node", "test", "staking", "validator-exit", "--shares", "100"]); + + expect(ValidatorExitAction).toHaveBeenCalledTimes(1); + expect(ValidatorExitAction.prototype.execute).toHaveBeenCalledWith({ + shares: "100", + }); + }); + }); + + describe("validator-claim", () => { + test("calls ValidatorClaimAction.execute", async () => { + program.parse(["node", "test", "staking", "validator-claim", "--validator", "0xValidator"]); + + expect(ValidatorClaimAction).toHaveBeenCalledTimes(1); + expect(ValidatorClaimAction.prototype.execute).toHaveBeenCalledWith({ + validator: "0xValidator", + }); + }); + + test("works without validator option", async () => { + program.parse(["node", "test", "staking", "validator-claim"]); + + expect(ValidatorClaimAction.prototype.execute).toHaveBeenCalledWith({}); + }); + }); + + describe("delegator-join", () => { + test("calls DelegatorJoinAction.execute", async () => { + program.parse([ + "node", + "test", + "staking", + "delegator-join", + "--validator", + "0xValidator", + "--amount", + "42gen", + ]); + + expect(DelegatorJoinAction).toHaveBeenCalledTimes(1); + expect(DelegatorJoinAction.prototype.execute).toHaveBeenCalledWith({ + validator: "0xValidator", + amount: "42gen", + }); + }); + }); + + describe("delegator-exit", () => { + test("calls DelegatorExitAction.execute", async () => { + program.parse([ + "node", + "test", + "staking", + "delegator-exit", + "--validator", + "0xValidator", + "--shares", + "50", + ]); + + expect(DelegatorExitAction).toHaveBeenCalledTimes(1); + expect(DelegatorExitAction.prototype.execute).toHaveBeenCalledWith({ + validator: "0xValidator", + shares: "50", + }); + }); + }); + + describe("delegator-claim", () => { + test("calls DelegatorClaimAction.execute", async () => { + program.parse([ + "node", + "test", + "staking", + "delegator-claim", + "--validator", + "0xValidator", + "--delegator", + "0xDelegator", + ]); + + expect(DelegatorClaimAction).toHaveBeenCalledTimes(1); + expect(DelegatorClaimAction.prototype.execute).toHaveBeenCalledWith({ + validator: "0xValidator", + delegator: "0xDelegator", + }); + }); + }); + + describe("validator-info", () => { + test("calls StakingInfoAction.getValidatorInfo", async () => { + program.parse(["node", "test", "staking", "validator-info", "--validator", "0xValidator"]); + + expect(StakingInfoAction).toHaveBeenCalledTimes(1); + expect(StakingInfoAction.prototype.getValidatorInfo).toHaveBeenCalledWith({ + validator: "0xValidator", + }); + }); + }); + + describe("epoch-info", () => { + test("calls StakingInfoAction.getEpochInfo", async () => { + program.parse(["node", "test", "staking", "epoch-info"]); + + expect(StakingInfoAction).toHaveBeenCalledTimes(1); + expect(StakingInfoAction.prototype.getEpochInfo).toHaveBeenCalledWith({}); + }); + }); + + describe("active-validators", () => { + test("calls StakingInfoAction.listActiveValidators", async () => { + program.parse(["node", "test", "staking", "active-validators"]); + + expect(StakingInfoAction).toHaveBeenCalledTimes(1); + expect(StakingInfoAction.prototype.listActiveValidators).toHaveBeenCalledWith({}); + }); + }); +}); diff --git a/tests/index.test.ts b/tests/index.test.ts index 1d060fe4..16150c17 100644 --- a/tests/index.test.ts +++ b/tests/index.test.ts @@ -13,8 +13,8 @@ vi.mock("../src/commands/general", () => ({ initializeGeneralCommands: vi.fn(), })); -vi.mock("../src/commands/keygen", () => ({ - initializeKeygenCommands: vi.fn(), +vi.mock("../src/commands/account", () => ({ + initializeAccountCommands: vi.fn(), })); vi.mock("../src/commands/contracts", () => ({ @@ -45,6 +45,10 @@ vi.mock("../src/commands/transactions", () => ({ initializeTransactionsCommands: vi.fn(), })); +vi.mock("../src/commands/staking", () => ({ + initializeStakingCommands: vi.fn(), +})); + describe("CLI", () => { it("should initialize CLI", () => { expect(initializeCLI).not.toThrow(); diff --git a/tests/libs/baseAction.test.ts b/tests/libs/baseAction.test.ts index b3464ee2..d224a178 100644 --- a/tests/libs/baseAction.test.ts +++ b/tests/libs/baseAction.test.ts @@ -109,7 +109,7 @@ describe("BaseAction", () => { test("should fail the spinner with an error message", () => { const error = new Error("Something went wrong"); - baseAction["failSpinner"]("Failure", error); + baseAction["failSpinner"]("Failure", error, false); // Don't exit for test expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining("Error:")); expect(consoleSpy).toHaveBeenCalledWith(inspect(error, {depth: null, colors: false})); @@ -117,6 +117,12 @@ describe("BaseAction", () => { expect(mockSpinner.fail).toHaveBeenCalledWith(expect.stringContaining("Failure")); }); + test("should fail the spinner and exit by default", () => { + const error = new Error("Fatal error"); + expect(() => baseAction["failSpinner"]("Fatal", error)).toThrow("process exited"); + expect(processExitSpy).toHaveBeenCalledWith(1); + }); + test("should stop the spinner", () => { baseAction["stopSpinner"](); expect(mockSpinner.stop).toHaveBeenCalled();