Back to Projects
Beginner2 hours

CLI Wallet

Build a command-line wallet for Beatoz

CLI Wallet Toy Project

In this project, you'll build a command-line wallet using Node.js and the @beatoz/web3 SDK.

Learning Objectives

  • Create and manage accounts
  • Encrypt and store private keys
  • Check account balances
  • Transfer tokens

Project Structure

beatoz-cli-wallet/
├── package.json
├── src/
│   ├── index.js          # Main entry
│   ├── commands/
│   │   ├── account.js    # Account commands
│   │   ├── balance.js    # Balance check
│   │   └── transfer.js   # Transfer
│   ├── utils/
│   │   ├── crypto.js     # Encryption
│   │   └── storage.js    # Storage
│   └── config.js         # Configuration
├── .env
└── README.md

Getting Started

1. Create Project

mkdir beatoz-cli-wallet
cd beatoz-cli-wallet
npm init -y

2. Install Dependencies

npm install @beatoz/web3 commander inquirer chalk dotenv
npm install --save-dev @types/node

Core Code

package.json

{
  "name": "beatoz-cli-wallet",
  "version": "1.0.0",
  "type": "module",
  "bin": {
    "beatoz-wallet": "./src/index.js"
  },
  "scripts": {
    "start": "node src/index.js",
    "wallet": "node src/index.js"
  },
  "dependencies": {
    "@beatoz/web3": "^1.0.0",
    "commander": "^11.0.0",
    "inquirer": "^9.0.0",
    "chalk": "^5.0.0",
    "dotenv": "^16.0.0"
  }
}

src/config.js

import dotenv from 'dotenv';
dotenv.config();

export const config = {
  networks: {
    testnet: {
      name: 'Beatoz Testnet',
      rpc: 'https://rpc-testnet0.beatoz.io',
      chainId: '0xbea701',
    },
  },
  defaultNetwork: process.env.NETWORK || 'testnet',
  walletPath: process.env.WALLET_PATH || './.wallet',
};

src/index.js

#!/usr/bin/env node

import { Command } from 'commander';
import chalk from 'chalk';
import { accountCommands } from './commands/account.js';
import { balanceCommands } from './commands/balance.js';
import { transferCommands } from './commands/transfer.js';

const program = new Command();

program
  .name('beatoz-wallet')
  .description('CLI wallet for Beatoz blockchain')
  .version('1.0.0');

// Account commands
program
  .command('create')
  .description('Create a new account')
  .action(accountCommands.create);

program
  .command('import')
  .description('Import account from private key')
  .action(accountCommands.importKey);

program
  .command('list')
  .description('List all accounts')
  .action(accountCommands.list);

program
  .command('export <address>')
  .description('Export private key')
  .action(accountCommands.exportKey);

// Balance commands
program
  .command('balance [address]')
  .description('Check account balance')
  .action(balanceCommands.check);

// Transfer commands
program
  .command('send')
  .description('Send BTZ to another address')
  .option('-t, --to <address>', 'Recipient address')
  .option('-a, --amount <amount>', 'Amount to send')
  .option('-f, --from <address>', 'Sender address')
  .action(transferCommands.send);

// Network info
program
  .command('network')
  .description('Show network information')
  .action(async () => {
    const { Web3 } = await import('@beatoz/web3');
    const { config } = await import('./config.js');
    
    const network = config.networks[config.defaultNetwork];
    const web3 = new Web3(network.rpc);
    
    try {
      const status = await web3.beatoz.status();
      console.log(chalk.cyan('\n📡 Network Information\n'));
      console.log(`  Network: ${chalk.green(network.name)}`);
      console.log(`  Chain ID: ${chalk.yellow(status.node_info.network)}`);
      console.log(`  Latest Block: ${chalk.white(status.sync_info.latest_block_height)}`);
      console.log(`  RPC: ${chalk.gray(network.rpc)}\n`);
    } catch (error) {
      console.error(chalk.red('Failed to connect to network'));
    }
  });

program.parse();

src/commands/account.js

import { Web3 } from '@beatoz/web3';
import inquirer from 'inquirer';
import chalk from 'chalk';
import crypto from 'crypto';
import fs from 'fs';
import { config } from '../config.js';

