프로젝트 목록으로
초급2시간

CLI 지갑

Beatoz용 커맨드라인 지갑 만들기

CLI Wallet 토이 프로젝트

이 프로젝트에서는 Node.js와 @beatoz/web3 SDK를 사용하여 커맨드라인 지갑을 만듭니다.

학습 목표

  • 계정 생성 및 관리
  • 개인키 암호화 저장
  • 잔액 조회
  • 토큰 전송

프로젝트 구조

beatoz-cli-wallet/
├── package.json
├── src/
│   ├── index.js          # 메인 엔트리
│   ├── commands/
│   │   ├── account.js    # 계정 명령어
│   │   ├── balance.js    # 잔액 조회
│   │   └── transfer.js   # 전송
│   ├── utils/
│   │   ├── crypto.js     # 암호화
│   │   └── storage.js    # 저장소
│   └── config.js         # 설정
├── .env
└── README.md

시작하기

1. 프로젝트 생성

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

2. 의존성 설치

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

핵심 코드

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('Beatoz 블록체인용 CLI 지갑')
  .version('1.0.0');

// 계정 명령어
program
  .command('create')
  .description('새 계정 생성')
  .action(accountCommands.create);

program
  .command('import')
  .description('개인키로 계정 가져오기')
  .action(accountCommands.importKey);

program
  .command('list')
  .description('모든 계정 목록 보기')
  .action(accountCommands.list);

program
  .command('export <address>')
  .description('개인키 내보내기')
  .action(accountCommands.exportKey);

// 잔액 명령어
program
  .command('balance [address]')
  .description('계정 잔액 확인')
  .action(balanceCommands.check);

// 전송 명령어
program
  .command('send')
  .description('다른 주소로 BTZ 전송')
  .option('-t, --to <address>', '수신자 주소')
  .option('-a, --amount <amount>', '전송 금액')
  .option('-f, --from <address>', '발신자 주소')
  .action(transferCommands.send);

