From ad50ec5ccc326743c49b23c3785c6e0ddbb57bdc Mon Sep 17 00:00:00 2001 From: Edgars Date: Tue, 2 Dec 2025 10:57:38 +0000 Subject: [PATCH 1/5] feat: Implements staking functionality Adds staking commands, account management, and documentation for validator and delegator operations. This commit introduces the staking functionality to the GenLayer CLI, enabling users to participate in the testnet-asimov staking program. It includes commands for validators to join, deposit, exit, and claim rewards, as well as commands for delegators to join, exit, and claim rewards. Also includes account management commands for creating, unlocking, and locking accounts, and updates the README with usage examples and links to the new validator and delegator guides. --- CLAUDE.md | 55 ++++ README.md | 129 ++++++++- docs/delegator-guide.md | 203 +++++++++++++ docs/validator-guide.md | 260 +++++++++++++++++ src/commands/account/create.ts | 23 ++ src/commands/account/index.ts | 55 ++++ src/commands/{keygen => account}/lock.ts | 13 +- src/commands/account/send.ts | 158 ++++++++++ src/commands/account/show.ts | 62 ++++ src/commands/{keygen => account}/unlock.ts | 24 +- src/commands/keygen/create.ts | 23 -- src/commands/keygen/index.ts | 39 --- src/commands/staking/StakingAction.ts | 133 +++++++++ src/commands/staking/delegatorClaim.ts | 41 +++ src/commands/staking/delegatorExit.ts | 50 ++++ src/commands/staking/delegatorJoin.ts | 42 +++ src/commands/staking/index.ts | 224 ++++++++++++++ src/commands/staking/setIdentity.ts | 61 ++++ src/commands/staking/setOperator.ts | 40 +++ src/commands/staking/stakingInfo.ts | 292 +++++++++++++++++++ src/commands/staking/validatorClaim.ts | 38 +++ src/commands/staking/validatorDeposit.ts | 35 +++ src/commands/staking/validatorExit.ts | 44 +++ src/commands/staking/validatorJoin.ts | 47 +++ src/commands/staking/validatorPrime.ts | 35 +++ src/index.ts | 6 +- src/lib/actions/BaseAction.ts | 13 +- tests/actions/create.test.ts | 36 +-- tests/actions/lock.test.ts | 14 +- tests/actions/staking.test.ts | 322 +++++++++++++++++++++ tests/actions/unlock.test.ts | 24 +- tests/commands/account.test.ts | 121 ++++++++ tests/commands/keygen.test.ts | 123 -------- tests/commands/staking.test.ts | 211 ++++++++++++++ tests/index.test.ts | 8 +- tests/libs/baseAction.test.ts | 8 +- 36 files changed, 2751 insertions(+), 261 deletions(-) create mode 100644 CLAUDE.md create mode 100644 docs/delegator-guide.md create mode 100644 docs/validator-guide.md create mode 100644 src/commands/account/create.ts create mode 100644 src/commands/account/index.ts rename src/commands/{keygen => account}/lock.ts (66%) create mode 100644 src/commands/account/send.ts create mode 100644 src/commands/account/show.ts rename src/commands/{keygen => account}/unlock.ts (59%) delete mode 100644 src/commands/keygen/create.ts delete mode 100644 src/commands/keygen/index.ts create mode 100644 src/commands/staking/StakingAction.ts create mode 100644 src/commands/staking/delegatorClaim.ts create mode 100644 src/commands/staking/delegatorExit.ts create mode 100644 src/commands/staking/delegatorJoin.ts create mode 100644 src/commands/staking/index.ts create mode 100644 src/commands/staking/setIdentity.ts create mode 100644 src/commands/staking/setOperator.ts create mode 100644 src/commands/staking/stakingInfo.ts create mode 100644 src/commands/staking/validatorClaim.ts create mode 100644 src/commands/staking/validatorDeposit.ts create mode 100644 src/commands/staking/validatorExit.ts create mode 100644 src/commands/staking/validatorJoin.ts create mode 100644 src/commands/staking/validatorPrime.ts create mode 100644 tests/actions/staking.test.ts create mode 100644 tests/commands/account.test.ts delete mode 100644 tests/commands/keygen.test.ts create mode 100644 tests/commands/staking.test.ts 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/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/index.ts b/src/commands/account/index.ts new file mode 100644 index 00000000..a0bfca43 --- /dev/null +++ b/src/commands/account/index.ts @@ -0,0 +1,55 @@ +import {Command} from "commander"; +import {ShowAccountAction} from "./show"; +import {CreateAccountAction, CreateAccountOptions} from "./create"; +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("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..37167bd5 --- /dev/null +++ b/src/commands/account/send.ts @@ -0,0 +1,158 @@ +import {BaseAction} from "../../lib/actions/BaseAction"; +import {parseEther, formatEther} from "viem"; +import {createClient, createAccount} from "genlayer-js"; +import {localnet, testnetAsimov} from "genlayer-js/chains"; +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; +} + +const NETWORKS: Record = { + localnet, + "testnet-asimov": testnetAsimov, + testnetAsimov: testnetAsimov, +}; + +export class SendAction extends BaseAction { + constructor() { + super(); + } + + private getNetwork(networkOption?: string): GenLayerChain { + if (networkOption) { + const network = NETWORKS[networkOption]; + if (!network) { + throw new Error(`Unknown network: ${networkOption}. Available: ${Object.keys(NETWORKS).join(", ")}`); + } + return {...network}; + } + const networkConfig = this.getConfig().network; + return networkConfig ? JSON.parse(networkConfig) : localnet; + } + + 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..070a7013 --- /dev/null +++ b/src/commands/account/show.ts @@ -0,0 +1,62 @@ +import {BaseAction} from "../../lib/actions/BaseAction"; +import {formatEther} from "viem"; +import {createClient} from "genlayer-js"; +import {localnet} from "genlayer-js/chains"; +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 { + const networkConfig = this.getConfig().network; + return networkConfig ? JSON.parse(networkConfig) : localnet; + } + + 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/staking/StakingAction.ts b/src/commands/staking/StakingAction.ts new file mode 100644 index 00000000..b64dcd49 --- /dev/null +++ b/src/commands/staking/StakingAction.ts @@ -0,0 +1,133 @@ +import {BaseAction} from "../../lib/actions/BaseAction"; +import {createClient, createAccount, formatStakingAmount, parseStakingAmount, abi} from "genlayer-js"; +import {localnet, testnetAsimov} from "genlayer-js/chains"; +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; +} + +const NETWORKS: Record = { + localnet, + "testnet-asimov": testnetAsimov, + testnetAsimov: testnetAsimov, +}; + +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 = NETWORKS[config.network]; + if (!network) { + throw new Error(`Unknown network: ${config.network}. Available: ${Object.keys(NETWORKS).join(", ")}`); + } + return {...network}; + } + + const networkConfig = this.getConfig().network; + return networkConfig ? JSON.parse(networkConfig) : localnet; + } + + 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..3bc44168 100644 --- a/src/lib/actions/BaseAction.ts +++ b/src/lib/actions/BaseAction.ts @@ -38,7 +38,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); } @@ -88,7 +87,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 +115,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 +124,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 +216,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/staking.test.ts b/tests/actions/staking.test.ts new file mode 100644 index 00000000..b66e777d --- /dev/null +++ b/tests/actions/staking.test.ts @@ -0,0 +1,322 @@ +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"]}}}, + testnetAsimov: {id: 2, 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(); From 0b120f3f171de262b6ac345e777e6fb103f14fce Mon Sep 17 00:00:00 2001 From: Edgars Date: Tue, 2 Dec 2025 17:23:40 +0000 Subject: [PATCH 2/5] Refactors network configuration handling Updates network configuration to use aliases for built-in networks. This change introduces a more robust and maintainable approach to handling network configurations within the application. It replaces the direct storage of network configurations as JSON strings with a system that uses aliases to reference built-in networks. This allows for easier management of network settings and ensures that the application always uses the most up-to-date configurations for known networks. The key changes include: - Introduction of `BUILT_IN_NETWORKS`: A centralized record that always resolves fresh from `genlayer-js`. - `resolveNetwork` function: Handles resolution of stored network configs, supporting both new (alias) and old (JSON) formats for backwards compatibility. - Updates to `setNetwork` command: Now stores network aliases instead of full JSON configurations. - Usage of `resolveNetwork` in account and staking actions: Ensures consistent and up-to-date network configurations across the application. --- src/commands/account/send.ts | 18 ++++-------- src/commands/account/show.ts | 6 ++-- src/commands/network/setNetwork.ts | 9 +++--- src/commands/staking/StakingAction.ts | 16 +++-------- src/lib/actions/BaseAction.ts | 40 +++++++++++++++++++++++++-- 5 files changed, 53 insertions(+), 36 deletions(-) diff --git a/src/commands/account/send.ts b/src/commands/account/send.ts index 37167bd5..acd7d1f3 100644 --- a/src/commands/account/send.ts +++ b/src/commands/account/send.ts @@ -1,7 +1,6 @@ -import {BaseAction} from "../../lib/actions/BaseAction"; +import {BaseAction, BUILT_IN_NETWORKS, resolveNetwork} from "../../lib/actions/BaseAction"; import {parseEther, formatEther} from "viem"; import {createClient, createAccount} from "genlayer-js"; -import {localnet, testnetAsimov} from "genlayer-js/chains"; import type {GenLayerChain, Address, Hash} from "genlayer-js/types"; import {readFileSync, existsSync} from "fs"; import {ethers} from "ethers"; @@ -14,12 +13,6 @@ export interface SendOptions { network?: string; } -const NETWORKS: Record = { - localnet, - "testnet-asimov": testnetAsimov, - testnetAsimov: testnetAsimov, -}; - export class SendAction extends BaseAction { constructor() { super(); @@ -27,14 +20,13 @@ export class SendAction extends BaseAction { private getNetwork(networkOption?: string): GenLayerChain { if (networkOption) { - const network = NETWORKS[networkOption]; + const network = BUILT_IN_NETWORKS[networkOption]; if (!network) { - throw new Error(`Unknown network: ${networkOption}. Available: ${Object.keys(NETWORKS).join(", ")}`); + throw new Error(`Unknown network: ${networkOption}. Available: ${Object.keys(BUILT_IN_NETWORKS).join(", ")}`); } - return {...network}; + return network; } - const networkConfig = this.getConfig().network; - return networkConfig ? JSON.parse(networkConfig) : localnet; + return resolveNetwork(this.getConfig().network); } private parseAmount(amount: string): bigint { diff --git a/src/commands/account/show.ts b/src/commands/account/show.ts index 070a7013..8bf99c5d 100644 --- a/src/commands/account/show.ts +++ b/src/commands/account/show.ts @@ -1,7 +1,6 @@ -import {BaseAction} from "../../lib/actions/BaseAction"; +import {BaseAction, resolveNetwork} from "../../lib/actions/BaseAction"; import {formatEther} from "viem"; import {createClient} from "genlayer-js"; -import {localnet} from "genlayer-js/chains"; import type {GenLayerChain, Address} from "genlayer-js/types"; import {readFileSync, existsSync} from "fs"; import {KeystoreData} from "../../lib/interfaces/KeystoreData"; @@ -12,8 +11,7 @@ export class ShowAccountAction extends BaseAction { } private getNetwork(): GenLayerChain { - const networkConfig = this.getConfig().network; - return networkConfig ? JSON.parse(networkConfig) : localnet; + return resolveNetwork(this.getConfig().network); } async execute(): Promise { 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 index b64dcd49..24605a03 100644 --- a/src/commands/staking/StakingAction.ts +++ b/src/commands/staking/StakingAction.ts @@ -1,6 +1,5 @@ -import {BaseAction} from "../../lib/actions/BaseAction"; +import {BaseAction, BUILT_IN_NETWORKS, resolveNetwork} from "../../lib/actions/BaseAction"; import {createClient, createAccount, formatStakingAmount, parseStakingAmount, abi} from "genlayer-js"; -import {localnet, testnetAsimov} from "genlayer-js/chains"; import type {GenLayerClient, GenLayerChain, Address} from "genlayer-js/types"; import {readFileSync, existsSync} from "fs"; import {ethers} from "ethers"; @@ -12,12 +11,6 @@ export interface StakingConfig { network?: string; } -const NETWORKS: Record = { - localnet, - "testnet-asimov": testnetAsimov, - testnetAsimov: testnetAsimov, -}; - export class StakingAction extends BaseAction { private _stakingClient: GenLayerClient | null = null; @@ -28,15 +21,14 @@ export class StakingAction extends BaseAction { private getNetwork(config: StakingConfig): GenLayerChain { // Priority: --network option > global config > localnet default if (config.network) { - const network = NETWORKS[config.network]; + const network = BUILT_IN_NETWORKS[config.network]; if (!network) { - throw new Error(`Unknown network: ${config.network}. Available: ${Object.keys(NETWORKS).join(", ")}`); + throw new Error(`Unknown network: ${config.network}. Available: ${Object.keys(BUILT_IN_NETWORKS).join(", ")}`); } return {...network}; } - const networkConfig = this.getConfig().network; - return networkConfig ? JSON.parse(networkConfig) : localnet; + return resolveNetwork(this.getConfig().network); } protected async getStakingClient(config: StakingConfig): Promise> { diff --git a/src/lib/actions/BaseAction.ts b/src/lib/actions/BaseAction.ts index 3bc44168..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"; @@ -61,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, From f2c5ae5a1eb4ad22308f56e6e8a731ff9369370d Mon Sep 17 00:00:00 2001 From: Edgars Date: Tue, 2 Dec 2025 17:25:23 +0000 Subject: [PATCH 3/5] Feat(account): Adds account import command This commit introduces a new "import" command to the account management suite. The new command allows users to import an existing account into the application by providing a private key. It prompts for the private key (if not provided as an option), validates it, encrypts it with a user-provided password, and saves the encrypted keystore to a file. The command includes options to specify the output path and overwrite existing files. It also handles password validation (minimum length) and confirmation, and removes the private key from the keychain after import. --- src/commands/account/import.ts | 81 ++++++++++++++++++++++++++++++++++ src/commands/account/index.ts | 12 +++++ 2 files changed, 93 insertions(+) create mode 100644 src/commands/account/import.ts 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 index a0bfca43..13fb19b0 100644 --- a/src/commands/account/index.ts +++ b/src/commands/account/index.ts @@ -1,6 +1,7 @@ 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"; @@ -25,6 +26,17 @@ export function initializeAccountCommands(program: Command) { 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") From 51340fdb4845c5795c7f4cc969bf0b920c77bf1b Mon Sep 17 00:00:00 2001 From: Edgars Date: Wed, 3 Dec 2025 22:28:46 +0000 Subject: [PATCH 4/5] fix: tests --- tests/actions/setNetwork.test.ts | 75 ++++++++------------------------ tests/actions/staking.test.ts | 3 +- 2 files changed, 20 insertions(+), 58 deletions(-) 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 index b66e777d..dea54b2c 100644 --- a/tests/actions/staking.test.ts +++ b/tests/actions/staking.test.ts @@ -26,7 +26,8 @@ vi.mock("genlayer-js", () => ({ vi.mock("genlayer-js/chains", () => ({ localnet: {id: 1, name: "localnet", rpcUrls: {default: {http: ["http://localhost:8545"]}}}, - testnetAsimov: {id: 2, name: "testnet-asimov", rpcUrls: {default: {http: ["https://testnet.genlayer.com"]}}}, + 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 = { From 8a26ae15ee04e1d3b9e62fd6bd59d86d7c160788 Mon Sep 17 00:00:00 2001 From: Edgars Date: Wed, 3 Dec 2025 22:58:21 +0000 Subject: [PATCH 5/5] feat: upgrades genlayer-js to v0.18.5 Upgrades the genlayer-js dependency from version 0.16.0 to 0.18.5. This update likely includes new features, bug fixes, and performance improvements within the genlayer-js library. --- package-lock.json | 8 ++++---- package.json | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) 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",