const WALLET_FILE = config.walletPath + '/accounts.json';

function ensureWalletDir() {
  if (!fs.existsSync(config.walletPath)) {
    fs.mkdirSync(config.walletPath, { recursive: true });
  }
}

function loadAccounts() {
  ensureWalletDir();
  if (!fs.existsSync(WALLET_FILE)) {
    return [];
  }
  return JSON.parse(fs.readFileSync(WALLET_FILE, 'utf-8'));
}

function saveAccounts(accounts) {
  ensureWalletDir();
  fs.writeFileSync(WALLET_FILE, JSON.stringify(accounts, null, 2));
}

function encrypt(text, password) {
  const iv = crypto.randomBytes(16);
  const key = crypto.scryptSync(password, 'salt', 32);
  const cipher = crypto.createCipheriv('aes-256-cbc', key, iv);
  let encrypted = cipher.update(text, 'utf8', 'hex');
  encrypted += cipher.final('hex');
  return iv.toString('hex') + ':' + encrypted;
}

function decrypt(encrypted, password) {
  const [ivHex, encryptedText] = encrypted.split(':');
  const iv = Buffer.from(ivHex, 'hex');
  const key = crypto.scryptSync(password, 'salt', 32);
  const decipher = crypto.createDecipheriv('aes-256-cbc', key, iv);
  let decrypted = decipher.update(encryptedText, 'hex', 'utf8');
  decrypted += decipher.final('utf8');
  return decrypted;
}

export const accountCommands = {
  async create() {
    const network = config.networks[config.defaultNetwork];
    const web3 = new Web3(network.rpc);
    
    const { password, confirmPassword, name } = await inquirer.prompt([
      {
        type: 'password',
        name: 'password',
        message: 'Enter password to encrypt the account:',
        mask: '*',
      },
      {
        type: 'password',
        name: 'confirmPassword',
        message: 'Confirm password:',
        mask: '*',
      },
      {
        type: 'input',
        name: 'name',
        message: 'Account name (optional):',
        default: 'Account',
      },
    ]);
    
    if (password !== confirmPassword) {
      console.log(chalk.red('Passwords do not match!'));
      return;
    }
    
    const account = web3.beatoz.accounts.create();
    const encryptedKey = encrypt(account.privateKey, password);
    
    const accounts = loadAccounts();
    accounts.push({
      name: name || `Account ${accounts.length + 1}`,
      address: account.address,
      encryptedKey,
      createdAt: new Date().toISOString(),
    });
    saveAccounts(accounts);
    
    console.log(chalk.green('\n✅ Account created successfully!\n'));
    console.log(`  Name: ${chalk.cyan(name)}`);
    console.log(`  Address: ${chalk.yellow(account.address)}\n`);
    console.log(chalk.gray('  Your private key is encrypted and stored locally.\n'));
  },
  
  async importKey() {
    const { privateKey, password, name } = await inquirer.prompt([
      {
        type: 'password',
        name: 'privateKey',
        message: 'Enter private key:',
        mask: '*',
      },
      {
        type: 'password',
        name: 'password',
        message: 'Enter password to encrypt:',
        mask: '*',
      },
      {
        type: 'input',
        name: 'name',
        message: 'Account name:',
        default: 'Imported Account',
      },
    ]);
    
    const network = config.networks[config.defaultNetwork];
    const web3 = new Web3(network.rpc);
    
    try {
      const account = web3.beatoz.accounts.privateKeyToAccount(privateKey);
      const encryptedKey = encrypt(privateKey, password);
      
      const accounts = loadAccounts();
      accounts.push({
        name,
        address: account.address,
        encryptedKey,
        createdAt: new Date().toISOString(),
      });
      saveAccounts(accounts);
      
      console.log(chalk.green('\n✅ Account imported successfully!\n'));
      console.log(`  Address: ${chalk.yellow(account.address)}\n`);
    } catch (error) {
      console.log(chalk.red('Invalid private key'));
    }
  },
  
  async list() {
    const accounts = loadAccounts();
    
    if (accounts.length === 0) {
      console.log(chalk.yellow('\nNo accounts found. Create one with: beatoz-wallet create\n'));
      return;
    }
    
    console.log(chalk.cyan('\n📋 Your Accounts\n'));
    
    accounts.forEach((acc, index) => {
      console.log(`  ${index + 1}. ${chalk.white(acc.name)}`);
      console.log(`     ${chalk.yellow(acc.address)}`);
      console.log(`     Created: ${chalk.gray(new Date(acc.createdAt).toLocaleDateString())}\n`);
    });
  },
  
  async exportKey(address) {
    const accounts = loadAccounts();
    const account = accounts.find(a => a.address.toLowerCase() === address.toLowerCase());
    
    if (!account) {
      console.log(chalk.red('Account not found'));
      return;
    }
    
    const { password, confirm } = await inquirer.prompt([
      {
        type: 'password',
        name: 'password',
        message: 'Enter password:',
        mask: '*',
      },
      {
        type: 'confirm',
        name: 'confirm',
        message: chalk.red('⚠️  This will display your private key. Continue?'),
        default: false,
      },
    ]);
    
    if (!confirm) return;
    
    try {
      const privateKey = decrypt(account.encryptedKey, password);
      console.log(chalk.yellow(`\nPrivate Key: ${privateKey}\n`));
      console.log(chalk.red('⚠️  Never share this key with anyone!\n'));
    } catch (error) {
      console.log(chalk.red('Incorrect password'));
    }
  },
};