// 네트워크 정보
program
  .command('network')
  .description('네트워크 정보 표시')
  .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📡 네트워크 정보\n'));
      console.log(`  네트워크: ${chalk.green(network.name)}`);
      console.log(`  체인 ID: ${chalk.yellow(status.node_info.network)}`);
      console.log(`  최신 블록: ${chalk.white(status.sync_info.latest_block_height)}`);
      console.log(`  RPC: ${chalk.gray(network.rpc)}\n`);
    } catch (error) {
      console.error(chalk.red('네트워크 연결 실패'));
    }
  });

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: '계정 암호화를 위한 비밀번호 입력:',
        mask: '*',
      },
      {
        type: 'password',
        name: 'confirmPassword',
        message: '비밀번호 확인:',
        mask: '*',
      },
      {
        type: 'input',
        name: 'name',
        message: '계정 이름 (선택사항):',
        default: 'Account',
      },
    ]);
    
    if (password !== confirmPassword) {
      console.log(chalk.red('비밀번호가 일치하지 않습니다!'));
      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✅ 계정이 성공적으로 생성되었습니다!\n'));
    console.log(`  이름: ${chalk.cyan(name)}`);
    console.log(`  주소: ${chalk.yellow(account.address)}\n`);
    console.log(chalk.gray('  개인키가 암호화되어 로컬에 저장되었습니다.\n'));
  },
  
  async importKey() {
    const { privateKey, password, name } = await inquirer.prompt([
      {
        type: 'password',
        name: 'privateKey',
        message: '개인키 입력:',
        mask: '*',
      },
      {
        type: 'password',
        name: 'password',
        message: '암호화를 위한 비밀번호 입력:',
        mask: '*',
      },
      {
        type: 'input',
        name: 'name',
        message: '계정 이름:',
        default: '가져온 계정',
      },
    ]);
    
    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✅ 계정을 성공적으로 가져왔습니다!\n'));
      console.log(`  주소: ${chalk.yellow(account.address)}\n`);
    } catch (error) {
      console.log(chalk.red('잘못된 개인키입니다'));
    }
  },
  
  async list() {
    const accounts = loadAccounts();
    
    if (accounts.length === 0) {
      console.log(chalk.yellow('\n계정이 없습니다. beatoz-wallet create로 생성하세요\n'));
      return;
    }
    
    console.log(chalk.cyan('\n📋 계정 목록\n'));
    
    accounts.forEach((acc, index) => {
      console.log(`  ${index + 1}. ${chalk.white(acc.name)}`);
      console.log(`     ${chalk.yellow(acc.address)}`);
      console.log(`     생성일: ${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('계정을 찾을 수 없습니다'));
      return;
    }
    
    const { password, confirm } = await inquirer.prompt([
      {
        type: 'password',
        name: 'password',
        message: '비밀번호 입력:',
        mask: '*',
      },
      {
        type: 'confirm',
        name: 'confirm',
        message: chalk.red('⚠️  개인키가 표시됩니다. 계속하시겠습니까?'),
        default: false,
      },
    ]);
    
    if (!confirm) return;
    
    try {
      const privateKey = decrypt(account.encryptedKey, password);
      console.log(chalk.yellow(`\n개인키: ${privateKey}\n`));
      console.log(chalk.red('⚠️  이 키를 절대 다른 사람과 공유하지 마세요!\n'));
    } catch (error) {
      console.log(chalk.red('비밀번호가 틀렸습니다'));
    }
  },
};

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 (!address) {
      const accounts = loadAccounts();
      
      if (accounts.length === 0) {
        console.log(chalk.yellow('계정이 없습니다'));
        return;
      }
      
      console.log(chalk.cyan('\n💰 계정 잔액\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(`  잔액: ${chalk.green(balance)} BTZ\n`);
        } catch (error) {
          console.log(`  ${chalk.white(acc.name)}`);
          console.log(`  ${chalk.gray(acc.address)}`);
          console.log(`  잔액: ${chalk.green('0')} BTZ\n`);
        }
      }
    } else {
      // 특정 주소 잔액 표시
      try {
        const account = await web3.beatoz.getAccount(address);
        const balance = web3.utils.fromFons(account.value.balance, 'beatoz');
        console.log(chalk.cyan('\n💰 계정 잔액\n'));
        console.log(`  주소: ${chalk.gray(address)}`);
        console.log(`  잔액: ${chalk.green(balance)} BTZ\n`);
      } catch (error) {
        console.log(chalk.cyan('\n💰 계정 잔액\n'));
        console.log(`  주소: ${chalk.gray(address)}`);
        console.log(`  잔액: ${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('계정이 없습니다. 먼저 계정을 생성하세요.'));
      return;
    }
    
    const network = config.networks[config.defaultNetwork];
    const web3 = new Web3(network.rpc);
    
    // 대화형 프롬프트
    const answers = await inquirer.prompt([
      {
        type: 'list',
        name: 'fromAccount',
        message: '발신 계정 선택:',
        choices: accounts.map(a => ({
          name: `${a.name} (${a.address.slice(0, 10)}...)`,
          value: a,
        })),
        when: !options.from,
      },
      {
        type: 'input',
        name: 'to',
        message: '수신자 주소:',
        when: !options.to,
        validate: (input) => /^(0x)?[0-9a-fA-F]{40}$/.test(input) || '잘못된 주소입니다',
      },
      {
        type: 'input',
        name: 'amount',
        message: '금액 (BTZ):',
        when: !options.amount,
        validate: (input) => !isNaN(parseFloat(input)) || '잘못된 금액입니다',
      },
      {
        type: 'password',
        name: 'password',
        message: '비밀번호 입력:',
        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('발신 계정을 찾을 수 없습니다'));
      return;
    }
    
    try {
      // 개인키 복호화
      const privateKey = decrypt(fromAccount.encryptedKey, answers.password);
      const sender = web3.beatoz.accounts.privateKeyToAccount(privateKey);
      
      // 계정 정보 조회
      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(`잔액 부족: ${balance} BTZ`));
        return;
      }
      
      // 트랜잭션 생성
      const tx = TrxProtoBuilder.buildTransferTrxProto({
        from: sender.address,
        to: toAddress,
        nonce: accountInfo.value.nonce,
        gas: 100000,
        gasPrice: '48000000000',
        amount: web3.utils.toFons(amount, 'beatoz'),
      });
      
      // 서명 및 브로드캐스트
      TrxProtoBuilder.signTrxProto(tx, sender, status.node_info.network);
      
      console.log(chalk.cyan('\n📤 트랜잭션 전송 중...\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(`트랜잭션 실패: ${result.check_tx?.log || result.deliver_tx?.log}`));
        return;
      }
      
      console.log(chalk.green('✅ 트랜잭션 전송 완료!\n'));
      console.log(`  발신: ${chalk.gray(sender.address)}`);
      console.log(`  수신: ${chalk.gray(toAddress)}`);
      console.log(`  금액: ${chalk.yellow(amount)} BTZ`);
      console.log(`  TX 해시: ${chalk.cyan(result.hash)}\n`);
      
    } catch (error) {
      if (error.message.includes('decipher')) {
        console.log(chalk.red('비밀번호가 틀렸습니다'));
      } else {
        console.log(chalk.red(`오류: ${error.message}`));
      }
    }
  },
};

사용 예시

# 네트워크 정보 확인
node src/index.js network

# 새 계정 생성
node src/index.js create

# 개인키로 계정 가져오기
node src/index.js import

# 계정 목록 보기
node src/index.js list

# 잔액 확인
node src/index.js balance

# 특정 주소 잔액 확인
node src/index.js balance 0x...

# BTZ 전송
node src/index.js send

# 옵션으로 전송
node src/index.js send -t 0x... -a 1.5

CLI Wallet 완성! 🎉

다음 단계

Beatoz Developer Portal