src/commands/balance.js

import { Web3 } from '@beatoz/web3';
import chalk from 'chalk';
import { config } from '../config.js';
import fs from 'fs';

const WALLET_FILE = config.walletPath + '/accounts.json';

function loadAccounts() {
  if (!fs.existsSync(WALLET_FILE)) return [];
  return JSON.parse(fs.readFileSync(WALLET_FILE, 'utf-8'));
}

export const balanceCommands = {
  async check(address) {
    const network = config.networks[config.defaultNetwork];
    const web3 = new Web3(network.rpc);
    
    // If no address, show all accounts
    if (!address) {
      const accounts = loadAccounts();
      
      if (accounts.length === 0) {
        console.log(chalk.yellow('No accounts found'));
        return;
      }
      
      console.log(chalk.cyan('\n💰 Account Balances\n'));
      
      for (const acc of accounts) {
        try {
          const account = await web3.beatoz.getAccount(acc.address);
          const balance = web3.utils.fromFons(account.value.balance, 'beatoz');
          console.log(`  ${chalk.white(acc.name)}`);
          console.log(`  ${chalk.gray(acc.address)}`);
          console.log(`  Balance: ${chalk.green(balance)} BTZ\n`);
        } catch (error) {
          console.log(`  ${chalk.white(acc.name)}`);
          console.log(`  ${chalk.gray(acc.address)}`);
          console.log(`  Balance: ${chalk.green('0')} BTZ\n`);
        }
      }
    } else {
      // Show specific address balance
      try {
        const account = await web3.beatoz.getAccount(address);
        const balance = web3.utils.fromFons(account.value.balance, 'beatoz');
        console.log(chalk.cyan('\n💰 Account Balance\n'));
        console.log(`  Address: ${chalk.gray(address)}`);
        console.log(`  Balance: ${chalk.green(balance)} BTZ\n`);
      } catch (error) {
        console.log(chalk.cyan('\n💰 Account Balance\n'));
        console.log(`  Address: ${chalk.gray(address)}`);
        console.log(`  Balance: ${chalk.green('0')} BTZ\n`);
      }
    }
  },
};

src/commands/transfer.js

import { Web3, TrxProtoBuilder } from '@beatoz/web3';
import inquirer from 'inquirer';
import chalk from 'chalk';
import crypto from 'crypto';
import fs from 'fs';
import { config } from '../config.js';

const WALLET_FILE = config.walletPath + '/accounts.json';

function loadAccounts() {
  if (!fs.existsSync(WALLET_FILE)) return [];
  return JSON.parse(fs.readFileSync(WALLET_FILE, 'utf-8'));
}

function decrypt(encrypted, password) {
  const [ivHex, encryptedText] = encrypted.split(':');
  const iv = Buffer.from(ivHex, 'hex');
  const key = crypto.scryptSync(password, 'salt', 32);
  const decipher = crypto.createDecipheriv('aes-256-cbc', key, iv);
  let decrypted = decipher.update(encryptedText, 'hex', 'utf8');
  decrypted += decipher.final('utf8');
  return decrypted;
}

export const transferCommands = {
  async send(options) {
    const accounts = loadAccounts();
    
    if (accounts.length === 0) {
      console.log(chalk.yellow('No accounts found. Create one first.'));
      return;
    }
    
    const network = config.networks[config.defaultNetwork];
    const web3 = new Web3(network.rpc);
    
    // Interactive prompts
    const answers = await inquirer.prompt([
      {
        type: 'list',
        name: 'fromAccount',
        message: 'Select sender account:',
        choices: accounts.map(a => ({
          name: `${a.name} (${a.address.slice(0, 10)}...)`,
          value: a,
        })),
        when: !options.from,
      },
      {
        type: 'input',
        name: 'to',
        message: 'Recipient address:',
        when: !options.to,
        validate: (input) => /^(0x)?[0-9a-fA-F]{40}$/.test(input) || 'Invalid address',
      },
      {
        type: 'input',
        name: 'amount',
        message: 'Amount (BTZ):',
        when: !options.amount,
        validate: (input) => !isNaN(parseFloat(input)) || 'Invalid amount',
      },
      {
        type: 'password',
        name: 'password',
        message: 'Enter password:',
        mask: '*',
      },
    ]);
    
    const fromAccount = answers.fromAccount || 
      accounts.find(a => a.address.toLowerCase() === options.from?.toLowerCase());
    const toAddress = options.to || answers.to;
    const amount = options.amount || answers.amount;
    
    if (!fromAccount) {
      console.log(chalk.red('Sender account not found'));
      return;
    }
    
    try {
      // Decrypt private key
      const privateKey = decrypt(fromAccount.encryptedKey, answers.password);
      const sender = web3.beatoz.accounts.privateKeyToAccount(privateKey);
      
      // Get account info
      const accountInfo = await web3.beatoz.getAccount(sender.address);
      const status = await web3.beatoz.status();
      
      const balance = web3.utils.fromFons(accountInfo.value.balance, 'beatoz');
      
      if (parseFloat(balance) < parseFloat(amount)) {
        console.log(chalk.red(`Insufficient balance: ${balance} BTZ`));
        return;
      }
      
      // Build transaction
      const tx = TrxProtoBuilder.buildTransferTrxProto({
        from: sender.address,
        to: toAddress,
        nonce: accountInfo.value.nonce,
        gas: 100000,
        gasPrice: '48000000000',
        amount: web3.utils.toFons(amount, 'beatoz'),
      });
      
      // Sign and broadcast
      TrxProtoBuilder.signTrxProto(tx, sender, status.node_info.network);
      
      console.log(chalk.cyan('\n📤 Sending transaction...\n'));
      
      const result = await web3.beatoz.broadcastTxCommit(tx);
      
      if ((result.check_tx?.code ?? 0) !== 0 || (result.deliver_tx?.code ?? 0) !== 0) {
        console.log(chalk.red(`Transaction failed: ${result.check_tx?.log || result.deliver_tx?.log}`));
        return;
      }
      
      console.log(chalk.green('✅ Transaction sent!\n'));
      console.log(`  From: ${chalk.gray(sender.address)}`);
      console.log(`  To: ${chalk.gray(toAddress)}`);
      console.log(`  Amount: ${chalk.yellow(amount)} BTZ`);
      console.log(`  TX Hash: ${chalk.cyan(result.hash)}\n`);
      
    } catch (error) {
      if (error.message.includes('decipher')) {
        console.log(chalk.red('Incorrect password'));
      } else {
        console.log(chalk.red(`Error: ${error.message}`));
      }
    }
  },
};

Usage Examples

# Check network information
node src/index.js network

# Create new account
node src/index.js create

# Import account from private key
node src/index.js import

# List all accounts
node src/index.js list

# Check balance
node src/index.js balance

# Check specific address balance
node src/index.js balance 0x...

# Send BTZ
node src/index.js send

# Send with options
node src/index.js send -t 0x... -a 1.5

CLI Wallet Complete! 🎉

Next Steps

Beatoz Developer Portal