From bcc5f220ff8f91f1d2cda8b49589a9107c03d3f8 Mon Sep 17 00:00:00 2001 From: dafuga Date: Mon, 10 Nov 2025 21:09:06 -0800 Subject: [PATCH 01/36] chore: added local chain scripts --- src/commands/chain/index.ts | 117 +++++++++++ src/commands/chain/install.ts | 214 ++++++++++++++++++++ src/commands/chain/local.ts | 370 ++++++++++++++++++++++++++++++++++ src/commands/chain/utils.ts | 271 +++++++++++++++++++++++++ src/index.ts | 4 + test/tests/chain-install.ts | 67 ++++++ test/tests/chain-utils.ts | 111 ++++++++++ 7 files changed, 1154 insertions(+) create mode 100644 src/commands/chain/index.ts create mode 100644 src/commands/chain/install.ts create mode 100644 src/commands/chain/local.ts create mode 100644 src/commands/chain/utils.ts create mode 100644 test/tests/chain-install.ts create mode 100644 test/tests/chain-utils.ts diff --git a/src/commands/chain/index.ts b/src/commands/chain/index.ts new file mode 100644 index 0000000..dbb8e4b --- /dev/null +++ b/src/commands/chain/index.ts @@ -0,0 +1,117 @@ +/* eslint-disable no-console */ +import {Command} from 'commander' +import {showChainLogs, showChainStatus, startLocalChain, stopLocalChain} from './local' +import {checkLeapInstallation} from './install' + +/** + * Create the chain command with subcommands + */ +export function createChainCommand(): Command { + const chain = new Command('chain') + chain.description('Manage local LEAP blockchain') + + // Local subcommand + const local = chain.command('local').description('Manage local blockchain instance') + + // Local start + local + .command('start') + .description('Start a local LEAP blockchain (installs LEAP automatically if needed)') + .option('-p, --port ', 'Port for the HTTP server', '8888') + .option('-c, --clean', 'Clean blockchain data before starting', false) + .action(async (options) => { + try { + await startLocalChain({ + port: parseInt(options.port, 10), + clean: options.clean, + }) + } catch (error: any) { + console.error(`Error: ${error.message}`) + process.exit(1) + } + }) + + // Local stop + local + .command('stop') + .description('Stop the local blockchain') + .action(async () => { + try { + await stopLocalChain() + } catch (error: any) { + console.error(`Error: ${error.message}`) + process.exit(1) + } + }) + + // Local status + local + .command('status') + .description('Check the status of the local blockchain') + .action(async () => { + try { + await showChainStatus() + } catch (error: any) { + console.error(`Error: ${error.message}`) + process.exit(1) + } + }) + + // Local logs + local + .command('logs') + .description('Show logs from the local blockchain (includes block production)') + .option('-f, --follow', 'Follow log output in real-time', false) + .option('-e, --errors', 'Show only errors and warnings', false) + .action(async (options) => { + try { + await showChainLogs({ + follow: options.follow, + errors: options.errors, + }) + } catch (error: any) { + console.error(`Error: ${error.message}`) + process.exit(1) + } + }) + + // Check installation + chain + .command('check') + .description('Check LEAP installation status') + .action(async () => { + try { + const status = await checkLeapInstallation() + + console.log('\nLEAP Installation Status:\n') + + if (status.installed) { + console.log('āœ… LEAP is installed') + console.log(` Version: ${status.version}`) + console.log(` nodeos: ${status.nodeosPath}`) + console.log(` cleos: ${status.cleosPath}`) + console.log(` keosd: ${status.keosdPath}`) + } else { + console.log('āŒ LEAP is not installed\n') + console.log('Missing components:') + if (!status.nodeos) console.log(' - nodeos') + if (!status.cleos) console.log(' - cleos') + if (!status.keosd) console.log(' - keosd') + console.log('\nšŸ’” Install automatically with: wharfkit chain local start') + } + } catch (error: any) { + console.error(`Error: ${error.message}`) + process.exit(1) + } + }) + + return chain +} + +/** + * Command handler for the chain command (called from main CLI) + */ +export function chainCommandHandler(): void { + const chain = createChainCommand() + chain.parse(process.argv) +} diff --git a/src/commands/chain/install.ts b/src/commands/chain/install.ts new file mode 100644 index 0000000..c925af6 --- /dev/null +++ b/src/commands/chain/install.ts @@ -0,0 +1,214 @@ +/* eslint-disable no-console */ +import {executeCommand, getPlatform} from './utils' + +export interface InstallationStatus { + installed: boolean + nodeos: boolean + cleos: boolean + keosd: boolean + nodeosPath?: string + cleosPath?: string + keosdPath?: string + version?: string +} + +/** + * Check if LEAP is installed + */ +export async function checkLeapInstallation(): Promise { + const status: InstallationStatus = { + installed: false, + nodeos: false, + cleos: false, + keosd: false, + } + + // Check nodeos + try { + const {stdout} = await executeCommand('which nodeos') + status.nodeosPath = stdout.trim() + status.nodeos = true + } catch { + // nodeos not found + } + + // Check cleos + try { + const {stdout} = await executeCommand('which cleos') + status.cleosPath = stdout.trim() + status.cleos = true + } catch { + // cleos not found + } + + // Check keosd + try { + const {stdout} = await executeCommand('which keosd') + status.keosdPath = stdout.trim() + status.keosd = true + } catch { + // keosd not found + } + + // Get version if nodeos is installed + if (status.nodeos) { + try { + const {stdout} = await executeCommand('nodeos --version') + const versionMatch = stdout.match(/v?(\d+\.\d+\.\d+)/) + if (versionMatch) { + status.version = versionMatch[1] + } + } catch { + // Version check failed + } + } + + status.installed = status.nodeos && status.cleos && status.keosd + + return status +} + +/** + * Install LEAP on macOS using Homebrew + */ +async function installLeapMacOS(): Promise { + console.log('Installing LEAP on macOS using Homebrew...') + + // Check if Homebrew is installed + try { + await executeCommand('which brew') + } catch { + throw new Error( + 'Homebrew is not installed. Please install it from https://brew.sh/ and try again.' + ) + } + + // Tap AntelopeIO + console.log('Adding AntelopeIO tap...') + try { + await executeCommand('brew tap antelopeio/leap') + } catch (error: any) { + throw new Error(`Failed to add AntelopeIO tap: ${error.message}`) + } + + // Install LEAP + console.log('Installing LEAP (this may take a few minutes)...') + try { + await executeCommand('brew install leap') + } catch (error: any) { + throw new Error(`Failed to install LEAP: ${error.message}`) + } + + console.log('LEAP installed successfully!') +} + +/** + * Install LEAP on Linux using apt + */ +async function installLeapLinux(): Promise { + console.log('Installing LEAP on Linux using apt...') + + // Detect distribution + let distro = 'ubuntu' + let version = '22.04' + + try { + const {stdout} = await executeCommand('lsb_release -is') + distro = stdout.trim().toLowerCase() + } catch { + console.log('Could not detect distribution, assuming Ubuntu') + } + + try { + const {stdout} = await executeCommand('lsb_release -rs') + version = stdout.trim() + } catch { + console.log('Could not detect version, assuming 22.04') + } + + // Add AntelopeIO repository + console.log('Adding AntelopeIO repository...') + try { + await executeCommand( + 'wget -qO - https://apt.antelope.io/repos/antelope.gpg.key | sudo apt-key add -' + ) + await executeCommand( + `echo "deb [arch=amd64] https://apt.antelope.io ${distro} ${version}" | sudo tee /etc/apt/sources.list.d/antelope.list` + ) + } catch (error: any) { + throw new Error(`Failed to add AntelopeIO repository: ${error.message}`) + } + + // Update package list + console.log('Updating package list...') + try { + await executeCommand('sudo apt-get update') + } catch (error: any) { + throw new Error(`Failed to update package list: ${error.message}`) + } + + // Install LEAP + console.log('Installing LEAP (this may take a few minutes)...') + try { + await executeCommand('sudo apt-get install -y leap') + } catch (error: any) { + throw new Error(`Failed to install LEAP: ${error.message}`) + } + + console.log('LEAP installed successfully!') +} + +/** + * Install LEAP based on platform + */ +export async function installLeap(): Promise { + const {os} = getPlatform() + + switch (os) { + case 'darwin': + await installLeapMacOS() + break + case 'linux': + await installLeapLinux() + break + default: + throw new Error( + `Automatic installation is not supported on ${os}. ` + + 'Please install LEAP manually from https://github.com/AntelopeIO/leap/releases' + ) + } + + // Verify installation + const status = await checkLeapInstallation() + if (!status.installed) { + throw new Error('Installation completed but LEAP binaries are not available in PATH') + } + + console.log(`LEAP ${status.version} is now installed and ready to use!`) +} + +/** + * Ensure LEAP is installed, install automatically if not found + */ +export async function ensureLeapInstalled(): Promise { + const status = await checkLeapInstallation() + + if (status.installed) { + console.log(`LEAP ${status.version} is already installed`) + return + } + + console.log('LEAP is not installed, installing automatically...') + + if (!status.nodeos) { + console.log(' - nodeos is not installed') + } + if (!status.cleos) { + console.log(' - cleos is not installed') + } + if (!status.keosd) { + console.log(' - keosd is not installed') + } + + await installLeap() +} diff --git a/src/commands/chain/local.ts b/src/commands/chain/local.ts new file mode 100644 index 0000000..7a69aa2 --- /dev/null +++ b/src/commands/chain/local.ts @@ -0,0 +1,370 @@ +/* eslint-disable no-console */ +import {spawn} from 'child_process' +import * as fs from 'fs' +import * as path from 'path' +import type {ChainStatus} from './utils' +import { + cleanDataDir, + ensureDir, + executeCommand, + getConfigIni, + getDefaultConfigDir, + getDefaultDataDir, + getDefaultWalletDir, + getDevKeys, + getGenesisJson, + isPortAvailable, + isProcessRunning, + readPid, + removePidFile, + savePid, + waitForChain, +} from './utils' +import {ensureLeapInstalled} from './install' + +export interface LocalStartOptions { + port: number + clean: boolean +} + +/** + * Start the local blockchain + */ +export async function startLocalChain(options: LocalStartOptions): Promise { + console.log('Starting local LEAP blockchain...') + + // Check if LEAP is installed, install automatically if not + await ensureLeapInstalled() + + // Check if chain is already running + const currentStatus = await getChainStatus() + if (currentStatus.running) { + console.log( + `Local chain is already running on port ${currentStatus.port} (PID: ${currentStatus.pid})` + ) + return + } + + // Setup directories + const dataDir = getDefaultDataDir() + const configDir = getDefaultConfigDir() + const walletDir = getDefaultWalletDir() + + await ensureDir(dataDir) + await ensureDir(configDir) + await ensureDir(walletDir) + + // Clean if requested + if (options.clean) { + console.log('Cleaning blockchain data...') + await cleanDataDir(dataDir) + } + + // Check if port is available + const portAvailable = await isPortAvailable(options.port) + if (!portAvailable) { + throw new Error( + `Port ${options.port} is already in use. Use --port to specify a different port.` + ) + } + + // Create config files + const configFile = path.join(configDir, 'config.ini') + const genesisFile = path.join(configDir, 'genesis.json') + + // Write config.ini + const configContent = getConfigIni(options.port) + await fs.promises.writeFile(configFile, configContent) + + // Write genesis.json if it doesn't exist or if cleaning + if (options.clean || !fs.existsSync(genesisFile)) { + const genesisContent = getGenesisJson() + await fs.promises.writeFile(genesisFile, genesisContent) + } + + // Start nodeos + console.log(`Starting nodeos on port ${options.port}...`) + + const nodeosArgs = [ + '--config-dir', + configDir, + '--data-dir', + dataDir, + '--genesis-json', + genesisFile, + '--disable-replay-opts', + ] + + // Setup log files + const stdoutLog = path.join(dataDir, 'nodeos.log') + const stderrLog = path.join(dataDir, 'nodeos-error.log') + const stdoutFd = fs.openSync(stdoutLog, 'a') + const stderrFd = fs.openSync(stderrLog, 'a') + + const nodeos = spawn('nodeos', nodeosArgs, { + detached: true, + stdio: ['ignore', stdoutFd, stderrFd], + }) + + // Close file descriptors in parent process (child has its own copy) + fs.closeSync(stdoutFd) + fs.closeSync(stderrFd) + + nodeos.unref() + + // Save PID + await savePid(nodeos.pid!) + + console.log(`nodeos started with PID ${nodeos.pid}`) + console.log(` Logs: ${stdoutLog}`) + + // Wait for chain to be ready + console.log('Waiting for chain to be ready...') + const isReady = await waitForChain(options.port) + + if (!isReady) { + const logFile = path.join(dataDir, 'nodeos-error.log') + throw new Error( + `Chain failed to start. Check logs for details:\n ${logFile}\n ${path.join( + dataDir, + 'nodeos.log' + )}` + ) + } + + console.log('Chain is ready!') + + // Setup dev wallet + await setupDevWallet() + + console.log('\nāœ… Local LEAP blockchain is running!') + console.log(` URL: http://127.0.0.1:${options.port}`) + console.log(` Data directory: ${dataDir}`) + console.log(` Config directory: ${configDir}`) + console.log('\nšŸ“ Development keys:') + const devKeys = getDevKeys() + console.log(` Public: ${devKeys.publicKey}`) + console.log(` Private: ${devKeys.privateKey}`) + console.log('\nšŸ›‘ To stop: wharfkit chain local stop') +} + +/** + * Stop the local blockchain + */ +export async function stopLocalChain(): Promise { + console.log('Stopping local LEAP blockchain...') + + const pid = await readPid() + + if (!pid) { + console.log('No running chain found') + return + } + + const running = await isProcessRunning(pid) + + if (!running) { + console.log('Chain is not running') + await removePidFile() + return + } + + // Try graceful shutdown first + try { + process.kill(pid, 'SIGTERM') + console.log('Sent shutdown signal to nodeos') + + // Wait for process to stop + let attempts = 0 + while (attempts < 20) { + const stillRunning = await isProcessRunning(pid) + if (!stillRunning) { + break + } + await new Promise((resolve) => setTimeout(resolve, 500)) + attempts++ + } + + // Force kill if still running + if (await isProcessRunning(pid)) { + console.log('Force stopping nodeos...') + process.kill(pid, 'SIGKILL') + } + } catch (error: any) { + throw new Error(`Failed to stop chain: ${error.message}`) + } + + await removePidFile() + console.log('Local chain stopped successfully') +} + +/** + * Get the status of the local blockchain + */ +export async function getChainStatus(): Promise { + const status: ChainStatus = { + running: false, + port: 8888, + dataDir: getDefaultDataDir(), + } + + const pid = await readPid() + + if (pid) { + status.pid = pid + status.running = await isProcessRunning(pid) + } + + // Try to get chain info + if (status.running) { + try { + const {stdout} = await executeCommand( + `cleos --url http://127.0.0.1:${status.port} get info 2>/dev/null` + ) + const info = JSON.parse(stdout) + status.headBlock = info.head_block_num + } catch (error: any) { + status.error = 'Could not connect to chain' + } + } + + return status +} + +/** + * Display the status of the local blockchain + */ +export async function showChainStatus(): Promise { + console.log('Checking local chain status...\n') + + const status = await getChainStatus() + + if (status.running) { + console.log('āœ… Local chain is running') + console.log(` PID: ${status.pid}`) + console.log(` URL: http://127.0.0.1:${status.port}`) + console.log(` Data directory: ${status.dataDir}`) + + if (status.headBlock !== undefined) { + console.log(` Head block: ${status.headBlock}`) + } + + if (status.error) { + console.log(` āš ļø ${status.error}`) + } + } else { + console.log('āŒ Local chain is not running') + console.log('\nšŸ’” Start with: wharfkit chain local start') + } +} + +/** + * Show logs from the local blockchain + */ +export async function showChainLogs(options: {follow: boolean; errors: boolean}): Promise { + const dataDir = getDefaultDataDir() + const logFile = path.join(dataDir, 'nodeos-error.log') // Contains all output: info, warnings, errors + + // Check if chain is running + const status = await getChainStatus() + if (!status.running) { + console.log('āŒ Local chain is not running') + console.log('\nšŸ’” Start with: wharfkit chain local start') + return + } + + const logType = options.errors ? 'Errors & Warnings' : 'All Logs' + console.log(`šŸ“‹ Showing ${logType}`) + console.log(`Press Ctrl+C to exit\n`) + + try { + // Check if log file exists + if (!fs.existsSync(logFile)) { + console.log(`No log file found at ${logFile}`) + return + } + + // Build grep filter if showing only errors + let command = '' + if (options.follow) { + if (options.errors) { + // Follow with error filtering + command = `tail -f ${logFile} | grep -E "error|warn|exception"` + } else { + // Just follow + command = `tail -f ${logFile}` + } + } else { + if (options.errors) { + // Show last 50 lines with error filtering + command = `tail -n 100 ${logFile} | grep -E "error|warn|exception" | tail -n 50` + } else { + // Show last 50 lines + command = `tail -n 50 ${logFile}` + } + } + + // Use spawn to run the command through shell + const process = spawn('sh', ['-c', command], { + stdio: 'inherit', + }) + + // Handle process exit + process.on('exit', (code) => { + if (code !== 0 && code !== null) { + console.log(`\nLog viewer exited with code ${code}`) + } + }) + + process.on('error', (error) => { + console.error(`Failed to read logs: ${error.message}`) + }) + } catch (error: any) { + throw new Error(`Failed to show logs: ${error.message}`) + } +} + +/** + * Setup development wallet with default keys + */ +async function setupDevWallet(): Promise { + console.log('Setting up development wallet...') + + const walletName = 'dev' + const walletPassword = 'PW5KKbTdHCGmrWXmtHFXz7eVZqzJ3cCJLQ4EwBSbQMKcZpXhsjzKM' + const devKeys = getDevKeys() + + try { + // Create wallet + try { + await executeCommand(`cleos wallet create -n ${walletName} --to-console`) + } catch { + // Wallet might already exist + } + + // Unlock wallet + try { + await executeCommand( + `echo "${walletPassword}" | cleos wallet unlock -n ${walletName} --password` + ) + } catch { + // Wallet might already be unlocked + } + + // Import dev key + try { + await executeCommand( + `cleos wallet import -n ${walletName} --private-key ${devKeys.privateKey}` + ) + } catch { + // Key might already be imported + } + + console.log('Development wallet ready') + } catch (error: any) { + console.log(`Warning: Could not setup dev wallet: ${error.message}`) + console.log('You can manually import keys using:') + console.log(` cleos wallet create -n ${walletName}`) + console.log(` cleos wallet import -n ${walletName} --private-key ${devKeys.privateKey}`) + } +} diff --git a/src/commands/chain/utils.ts b/src/commands/chain/utils.ts new file mode 100644 index 0000000..2c25d09 --- /dev/null +++ b/src/commands/chain/utils.ts @@ -0,0 +1,271 @@ +/* eslint-disable no-console */ +import {exec} from 'child_process' +import {promisify} from 'util' +import * as fs from 'fs' +import * as path from 'path' +import * as os from 'os' + +const execAsync = promisify(exec) + +export interface ChainConfig { + port: number + dataDir: string + configDir: string + walletDir: string + nodeosPid?: number +} + +export interface ChainStatus { + running: boolean + pid?: number + port: number + dataDir: string + headBlock?: number + error?: string +} + +/** + * Get the default data directory for the local chain + */ +export function getDefaultDataDir(): string { + return path.join(os.homedir(), '.wharfkit', 'chain') +} + +/** + * Get the default config directory for the local chain + */ +export function getDefaultConfigDir(): string { + return path.join(os.homedir(), '.wharfkit', 'config') +} + +/** + * Get the default wallet directory + */ +export function getDefaultWalletDir(): string { + return path.join(os.homedir(), '.wharfkit', 'wallet') +} + +/** + * Ensure directory exists, create if it doesn't + */ +export async function ensureDir(dir: string): Promise { + try { + await fs.promises.access(dir) + } catch { + await fs.promises.mkdir(dir, {recursive: true}) + } +} + +/** + * Execute a command and return the result + */ +export async function executeCommand(command: string): Promise<{stdout: string; stderr: string}> { + try { + return await execAsync(command) + } catch (error: any) { + throw new Error(`Command failed: ${error.message}`) + } +} + +/** + * Check if a process is running + */ +export async function isProcessRunning(pid: number): Promise { + try { + process.kill(pid, 0) + return true + } catch { + return false + } +} + +/** + * Get the PID file path + */ +export function getPidFilePath(): string { + return path.join(getDefaultDataDir(), 'nodeos.pid') +} + +/** + * Save PID to file + */ +export async function savePid(pid: number): Promise { + const pidFile = getPidFilePath() + await ensureDir(path.dirname(pidFile)) + await fs.promises.writeFile(pidFile, pid.toString()) +} + +/** + * Read PID from file + */ +export async function readPid(): Promise { + const pidFile = getPidFilePath() + try { + const content = await fs.promises.readFile(pidFile, 'utf-8') + return parseInt(content.trim(), 10) + } catch { + return null + } +} + +/** + * Remove PID file + */ +export async function removePidFile(): Promise { + const pidFile = getPidFilePath() + try { + await fs.promises.unlink(pidFile) + } catch { + // Ignore errors if file doesn't exist + } +} + +/** + * Check if port is available + */ +export async function isPortAvailable(port: number): Promise { + try { + const {stdout} = await execAsync(`lsof -i :${port} || echo "free"`) + return stdout.includes('free') + } catch { + return true + } +} + +/** + * Get platform information + */ +export function getPlatform(): {os: string; arch: string} { + return { + os: os.platform(), + arch: os.arch(), + } +} + +/** + * Clean blockchain data directory + */ +export async function cleanDataDir(dataDir: string): Promise { + try { + // Remove blocks and state directories + const blocksDir = path.join(dataDir, 'blocks') + const stateDir = path.join(dataDir, 'state') + + if (fs.existsSync(blocksDir)) { + await fs.promises.rm(blocksDir, {recursive: true, force: true}) + } + if (fs.existsSync(stateDir)) { + await fs.promises.rm(stateDir, {recursive: true, force: true}) + } + + console.log('Blockchain data cleaned successfully') + } catch (error: any) { + throw new Error(`Failed to clean data directory: ${error.message}`) + } +} + +/** + * Get default development keys + */ +export function getDevKeys(): {publicKey: string; privateKey: string} { + return { + publicKey: 'EOS6MRyAjQq8ud7hVNYcfnVPJqcVpscN5So8BhtHuGYqET5GDW5CV', + privateKey: '5KQwrPbwdL6PhXujxW37FSSQZ1JiwsST4cqQzDeyXtP79zkvFD3', + } +} + +/** + * Create default genesis.json + */ +export function getGenesisJson(): string { + const devKeys = getDevKeys() + // Use a fixed timestamp for deterministic blockchain + const timestamp = '2018-12-05T08:55:00.000' + return JSON.stringify( + { + initial_timestamp: timestamp, + initial_key: devKeys.publicKey, + initial_configuration: { + max_block_net_usage: 1048576, + target_block_net_usage_pct: 1000, + max_transaction_net_usage: 524288, + base_per_transaction_net_usage: 12, + net_usage_leeway: 500, + context_free_discount_net_usage_num: 20, + context_free_discount_net_usage_den: 100, + max_block_cpu_usage: 200000, + target_block_cpu_usage_pct: 1000, + max_transaction_cpu_usage: 150000, + min_transaction_cpu_usage: 100, + max_transaction_lifetime: 3600, + deferred_trx_expiration_window: 600, + max_transaction_delay: 3888000, + max_inline_action_size: 4096, + max_inline_action_depth: 4, + max_authority_depth: 6, + }, + }, + null, + 2 + ) +} + +/** + * Create default config.ini + */ +export function getConfigIni(port: number): string { + const devKeys = getDevKeys() + return `# Plugins +plugin = eosio::chain_api_plugin +plugin = eosio::chain_plugin +plugin = eosio::http_plugin +plugin = eosio::producer_plugin +plugin = eosio::producer_api_plugin + +# HTTP settings +http-server-address = 0.0.0.0:${port} +access-control-allow-origin = * +access-control-allow-headers = * +http-validate-host = false +verbose-http-errors = true + +# Chain settings +chain-state-db-size-mb = 1024 +contracts-console = true +abi-serializer-max-time-ms = 2000 + +# Resource monitoring - relaxed for local development +chain-state-db-guard-size-mb = 128 +chain-threads = 2 +resource-monitor-not-shutdown-on-threshold-exceeded = true + +# Producer settings +producer-name = eosio +signature-provider = ${devKeys.publicKey}=KEY:${devKeys.privateKey} +enable-stale-production = true +pause-on-startup = false +` +} + +/** + * Wait for chain to be ready + */ +export async function waitForChain(port: number, timeoutMs: number = 10000): Promise { + const startTime = Date.now() + + while (Date.now() - startTime < timeoutMs) { + try { + const {stdout} = await execAsync( + `cleos --url http://127.0.0.1:${port} get info 2>/dev/null` + ) + if (stdout.includes('head_block_num')) { + return true + } + } catch { + // Chain not ready yet + } + await new Promise((resolve) => setTimeout(resolve, 500)) + } + + return false +} diff --git a/src/index.ts b/src/index.ts index 51c43e0..3a625b9 100644 --- a/src/index.ts +++ b/src/index.ts @@ -4,6 +4,7 @@ import {version} from '../package.json' import {generateContractFromCommand} from './commands/contract' import {generateKeysFromCommand} from './commands/keys/index' import {createAccountFromCommand} from './commands/account/index' +import {createChainCommand} from './commands/chain/index' const program = new Command() @@ -45,4 +46,7 @@ program ) .action(generateContractFromCommand) +// 4. Command to manage local blockchain +program.addCommand(createChainCommand()) + program.parse(process.argv) diff --git a/test/tests/chain-install.ts b/test/tests/chain-install.ts new file mode 100644 index 0000000..427ec47 --- /dev/null +++ b/test/tests/chain-install.ts @@ -0,0 +1,67 @@ +import {assert} from 'chai' +import {checkLeapInstallation} from '../../src/commands/chain/install' + +suite('Chain Install', function () { + suite('LEAP Installation Check', function () { + test('Checks LEAP installation status', async function () { + this.timeout(5000) + + const status = await checkLeapInstallation() + + assert.isObject(status) + assert.property(status, 'installed') + assert.property(status, 'nodeos') + assert.property(status, 'cleos') + assert.property(status, 'keosd') + assert.isBoolean(status.installed) + assert.isBoolean(status.nodeos) + assert.isBoolean(status.cleos) + assert.isBoolean(status.keosd) + }) + + test('Has paths if binaries are installed', async function () { + this.timeout(5000) + + const status = await checkLeapInstallation() + + if (status.nodeos) { + assert.property(status, 'nodeosPath') + assert.isString(status.nodeosPath) + } + + if (status.cleos) { + assert.property(status, 'cleosPath') + assert.isString(status.cleosPath) + } + + if (status.keosd) { + assert.property(status, 'keosdPath') + assert.isString(status.keosdPath) + } + }) + + test('Has version if nodeos is installed', async function () { + this.timeout(5000) + + const status = await checkLeapInstallation() + + if (status.nodeos) { + assert.property(status, 'version') + assert.isString(status.version) + assert.match(status.version!, /\d+\.\d+\.\d+/) + } + }) + + test('Marks installed as true only if all binaries exist', async function () { + this.timeout(5000) + + const status = await checkLeapInstallation() + + if (status.installed) { + assert.isTrue(status.nodeos) + assert.isTrue(status.cleos) + assert.isTrue(status.keosd) + } + }) + }) +}) diff --git a/test/tests/chain-utils.ts b/test/tests/chain-utils.ts new file mode 100644 index 0000000..908745f --- /dev/null +++ b/test/tests/chain-utils.ts @@ -0,0 +1,111 @@ +import {assert} from 'chai' +import * as os from 'os' +import { + getConfigIni, + getDefaultConfigDir, + getDefaultDataDir, + getDefaultWalletDir, + getDevKeys, + getGenesisJson, + getPidFilePath, + getPlatform, +} from '../../src/commands/chain/utils' + +suite('Chain Utils', function () { + suite('Directory Functions', function () { + test('Returns default data directory', function () { + const dataDir = getDefaultDataDir() + assert.isString(dataDir) + assert.include(dataDir, '.wharfkit') + assert.include(dataDir, 'chain') + }) + + test('Returns default config directory', function () { + const configDir = getDefaultConfigDir() + assert.isString(configDir) + assert.include(configDir, '.wharfkit') + assert.include(configDir, 'config') + }) + + test('Returns default wallet directory', function () { + const walletDir = getDefaultWalletDir() + assert.isString(walletDir) + assert.include(walletDir, '.wharfkit') + assert.include(walletDir, 'wallet') + }) + + test('Returns PID file path', function () { + const pidFile = getPidFilePath() + assert.isString(pidFile) + assert.include(pidFile, 'nodeos.pid') + }) + }) + + suite('Dev Keys', function () { + test('Returns development keys', function () { + const keys = getDevKeys() + assert.isObject(keys) + assert.property(keys, 'publicKey') + assert.property(keys, 'privateKey') + assert.isString(keys.publicKey) + assert.isString(keys.privateKey) + assert.match(keys.publicKey, /^EOS/) + assert.equal(keys.publicKey, 'EOS6MRyAjQq8ud7hVNYcfnVPJqcVpscN5So8BhtHuGYqET5GDW5CV') + assert.equal(keys.privateKey, '5KQwrPbwdL6PhXujxW37FSSQZ1JiwsST4cqQzDeyXtP79zkvFD3') + }) + }) + + suite('Configuration', function () { + test('Generates valid genesis JSON', function () { + const genesisJson = getGenesisJson() + assert.isString(genesisJson) + + const genesis = JSON.parse(genesisJson) + assert.isObject(genesis) + assert.property(genesis, 'initial_timestamp') + assert.property(genesis, 'initial_key') + assert.property(genesis, 'initial_configuration') + assert.equal( + genesis.initial_key, + 'EOS6MRyAjQq8ud7hVNYcfnVPJqcVpscN5So8BhtHuGYqET5GDW5CV' + ) + }) + + test('Generates valid config.ini', function () { + const port = 8888 + const configIni = getConfigIni(port) + + assert.isString(configIni) + assert.include(configIni, `http-server-address = 0.0.0.0:${port}`) + assert.include(configIni, 'producer-name = eosio') + assert.include(configIni, 'plugin = eosio::chain_api_plugin') + assert.include(configIni, 'plugin = eosio::http_plugin') + }) + + test('Includes dev keys in config', function () { + const port = 8888 + const configIni = getConfigIni(port) + const devKeys = getDevKeys() + + assert.include(configIni, devKeys.publicKey) + assert.include(configIni, devKeys.privateKey) + }) + }) + + suite('Platform Detection', function () { + test('Returns platform information', function () { + const platform = getPlatform() + assert.isObject(platform) + assert.property(platform, 'os') + assert.property(platform, 'arch') + assert.isString(platform.os) + assert.isString(platform.arch) + }) + + test('Matches Node.js platform', function () { + const platform = getPlatform() + assert.equal(platform.os, os.platform()) + assert.equal(platform.arch, os.arch()) + }) + }) +}) From 00f53945f6f066a9c7787d62bc37cae93ea47202 Mon Sep 17 00:00:00 2001 From: dafuga Date: Mon, 10 Nov 2025 22:12:22 -0800 Subject: [PATCH 02/36] chore: added compile script --- README.md | 142 +++++++++++++++++++++++++++++++ src/commands/wharfkit/compile.ts | 134 +++++++++++++++++++++++++++++ src/commands/wharfkit/index.ts | 36 ++++++++ src/index.ts | 4 + 4 files changed, 316 insertions(+) create mode 100644 src/commands/wharfkit/compile.ts create mode 100644 src/commands/wharfkit/index.ts diff --git a/README.md b/README.md index 0c2e458..37f9e2a 100644 --- a/README.md +++ b/README.md @@ -66,6 +66,148 @@ To see a full list of options for the `generate` command, run the `help` command npx @wharfkit/cli help generate ``` +### Compiling Smart Contracts + +The CLI includes a `compile` command to compile C++ contract files to WASM format using the CDT (Contract Development Toolkit) that comes with LEAP. + +#### Prerequisites + +The compile command requires LEAP to be installed (which includes the CDT compiler). You can install it automatically by running: + +```bash +wharfkit chain local start +``` + +#### Usage + +**Compile a single file:** +```bash +wharfkit wharfkit compile mycontract.cpp +``` + +**Compile all .cpp files in the current directory:** +```bash +wharfkit wharfkit compile +``` + +**Specify output directory:** +```bash +wharfkit wharfkit compile mycontract.cpp -o ./build +``` + +#### Output + +By default, compiled WASM files are output to the current directory. You can specify a different output directory using the `-o` or `--output` flag. + +For example: +```bash +wharfkit wharfkit compile -o ./build +``` + +This will compile all .cpp files in the current directory and save the resulting .wasm files to the `./build` directory. + +#### Options + +``` +-o, --output Output directory for compiled WASM files (default: ".") +-h, --help display help for command +``` + +### Managing a Local Blockchain + +The CLI includes tools to quickly set up and manage a local LEAP blockchain for development and testing. + +#### Quick Start + +Start a local blockchain with one command (automatically installs LEAP if needed): + +```bash +wharfkit chain local start +``` + +This will: +- āœ… Automatically detect and install LEAP (nodeos/cleos) if not present +- āœ… Create necessary configuration and data directories +- āœ… Start nodeos with sensible defaults for development +- āœ… Set up a dev wallet with pre-configured keys +- āœ… Begin producing blocks immediately + +#### Available Commands + +**Start the local chain:** +```bash +# Basic start +wharfkit chain local start + +# Start with clean state (reset blockchain data) +wharfkit chain local start --clean + +# Start on a custom port (default: 8888) +wharfkit chain local start --port 9000 +``` + +**Check chain status:** +```bash +wharfkit chain local status +``` + +Shows: +- Running status and PID +- Chain URL and data directory +- Current head block number + +**View chain logs:** +```bash +# Show last 50 log lines (includes block production) +wharfkit chain local logs + +# Follow logs in real-time +wharfkit chain local logs --follow + +# Show only errors and warnings +wharfkit chain local logs --errors + +# Follow only errors and warnings +wharfkit chain local logs --follow --errors +``` + +**Stop the chain:** +```bash +wharfkit chain local stop +``` + +**Check LEAP installation:** +```bash +wharfkit chain check +``` + +Shows which LEAP binaries are installed and their versions. + +#### Development Keys + +The local chain comes with pre-configured development keys: + +``` +Public Key: EOS6MRyAjQq8ud7hVNYcfnVPJqcVpscN5So8BhtHuGYqET5GDW5CV +Private Key: 5KQwrPbwdL6PhXujxW37FSSQZ1JiwsST4cqQzDeyXtP79zkvFD3 +``` + +These are automatically imported into the dev wallet. + +#### Configuration + +- **Chain URL:** `http://127.0.0.1:8888` (or custom port) +- **Data Directory:** `~/.wharfkit/chain` +- **Config Directory:** `~/.wharfkit/config` +- **Wallet Directory:** `~/.wharfkit/wallet` + +#### Troubleshooting + +If the chain fails to start: +1. Check logs: `wharfkit chain local logs --errors` +2. Clean state: `wharfkit chain local start --clean` +3. Verify LEAP: `wharfkit chain check` + --- Made with ā˜•ļø & ā¤ļø by [Greymass](https://greymass.com). diff --git a/src/commands/wharfkit/compile.ts b/src/commands/wharfkit/compile.ts new file mode 100644 index 0000000..6800a31 --- /dev/null +++ b/src/commands/wharfkit/compile.ts @@ -0,0 +1,134 @@ +/* eslint-disable no-console */ +import {execSync} from 'child_process' +import {existsSync, readdirSync} from 'fs' +import {join, resolve, basename, extname} from 'path' +import {platform} from 'os' +import {checkLeapInstallation} from '../chain/install' + +interface CompileOptions { + output: string +} + +/** + * Compile a single C++ file or all .cpp files in the current directory + * @param file - Optional file path to compile. If not provided, compiles all .cpp files in current directory + * @param outputDir - Output directory for compiled WASM files + */ +export async function compileContract(file: string | undefined, outputDir: string): Promise { + const currentDir = process.cwd() + const files = await getFilesToCompile(file, currentDir) + + if (files.length === 0) { + console.log('No C++ files found to compile.') + return + } + + const absoluteOutputDir = resolve(outputDir) + ensureOutputDirectory(absoluteOutputDir) + + // Ensure cdt-cpp is installed + await ensureCdtCppInstalled() + + console.log(`Compiling ${files.length} file(s) to ${absoluteOutputDir}\n`) + + for (const filePath of files) { + await compileSingleFile(filePath, absoluteOutputDir) + } + + console.log('\nCompilation complete!') +} + +/** + * Get list of files to compile + */ +async function getFilesToCompile(file: string | undefined, currentDir: string): Promise { + if (file) { + const filePath = resolve(currentDir, file) + if (!existsSync(filePath)) { + throw new Error(`File not found: ${filePath}`) + } + if (extname(filePath) !== '.cpp') { + throw new Error(`File must be a C++ file (.cpp): ${filePath}`) + } + return [filePath] + } + + // Get all .cpp files in current directory + const files = readdirSync(currentDir) + .filter((f) => extname(f) === '.cpp') + .map((f) => join(currentDir, f)) + + return files +} + +/** + * Ensure output directory exists + */ +function ensureOutputDirectory(dir: string): void { + if (!existsSync(dir)) { + throw new Error(`Output directory does not exist: ${dir}`) + } +} + +/** + * Check if cdt-cpp is installed and install if necessary + * First checks if LEAP is installed (includes cdt), then checks PATH + */ +async function ensureCdtCppInstalled(): Promise { + if (isCdtCppInstalled()) { + return + } + + // Check if LEAP is installed, which includes cdt-cpp + const leapStatus = await checkLeapInstallation() + if (leapStatus.installed) { + console.log('Note: LEAP is installed, which includes cdt-cpp') + if (!isCdtCppInstalled()) { + throw new Error( + 'cdt-cpp not found in PATH. Make sure LEAP is properly installed or add it to your PATH.' + ) + } + return + } + + console.log('cdt-cpp not found. LEAP should be installed to get cdt-cpp.') + console.log('Install LEAP with: wharfkit chain local start\n') + throw new Error('cdt-cpp is not available. Please install LEAP first.') +} + +/** + * Check if cdt-cpp is available in PATH + */ +function isCdtCppInstalled(): boolean { + try { + execSync('which cdt-cpp', {stdio: 'pipe'}) + return true + } catch { + return false + } +} + +/** + * Compile a single C++ file to WASM using cdt-cpp + */ +async function compileSingleFile(filePath: string, outputDir: string): Promise { + const fileName = basename(filePath, '.cpp') + const wasmOutput = join(outputDir, `${fileName}.wasm`) + + console.log(`Compiling: ${filePath}`) + console.log(`Output: ${wasmOutput}`) + + try { + const command = `cdt-cpp -abigen -o "${wasmOutput}" "${filePath}"` + execSync(command, { + stdio: 'inherit', + cwd: process.cwd(), + }) + console.log(`āœ“ Successfully compiled: ${wasmOutput}\n`) + } catch (error: any) { + throw new Error( + `Failed to compile ${filePath}: ${error.message || 'Unknown error'}. Make sure cdt-cpp is installed and in your PATH.` + ) + } +} + diff --git a/src/commands/wharfkit/index.ts b/src/commands/wharfkit/index.ts new file mode 100644 index 0000000..8fdb0bd --- /dev/null +++ b/src/commands/wharfkit/index.ts @@ -0,0 +1,36 @@ +/* eslint-disable no-console */ +import {Command} from 'commander' +import {compileContract} from './compile' + +/** + * Create the wharfkit command with subcommands + */ +export function createWharfkitCommand(): Command { + const wharfkit = new Command('wharfkit') + wharfkit.description('Wharf development utilities') + + // Compile subcommand + wharfkit + .command('compile [file]') + .description('Compile C++ contract files (single file or all .cpp files in current directory)') + .option('-o, --output ', 'Output directory for compiled WASM files', '.') + .action(async (file, options) => { + try { + await compileContract(file, options.output) + } catch (error: any) { + console.error(`Error: ${error.message}`) + process.exit(1) + } + }) + + return wharfkit +} + +/** + * Command handler for the wharfkit command (called from main CLI) + */ +export function wharfkitCommandHandler(): void { + const wharfkit = createWharfkitCommand() + wharfkit.parse(process.argv) +} + diff --git a/src/index.ts b/src/index.ts index 3a625b9..57e5805 100644 --- a/src/index.ts +++ b/src/index.ts @@ -5,6 +5,7 @@ import {generateContractFromCommand} from './commands/contract' import {generateKeysFromCommand} from './commands/keys/index' import {createAccountFromCommand} from './commands/account/index' import {createChainCommand} from './commands/chain/index' +import {createWharfkitCommand} from './commands/wharfkit/index' const program = new Command() @@ -49,4 +50,7 @@ program // 4. Command to manage local blockchain program.addCommand(createChainCommand()) +// 5. Command to compile contracts +program.addCommand(createWharfkitCommand()) + program.parse(process.argv) From 83bbc67967c9a11389b2e4ac91c7ea3936a6e1ad Mon Sep 17 00:00:00 2001 From: dafuga Date: Mon, 10 Nov 2025 22:44:40 -0800 Subject: [PATCH 03/36] chore: added wallet commands --- README.md | 118 ++++++++++++++++++ src/commands/wallet/create.ts | 117 +++++++++++++++++ src/commands/wallet/index.ts | 57 +++++++++ src/commands/wallet/keys.ts | 149 ++++++++++++++++++++++ src/commands/wallet/sign.ts | 205 ++++++++++++++++++++++++++++++ src/commands/wallet/utils.ts | 228 ++++++++++++++++++++++++++++++++++ src/index.ts | 4 + 7 files changed, 878 insertions(+) create mode 100644 src/commands/wallet/create.ts create mode 100644 src/commands/wallet/index.ts create mode 100644 src/commands/wallet/keys.ts create mode 100644 src/commands/wallet/sign.ts create mode 100644 src/commands/wallet/utils.ts diff --git a/README.md b/README.md index 37f9e2a..40fda58 100644 --- a/README.md +++ b/README.md @@ -34,10 +34,128 @@ Options: -h, --help display help for command Commands: + keys Generate a new set of public and private keys + account [options] Create a new account with an optional public key generate [options] Generate Contract Kit code for the named smart contract + chain Manage local LEAP blockchain + wharfkit Compile C++ contract files + wallet Manage local wallet and sign transactions help [command] display help for command ``` +### Managing Wallet Keys + +The CLI includes a secure wallet system for managing private keys and signing transactions locally. + +#### Creating Keys + +Create a new wallet key with default encryption: + +```bash +wharfkit wallet create +``` + +Create a key with a custom name and password: + +```bash +wharfkit wallet create --name mykey --password +``` + +When the `--password` flag is used, you'll be prompted to enter and confirm a password. Otherwise, keys are encrypted with a default password (not stored in plain text). + +#### Listing Keys + +View all keys in your wallet: + +```bash +wharfkit wallet keys +``` + +Output example: +``` +Found 2 key(s) in wallet: + +1. default + Public Key: PUB_K1_5TXDWwucfSa9Ghh49di3vxthzUcLSDE5yuxEMCJvw29Jpjq4mp + Created: 11/10/2025, 10:25:34 PM + +2. mykey + Public Key: PUB_K1_8KL3xG2WPZQAVd1ze2eY3Fgzr9DWF9hDhjusYgPegfHeNQDeF1 + Created: 11/10/2025, 10:25:47 PM +``` + +#### Creating Additional Keys + +Create additional keys in your wallet: + +```bash +# With auto-generated name +wharfkit wallet keys create + +# With custom name +wharfkit wallet keys create --name production + +# With custom password +wharfkit wallet keys create --name production --password +``` + +#### Signing Transactions + +Sign a transaction using a key from your wallet: + +```bash +# Sign with default key (uses 'default' key or first available) +wharfkit wallet sign transaction.json + +# Sign with specific key +wharfkit wallet sign transaction.json --key mykey + +# Sign with password-protected key +wharfkit wallet sign transaction.json --key production --password + +# Save signed transaction to file +wharfkit wallet sign transaction.json --output signed.json +``` + +The transaction can be provided as: +- A path to a JSON file containing the transaction +- A JSON string directly on the command line + +Example transaction format: +```json +{ + "expiration": "2025-11-11T00:00:00", + "ref_block_num": 12345, + "ref_block_prefix": 67890, + "max_net_usage_words": 0, + "max_cpu_usage_ms": 0, + "delay_sec": 0, + "context_free_actions": [], + "actions": [ + { + "account": "eosio.token", + "name": "transfer", + "authorization": [ + { + "actor": "testaccount", + "permission": "active" + } + ], + "data": "..." + } + ], + "transaction_extensions": [] +} +``` + +#### Security Notes + +- All keys are stored encrypted in `~/.wharfkit/wallet/keys.json` +- Keys are **never** stored in plain text +- If no password is provided, a default encryption password is used +- For production use, always use custom passwords with the `--password` flag +- The wallet file has restrictive permissions (0600) to prevent unauthorized access + ### Generating Contract Code The cli tool is capable of generating Typescript code based on a deployed smart contract for use in your application. diff --git a/src/commands/wallet/create.ts b/src/commands/wallet/create.ts new file mode 100644 index 0000000..e415abb --- /dev/null +++ b/src/commands/wallet/create.ts @@ -0,0 +1,117 @@ +import {KeyType, PrivateKey} from '@wharfkit/antelope' +import {log} from '../../utils' +import {addKeyToWallet, generateDefaultKeyName} from './utils' +import * as readline from 'readline' + +interface CreateOptions { + name?: string + password?: boolean +} + +/** + * Prompt for password from stdin + */ +async function promptForPassword(prompt: string): Promise { + return new Promise((resolve) => { + const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout, + }) + + // Hide password input + const stdin = process.stdin + ;(stdin as any).setRawMode?.(true) + + let password = '' + + process.stdout.write(prompt) + + stdin.on('data', (char) => { + const charStr = char.toString() + + if (charStr === '\n' || charStr === '\r' || charStr === '\u0004') { + // Enter or Ctrl+D + ;(stdin as any).setRawMode?.(false) + stdin.pause() + process.stdout.write('\n') + rl.close() + resolve(password) + } else if (charStr === '\u0003') { + // Ctrl+C + ;(stdin as any).setRawMode?.(false) + stdin.pause() + process.stdout.write('\n') + rl.close() + process.exit(0) + } else if (charStr === '\u007f') { + // Backspace + if (password.length > 0) { + password = password.slice(0, -1) + process.stdout.write('\b \b') + } + } else { + password += charStr + process.stdout.write('*') + } + }) + }) +} + +/** + * Get password from user or return undefined + */ +async function getPassword(usePassword: boolean): Promise { + if (!usePassword) { + return undefined + } + + const password = await promptForPassword('Enter password: ') + if (!password) { + throw new Error('Password cannot be empty') + } + + const confirm = await promptForPassword('Confirm password: ') + if (password !== confirm) { + throw new Error('Passwords do not match') + } + + return password +} + +/** + * Create a new wallet key + */ +export async function createWalletKey(options: CreateOptions): Promise { + try { + // Generate a new private key + const privateKey = PrivateKey.generate(KeyType.K1) + const publicKey = privateKey.toPublic() + + // Get password if requested + const password = await getPassword(!!options.password) + + // Determine key name + const keyName = options.name || generateDefaultKeyName() + + // Store the key + addKeyToWallet(privateKey, keyName, password) + + log('āœ… Key created successfully!', 'info') + log(`Name: ${keyName}`, 'info') + log(`Public Key: ${publicKey.toString()}`, 'info') + log(`Private Key: ${privateKey.toString()}`, 'info') + log('', 'info') + + if (!options.password) { + log( + 'āš ļø Note: Key is encrypted with default password. Use --password flag for custom password.', + 'info' + ) + } else { + log('šŸ”’ Key is encrypted with your custom password.', 'info') + } + } catch (error) { + log(`āŒ Failed to create key: ${(error as Error).message}`, 'info') + process.exit(1) + } +} diff --git a/src/commands/wallet/index.ts b/src/commands/wallet/index.ts new file mode 100644 index 0000000..6b4a1d2 --- /dev/null +++ b/src/commands/wallet/index.ts @@ -0,0 +1,57 @@ +import {Command} from 'commander' +import {createWalletKey} from './create' +import {createKey, listKeys} from './keys' +import {signTransaction} from './sign' + +/** + * Create the wallet command with subcommands + */ +export function createWalletCommand(): Command { + const walletCommand = new Command('wallet') + walletCommand.description('Manage local wallet and sign transactions') + + // wallet create - Create a new wallet key + walletCommand + .command('create') + .description('Create a new wallet key') + .option('-n, --name ', 'Name for the key (default: auto-generated)') + .option('-p, --password', 'Prompt for a password to encrypt the key') + .action(async (options) => { + await createWalletKey(options) + }) + + // wallet keys - Manage keys + const keysCommand = new Command('keys') + keysCommand.description('Manage wallet keys') + + // wallet keys (no subcommand) - List all keys + keysCommand.action(() => { + listKeys() + }) + + // wallet keys create - Create a new key + keysCommand + .command('create') + .description('Create a new key in the wallet') + .option('-n, --name ', 'Name for the key (default: auto-generated)') + .option('-p, --password', 'Prompt for a password to encrypt the key') + .action(async (options) => { + await createKey(options) + }) + + walletCommand.addCommand(keysCommand) + + // wallet sign - Sign a transaction + walletCommand + .command('sign') + .description('Sign a transaction with a key from the wallet') + .argument('', 'Transaction JSON string or path to JSON file') + .option('-k, --key ', 'Name or public key of the key to use for signing') + .option('-p, --password', 'Prompt for password if key is encrypted with custom password') + .option('-o, --output ', 'Output file path for signed transaction (default: stdout)') + .action(async (transaction, options) => { + await signTransaction(transaction, options) + }) + + return walletCommand +} diff --git a/src/commands/wallet/keys.ts b/src/commands/wallet/keys.ts new file mode 100644 index 0000000..1954d3e --- /dev/null +++ b/src/commands/wallet/keys.ts @@ -0,0 +1,149 @@ +import {KeyType, PrivateKey} from '@wharfkit/antelope' +import {log} from '../../utils' +import {addKeyToWallet, generateDefaultKeyName, listWalletKeys} from './utils' +import * as readline from 'readline' + +interface KeysCreateOptions { + name?: string + password?: boolean +} + +/** + * Prompt for password from stdin + */ +async function promptForPassword(prompt: string): Promise { + return new Promise((resolve) => { + const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout, + }) + + // Hide password input + const stdin = process.stdin + ;(stdin as any).setRawMode?.(true) + + let password = '' + + process.stdout.write(prompt) + + stdin.on('data', (char) => { + const charStr = char.toString() + + if (charStr === '\n' || charStr === '\r' || charStr === '\u0004') { + // Enter or Ctrl+D + ;(stdin as any).setRawMode?.(false) + stdin.pause() + process.stdout.write('\n') + rl.close() + resolve(password) + } else if (charStr === '\u0003') { + // Ctrl+C + ;(stdin as any).setRawMode?.(false) + stdin.pause() + process.stdout.write('\n') + rl.close() + process.exit(0) + } else if (charStr === '\u007f') { + // Backspace + if (password.length > 0) { + password = password.slice(0, -1) + process.stdout.write('\b \b') + } + } else { + password += charStr + process.stdout.write('*') + } + }) + }) +} + +/** + * Get password from user or return undefined + */ +async function getPassword(usePassword: boolean): Promise { + if (!usePassword) { + return undefined + } + + const password = await promptForPassword('Enter password: ') + if (!password) { + throw new Error('Password cannot be empty') + } + + const confirm = await promptForPassword('Confirm password: ') + if (password !== confirm) { + throw new Error('Passwords do not match') + } + + return password +} + +/** + * List all keys in the wallet + */ +export function listKeys(): void { + try { + const keys = listWalletKeys() + + if (keys.length === 0) { + log('No keys found in wallet.', 'info') + log('Create a new key with: wharfkit wallet keys create', 'info') + return + } + + log(`Found ${keys.length} key(s) in wallet:\n`, 'info') + + keys.forEach((key, index) => { + log(`${index + 1}. ${key.name}`, 'info') + log(` Public Key: ${key.publicKey}`, 'info') + log(` Created: ${new Date(key.createdAt).toLocaleString()}`, 'info') + if (index < keys.length - 1) { + log('', 'info') + } + }) + } catch (error) { + log(`āŒ Failed to list keys: ${(error as Error).message}`, 'info') + process.exit(1) + } +} + +/** + * Create a new key in the wallet + */ +export async function createKey(options: KeysCreateOptions): Promise { + try { + // Generate a new private key + const privateKey = PrivateKey.generate(KeyType.K1) + const publicKey = privateKey.toPublic() + + // Get password if requested + const password = await getPassword(!!options.password) + + // Determine key name + const keyName = options.name || generateDefaultKeyName() + + // Store the key + addKeyToWallet(privateKey, keyName, password) + + log('āœ… Key created successfully!', 'info') + log(`Name: ${keyName}`, 'info') + log(`Public Key: ${publicKey.toString()}`, 'info') + log(`Private Key: ${privateKey.toString()}`, 'info') + log('', 'info') + + if (!options.password) { + log( + 'āš ļø Note: Key is encrypted with default password. Use --password flag for custom password.', + 'info' + ) + } else { + log('šŸ”’ Key is encrypted with your custom password.', 'info') + } + + log('', 'info') + log('šŸ’” To view all keys: wharfkit wallet keys', 'info') + } catch (error) { + log(`āŒ Failed to create key: ${(error as Error).message}`, 'info') + process.exit(1) + } +} diff --git a/src/commands/wallet/sign.ts b/src/commands/wallet/sign.ts new file mode 100644 index 0000000..ad7cd4e --- /dev/null +++ b/src/commands/wallet/sign.ts @@ -0,0 +1,205 @@ +import {Checksum256, SignedTransaction, Transaction} from '@wharfkit/antelope' +import {log} from '../../utils' +import {getKeyFromWallet, listWalletKeys} from './utils' +import * as readline from 'readline' +import * as fs from 'fs' + +interface SignOptions { + key?: string + password?: boolean + output?: string +} + +/** + * Prompt for password from stdin + */ +async function promptForPassword(prompt: string): Promise { + return new Promise((resolve) => { + const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout, + }) + + // Hide password input + const stdin = process.stdin + ;(stdin as any).setRawMode?.(true) + + let password = '' + + process.stdout.write(prompt) + + stdin.on('data', (char) => { + const charStr = char.toString() + + if (charStr === '\n' || charStr === '\r' || charStr === '\u0004') { + // Enter or Ctrl+D + ;(stdin as any).setRawMode?.(false) + stdin.pause() + process.stdout.write('\n') + rl.close() + resolve(password) + } else if (charStr === '\u0003') { + // Ctrl+C + ;(stdin as any).setRawMode?.(false) + stdin.pause() + process.stdout.write('\n') + rl.close() + process.exit(0) + } else if (charStr === '\u007f') { + // Backspace + if (password.length > 0) { + password = password.slice(0, -1) + process.stdout.write('\b \b') + } + } else { + password += charStr + process.stdout.write('*') + } + }) + }) +} + +/** + * Get password from user or return undefined + */ +async function getPassword(usePassword: boolean): Promise { + if (!usePassword) { + return undefined + } + + const password = await promptForPassword('Enter password: ') + if (!password) { + throw new Error('Password cannot be empty') + } + + return password +} + +/** + * Load transaction from JSON file or string + */ +function loadTransaction(transactionJson: string): Transaction { + let transactionData: any + + try { + // Try to read as file first + if (fs.existsSync(transactionJson)) { + const fileContent = fs.readFileSync(transactionJson, 'utf8') + transactionData = JSON.parse(fileContent) + } else { + // Try to parse as JSON string + transactionData = JSON.parse(transactionJson) + } + } catch (error) { + throw new Error( + `Failed to load transaction: ${(error as Error).message}. ` + + 'Provide either a JSON file path or a JSON string.' + ) + } + + try { + return Transaction.from(transactionData) + } catch (error) { + throw new Error(`Invalid transaction format: ${(error as Error).message}`) + } +} + +/** + * Select a key from the wallet + */ +function selectKey(keyName?: string): string { + const keys = listWalletKeys() + + if (keys.length === 0) { + throw new Error('No keys found in wallet. Create one with: wharfkit wallet keys create') + } + + if (keyName) { + const key = keys.find((k) => k.name === keyName || k.publicKey === keyName) + if (!key) { + throw new Error(`Key "${keyName}" not found in wallet`) + } + return key.name + } + + // If only one key, use it + if (keys.length === 1) { + return keys[0].name + } + + // If multiple keys and no key specified, use 'default' if it exists + const defaultKey = keys.find((k) => k.name === 'default') + if (defaultKey) { + return defaultKey.name + } + + // Otherwise, use the first key + return keys[0].name +} + +/** + * Sign a transaction + */ +export async function signTransaction( + transactionJson: string, + options: SignOptions +): Promise { + try { + // Load the transaction + const transaction = loadTransaction(transactionJson) + + log('Transaction loaded:', 'info') + log(JSON.stringify(transaction, null, 2), 'info') + log('', 'info') + + // Select the key to use + const keyName = selectKey(options.key) + log(`Using key: ${keyName}`, 'info') + + // Get password if needed + const password = await getPassword(!!options.password) + + // Load the private key + const privateKey = getKeyFromWallet(keyName, password) + + // Create chain ID (you might want to make this configurable) + // For now, we'll use a placeholder. In a real scenario, this should come from + // the transaction data or be specified by the user + const chainId = Checksum256.from( + transaction.ref_block_num + ? '73e4385a2708e6d7048834fbc1079f2fabb17b3c125b146af438971e90716c4d' // EOS mainnet as default + : '0000000000000000000000000000000000000000000000000000000000000000' + ) + + // Sign the transaction + const digest = transaction.signingDigest(chainId) + const signature = privateKey.signDigest(digest) + + // Create signed transaction + const signedTransaction = SignedTransaction.from({ + ...transaction, + signatures: [signature], + }) + + log('āœ… Transaction signed successfully!', 'info') + log('', 'info') + + const output = JSON.stringify(signedTransaction, null, 2) + + if (options.output) { + // Save to file + fs.writeFileSync(options.output, output, 'utf8') + log(`Signed transaction saved to: ${options.output}`, 'info') + } else { + // Print to stdout + log('Signed Transaction:', 'info') + log(output, 'info') + } + + log('', 'info') + log(`Signature: ${signature.toString()}`, 'info') + } catch (error) { + log(`āŒ Failed to sign transaction: ${(error as Error).message}`, 'info') + process.exit(1) + } +} diff --git a/src/commands/wallet/utils.ts b/src/commands/wallet/utils.ts new file mode 100644 index 0000000..c9928df --- /dev/null +++ b/src/commands/wallet/utils.ts @@ -0,0 +1,228 @@ +import * as crypto from 'crypto' +import * as fs from 'fs' +import * as path from 'path' +import * as os from 'os' +import {PrivateKey} from '@wharfkit/antelope' + +// Default password for encryption when user doesn't provide one +const DEFAULT_PASSWORD = 'wharfkit-default-encryption-key-do-not-use-in-production' + +// Encryption algorithm +const ALGORITHM = 'aes-256-gcm' +const KEY_LENGTH = 32 +const IV_LENGTH = 16 +const SALT_LENGTH = 32 +const TAG_LENGTH = 16 + +export interface StoredKey { + name: string + publicKey: string + encryptedPrivateKey: string + createdAt: string +} + +export interface WalletData { + keys: StoredKey[] +} + +/** + * Get the wallet directory path + */ +export function getWalletDir(): string { + return path.join(os.homedir(), '.wharfkit', 'wallet') +} + +/** + * Get the wallet file path + */ +export function getWalletFilePath(): string { + return path.join(getWalletDir(), 'keys.json') +} + +/** + * Ensure wallet directory exists + */ +export function ensureWalletDir(): void { + const dir = getWalletDir() + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, {recursive: true, mode: 0o700}) + } +} + +/** + * Derive a key from password using PBKDF2 + */ +function deriveKey(password: string, salt: Buffer): Buffer { + return crypto.pbkdf2Sync(password, salt, 100000, KEY_LENGTH, 'sha256') +} + +/** + * Encrypt a private key + */ +export function encryptPrivateKey(privateKey: string, password?: string): string { + const pwd = password || DEFAULT_PASSWORD + const salt = crypto.randomBytes(SALT_LENGTH) + const key = deriveKey(pwd, salt) + const iv = crypto.randomBytes(IV_LENGTH) + + const cipher = crypto.createCipheriv(ALGORITHM, key, iv) + const encrypted = Buffer.concat([cipher.update(privateKey, 'utf8'), cipher.final()]) + const tag = cipher.getAuthTag() + + // Combine salt + iv + tag + encrypted data + const result = Buffer.concat([salt, iv, tag, encrypted]) + return result.toString('base64') +} + +/** + * Decrypt a private key + */ +export function decryptPrivateKey(encryptedData: string, password?: string): string { + const pwd = password || DEFAULT_PASSWORD + const buffer = Buffer.from(encryptedData, 'base64') + + // Extract salt, iv, tag, and encrypted data + const salt = buffer.subarray(0, SALT_LENGTH) + const iv = buffer.subarray(SALT_LENGTH, SALT_LENGTH + IV_LENGTH) + const tag = buffer.subarray(SALT_LENGTH + IV_LENGTH, SALT_LENGTH + IV_LENGTH + TAG_LENGTH) + const encrypted = buffer.subarray(SALT_LENGTH + IV_LENGTH + TAG_LENGTH) + + const key = deriveKey(pwd, salt) + const decipher = crypto.createDecipheriv(ALGORITHM, key, iv) + decipher.setAuthTag(tag) + + try { + const decrypted = Buffer.concat([decipher.update(encrypted), decipher.final()]) + return decrypted.toString('utf8') + } catch (error) { + throw new Error('Failed to decrypt private key. Invalid password.') + } +} + +/** + * Load wallet data from file + */ +export function loadWalletData(): WalletData { + const filePath = getWalletFilePath() + if (!fs.existsSync(filePath)) { + return {keys: []} + } + + try { + const data = fs.readFileSync(filePath, 'utf8') + return JSON.parse(data) as WalletData + } catch (error) { + throw new Error(`Failed to load wallet data: ${(error as Error).message}`) + } +} + +/** + * Save wallet data to file + */ +export function saveWalletData(data: WalletData): void { + ensureWalletDir() + const filePath = getWalletFilePath() + + try { + fs.writeFileSync(filePath, JSON.stringify(data, null, 2), {mode: 0o600}) + } catch (error) { + throw new Error(`Failed to save wallet data: ${(error as Error).message}`) + } +} + +/** + * Add a key to the wallet + */ +export function addKeyToWallet(privateKey: PrivateKey, name: string, password?: string): StoredKey { + const walletData = loadWalletData() + + // Check if key already exists + const publicKey = privateKey.toPublic().toString() + const existingKey = walletData.keys.find((k) => k.publicKey === publicKey) + if (existingKey) { + throw new Error(`Key with public key ${publicKey} already exists as "${existingKey.name}"`) + } + + // Check if name already exists + const nameExists = walletData.keys.find((k) => k.name === name) + if (nameExists) { + throw new Error(`Key with name "${name}" already exists`) + } + + // Encrypt and store the key + const encryptedPrivateKey = encryptPrivateKey(privateKey.toString(), password) + const storedKey: StoredKey = { + name, + publicKey, + encryptedPrivateKey, + createdAt: new Date().toISOString(), + } + + walletData.keys.push(storedKey) + saveWalletData(walletData) + + return storedKey +} + +/** + * Get a key from the wallet by name or public key + */ +export function getKeyFromWallet(nameOrPublicKey: string, password?: string): PrivateKey { + const walletData = loadWalletData() + + const storedKey = walletData.keys.find( + (k) => k.name === nameOrPublicKey || k.publicKey === nameOrPublicKey + ) + + if (!storedKey) { + throw new Error(`Key "${nameOrPublicKey}" not found in wallet`) + } + + const decryptedPrivateKey = decryptPrivateKey(storedKey.encryptedPrivateKey, password) + return PrivateKey.from(decryptedPrivateKey) +} + +/** + * List all keys in the wallet + */ +export function listWalletKeys(): StoredKey[] { + const walletData = loadWalletData() + return walletData.keys +} + +/** + * Remove a key from the wallet + */ +export function removeKeyFromWallet(nameOrPublicKey: string): void { + const walletData = loadWalletData() + + const index = walletData.keys.findIndex( + (k) => k.name === nameOrPublicKey || k.publicKey === nameOrPublicKey + ) + + if (index === -1) { + throw new Error(`Key "${nameOrPublicKey}" not found in wallet`) + } + + walletData.keys.splice(index, 1) + saveWalletData(walletData) +} + +/** + * Generate a default key name + */ +export function generateDefaultKeyName(): string { + const walletData = loadWalletData() + const existingNames = walletData.keys.map((k) => k.name) + + // Try default, key1, key2, etc. + if (!existingNames.includes('default')) { + return 'default' + } + + let i = 1 + while (existingNames.includes(`key${i}`)) { + i++ + } + return `key${i}` +} diff --git a/src/index.ts b/src/index.ts index 57e5805..8307fc5 100644 --- a/src/index.ts +++ b/src/index.ts @@ -6,6 +6,7 @@ import {generateKeysFromCommand} from './commands/keys/index' import {createAccountFromCommand} from './commands/account/index' import {createChainCommand} from './commands/chain/index' import {createWharfkitCommand} from './commands/wharfkit/index' +import {createWalletCommand} from './commands/wallet/index' const program = new Command() @@ -53,4 +54,7 @@ program.addCommand(createChainCommand()) // 5. Command to compile contracts program.addCommand(createWharfkitCommand()) +// 6. Command to manage wallet +program.addCommand(createWalletCommand()) + program.parse(process.argv) From 4d9d51fc4cc3b908ea216521fae1795bfaa46e84 Mon Sep 17 00:00:00 2001 From: dafuga Date: Tue, 11 Nov 2025 14:16:26 -0800 Subject: [PATCH 04/36] chore: added e2e tests --- README.md | 167 +++++++++++++++++++- package.json | 2 + src/commands/account/index.ts | 138 ----------------- src/commands/wallet/account.ts | 175 +++++++++++++++++++++ src/commands/wallet/index.ts | 17 +++ src/commands/wharfkit/compile.ts | 7 +- src/commands/wharfkit/deploy.ts | 207 +++++++++++++++++++++++++ src/commands/wharfkit/dev.ts | 156 +++++++++++++++++++ src/commands/wharfkit/index.ts | 69 +++++++-- src/index.ts | 34 ++--- test/tests/e2e-workflow.ts | 239 +++++++++++++++++++++++++++++ test/tests/wallet.ts | 255 +++++++++++++++++++++++++++++++ yarn.lock | 38 ++++- 13 files changed, 1318 insertions(+), 186 deletions(-) delete mode 100644 src/commands/account/index.ts create mode 100644 src/commands/wallet/account.ts create mode 100644 src/commands/wharfkit/deploy.ts create mode 100644 src/commands/wharfkit/dev.ts create mode 100644 test/tests/e2e-workflow.ts create mode 100644 test/tests/wallet.ts diff --git a/README.md b/README.md index 40fda58..03f6992 100644 --- a/README.md +++ b/README.md @@ -35,17 +35,51 @@ Options: Commands: keys Generate a new set of public and private keys - account [options] Create a new account with an optional public key generate [options] Generate Contract Kit code for the named smart contract chain Manage local LEAP blockchain - wharfkit Compile C++ contract files - wallet Manage local wallet and sign transactions + compile [options] [file] Compile C++ contract files + deploy [options] [wasm] Deploy a compiled contract to the blockchain + dev [options] Start local chain and watch for changes + wallet Manage wallet, accounts, and sign transactions help [command] display help for command ``` +### Creating Accounts + +Create new accounts on the local blockchain (requires local chain to be running). + +#### Create Account with Auto-generated Name + +```bash +wharfkit wallet account create +``` + +This will: +1. Generate a random 12-character account name +2. Generate a new key pair for the account +3. Create the account on the blockchain using the dev keys +4. Allocate RAM (8192 bytes) and stake CPU/NET (1.0000 SYS each) +5. Automatically store the private key in your wallet with the account name + +#### Create Account with Custom Name + +```bash +wharfkit wallet account create --name mycontract +``` + +#### Create Account on Remote Chain + +```bash +wharfkit wallet account create --url https://jungle4.greymass.com --name myaccount +``` + +**Note:** The account creation command uses Wharf Session Kit and automatically stores the generated key in your wallet with the account name. This makes it seamless to deploy contracts later - just use `wharfkit deploy --account mycontract` and it will automatically find and use the right key! + ### Managing Wallet Keys -The CLI includes a secure wallet system for managing private keys and signing transactions locally. +The CLI includes a secure wallet system for managing private keys, creating accounts, and signing transactions locally. + +The `wallet` command is your central hub for all key and account management. #### Creating Keys @@ -200,17 +234,17 @@ wharfkit chain local start **Compile a single file:** ```bash -wharfkit wharfkit compile mycontract.cpp +wharfkit compile mycontract.cpp ``` **Compile all .cpp files in the current directory:** ```bash -wharfkit wharfkit compile +wharfkit compile ``` **Specify output directory:** ```bash -wharfkit wharfkit compile mycontract.cpp -o ./build +wharfkit compile mycontract.cpp -o ./build ``` #### Output @@ -219,7 +253,7 @@ By default, compiled WASM files are output to the current directory. You can spe For example: ```bash -wharfkit wharfkit compile -o ./build +wharfkit compile -o ./build ``` This will compile all .cpp files in the current directory and save the resulting .wasm files to the `./build` directory. @@ -231,6 +265,123 @@ This will compile all .cpp files in the current directory and save the resulting -h, --help display help for command ``` +### Deploying Contracts + +Deploy compiled smart contracts to a blockchain. + +#### Deploy a Specific WASM File + +```bash +wharfkit deploy mycontract.wasm +``` + +The command will automatically look for the corresponding `.abi` file alongside the WASM file. + +#### Deploy with Custom Account Name + +```bash +wharfkit deploy mycontract.wasm --account myaccount +``` + +#### Deploy to a Specific Blockchain + +```bash +# Deploy to local chain (default) +wharfkit deploy mycontract.wasm + +# Deploy to a remote chain +wharfkit deploy mycontract.wasm --url https://jungle4.greymass.com +``` + +#### Auto-detect WASM File + +If you have only one `.wasm` file in your current directory, you can omit the filename: + +```bash +wharfkit deploy +``` + +#### Deploy Options + +``` +-a, --account Contract account name (default: derived from filename) +-u, --url Blockchain API URL (default: http://127.0.0.1:8888) +-h, --help display help for command +``` + +**Note:** Deploy uses Wharf Session Kit with your wallet keys. The deployment key is automatically selected: +1. First, tries to find a wallet key with the same name as the account +2. Falls back to the 'default' key if available +3. Uses the first available key as a last resort + +To ensure smooth deployment, create an account first with `wharfkit wallet account create`, which automatically stores the key in your wallet. + +### Development Mode + +The `dev` command provides a complete development workflow: it starts a local blockchain and automatically compiles and deploys your contract whenever you make changes to your C++ files. + +#### Start Development Mode + +```bash +wharfkit dev +``` + +This will: +1. āœ… Start a local LEAP blockchain (if not already running) +2. āœ… Compile all `.cpp` files in the current directory +3. āœ… Deploy the compiled contract using Wharf Session Kit +4. āœ… Watch for changes to `.cpp`, `.hpp`, and `.h` files +5. āœ… Automatically recompile and redeploy on changes + +#### Development Mode with Options + +```bash +# Start with a clean blockchain state +wharfkit dev --clean + +# Specify contract account name +wharfkit dev --account mycontract + +# Use a custom port +wharfkit dev --port 9000 + +# Combine options +wharfkit dev --clean --account mycontract --port 9000 +``` + +#### Dev Mode Options + +``` +-a, --account Contract account name (default: derived from filename) +-p, --port Port for local blockchain (default: 8888) +-c, --clean Start with a clean blockchain state +-h, --help display help for command +``` + +#### Development Workflow + +The typical development workflow is: + +1. Navigate to your contract directory: + ```bash + cd my-contract + ``` + +2. Start development mode: + ```bash + wharfkit dev + ``` + +3. Edit your contract files (`.cpp`, `.hpp`) + - The contract will automatically recompile and redeploy + - Watch the console for compilation and deployment status + +4. Test your contract using `cleos` or your preferred tools + +5. Stop development mode with `Ctrl+C` + +**Note:** Development mode is designed for rapid iteration during development. For production deployments, use `wharfkit compile` followed by `wharfkit deploy` with appropriate production settings. + ### Managing a Local Blockchain The CLI includes tools to quickly set up and manage a local LEAP blockchain for development and testing. diff --git a/package.json b/package.json index 165124b..a336db0 100644 --- a/package.json +++ b/package.json @@ -28,6 +28,8 @@ "@wharfkit/antelope": "^1.0.0", "@wharfkit/common": "^1.2.4", "@wharfkit/contract": "^1.1.4", + "@wharfkit/session": "^1.6.1", + "@wharfkit/wallet-plugin-privatekey": "^1.1.0", "commander": "^11.0.0", "eslint": "^8.48.0", "node-fetch": "^2.6.1", diff --git a/src/commands/account/index.ts b/src/commands/account/index.ts deleted file mode 100644 index 8dee0ac..0000000 --- a/src/commands/account/index.ts +++ /dev/null @@ -1,138 +0,0 @@ -import type {PublicKeyType} from '@wharfkit/antelope' -import {KeyType, type NameType, PrivateKey} from '@wharfkit/antelope' -import {type ChainDefinition, type ChainIndices, Chains} from '@wharfkit/common' -import fetch from 'node-fetch' - -import {log, makeClient} from '../../utils' - -interface CommandOptions { - key?: PublicKeyType - name?: NameType - chain?: ChainIndices -} - -const supportedChains = ['Jungle4', 'KylinTestnet'] - -export async function createAccountFromCommand(options: CommandOptions) { - let publicKey - let privateKey - - if (options.chain && !supportedChains.includes(options.chain)) { - log( - `Unsupported chain "${options.chain}". Supported chains are: ${supportedChains.join( - ', ' - )}`, - 'info' - ) - return - } - - const chainIndex: ChainIndices = options.chain || 'Jungle4' - const chainDefinition: ChainDefinition = Chains[chainIndex] - - // Default to "jungle4" if no chain option is provided - const chainUrl = chainDefinition - ? chainDefinition.url - : `http://${chainIndex.toLowerCase()}.greymass.com` - - if (options.name) { - if (!String(options.name).endsWith('.gm')) { - log('Account name must end with ".gm"', 'info') - return - } - - if (options.name && (String(options.name).length > 12 || String(options.name).length < 3)) { - log('Account name must be between 3 and 12 characters long', 'info') - return - } - - const accountNameExists = - options.name && (await checkAccountNameExists(options.name, chainUrl)) - - if (accountNameExists) { - log( - `Account name "${options.name}" is already taken. Please choose another name.`, - 'info' - ) - return - } - } - - // Generate a random account name if not provided - const accountName = options.name || generateRandomAccountName() - - try { - // Check if a public key is provided in the options - if (options.key) { - publicKey = String(options.key) - } else { - // Generate a new private key if none is provided - privateKey = PrivateKey.generate(KeyType.K1) - // Derive the corresponding public key - publicKey = String(privateKey.toPublic()) - } - - // Prepare the data for the POST request - const data = { - accountName: accountName, - activeKey: publicKey, - ownerKey: publicKey, - network: chainDefinition.id, - } - - // Make the POST request to create the account - const response = await fetch(`${chainUrl}/account/create`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify(data), - }) - - if (response.status === 201) { - log('Account created successfully!', 'info') - log(`Account Name: ${accountName}`, 'info') - if (privateKey) { - // Only print the private key if it was generated - log(`Private Key: ${privateKey.toString()}`, 'info') - } - log(`Public Key: ${publicKey}`, 'info') - } else { - const responseData = await response.json() - log(`Failed to create account: ${responseData.message || responseData.reason}`, 'info') - } - } catch (error: unknown) { - log(`Error during account creation: ${(error as {message: string}).message}`, 'info') - } -} - -function generateRandomAccountName(): string { - // Generate a random 12-character account name using the allowed characters for Antelope accounts - const characters = 'abcdefghijklmnopqrstuvwxyz12345' - let result = '' - for (let i = 0; i < 9; i++) { - result += characters.charAt(Math.floor(Math.random() * characters.length)) - } - return `${result}.gm` -} - -async function checkAccountNameExists(accountName: NameType, chainUrl: string): Promise { - const client = makeClient(chainUrl) - - try { - const account = await client.v1.chain.get_account(accountName) - - return !!account?.account_name - } catch (error: unknown) { - const errorMessage = (error as {message: string}).message - - if ( - errorMessage.includes('Account not found') || - errorMessage.includes('Account Query Exception') - ) { - return false - } - - throw Error(`Error checking if account name exists: ${errorMessage}`) - } -} diff --git a/src/commands/wallet/account.ts b/src/commands/wallet/account.ts new file mode 100644 index 0000000..dada09b --- /dev/null +++ b/src/commands/wallet/account.ts @@ -0,0 +1,175 @@ +/* eslint-disable no-console */ +import {APIClient, FetchProvider, KeyType, PrivateKey} from '@wharfkit/antelope' +import {Session} from '@wharfkit/session' +import {WalletPluginPrivateKey} from '@wharfkit/wallet-plugin-privatekey' +import fetch from 'node-fetch' +import {getDevKeys} from '../chain/utils' +import {addKeyToWallet} from './utils' + +interface AccountCreateOptions { + name?: string + url?: string +} + +export async function createAccount(options: AccountCreateOptions): Promise { + const url = options.url || 'http://127.0.0.1:8888' + + // Generate account name if not provided + const accountName = options.name || generateRandomAccountName() + + // Validate account name + if (accountName.length > 12 || accountName.length < 3) { + console.error('Account name must be between 3 and 12 characters long') + process.exit(1) + } + + console.log('Creating account on local chain...') + console.log(` Account: ${accountName}`) + console.log(` URL: ${url}`) + + try { + // Generate a new key pair for the account + const newPrivateKey = PrivateKey.generate(KeyType.K1) + const newPublicKey = newPrivateKey.toPublic() + + console.log(` Public Key: ${newPublicKey.toString()}`) + + // Get dev keys for signing the newaccount action + const devKeys = getDevKeys() + const devPrivateKey = PrivateKey.from(devKeys.privateKey) + + // Create API client + const client = new APIClient({ + provider: new FetchProvider(url, {fetch}), + }) + + // Get chain info + const info = await client.v1.chain.get_info() + + // Create session with dev key + const walletPlugin = new WalletPluginPrivateKey(devPrivateKey) + const session = new Session({ + chain: { + id: String(info.chain_id), + url, + }, + actor: 'eosio', + permission: 'active', + walletPlugin, + }) + + // Create newaccount action + const result = await session.transact( + { + actions: [ + { + account: 'eosio', + name: 'newaccount', + authorization: [ + { + actor: 'eosio', + permission: 'active', + }, + ], + data: { + creator: 'eosio', + name: accountName, + owner: { + threshold: 1, + keys: [ + { + key: newPublicKey, + weight: 1, + }, + ], + accounts: [], + waits: [], + }, + active: { + threshold: 1, + keys: [ + { + key: newPublicKey, + weight: 1, + }, + ], + accounts: [], + waits: [], + }, + }, + }, + { + account: 'eosio', + name: 'buyrambytes', + authorization: [ + { + actor: 'eosio', + permission: 'active', + }, + ], + data: { + payer: 'eosio', + receiver: accountName, + bytes: 8192, + }, + }, + { + account: 'eosio', + name: 'delegatebw', + authorization: [ + { + actor: 'eosio', + permission: 'active', + }, + ], + data: { + from: 'eosio', + receiver: accountName, + stake_net_quantity: '1.0000 SYS', + stake_cpu_quantity: '1.0000 SYS', + transfer: false, + }, + }, + ], + }, + { + broadcast: true, + } + ) + + console.log('\nāœ… Account created successfully!') + console.log(`Account: ${accountName}`) + console.log(`Private Key: ${newPrivateKey.toString()}`) + console.log(`Public Key: ${newPublicKey.toString()}`) + console.log(`Transaction ID: ${result.resolved?.transaction.id}`) + + // Store the key in wallet with account name + try { + addKeyToWallet(newPrivateKey, accountName) + console.log(`\nšŸ” Key stored in wallet as: ${accountName}`) + console.log( + 'You can now deploy contracts with: wharfkit deploy --account ' + accountName + ) + } catch (error) { + console.log( + '\nāš ļø Could not store key in wallet (may already exist): ' + + (error as Error).message + ) + } + } catch (error) { + console.error(`\nāŒ Failed to create account: ${(error as Error).message}`) + console.error('\nMake sure the local chain is running: wharfkit chain local start') + process.exit(1) + } +} + +function generateRandomAccountName(): string { + // Generate a random 12-character account name using the allowed characters for Antelope accounts + const characters = 'abcdefghijklmnopqrstuvwxyz12345' + let result = '' + for (let i = 0; i < 12; i++) { + result += characters.charAt(Math.floor(Math.random() * characters.length)) + } + return result +} + diff --git a/src/commands/wallet/index.ts b/src/commands/wallet/index.ts index 6b4a1d2..43f6487 100644 --- a/src/commands/wallet/index.ts +++ b/src/commands/wallet/index.ts @@ -1,4 +1,5 @@ import {Command} from 'commander' +import {createAccount} from './account' import {createWalletKey} from './create' import {createKey, listKeys} from './keys' import {signTransaction} from './sign' @@ -41,6 +42,22 @@ export function createWalletCommand(): Command { walletCommand.addCommand(keysCommand) + // wallet account - Manage accounts + const accountCommand = new Command('account') + accountCommand.description('Manage blockchain accounts') + + // wallet account create - Create a new account + accountCommand + .command('create') + .description('Create a new account on the blockchain') + .option('-n, --name ', 'Account name (default: auto-generated)') + .option('-u, --url ', 'Blockchain API URL (default: http://127.0.0.1:8888)') + .action(async (options) => { + await createAccount(options) + }) + + walletCommand.addCommand(accountCommand) + // wallet sign - Sign a transaction walletCommand .command('sign') diff --git a/src/commands/wharfkit/compile.ts b/src/commands/wharfkit/compile.ts index 6800a31..40e0087 100644 --- a/src/commands/wharfkit/compile.ts +++ b/src/commands/wharfkit/compile.ts @@ -1,7 +1,7 @@ /* eslint-disable no-console */ import {execSync} from 'child_process' import {existsSync, readdirSync} from 'fs' -import {join, resolve, basename, extname} from 'path' +import {basename, extname, join, resolve} from 'path' import {platform} from 'os' import {checkLeapInstallation} from '../chain/install' @@ -127,8 +127,9 @@ async function compileSingleFile(filePath: string, outputDir: string): Promise { + // Determine the WASM file to deploy + const wasmPath = wasmFile ? resolve(wasmFile) : await findWasmFile() + + if (!existsSync(wasmPath)) { + throw new Error(`WASM file not found: ${wasmPath}`) + } + + if (extname(wasmPath) !== '.wasm') { + throw new Error(`File must be a .wasm file: ${wasmPath}`) + } + + // Find the ABI file + const abiPath = wasmPath.replace('.wasm', '.abi') + if (!existsSync(abiPath)) { + throw new Error(`ABI file not found: ${abiPath}`) + } + + // Determine the contract account name + const accountName = options.account || basename(wasmPath, '.wasm') + + // Determine the blockchain URL + const url = options.url || 'http://127.0.0.1:8888' + + console.log(`Deploying contract...`) + console.log(` WASM: ${wasmPath}`) + console.log(` ABI: ${abiPath}`) + console.log(` Account: ${accountName}`) + console.log(` URL: ${url}`) + + try { + // Read WASM and ABI files + const wasmCode = readFileSync(wasmPath) + const abiJson = JSON.parse(readFileSync(abiPath, 'utf8')) + + // Get private key from wallet for this account + const privateKey = await getPrivateKeyForDeploy(accountName) + + // Create API client + const client = new APIClient({ + provider: new FetchProvider(url, {fetch}), + }) + + // Create session with private key wallet plugin + const walletPlugin = new WalletPluginPrivateKey(privateKey) + + const session = new Session({ + chain: { + id: await getChainId(client), + url, + }, + actor: accountName, + permission: 'active', + walletPlugin, + }) + + console.log('\nšŸš€ Deploying contract...') + + // Create setcode action + const setcodeAction = { + account: 'eosio', + name: 'setcode', + authorization: [ + { + actor: accountName, + permission: 'active', + }, + ], + data: { + account: accountName, + vmtype: 0, + vmversion: 0, + code: wasmCode.toString('hex'), + }, + } + + // Create setabi action + const setabiAction = { + account: 'eosio', + name: 'setabi', + authorization: [ + { + actor: accountName, + permission: 'active', + }, + ], + data: { + account: accountName, + abi: Serializer.encode({object: abiJson}).hexString, + }, + } + + // Transact both actions + const result = await session.transact( + { + actions: [setcodeAction, setabiAction], + }, + { + broadcast: true, + } + ) + + console.log('\nāœ… Contract deployed successfully!') + console.log(`Transaction ID: ${result.resolved?.transaction.id}`) + } catch (error) { + const errorMessage = (error as Error).message + throw new Error( + `Failed to deploy contract: ${errorMessage}\n\n` + + `Make sure:\n` + + `1. The blockchain is running (wharfkit chain local start)\n` + + `2. The account "${accountName}" exists\n` + + `3. You have a wallet key with permissions for this account\n` + + `4. The ABI file exists alongside the WASM file` + ) + } +} + +/** + * Get private key for deployment based on account name + */ +async function getPrivateKeyForDeploy(accountName: string): Promise { + const keys = listWalletKeys() + + if (keys.length === 0) { + throw new Error('No keys found in wallet. Create one with: wharfkit wallet create') + } + + // Try to find a key with the same name as the account + const accountKey = keys.find((k) => k.name === accountName) + if (accountKey) { + console.log(`Using wallet key: ${accountKey.name}`) + return getKeyFromWallet(accountName) + } + + // Otherwise, try 'default' key + const defaultKey = keys.find((k) => k.name === 'default') + if (defaultKey) { + console.log(`Using wallet key: default`) + return getKeyFromWallet('default') + } + + // Use first available key + console.log(`Using wallet key: ${keys[0].name}`) + return getKeyFromWallet(keys[0].name) +} + +/** + * Get chain ID from the API + */ +async function getChainId(client: APIClient): Promise { + try { + const info = await client.v1.chain.get_info() + return String(info.chain_id) + } catch (error) { + // Default to local chain ID if we can't get it + return '8a34ec7df1b8cd06ff4a8abbaa7cc50300823350cadc59ab296cb00d104d2b8f' + } +} + +/** + * Find a WASM file in the current directory + */ +async function findWasmFile(): Promise { + const currentDir = process.cwd() + + const wasmFiles = readdirSync(currentDir) + .filter((file) => extname(file) === '.wasm') + .map((file) => resolve(currentDir, file)) + + if (wasmFiles.length === 0) { + throw new Error( + 'No .wasm files found in current directory. Please specify a file or compile first with: wharfkit compile' + ) + } + + if (wasmFiles.length > 1) { + throw new Error( + `Multiple .wasm files found: ${wasmFiles.map((f) => basename(f)).join(', ')}\n` + + `Please specify which file to deploy.` + ) + } + + return wasmFiles[0] +} diff --git a/src/commands/wharfkit/dev.ts b/src/commands/wharfkit/dev.ts new file mode 100644 index 0000000..bf6948c --- /dev/null +++ b/src/commands/wharfkit/dev.ts @@ -0,0 +1,156 @@ +/* eslint-disable no-console */ +import {watch} from 'fs' +import {extname} from 'path' +import {compileContract} from './compile' +import {deployContract} from './deploy' +import {getChainStatus, startLocalChain, stopLocalChain} from '../chain/local' + +interface DevOptions { + account?: string + port?: number + clean?: boolean +} + +let isCompiling = false +const compileQueue: Set = new Set() +let debounceTimer: NodeJS.Timeout | null = null + +/** + * Start development mode: local chain + auto-compile + auto-deploy + * @param options - Development options + */ +export async function startDevMode(options: DevOptions): Promise { + const port = options.port || 8888 + const url = `http://127.0.0.1:${port}` + + console.log('šŸš€ Starting Wharf development mode...\n') + + // Check if chain is already running + const status = await getChainStatus() + + if (!status.running) { + console.log('šŸ“¦ Starting local blockchain...') + try { + await startLocalChain({ + port, + clean: options.clean || false, + }) + console.log('āœ… Local blockchain started\n') + + // Wait a bit for the chain to be fully ready + await new Promise((resolve) => setTimeout(resolve, 2000)) + } catch (error) { + console.error(`Failed to start local chain: ${(error as Error).message}`) + process.exit(1) + } + } else { + console.log('āœ… Local blockchain already running\n') + } + + // Do initial compile and deploy + console.log('šŸ”Ø Initial compilation and deployment...\n') + try { + await compileContract(undefined, '.') + await deployContract(undefined, {account: options.account, url}) + console.log('\nāœ… Initial deployment complete\n') + } catch (error) { + console.error(`Warning: Initial deployment failed: ${(error as Error).message}`) + console.log('Will retry on file changes...\n') + } + + // Start watching for file changes + console.log('šŸ‘€ Watching for file changes...') + console.log(' Press Ctrl+C to stop\n') + + await watchFiles(options.account, url) +} + +/** + * Watch for file changes and trigger recompile/redeploy + */ +async function watchFiles(account: string | undefined, url: string): Promise { + const currentDir = process.cwd() + watch(currentDir, {recursive: true}, (eventType, filename) => { + if (!filename) return + + // Only watch .cpp and .hpp files + const ext = extname(filename) + if (ext !== '.cpp' && ext !== '.hpp' && ext !== '.h') return + + // Ignore hidden files and node_modules + if (filename.startsWith('.') || filename.includes('node_modules')) return + + console.log(`\nšŸ“ File changed: ${filename}`) + + // Add to compile queue + compileQueue.add(filename) + + // Debounce: wait for 500ms of inactivity before compiling + if (debounceTimer) { + clearTimeout(debounceTimer) + } + + debounceTimer = setTimeout(async () => { + await handleFileChange(account, url) + }, 500) + }) + + // Keep the process running + await new Promise(() => { + // This promise never resolves, keeping the watcher active + }) +} + +/** + * Handle file changes: compile and deploy + */ +async function handleFileChange(account: string | undefined, url: string): Promise { + if (isCompiling) { + console.log('ā³ Compilation already in progress, queuing...') + return + } + + isCompiling = true + compileQueue.clear() + + try { + console.log('\nšŸ”Ø Compiling...') + await compileContract(undefined, '.') + + console.log('\nšŸš€ Deploying...') + await deployContract(undefined, {account, url}) + + console.log('\nāœ… Deploy complete!') + } catch (error) { + console.error(`\nāŒ Error: ${(error as Error).message}`) + } finally { + isCompiling = false + console.log('\nšŸ‘€ Watching for changes...') + } +} + +/** + * Stop development mode (cleanup) + */ +export async function stopDevMode(): Promise { + console.log('\n\nšŸ›‘ Stopping development mode...') + + // Stop the local chain + try { + await stopLocalChain() + console.log('āœ… Local blockchain stopped') + } catch (error) { + console.log('Note: Could not stop local chain (may not be running)') + } + + process.exit(0) +} + +// Handle Ctrl+C gracefully +process.on('SIGINT', async () => { + await stopDevMode() +}) + +process.on('SIGTERM', async () => { + await stopDevMode() +}) diff --git a/src/commands/wharfkit/index.ts b/src/commands/wharfkit/index.ts index 8fdb0bd..35d3b08 100644 --- a/src/commands/wharfkit/index.ts +++ b/src/commands/wharfkit/index.ts @@ -1,18 +1,19 @@ /* eslint-disable no-console */ import {Command} from 'commander' import {compileContract} from './compile' +import {deployContract} from './deploy' +import {startDevMode} from './dev' /** - * Create the wharfkit command with subcommands + * Create the compile command */ -export function createWharfkitCommand(): Command { - const wharfkit = new Command('wharfkit') - wharfkit.description('Wharf development utilities') - - // Compile subcommand - wharfkit - .command('compile [file]') - .description('Compile C++ contract files (single file or all .cpp files in current directory)') +export function createCompileCommand(): Command { + const compile = new Command('compile') + compile + .description( + 'Compile C++ contract files (single file or all .cpp files in current directory)' + ) + .argument('[file]', 'Optional file to compile (compiles all .cpp files if not specified)') .option('-o, --output ', 'Output directory for compiled WASM files', '.') .action(async (file, options) => { try { @@ -23,14 +24,54 @@ export function createWharfkitCommand(): Command { } }) - return wharfkit + return compile } /** - * Command handler for the wharfkit command (called from main CLI) + * Create the deploy command */ -export function wharfkitCommandHandler(): void { - const wharfkit = createWharfkitCommand() - wharfkit.parse(process.argv) +export function createDeployCommand(): Command { + const deploy = new Command('deploy') + deploy + .description('Deploy a compiled contract to the blockchain') + .argument('[wasm]', 'WASM file to deploy (auto-detects if not specified)') + .option('-a, --account ', 'Contract account name (default: derived from filename)') + .option('-u, --url ', 'Blockchain API URL (default: http://127.0.0.1:8888)') + .action(async (wasm, options) => { + try { + await deployContract(wasm, options) + } catch (error: any) { + console.error(`Error: ${error.message}`) + process.exit(1) + } + }) + + return deploy } +/** + * Create the dev command + */ +export function createDevCommand(): Command { + const dev = new Command('dev') + dev.description( + 'Start local chain and watch for changes (auto-compile and auto-deploy on file changes)' + ) + .option('-a, --account ', 'Contract account name (default: derived from filename)') + .option('-p, --port ', 'Port for local blockchain', '8888') + .option('-c, --clean', 'Start with a clean blockchain state') + .action(async (options) => { + try { + await startDevMode({ + account: options.account, + port: parseInt(options.port), + clean: options.clean, + }) + } catch (error: any) { + console.error(`Error: ${error.message}`) + process.exit(1) + } + }) + + return dev +} diff --git a/src/index.ts b/src/index.ts index 8307fc5..78c650a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -3,9 +3,8 @@ import {Command} from 'commander' import {version} from '../package.json' import {generateContractFromCommand} from './commands/contract' import {generateKeysFromCommand} from './commands/keys/index' -import {createAccountFromCommand} from './commands/account/index' import {createChainCommand} from './commands/chain/index' -import {createWharfkitCommand} from './commands/wharfkit/index' +import {createCompileCommand, createDeployCommand, createDevCommand} from './commands/wharfkit/index' import {createWalletCommand} from './commands/wallet/index' const program = new Command() @@ -18,22 +17,7 @@ program .description('Generate a new set of public and private keys') .action(generateKeysFromCommand) -// 2. Command to create an account -program - .command('account') - .description('Create a new account with an optional public key') - .option('-c, --chain [chain]', 'The chain to create the account on. Defaults to "jungle4".') - .option( - '-n, --name [name]', - 'Account name for the new account. Must end with ".gm". If not provided, a random name is generated.' - ) - .option( - '-k, --key [key]', - 'Public key for the new account. If not provided, keys are generated.' - ) - .action(createAccountFromCommand) - -// 3. Existing command to generate a contract +// 2. Existing command to generate a contract program .command('generate') .description('Generate Contract Kit code for the named smart contract') @@ -48,13 +32,19 @@ program ) .action(generateContractFromCommand) -// 4. Command to manage local blockchain +// 3. Command to manage local blockchain program.addCommand(createChainCommand()) -// 5. Command to compile contracts -program.addCommand(createWharfkitCommand()) +// 4. Command to compile contracts +program.addCommand(createCompileCommand()) + +// 5. Command to deploy contracts +program.addCommand(createDeployCommand()) + +// 6. Command for development mode +program.addCommand(createDevCommand()) -// 6. Command to manage wallet +// 7. Command to manage wallet (includes account creation) program.addCommand(createWalletCommand()) program.parse(process.argv) diff --git a/test/tests/e2e-workflow.ts b/test/tests/e2e-workflow.ts new file mode 100644 index 0000000..eebf5fc --- /dev/null +++ b/test/tests/e2e-workflow.ts @@ -0,0 +1,239 @@ +import {assert} from 'chai' +import {execSync} from 'child_process' +import * as fs from 'fs' +import * as path from 'path' +import * as os from 'os' + +/** + * E2E tests for the complete workflow: + * 1. Create wallet keys + * 2. Create accounts + * 3. Compile contracts + * 4. Deploy contracts + */ +suite('E2E Workflow', () => { + const cliPath = path.join(__dirname, '../../lib/cli.js') + let testDir: string + let testWalletDir: string + let originalHome: string + + setup(function () { + // Create a temporary test directory + testDir = path.join(os.tmpdir(), `wharfkit-e2e-test-${Date.now()}`) + fs.mkdirSync(testDir, {recursive: true}) + + // Create a temporary wallet directory for tests + testWalletDir = path.join(testDir, '.wharfkit', 'wallet') + fs.mkdirSync(testWalletDir, {recursive: true}) + + // Mock HOME to use test wallet directory + originalHome = process.env.HOME || '' + process.env.HOME = testDir + }) + + teardown(function () { + // Restore original HOME + process.env.HOME = originalHome + + // Clean up test directory + if (fs.existsSync(testDir)) { + fs.rmSync(testDir, {recursive: true, force: true}) + } + }) + + suite('Wallet Key Management', () => { + test('can create a wallet key', function () { + const output = execSync(`node ${cliPath} wallet create --name testkey`, { + encoding: 'utf8', + }) + + assert.include(output, 'āœ… Key created successfully!') + assert.include(output, 'Name: testkey') + assert.include(output, 'Public Key: PUB_K1_') + assert.include(output, 'Private Key: PVT_K1_') + }) + + test('can list wallet keys', function () { + // Create a key first + execSync(`node ${cliPath} wallet create --name listtest`, {encoding: 'utf8'}) + + // List keys + const output = execSync(`node ${cliPath} wallet keys`, {encoding: 'utf8'}) + + assert.include(output, 'listtest') + assert.include(output, 'Public Key:') + assert.include(output, 'Created:') + }) + + test('generates random key name when not specified', function () { + const output = execSync(`node ${cliPath} wallet keys create`, {encoding: 'utf8'}) + + assert.include(output, 'āœ… Key created successfully!') + // Should have a name (either 'default' or 'keyN') + assert.match(output, /Name: (default|key\d+)/) + }) + }) + + suite('Transaction Signing', () => { + test('can sign a transaction with wallet key', function () { + // Create a key first + execSync(`node ${cliPath} wallet create --name signtest`, {encoding: 'utf8'}) + + // Create test transaction + const transaction = { + expiration: '2025-11-11T00:00:00', + ref_block_num: 12345, + ref_block_prefix: 67890, + max_net_usage_words: 0, + max_cpu_usage_ms: 0, + delay_sec: 0, + context_free_actions: [], + actions: [ + { + account: 'eosio.token', + name: 'transfer', + authorization: [{actor: 'testaccount', permission: 'active'}], + data: '0000000000ea305500000000487a2b9d0100000000000000045359530000000007746573742074', + }, + ], + transaction_extensions: [], + } + + const txPath = path.join(testDir, 'transaction.json') + fs.writeFileSync(txPath, JSON.stringify(transaction)) + + // Sign the transaction + const output = execSync(`node ${cliPath} wallet sign ${txPath}`, {encoding: 'utf8'}) + + assert.include(output, 'āœ… Transaction signed successfully!') + assert.include(output, 'Signature: SIG_K1_') + assert.include(output, 'signatures') + }) + }) + + suite('Contract Compilation', () => { + test('shows helpful error when no cpp files found', function () { + try { + execSync(`node ${cliPath} compile`, { + encoding: 'utf8', + cwd: testDir, + }) + assert.fail('Should throw error when no cpp files found') + } catch (error: any) { + // Command should exit with error when no files found + assert.isTrue(error.status !== 0 || error.code !== 0) + } + }) + + test('can compile a cpp file when cdt is installed', function () { + // Create a simple contract + const contractCode = ` +#include + +class [[eosio::contract]] hello : public eosio::contract { + public: + using eosio::contract::contract; + + [[eosio::action]] + void hi(eosio::name user) { + print("Hello, ", user); + } +}; +` + const cppPath = path.join(testDir, 'hello.cpp') + fs.writeFileSync(cppPath, contractCode) + + try { + const output = execSync(`node ${cliPath} compile`, { + encoding: 'utf8', + cwd: testDir, + }) + + // Should either compile successfully or show CDT not installed + assert.isTrue( + output.includes('Compilation complete!') || + output.includes('cdt-cpp is not installed') || + output.includes('LEAP is not installed') + ) + } catch (error: any) { + // It's okay if CDT is not installed + const output = error.stderr || error.stdout + assert.isTrue( + output.includes('cdt-cpp is not installed') || + output.includes('LEAP is not installed') + ) + } + }) + }) + + suite('Command Structure', () => { + test('wallet command has correct subcommands', function () { + const output = execSync(`node ${cliPath} wallet --help`, {encoding: 'utf8'}) + + assert.include(output, 'create') + assert.include(output, 'keys') + assert.include(output, 'account') + assert.include(output, 'sign') + }) + + test('wallet account command has create subcommand', function () { + const output = execSync(`node ${cliPath} wallet account --help`, {encoding: 'utf8'}) + + assert.include(output, 'create') + assert.include(output, 'Create a new account on the blockchain') + }) + + test('deploy command is at top level', function () { + const output = execSync(`node ${cliPath} deploy --help`, {encoding: 'utf8'}) + + assert.include(output, 'Deploy a compiled contract') + assert.include(output, '--account') + assert.include(output, '--url') + assert.notInclude(output, '--key') + }) + + test('dev command is at top level', function () { + const output = execSync(`node ${cliPath} dev --help`, {encoding: 'utf8'}) + + assert.include(output, 'Start local chain and watch for changes') + assert.include(output, '--account') + assert.include(output, '--port') + assert.include(output, '--clean') + }) + + test('compile command is at top level', function () { + const output = execSync(`node ${cliPath} compile --help`, {encoding: 'utf8'}) + + assert.include(output, 'Compile C++ contract files') + assert.include(output, '--output') + }) + }) + + suite('Key Selection Logic', () => { + test('deploy uses account-named key if available', function () { + // This test verifies the key selection logic exists + // Actual deployment would require a running chain + const deployHelp = execSync(`node ${cliPath} deploy --help`, {encoding: 'utf8'}) + + // Verify --account option exists (used for key selection) + assert.include(deployHelp, '--account') + }) + }) + + suite('Integration: Wallet Key Storage', () => { + test('created keys are persisted in wallet', function () { + const keyName = `persistent-${Date.now()}` + + // Create a key + const createOutput = execSync(`node ${cliPath} wallet create --name ${keyName}`, { + encoding: 'utf8', + }) + assert.include(createOutput, keyName) + + // Verify it shows up in list + const listOutput = execSync(`node ${cliPath} wallet keys`, {encoding: 'utf8'}) + assert.include(listOutput, keyName) + }) + }) +}) + diff --git a/test/tests/wallet.ts b/test/tests/wallet.ts new file mode 100644 index 0000000..3e502b0 --- /dev/null +++ b/test/tests/wallet.ts @@ -0,0 +1,255 @@ +import {assert} from 'chai' +import * as fs from 'fs' +import * as path from 'path' +import * as os from 'os' +import {KeyType, PrivateKey, Transaction} from '@wharfkit/antelope' + +import { + decryptPrivateKey, + encryptPrivateKey, + generateDefaultKeyName, + getWalletDir, +} from 'src/commands/wallet/utils' + +suite('Wallet Utils', () => { + let testWalletDir: string + + setup(function () { + // Create a temporary test wallet directory + testWalletDir = path.join(os.tmpdir(), `wharfkit-test-wallet-${Date.now()}`) + fs.mkdirSync(testWalletDir, {recursive: true}) + + // Note: getWalletDir() is called here to verify it returns a valid path + getWalletDir() + }) + + teardown(function () { + // Clean up test wallet directory + if (fs.existsSync(testWalletDir)) { + fs.rmSync(testWalletDir, {recursive: true, force: true}) + } + }) + + suite('Encryption/Decryption', () => { + test('encrypts and decrypts a private key without password', () => { + const privateKey = PrivateKey.generate(KeyType.K1) + const privateKeyString = privateKey.toString() + + const encrypted = encryptPrivateKey(privateKeyString) + assert.isString(encrypted) + assert.notEqual(encrypted, privateKeyString) + + const decrypted = decryptPrivateKey(encrypted) + assert.equal(decrypted, privateKeyString) + }) + + test('encrypts and decrypts a private key with custom password', () => { + const privateKey = PrivateKey.generate(KeyType.K1) + const privateKeyString = privateKey.toString() + const password = 'my-secure-password' + + const encrypted = encryptPrivateKey(privateKeyString, password) + assert.isString(encrypted) + assert.notEqual(encrypted, privateKeyString) + + const decrypted = decryptPrivateKey(encrypted, password) + assert.equal(decrypted, privateKeyString) + }) + + test('fails to decrypt with wrong password', () => { + const privateKey = PrivateKey.generate(KeyType.K1) + const privateKeyString = privateKey.toString() + const password = 'correct-password' + const wrongPassword = 'wrong-password' + + const encrypted = encryptPrivateKey(privateKeyString, password) + + try { + decryptPrivateKey(encrypted, wrongPassword) + assert.fail('Should throw error with wrong password') + } catch (error) { + assert.include((error as Error).message, 'Failed to decrypt') + } + }) + + test('each encryption produces different ciphertext (random salt/iv)', () => { + const privateKey = PrivateKey.generate(KeyType.K1) + const privateKeyString = privateKey.toString() + + const encrypted1 = encryptPrivateKey(privateKeyString) + const encrypted2 = encryptPrivateKey(privateKeyString) + + // Different encrypted values due to random salt/iv + assert.notEqual(encrypted1, encrypted2) + + // But both decrypt to the same value + assert.equal(decryptPrivateKey(encrypted1), privateKeyString) + assert.equal(decryptPrivateKey(encrypted2), privateKeyString) + }) + }) + + suite('Wallet Data Management', () => { + test('loads empty wallet data when file does not exist', () => { + // Point to test directory + const testFilePath = path.join(testWalletDir, 'keys.json') + assert.isFalse(fs.existsSync(testFilePath)) + + // Since getWalletFilePath uses getWalletDir, we need to work with the actual path + // For this test, we'll just verify the data structure + const emptyData = {keys: []} + assert.deepEqual(emptyData, {keys: []}) + }) + + test('saves and loads wallet data', () => { + const testFilePath = path.join(testWalletDir, 'keys.json') + const walletData = { + keys: [ + { + name: 'test-key', + publicKey: 'PUB_K1_test', + encryptedPrivateKey: 'encrypted-data', + createdAt: new Date().toISOString(), + }, + ], + } + + // Save data + fs.writeFileSync(testFilePath, JSON.stringify(walletData, null, 2)) + + // Load data + const loaded = JSON.parse(fs.readFileSync(testFilePath, 'utf8')) + assert.deepEqual(loaded, walletData) + }) + + test('wallet file is created with secure permissions', () => { + const testFilePath = path.join(testWalletDir, 'keys.json') + const walletData = {keys: []} + + fs.writeFileSync(testFilePath, JSON.stringify(walletData), {mode: 0o600}) + + const stats = fs.statSync(testFilePath) + const mode = stats.mode & 0o777 + + // Check if only owner has read/write (600) + assert.equal(mode, 0o600, 'File should have 600 permissions') + }) + }) + + suite('Key Name Generation', () => { + test('generates valid key name', () => { + const name = generateDefaultKeyName() + assert.isString(name) + // Should be either "default" or "keyN" format + assert.match(name, /^(default|key\d+)$/) + }) + + test('generates consistent sequential names', () => { + // Verify the function always returns a string in expected format + const name = generateDefaultKeyName() + assert.isString(name) + assert.isTrue(name.length > 0) + }) + }) + + suite('Key Management (Integration)', () => { + let testPrivateKey: PrivateKey + + setup(() => { + testPrivateKey = PrivateKey.generate(KeyType.K1) + }) + + test('key storage structure is correct', () => { + const privateKeyString = testPrivateKey.toString() + const publicKeyString = testPrivateKey.toPublic().toString() + const keyName = 'integration-test' + + const encrypted = encryptPrivateKey(privateKeyString) + + const storedKey = { + name: keyName, + publicKey: publicKeyString, + encryptedPrivateKey: encrypted, + createdAt: new Date().toISOString(), + } + + assert.isString(storedKey.name) + assert.isString(storedKey.publicKey) + assert.isString(storedKey.encryptedPrivateKey) + assert.isString(storedKey.createdAt) + assert.notEqual(storedKey.encryptedPrivateKey, privateKeyString) + }) + + test('can retrieve and decrypt stored key', () => { + const privateKeyString = testPrivateKey.toString() + const encrypted = encryptPrivateKey(privateKeyString) + + const decrypted = decryptPrivateKey(encrypted) + const recoveredKey = PrivateKey.from(decrypted) + + assert.equal(recoveredKey.toString(), testPrivateKey.toString()) + assert.equal(recoveredKey.toPublic().toString(), testPrivateKey.toPublic().toString()) + }) + + test('private keys are never stored in plain text', () => { + const privateKeyString = testPrivateKey.toString() + + // Encrypt without password (uses default) + const encrypted1 = encryptPrivateKey(privateKeyString) + assert.notInclude(encrypted1, privateKeyString) + + // Encrypt with password + const encrypted2 = encryptPrivateKey(privateKeyString, 'password') + assert.notInclude(encrypted2, privateKeyString) + + // Verify both can be decrypted + const decrypted1 = decryptPrivateKey(encrypted1) + const decrypted2 = decryptPrivateKey(encrypted2, 'password') + assert.equal(decrypted1, privateKeyString) + assert.equal(decrypted2, privateKeyString) + }) + }) + + suite('Transaction Signing', () => { + test('can sign a transaction with decrypted key', () => { + const privateKey = PrivateKey.generate(KeyType.K1) + + // Create a test transaction + const transaction = Transaction.from({ + expiration: '2025-11-11T00:00:00', + ref_block_num: 12345, + ref_block_prefix: 67890, + max_net_usage_words: 0, + max_cpu_usage_ms: 0, + delay_sec: 0, + context_free_actions: [], + actions: [ + { + account: 'eosio.token', + name: 'transfer', + authorization: [ + { + actor: 'testaccount', + permission: 'active', + }, + ], + data: '0000000000ea305500000000487a2b9d0100000000000000045359530000000007746573742074', + }, + ], + transaction_extensions: [], + }) + + // Encrypt and decrypt the key + const encrypted = encryptPrivateKey(privateKey.toString()) + const decrypted = decryptPrivateKey(encrypted) + const recoveredKey = PrivateKey.from(decrypted) + + // Sign the transaction + const chainId = '73e4385a2708e6d7048834fbc1079f2fabb17b3c125b146af438971e90716c4d' + const digest = transaction.signingDigest(chainId) + const signature = recoveredKey.signDigest(digest) + + assert.isString(signature.toString()) + assert.include(signature.toString(), 'SIG_K1_') + }) + }) +}) diff --git a/yarn.lock b/yarn.lock index 047cb12..04e39f5 100644 --- a/yarn.lock +++ b/yarn.lock @@ -647,7 +647,17 @@ pako "^2.0.4" tslib "^2.1.0" -"@wharfkit/antelope@^0.7.3", "@wharfkit/antelope@^1.0.0", "@wharfkit/antelope@^1.0.4": +"@wharfkit/abicache@^1.2.1": + version "1.2.2" + resolved "https://registry.yarnpkg.com/@wharfkit/abicache/-/abicache-1.2.2.tgz#c3d83485e3e3782ac94ced460915bad1ceee0be9" + integrity sha512-yOsYz2qQpQy7Nb8XZj62pZqp8YnmWDqFlrenYksBb9jl+1aWIpFhWd+14VEez4tUAezRH4UWW+w1SX5vhmUY9A== + dependencies: + "@wharfkit/antelope" "^1.0.2" + "@wharfkit/signing-request" "^3.1.0" + pako "^2.0.4" + tslib "^2.1.0" + +"@wharfkit/antelope@^0.7.3", "@wharfkit/antelope@^1.0.0", "@wharfkit/antelope@^1.0.11", "@wharfkit/antelope@^1.0.2", "@wharfkit/antelope@^1.0.4": version "1.0.0" resolved "https://registry.yarnpkg.com/@wharfkit/antelope/-/antelope-1.0.0.tgz#c3057b70575991be5de3aea19e0c474614783c80" integrity sha512-gwc6L3AzceN/menx9HCV22Ekd3it1wRruY6dIkyfCaV2UBGmfvIVJ3wPaDi4Ppj2k50b86ShSSHdd52jOFd+dg== @@ -666,6 +676,13 @@ dependencies: tslib "^2.1.0" +"@wharfkit/common@^1.2.0": + version "1.5.0" + resolved "https://registry.yarnpkg.com/@wharfkit/common/-/common-1.5.0.tgz#66023ade5acc7e768ec2cd1c040c3043ec5cc59d" + integrity sha512-eqXkOy+vshcEzK8kED+EsoTPJjlBKHYglgV9CBnZQgIlGrWIRXWH4YaXH3W7EbI/nCRJCaNqxm5fC+pgpFcp8g== + dependencies: + tslib "^2.1.0" + "@wharfkit/common@^1.2.4": version "1.2.4" resolved "https://registry.yarnpkg.com/@wharfkit/common/-/common-1.2.4.tgz#a86f10ef6bb9cbe3bacf20330b5383dabca31334" @@ -706,6 +723,18 @@ pako "^2.0.4" tslib "^2.1.0" +"@wharfkit/session@^1.6.1": + version "1.6.1" + resolved "https://registry.yarnpkg.com/@wharfkit/session/-/session-1.6.1.tgz#087856dc547fea7b5aff547ca9c984a772da9239" + integrity sha512-k6ntDGOe8bvD/Ps0erTPTFMdYVFrw5cRvPcEwxytlmRRcNV/M8xWcpCYWdmGDxa8QYqynf/hAkbVh1PSwRGl5A== + dependencies: + "@wharfkit/abicache" "^1.2.1" + "@wharfkit/antelope" "^1.0.11" + "@wharfkit/common" "^1.2.0" + "@wharfkit/signing-request" "^3.1.0" + pako "^2.0.4" + tslib "^2.1.0" + "@wharfkit/signing-request@^3.0.0": version "3.0.0" resolved "https://registry.npmjs.org/@wharfkit/signing-request/-/signing-request-3.0.0.tgz" @@ -729,6 +758,13 @@ dependencies: tslib "^2.1.0" +"@wharfkit/wallet-plugin-privatekey@^1.1.0": + version "1.1.0" + resolved "https://registry.yarnpkg.com/@wharfkit/wallet-plugin-privatekey/-/wallet-plugin-privatekey-1.1.0.tgz#5985bff61895c54d2afbef359cd42da4f3871c7d" + integrity sha512-45LPj7AOVDm4RugDEhy0fnQX/BcMffeJPjGUCUrLazJ2S0Sti8nNk4nqiJqyme84c/0gq7d65vvwlmVfGtPVEg== + dependencies: + tslib "^2.1.0" + acorn-jsx@^5.3.2: version "5.3.2" resolved "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz" From ca82dbf648f6b18dee9ecb70adf8a1bcee093134 Mon Sep 17 00:00:00 2001 From: dafuga Date: Tue, 11 Nov 2025 15:56:25 -0800 Subject: [PATCH 05/36] chore: added better test coverage --- README.md | 12 ++-- src/commands/chain/index.ts | 18 ++++-- src/commands/chain/install.ts | 66 +++++++++++--------- src/commands/chain/local.ts | 57 ++++++++--------- src/commands/chain/utils.ts | 15 +++-- src/commands/wallet/account.ts | 2 + src/commands/wallet/index.ts | 10 +-- src/commands/wallet/{sign.ts => transact.ts} | 12 ++-- src/commands/wharfkit/deploy.ts | 2 + test/tests/chain-install.ts | 24 +++---- test/tests/e2e-workflow.ts | 49 +++++++++++++-- 11 files changed, 162 insertions(+), 105 deletions(-) rename src/commands/wallet/{sign.ts => transact.ts} (95%) diff --git a/README.md b/README.md index 03f6992..d5571b8 100644 --- a/README.md +++ b/README.md @@ -139,16 +139,16 @@ Sign a transaction using a key from your wallet: ```bash # Sign with default key (uses 'default' key or first available) -wharfkit wallet sign transaction.json + wharfkit wallet transact transaction.json # Sign with specific key -wharfkit wallet sign transaction.json --key mykey + wharfkit wallet transact transaction.json --key mykey # Sign with password-protected key -wharfkit wallet sign transaction.json --key production --password + wharfkit wallet transact transaction.json --key production --password # Save signed transaction to file -wharfkit wallet sign transaction.json --output signed.json + wharfkit wallet transact transaction.json --output signed.json ``` The transaction can be provided as: @@ -376,7 +376,7 @@ The typical development workflow is: - The contract will automatically recompile and redeploy - Watch the console for compilation and deployment status -4. Test your contract using `cleos` or your preferred tools +4. Test your contract using WharfKit sessions or your preferred tools 5. Stop development mode with `Ctrl+C` @@ -395,7 +395,7 @@ wharfkit chain local start ``` This will: -- āœ… Automatically detect and install LEAP (nodeos/cleos) if not present +- āœ… Automatically detect and install LEAP (nodeos) if not present - āœ… Create necessary configuration and data directories - āœ… Start nodeos with sensible defaults for development - āœ… Set up a dev wallet with pre-configured keys diff --git a/src/commands/chain/index.ts b/src/commands/chain/index.ts index dbb8e4b..7a35875 100644 --- a/src/commands/chain/index.ts +++ b/src/commands/chain/index.ts @@ -89,14 +89,24 @@ export function createChainCommand(): Command { console.log('āœ… LEAP is installed') console.log(` Version: ${status.version}`) console.log(` nodeos: ${status.nodeosPath}`) - console.log(` cleos: ${status.cleosPath}`) - console.log(` keosd: ${status.keosdPath}`) + console.log( + ` WharfKit console renderer: ${ + status.wharfkit.consoleRenderer ? 'available' : 'missing' + }` + ) + console.log( + ` WharfKit wallet plugin: ${ + status.wharfkit.walletPlugin ? 'available' : 'missing' + }` + ) } else { console.log('āŒ LEAP is not installed\n') console.log('Missing components:') if (!status.nodeos) console.log(' - nodeos') - if (!status.cleos) console.log(' - cleos') - if (!status.keosd) console.log(' - keosd') + if (!status.wharfkit.consoleRenderer) + console.log(' - WharfKit console renderer') + if (!status.wharfkit.walletPlugin) + console.log(' - WharfKit private key wallet plugin') console.log('\nšŸ’” Install automatically with: wharfkit chain local start') } } catch (error: any) { diff --git a/src/commands/chain/install.ts b/src/commands/chain/install.ts index c925af6..ca99ace 100644 --- a/src/commands/chain/install.ts +++ b/src/commands/chain/install.ts @@ -1,15 +1,17 @@ /* eslint-disable no-console */ -import {executeCommand, getPlatform} from './utils' +import {ConsoleRenderer} from '@wharfkit/console-rendered' +import {WalletPluginPrivateKey} from '@wharfkit/wallet-plugin-privatekey' +import {executeCommand, getDevKeys, getPlatform} from './utils' export interface InstallationStatus { installed: boolean nodeos: boolean - cleos: boolean - keosd: boolean nodeosPath?: string - cleosPath?: string - keosdPath?: string version?: string + wharfkit: { + consoleRenderer: boolean + walletPlugin: boolean + } } /** @@ -19,8 +21,10 @@ export async function checkLeapInstallation(): Promise { const status: InstallationStatus = { installed: false, nodeos: false, - cleos: false, - keosd: false, + wharfkit: { + consoleRenderer: false, + walletPlugin: false, + }, } // Check nodeos @@ -32,23 +36,8 @@ export async function checkLeapInstallation(): Promise { // nodeos not found } - // Check cleos - try { - const {stdout} = await executeCommand('which cleos') - status.cleosPath = stdout.trim() - status.cleos = true - } catch { - // cleos not found - } - - // Check keosd - try { - const {stdout} = await executeCommand('which keosd') - status.keosdPath = stdout.trim() - status.keosd = true - } catch { - // keosd not found - } + status.wharfkit.consoleRenderer = checkConsoleRenderer() + status.wharfkit.walletPlugin = checkWalletPlugin() // Get version if nodeos is installed if (status.nodeos) { @@ -63,7 +52,7 @@ export async function checkLeapInstallation(): Promise { } } - status.installed = status.nodeos && status.cleos && status.keosd + status.installed = status.nodeos && status.wharfkit.consoleRenderer && status.wharfkit.walletPlugin return status } @@ -203,12 +192,31 @@ export async function ensureLeapInstalled(): Promise { if (!status.nodeos) { console.log(' - nodeos is not installed') } - if (!status.cleos) { - console.log(' - cleos is not installed') + if (!status.wharfkit.consoleRenderer) { + console.log(' - WharfKit console renderer is unavailable') } - if (!status.keosd) { - console.log(' - keosd is not installed') + if (!status.wharfkit.walletPlugin) { + console.log(' - WharfKit private key wallet plugin is unavailable') } await installLeap() } + +function checkConsoleRenderer(): boolean { + try { + new ConsoleRenderer() + return true + } catch { + return false + } +} + +function checkWalletPlugin(): boolean { + try { + const devKeys = getDevKeys() + new WalletPluginPrivateKey(devKeys.privateKey) + return true + } catch { + return false + } +} diff --git a/src/commands/chain/local.ts b/src/commands/chain/local.ts index 7a69aa2..d03f87c 100644 --- a/src/commands/chain/local.ts +++ b/src/commands/chain/local.ts @@ -1,4 +1,7 @@ /* eslint-disable no-console */ +import {ConsoleRenderer} from '@wharfkit/console-rendered' +import {PrivateKey} from '@wharfkit/antelope' +import {WalletPluginPrivateKey} from '@wharfkit/wallet-plugin-privatekey' import {spawn} from 'child_process' import * as fs from 'fs' import * as path from 'path' @@ -6,7 +9,6 @@ import type {ChainStatus} from './utils' import { cleanDataDir, ensureDir, - executeCommand, getConfigIni, getDefaultConfigDir, getDefaultDataDir, @@ -19,8 +21,10 @@ import { removePidFile, savePid, waitForChain, + createApiClientForPort, } from './utils' import {ensureLeapInstalled} from './install' +import {addKeyToWallet, listWalletKeys} from '../wallet/utils' export interface LocalStartOptions { port: number @@ -218,11 +222,9 @@ export async function getChainStatus(): Promise { // Try to get chain info if (status.running) { try { - const {stdout} = await executeCommand( - `cleos --url http://127.0.0.1:${status.port} get info 2>/dev/null` - ) - const info = JSON.parse(stdout) - status.headBlock = info.head_block_num + const client = createApiClientForPort(status.port) + const info = await client.v1.chain.get_info() + status.headBlock = Number(info.head_block_num) } catch (error: any) { status.error = 'Could not connect to chain' } @@ -331,40 +333,35 @@ async function setupDevWallet(): Promise { console.log('Setting up development wallet...') const walletName = 'dev' - const walletPassword = 'PW5KKbTdHCGmrWXmtHFXz7eVZqzJ3cCJLQ4EwBSbQMKcZpXhsjzKM' const devKeys = getDevKeys() + const devPrivateKey = PrivateKey.from(devKeys.privateKey) try { - // Create wallet - try { - await executeCommand(`cleos wallet create -n ${walletName} --to-console`) - } catch { - // Wallet might already exist - } + const existingKeys = listWalletKeys() + const existingEntry = existingKeys.find( + (key) => key.name === walletName || key.publicKey === devKeys.publicKey + ) - // Unlock wallet - try { - await executeCommand( - `echo "${walletPassword}" | cleos wallet unlock -n ${walletName} --password` + if (!existingEntry) { + addKeyToWallet(devPrivateKey, walletName) + console.log(`Stored development key in WharfKit wallet as "${walletName}"`) + } else if (existingEntry.name !== walletName) { + console.log( + `Development key already stored as "${existingEntry.name}", keeping existing entry` ) - } catch { - // Wallet might already be unlocked + } else { + console.log('Development key already stored') } - // Import dev key - try { - await executeCommand( - `cleos wallet import -n ${walletName} --private-key ${devKeys.privateKey}` - ) - } catch { - // Key might already be imported - } + const walletPlugin = new WalletPluginPrivateKey(devPrivateKey) + const renderer = new ConsoleRenderer() + renderer.status('WharfKit wallet plugin initialized for local development') + void walletPlugin console.log('Development wallet ready') } catch (error: any) { console.log(`Warning: Could not setup dev wallet: ${error.message}`) - console.log('You can manually import keys using:') - console.log(` cleos wallet create -n ${walletName}`) - console.log(` cleos wallet import -n ${walletName} --private-key ${devKeys.privateKey}`) + console.log('You can manually store the development key with:') + console.log(` wharfkit wallet keys add --name ${walletName} --private ${devKeys.privateKey}`) } } diff --git a/src/commands/chain/utils.ts b/src/commands/chain/utils.ts index 2c25d09..0da3033 100644 --- a/src/commands/chain/utils.ts +++ b/src/commands/chain/utils.ts @@ -1,5 +1,7 @@ /* eslint-disable no-console */ +import {APIClient, FetchProvider} from '@wharfkit/antelope' import {exec} from 'child_process' +import fetch from 'node-fetch' import {promisify} from 'util' import * as fs from 'fs' import * as path from 'path' @@ -252,13 +254,12 @@ pause-on-startup = false */ export async function waitForChain(port: number, timeoutMs: number = 10000): Promise { const startTime = Date.now() + const client = createApiClientForPort(port) while (Date.now() - startTime < timeoutMs) { try { - const {stdout} = await execAsync( - `cleos --url http://127.0.0.1:${port} get info 2>/dev/null` - ) - if (stdout.includes('head_block_num')) { + const info = await client.v1.chain.get_info() + if (Number(info.head_block_num) >= 0) { return true } } catch { @@ -269,3 +270,9 @@ export async function waitForChain(port: number, timeoutMs: number = 10000): Pro return false } + +export function createApiClientForPort(port: number): APIClient { + const url = `http://127.0.0.1:${port}` + const provider = new FetchProvider(url, {fetch}) + return new APIClient({provider}) +} diff --git a/src/commands/wallet/account.ts b/src/commands/wallet/account.ts index dada09b..f83dbdb 100644 --- a/src/commands/wallet/account.ts +++ b/src/commands/wallet/account.ts @@ -1,4 +1,5 @@ /* eslint-disable no-console */ +import {ConsoleRenderer} from '@wharfkit/console-rendered' import {APIClient, FetchProvider, KeyType, PrivateKey} from '@wharfkit/antelope' import {Session} from '@wharfkit/session' import {WalletPluginPrivateKey} from '@wharfkit/wallet-plugin-privatekey' @@ -56,6 +57,7 @@ export async function createAccount(options: AccountCreateOptions): Promise', 'Transaction JSON string or path to JSON file') .option('-k, --key ', 'Name or public key of the key to use for signing') .option('-p, --password', 'Prompt for password if key is encrypted with custom password') .option('-o, --output ', 'Output file path for signed transaction (default: stdout)') .action(async (transaction, options) => { - await signTransaction(transaction, options) + await transactTransaction(transaction, options) }) return walletCommand diff --git a/src/commands/wallet/sign.ts b/src/commands/wallet/transact.ts similarity index 95% rename from src/commands/wallet/sign.ts rename to src/commands/wallet/transact.ts index ad7cd4e..3505b3d 100644 --- a/src/commands/wallet/sign.ts +++ b/src/commands/wallet/transact.ts @@ -4,7 +4,7 @@ import {getKeyFromWallet, listWalletKeys} from './utils' import * as readline from 'readline' import * as fs from 'fs' -interface SignOptions { +interface TransactOptions { key?: string password?: boolean output?: string @@ -138,11 +138,11 @@ function selectKey(keyName?: string): string { } /** - * Sign a transaction + * Transact a transaction (sign-only for now) */ -export async function signTransaction( +export async function transactTransaction( transactionJson: string, - options: SignOptions + options: TransactOptions ): Promise { try { // Load the transaction @@ -189,7 +189,7 @@ export async function signTransaction( if (options.output) { // Save to file fs.writeFileSync(options.output, output, 'utf8') - log(`Signed transaction saved to: ${options.output}`, 'info') + log(`Transaction output saved to: ${options.output}`, 'info') } else { // Print to stdout log('Signed Transaction:', 'info') @@ -199,7 +199,7 @@ export async function signTransaction( log('', 'info') log(`Signature: ${signature.toString()}`, 'info') } catch (error) { - log(`āŒ Failed to sign transaction: ${(error as Error).message}`, 'info') + log(`āŒ Failed to process transaction: ${(error as Error).message}`, 'info') process.exit(1) } } diff --git a/src/commands/wharfkit/deploy.ts b/src/commands/wharfkit/deploy.ts index 648b107..6ddc8c8 100644 --- a/src/commands/wharfkit/deploy.ts +++ b/src/commands/wharfkit/deploy.ts @@ -7,6 +7,7 @@ import {Session} from '@wharfkit/session' import {WalletPluginPrivateKey} from '@wharfkit/wallet-plugin-privatekey' import fetch from 'node-fetch' import {getKeyFromWallet, listWalletKeys} from '../wallet/utils' +import {createConsoleRenderer} from '../../utils/console-renderer' interface DeployOptions { account?: string @@ -75,6 +76,7 @@ export async function deployContract( actor: accountName, permission: 'active', walletPlugin, + ui: createConsoleRenderer(), }) console.log('\nšŸš€ Deploying contract...') diff --git a/test/tests/chain-install.ts b/test/tests/chain-install.ts index 427ec47..6ade37c 100644 --- a/test/tests/chain-install.ts +++ b/test/tests/chain-install.ts @@ -11,12 +11,14 @@ suite('Chain Install', function () { assert.isObject(status) assert.property(status, 'installed') assert.property(status, 'nodeos') - assert.property(status, 'cleos') - assert.property(status, 'keosd') + assert.property(status, 'wharfkit') assert.isBoolean(status.installed) assert.isBoolean(status.nodeos) - assert.isBoolean(status.cleos) - assert.isBoolean(status.keosd) + assert.isObject(status.wharfkit) + assert.property(status.wharfkit, 'consoleRenderer') + assert.property(status.wharfkit, 'walletPlugin') + assert.isBoolean(status.wharfkit.consoleRenderer) + assert.isBoolean(status.wharfkit.walletPlugin) }) test('Has paths if binaries are installed', async function () { @@ -28,16 +30,6 @@ suite('Chain Install', function () { assert.property(status, 'nodeosPath') assert.isString(status.nodeosPath) } - - if (status.cleos) { - assert.property(status, 'cleosPath') - assert.isString(status.cleosPath) - } - - if (status.keosd) { - assert.property(status, 'keosdPath') - assert.isString(status.keosdPath) - } }) test('Has version if nodeos is installed', async function () { @@ -59,8 +51,8 @@ suite('Chain Install', function () { if (status.installed) { assert.isTrue(status.nodeos) - assert.isTrue(status.cleos) - assert.isTrue(status.keosd) + assert.isTrue(status.wharfkit.consoleRenderer) + assert.isTrue(status.wharfkit.walletPlugin) } }) }) diff --git a/test/tests/e2e-workflow.ts b/test/tests/e2e-workflow.ts index eebf5fc..4153836 100644 --- a/test/tests/e2e-workflow.ts +++ b/test/tests/e2e-workflow.ts @@ -74,8 +74,8 @@ suite('E2E Workflow', () => { }) }) - suite('Transaction Signing', () => { - test('can sign a transaction with wallet key', function () { + suite('Transaction Transacting', () => { + test('can transact (sign) a transaction with wallet key', function () { // Create a key first execSync(`node ${cliPath} wallet create --name signtest`, {encoding: 'utf8'}) @@ -102,13 +102,52 @@ suite('E2E Workflow', () => { const txPath = path.join(testDir, 'transaction.json') fs.writeFileSync(txPath, JSON.stringify(transaction)) - // Sign the transaction - const output = execSync(`node ${cliPath} wallet sign ${txPath}`, {encoding: 'utf8'}) + // Transact the transaction + const output = execSync(`node ${cliPath} wallet transact ${txPath}`, {encoding: 'utf8'}) assert.include(output, 'āœ… Transaction signed successfully!') assert.include(output, 'Signature: SIG_K1_') assert.include(output, 'signatures') }) + + test('writes signed transaction to file when --output is provided', function () { + execSync(`node ${cliPath} wallet create --name outputtest`, {encoding: 'utf8'}) + + const transaction = { + expiration: '2025-11-11T00:00:00', + ref_block_num: 54321, + ref_block_prefix: 98765, + max_net_usage_words: 0, + max_cpu_usage_ms: 0, + delay_sec: 0, + context_free_actions: [], + actions: [ + { + account: 'eosio.token', + name: 'transfer', + authorization: [{actor: 'testaccount', permission: 'active'}], + data: '0000000000ea305500000000487a2b9d0100000000000000045359530000000007746573742074', + }, + ], + transaction_extensions: [], + } + + const txPath = path.join(testDir, 'transaction-output.json') + const signedPath = path.join(testDir, 'signed-transaction.json') + fs.writeFileSync(txPath, JSON.stringify(transaction)) + + const output = execSync( + `node ${cliPath} wallet transact ${txPath} --output ${signedPath}`, + {encoding: 'utf8'} + ) + + assert.include(output, 'Transaction output saved to:') + assert.isTrue(fs.existsSync(signedPath)) + + const saved = JSON.parse(fs.readFileSync(signedPath, 'utf8')) + assert.isArray(saved.signatures, 'signed transaction should include signatures array') + assert.isAbove(saved.signatures.length, 0, 'signed transaction should contain at least one signature') + }) }) suite('Contract Compilation', () => { @@ -173,7 +212,7 @@ class [[eosio::contract]] hello : public eosio::contract { assert.include(output, 'create') assert.include(output, 'keys') assert.include(output, 'account') - assert.include(output, 'sign') + assert.include(output, 'transact') }) test('wallet account command has create subcommand', function () { From eec3eeb53aadde3e459a59a23369859a743e7d1a Mon Sep 17 00:00:00 2001 From: dafuga Date: Wed, 12 Nov 2025 22:59:01 -0800 Subject: [PATCH 06/36] cleanup: completely eliminating cleos --- README.md | 3 + package.json | 1 + src/commands/chain/install.ts | 4 +- src/commands/chain/local.ts | 4 +- src/commands/wallet/account.ts | 8 ++- src/commands/wallet/index.ts | 4 +- src/commands/wallet/transact.ts | 70 ++++++++++++++++++---- src/commands/wharfkit/deploy.ts | 7 ++- src/types/wharfkit-session.d.ts | 12 ++++ src/utils/wharfkit-ui.ts | 26 ++++++++ test/tests/e2e-workflow.ts | 103 ++++++++++++++++++++++++++++++++ yarn.lock | 62 +++++++++++++++++++ 12 files changed, 282 insertions(+), 22 deletions(-) create mode 100644 src/types/wharfkit-session.d.ts create mode 100644 src/utils/wharfkit-ui.ts diff --git a/README.md b/README.md index d5571b8..05ea20d 100644 --- a/README.md +++ b/README.md @@ -149,6 +149,9 @@ Sign a transaction using a key from your wallet: # Save signed transaction to file wharfkit wallet transact transaction.json --output signed.json + +# Sign and broadcast to a local node + wharfkit wallet transact transaction.json --broadcast --url http://127.0.0.1:8888 ``` The transaction can be provided as: diff --git a/package.json b/package.json index a336db0..693e816 100644 --- a/package.json +++ b/package.json @@ -27,6 +27,7 @@ "dependencies": { "@wharfkit/antelope": "^1.0.0", "@wharfkit/common": "^1.2.4", + "@wharfkit/console-renderer": "^0.1.1", "@wharfkit/contract": "^1.1.4", "@wharfkit/session": "^1.6.1", "@wharfkit/wallet-plugin-privatekey": "^1.1.0", diff --git a/src/commands/chain/install.ts b/src/commands/chain/install.ts index ca99ace..2e7d731 100644 --- a/src/commands/chain/install.ts +++ b/src/commands/chain/install.ts @@ -1,6 +1,6 @@ /* eslint-disable no-console */ -import {ConsoleRenderer} from '@wharfkit/console-rendered' import {WalletPluginPrivateKey} from '@wharfkit/wallet-plugin-privatekey' +import {NonInteractiveConsoleUI} from '../../utils/wharfkit-ui' import {executeCommand, getDevKeys, getPlatform} from './utils' export interface InstallationStatus { @@ -204,7 +204,7 @@ export async function ensureLeapInstalled(): Promise { function checkConsoleRenderer(): boolean { try { - new ConsoleRenderer() + new NonInteractiveConsoleUI() return true } catch { return false diff --git a/src/commands/chain/local.ts b/src/commands/chain/local.ts index d03f87c..18d73d0 100644 --- a/src/commands/chain/local.ts +++ b/src/commands/chain/local.ts @@ -1,5 +1,4 @@ /* eslint-disable no-console */ -import {ConsoleRenderer} from '@wharfkit/console-rendered' import {PrivateKey} from '@wharfkit/antelope' import {WalletPluginPrivateKey} from '@wharfkit/wallet-plugin-privatekey' import {spawn} from 'child_process' @@ -25,6 +24,7 @@ import { } from './utils' import {ensureLeapInstalled} from './install' import {addKeyToWallet, listWalletKeys} from '../wallet/utils' +import {NonInteractiveConsoleUI} from '../../utils/wharfkit-ui' export interface LocalStartOptions { port: number @@ -354,7 +354,7 @@ async function setupDevWallet(): Promise { } const walletPlugin = new WalletPluginPrivateKey(devPrivateKey) - const renderer = new ConsoleRenderer() + const renderer = new NonInteractiveConsoleUI() renderer.status('WharfKit wallet plugin initialized for local development') void walletPlugin diff --git a/src/commands/wallet/account.ts b/src/commands/wallet/account.ts index f83dbdb..d8c1f2b 100644 --- a/src/commands/wallet/account.ts +++ b/src/commands/wallet/account.ts @@ -1,10 +1,10 @@ /* eslint-disable no-console */ -import {ConsoleRenderer} from '@wharfkit/console-rendered' import {APIClient, FetchProvider, KeyType, PrivateKey} from '@wharfkit/antelope' import {Session} from '@wharfkit/session' import {WalletPluginPrivateKey} from '@wharfkit/wallet-plugin-privatekey' import fetch from 'node-fetch' import {getDevKeys} from '../chain/utils' +import {NonInteractiveConsoleUI} from '../../utils/wharfkit-ui' import {addKeyToWallet} from './utils' interface AccountCreateOptions { @@ -49,6 +49,10 @@ export async function createAccount(options: AccountCreateOptions): Promise', 'Transaction JSON string or path to JSON file') .option('-k, --key ', 'Name or public key of the key to use for signing') .option('-p, --password', 'Prompt for password if key is encrypted with custom password') .option('-o, --output ', 'Output file path for signed transaction (default: stdout)') + .option('-b, --broadcast', 'Broadcast the signed transaction to the network') + .option('-u, --url ', 'API endpoint for broadcasting (default: http://127.0.0.1:8888)') .action(async (transaction, options) => { await transactTransaction(transaction, options) }) diff --git a/src/commands/wallet/transact.ts b/src/commands/wallet/transact.ts index 3505b3d..a23705f 100644 --- a/src/commands/wallet/transact.ts +++ b/src/commands/wallet/transact.ts @@ -1,13 +1,22 @@ -import {Checksum256, SignedTransaction, Transaction} from '@wharfkit/antelope' +import { + APIClient, + Checksum256, + FetchProvider, + SignedTransaction, + Transaction, +} from '@wharfkit/antelope' import {log} from '../../utils' import {getKeyFromWallet, listWalletKeys} from './utils' import * as readline from 'readline' import * as fs from 'fs' +import fetch from 'node-fetch' interface TransactOptions { key?: string password?: boolean output?: string + broadcast?: boolean + url?: string } /** @@ -140,10 +149,7 @@ function selectKey(keyName?: string): string { /** * Transact a transaction (sign-only for now) */ -export async function transactTransaction( - transactionJson: string, - options: TransactOptions -): Promise { +export async function transactTransaction(transactionJson: string, options: TransactOptions): Promise { try { // Load the transaction const transaction = loadTransaction(transactionJson) @@ -152,6 +158,9 @@ export async function transactTransaction( log(JSON.stringify(transaction, null, 2), 'info') log('', 'info') + const shouldBroadcast = !!options.broadcast + const apiUrl = options.url || 'http://127.0.0.1:8888' + // Select the key to use const keyName = selectKey(options.key) log(`Using key: ${keyName}`, 'info') @@ -162,14 +171,31 @@ export async function transactTransaction( // Load the private key const privateKey = getKeyFromWallet(keyName, password) - // Create chain ID (you might want to make this configurable) - // For now, we'll use a placeholder. In a real scenario, this should come from - // the transaction data or be specified by the user - const chainId = Checksum256.from( - transaction.ref_block_num - ? '73e4385a2708e6d7048834fbc1079f2fabb17b3c125b146af438971e90716c4d' // EOS mainnet as default - : '0000000000000000000000000000000000000000000000000000000000000000' - ) + let client: APIClient | undefined + let chainId: Checksum256 + + if (shouldBroadcast) { + try { + client = new APIClient({ + provider: new FetchProvider(apiUrl, {fetch}), + }) + const info = await client.v1.chain.get_info() + chainId = Checksum256.from(String(info.chain_id)) + log(`Broadcast target: ${apiUrl}`, 'info') + log(`Chain ID: ${chainId.toString()}`, 'info') + log('', 'info') + } catch (error) { + log(`āŒ Failed to fetch chain info: ${(error as Error).message}`, 'info') + process.exit(1) + } + } else { + // Default placeholder chain IDs for offline signing + chainId = Checksum256.from( + transaction.ref_block_num + ? '73e4385a2708e6d7048834fbc1079f2fabb17b3c125b146af438971e90716c4d' + : '0000000000000000000000000000000000000000000000000000000000000000' + ) + } // Sign the transaction const digest = transaction.signingDigest(chainId) @@ -198,6 +224,24 @@ export async function transactTransaction( log('', 'info') log(`Signature: ${signature.toString()}`, 'info') + + if (shouldBroadcast && client) { + try { + const result = await client.v1.chain.push_transaction(signedTransaction) + log('', 'info') + log('šŸš€ Transaction broadcast successfully!', 'info') + if (result.transaction_id) { + log(`Transaction ID: ${result.transaction_id}`, 'info') + } + const status = result.processed?.receipt?.status + if (status) { + log(`Status: ${status}`, 'info') + } + } catch (error) { + log(`āŒ Failed to broadcast transaction: ${(error as Error).message}`, 'info') + process.exit(1) + } + } } catch (error) { log(`āŒ Failed to process transaction: ${(error as Error).message}`, 'info') process.exit(1) diff --git a/src/commands/wharfkit/deploy.ts b/src/commands/wharfkit/deploy.ts index 6ddc8c8..c8c1044 100644 --- a/src/commands/wharfkit/deploy.ts +++ b/src/commands/wharfkit/deploy.ts @@ -6,8 +6,8 @@ import {APIClient, FetchProvider, Serializer} from '@wharfkit/antelope' import {Session} from '@wharfkit/session' import {WalletPluginPrivateKey} from '@wharfkit/wallet-plugin-privatekey' import fetch from 'node-fetch' +import {NonInteractiveConsoleUI} from '../../utils/wharfkit-ui' import {getKeyFromWallet, listWalletKeys} from '../wallet/utils' -import {createConsoleRenderer} from '../../utils/console-renderer' interface DeployOptions { account?: string @@ -67,6 +67,9 @@ export async function deployContract( // Create session with private key wallet plugin const walletPlugin = new WalletPluginPrivateKey(privateKey) + walletPlugin.config.requiresChainSelect = false + walletPlugin.config.requiresPermissionSelect = false + walletPlugin.config.requiresPermissionEntry = false const session = new Session({ chain: { @@ -76,7 +79,7 @@ export async function deployContract( actor: accountName, permission: 'active', walletPlugin, - ui: createConsoleRenderer(), + ui: new NonInteractiveConsoleUI(), }) console.log('\nšŸš€ Deploying contract...') diff --git a/src/types/wharfkit-session.d.ts b/src/types/wharfkit-session.d.ts new file mode 100644 index 0000000..b781a76 --- /dev/null +++ b/src/types/wharfkit-session.d.ts @@ -0,0 +1,12 @@ +import type {UserInterface} from '@wharfkit/session' + +declare module '@wharfkit/session' { + interface SessionArgs { + ui?: UserInterface + } + + interface SessionOptions { + ui?: UserInterface + } +} + diff --git a/src/utils/wharfkit-ui.ts b/src/utils/wharfkit-ui.ts new file mode 100644 index 0000000..b57796a --- /dev/null +++ b/src/utils/wharfkit-ui.ts @@ -0,0 +1,26 @@ +import {ConsoleUserInterface} from '@wharfkit/console-renderer' +import { + cancelable, + type Cancelable, + type PromptArgs, + type PromptResponse, +} from '@wharfkit/session' + +/** + * Console UI wrapper that avoids interactive prompts so automated tests can complete. + */ +export class NonInteractiveConsoleUI extends ConsoleUserInterface { + prompt(args: PromptArgs): Cancelable { + if (args.title) { + console.log(`[wharfkit] ${args.title}`) + } + if (args.body) { + console.log(args.body) + } + + return cancelable(Promise.resolve({} as PromptResponse), () => { + /* no cancellation work required */ + }) + } +} + diff --git a/test/tests/e2e-workflow.ts b/test/tests/e2e-workflow.ts index 4153836..9129595 100644 --- a/test/tests/e2e-workflow.ts +++ b/test/tests/e2e-workflow.ts @@ -3,6 +3,7 @@ import {execSync} from 'child_process' import * as fs from 'fs' import * as path from 'path' import * as os from 'os' +import * as http from 'http' /** * E2E tests for the complete workflow: @@ -148,6 +149,108 @@ suite('E2E Workflow', () => { assert.isArray(saved.signatures, 'signed transaction should include signatures array') assert.isAbove(saved.signatures.length, 0, 'signed transaction should contain at least one signature') }) + + test('broadcasts transaction when --broadcast is provided', async function () { + const chainId = + 'b94d27b9934d3e08a52e52d7da7dabfade8882abff6b19413ababd9f146e6e1' + const requests: Array<{path: string; method: string; body?: any}> = [] + + const server = http.createServer((req, res) => { + if (!req.url || !req.method) { + res.writeHead(400) + res.end() + return + } + + if (req.method === 'GET' && req.url === '/v1/chain/get_info') { + requests.push({path: req.url, method: req.method}) + res.writeHead(200, {'Content-Type': 'application/json'}) + res.end(JSON.stringify({chain_id: chainId})) + return + } + + if (req.method === 'POST' && req.url === '/v1/chain/push_transaction') { + let body = '' + req.on('data', (chunk) => { + body += chunk + }) + req.on('end', () => { + requests.push({ + path: req.url as string, + method: req.method as string, + body: body ? JSON.parse(body) : undefined, + }) + res.writeHead(200, {'Content-Type': 'application/json'}) + res.end( + JSON.stringify({ + transaction_id: 'abcd1234ef567890', + processed: {receipt: {status: 'executed'}}, + }) + ) + }) + return + } + + res.writeHead(404) + res.end() + }) + + const port = await new Promise((resolve) => { + server.listen(0, () => { + const address = server.address() + if (typeof address === 'object' && address?.port) { + resolve(address.port) + } else { + resolve(0) + } + }) + }) + + const txPath = path.join(testDir, 'transaction-broadcast.json') + const transaction = { + expiration: '2025-11-11T00:00:00', + ref_block_num: 1111, + ref_block_prefix: 2222, + max_net_usage_words: 0, + max_cpu_usage_ms: 0, + delay_sec: 0, + context_free_actions: [], + actions: [ + { + account: 'eosio.token', + name: 'transfer', + authorization: [{actor: 'broadcastacc', permission: 'active'}], + data: '0000000000ea305500000000487a2b9d010000000000000004535953000000000b62726f616463617374', + }, + ], + transaction_extensions: [], + } + fs.writeFileSync(txPath, JSON.stringify(transaction)) + + execSync(`node ${cliPath} wallet create --name broadcastkey`, {encoding: 'utf8'}) + + let output: string | undefined + try { + output = execSync( + `node ${cliPath} wallet transact ${txPath} --broadcast --url http://127.0.0.1:${port}`, + {encoding: 'utf8'} + ) + } finally { + await new Promise((resolve) => server.close(resolve)) + } + + assert.isString(output) + assert.include(output, 'šŸš€ Transaction broadcast successfully!') + assert.include(output, 'Transaction ID: abcd1234ef567890') + assert.include(output, 'Status: executed') + + const broadcastRequest = requests.find( + (request) => request.path === '/v1/chain/push_transaction' + ) + assert.isDefined(broadcastRequest, 'push_transaction should be called') + assert.isArray(broadcastRequest?.body?.signatures) + assert.isAbove(broadcastRequest?.body?.signatures.length ?? 0, 0) + }) }) suite('Contract Compilation', () => { diff --git a/yarn.lock b/yarn.lock index 04e39f5..0dbfbae 100644 --- a/yarn.lock +++ b/yarn.lock @@ -255,6 +255,17 @@ resolved "https://registry.npmjs.org/@eslint/js/-/js-8.48.0.tgz" integrity sha512-ZSjtmelB7IJfWD2Fvb7+Z+ChTIKWq6kjda95fLcQKNS5aheVHn4IkfgRQE3sIIzTcSLwLcLZUD9UBt+V7+h+Pw== +"@greymass/eosio@^0.6.0", "@greymass/eosio@^0.6.9": + version "0.6.11" + resolved "https://registry.yarnpkg.com/@greymass/eosio/-/eosio-0.6.11.tgz#9bd1783f8467834563ac893d41524c01aba0228c" + integrity sha512-Ud6V7+vQJ+OLxqD7QcKTv/ik/Hd5uaRHyGY9Hzc+cJXezYU4sADX4TyQ8sn6XIr8ebVVNZFvWpc5JR0a9lBzKw== + dependencies: + bn.js "^4.11.9" + brorand "^1.1.0" + elliptic "^6.5.4" + hash.js "^1.0.0" + tslib "^2.0.3" + "@humanwhocodes/config-array@^0.11.10": version "0.11.10" resolved "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.11.10.tgz" @@ -690,6 +701,16 @@ dependencies: tslib "^2.1.0" +"@wharfkit/console-renderer@^0.1.1": + version "0.1.1" + resolved "https://registry.yarnpkg.com/@wharfkit/console-renderer/-/console-renderer-0.1.1.tgz#dc499c92e6097d230a38e81faff82f2f16fdfde2" + integrity sha512-a+eQBoOZFcfjqJLwBPVkfJCiuYJUqD79s6iTMs4zAz+4paogxUR3WHyVBeG1yVvCRBPpxECV53uO9A7mSyeE+A== + dependencies: + "@wharfkit/session" "0.3.1" + prompts "^2.4.2" + qrcode-terminal "^0.12.0" + tslib "^2.1.0" + "@wharfkit/contract@^1.1.4": version "1.1.4" resolved "https://registry.yarnpkg.com/@wharfkit/contract/-/contract-1.1.4.tgz#5d65a7effa02bb71c98a7493cfdca2472578e6d5" @@ -711,6 +732,16 @@ node-fetch "^2.6.1" tslib "^2.1.0" +"@wharfkit/session@0.3.1": + version "0.3.1" + resolved "https://registry.yarnpkg.com/@wharfkit/session/-/session-0.3.1.tgz#7aa8380481925806ba986025cca05bac8a14b773" + integrity sha512-ZipRLwPR2/kNDqM9kQvQyijeURJQ0mSiCtEYMOPRL17Chp39bZa3zTeBfFlTZMoY8nkn0M3k7+bkkOh9D+UwBQ== + dependencies: + "@greymass/eosio" "^0.6.9" + eosio-signing-request "^2.5.2" + pako "^2.0.4" + tslib "^2.1.0" + "@wharfkit/session@^1.0.0": version "1.0.0" resolved "https://registry.npmjs.org/@wharfkit/session/-/session-1.0.0.tgz" @@ -1200,6 +1231,14 @@ emoji-regex@^8.0.0: resolved "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz" integrity sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A== +eosio-signing-request@^2.5.2: + version "2.5.3" + resolved "https://registry.yarnpkg.com/eosio-signing-request/-/eosio-signing-request-2.5.3.tgz#4b15bcc67d2814393a83084c45e4473d638b16e1" + integrity sha512-jb4cKjQM+NI8+JwtGClYiCDX9sgSeUreRTE7YkIAga0fo21vtLTjTn0ZUH1zyfQU5b4Drdcs/yUMXdPUtqp9bg== + dependencies: + "@greymass/eosio" "^0.6.0" + tslib "^2.0.3" + es6-error@^4.0.1: version "4.1.1" resolved "https://registry.npmjs.org/es6-error/-/es6-error-4.1.1.tgz" @@ -1875,6 +1914,11 @@ keyv@^4.5.3: dependencies: json-buffer "3.0.1" +kleur@^3.0.3: + version "3.0.3" + resolved "https://registry.yarnpkg.com/kleur/-/kleur-3.0.3.tgz#a79c9ecc86ee1ce3fa6206d1216c501f147fc07e" + integrity sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w== + levn@^0.4.1: version "0.4.1" resolved "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz" @@ -2307,11 +2351,24 @@ process-on-spawn@^1.0.0: dependencies: fromentries "^1.2.0" +prompts@^2.4.2: + version "2.4.2" + resolved "https://registry.yarnpkg.com/prompts/-/prompts-2.4.2.tgz#7b57e73b3a48029ad10ebd44f74b01722a4cb069" + integrity sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q== + dependencies: + kleur "^3.0.3" + sisteransi "^1.0.5" + punycode@^2.1.0: version "2.3.0" resolved "https://registry.npmjs.org/punycode/-/punycode-2.3.0.tgz" integrity sha512-rRV+zQD8tVFys26lAGR9WUuS4iUAngJScM+ZRSKtvl5tKeZ2t5bvdNFdNHBW9FWR4guGHlgmsZ1G7BSm2wTbuA== +qrcode-terminal@^0.12.0: + version "0.12.0" + resolved "https://registry.yarnpkg.com/qrcode-terminal/-/qrcode-terminal-0.12.0.tgz#bb5b699ef7f9f0505092a3748be4464fe71b5819" + integrity sha512-EXtzRZmC+YGmGlDFbXKxQiMZNwCLEO6BANKXG4iCtSIM0yqc/pappSx3RIKr4r0uh5JsBckOXeKrB3Iz7mdQpQ== + queue-microtask@^1.2.2: version "1.2.3" resolved "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz" @@ -2492,6 +2549,11 @@ sinon@^15.2.0: nise "^5.1.4" supports-color "^7.2.0" +sisteransi@^1.0.5: + version "1.0.5" + resolved "https://registry.yarnpkg.com/sisteransi/-/sisteransi-1.0.5.tgz#134d681297756437cc05ca01370d3a7a571075ed" + integrity sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg== + skip-regex@^1.0.2: version "1.0.2" resolved "https://registry.npmjs.org/skip-regex/-/skip-regex-1.0.2.tgz" From 7ccbcc5891f212eea23a163923eee937323f1f1d Mon Sep 17 00:00:00 2001 From: dafuga Date: Thu, 13 Nov 2025 23:01:27 -0800 Subject: [PATCH 07/36] fix: getting tests passing --- Makefile | 4 +- package.json | 1 - src/commands/chain/install.ts | 2 +- src/utils/wharfkit-ui.ts | 83 ++++++++++++++++++++++++++++++++--- yarn.lock | 62 -------------------------- 5 files changed, 81 insertions(+), 71 deletions(-) diff --git a/Makefile b/Makefile index 25db953..3cfe710 100644 --- a/Makefile +++ b/Makefile @@ -10,13 +10,13 @@ lib: ${SRC_FILES} package.json tsconfig.json node_modules rollup.config.js .PHONY: test test: node_modules @TS_NODE_PROJECT='./test/tsconfig.json' MOCK_DIR='./test/data/requests' \ - ${BIN}/mocha ${MOCHA_OPTS} ${TEST_FILES} --no-timeout --grep '$(grep)' + ${BIN}/mocha ${MOCHA_OPTS} ${TEST_FILES} --no-timeout --grep '$(grep)' --exit .PHONY: ci-test ci-test: node_modules @TS_NODE_PROJECT='./test/tsconfig.json' MOCK_DIR='./test/data/requests' \ ${BIN}/nyc ${NYC_OPTS} --reporter=text \ - ${BIN}/mocha ${MOCHA_OPTS} -R list ${TEST_FILES} --no-timeout + ${BIN}/mocha ${MOCHA_OPTS} -R list ${TEST_FILES} --no-timeout --exit .PHONY: test_generate test_generate: node_modules clean lib diff --git a/package.json b/package.json index 693e816..a336db0 100644 --- a/package.json +++ b/package.json @@ -27,7 +27,6 @@ "dependencies": { "@wharfkit/antelope": "^1.0.0", "@wharfkit/common": "^1.2.4", - "@wharfkit/console-renderer": "^0.1.1", "@wharfkit/contract": "^1.1.4", "@wharfkit/session": "^1.6.1", "@wharfkit/wallet-plugin-privatekey": "^1.1.0", diff --git a/src/commands/chain/install.ts b/src/commands/chain/install.ts index 2e7d731..69f774b 100644 --- a/src/commands/chain/install.ts +++ b/src/commands/chain/install.ts @@ -1,7 +1,7 @@ /* eslint-disable no-console */ import {WalletPluginPrivateKey} from '@wharfkit/wallet-plugin-privatekey' -import {NonInteractiveConsoleUI} from '../../utils/wharfkit-ui' import {executeCommand, getDevKeys, getPlatform} from './utils' +import {NonInteractiveConsoleUI} from '../../utils/wharfkit-ui' export interface InstallationStatus { installed: boolean diff --git a/src/utils/wharfkit-ui.ts b/src/utils/wharfkit-ui.ts index b57796a..9a68eb5 100644 --- a/src/utils/wharfkit-ui.ts +++ b/src/utils/wharfkit-ui.ts @@ -1,15 +1,77 @@ -import {ConsoleUserInterface} from '@wharfkit/console-renderer' import { + AbstractUserInterface, cancelable, type Cancelable, + type CreateAccountContext, + type LocaleDefinitions, + type LoginContext, + type LoginOptions, type PromptArgs, type PromptResponse, + type UserInterfaceAccountCreationResponse, + type UserInterfaceLoginResponse, + type UserInterfaceTranslateOptions, } from '@wharfkit/session' /** - * Console UI wrapper that avoids interactive prompts so automated tests can complete. + * Non-interactive console UI for CLI usage. + * Avoids stdin listeners that would prevent process exit. */ -export class NonInteractiveConsoleUI extends ConsoleUserInterface { +export class NonInteractiveConsoleUI extends AbstractUserInterface { + async login(context: LoginContext): Promise { + return { + walletPluginIndex: 0, + chainId: context.chain?.id ?? context.chains?.[0]?.id, + permissionLevel: context.permissionLevel, + } + } + + async onError(error: Error): Promise { + console.error(`[wharfkit] ${error.message}`) + } + + async onAccountCreate( + _context: CreateAccountContext + ): Promise { + return {} + } + + async onAccountCreateComplete(): Promise { + // No-op + } + + async onLogin(_options?: LoginOptions): Promise { + // No-op + } + + async onLoginComplete(): Promise { + // No-op + } + + async onTransact(): Promise { + // No-op + } + + async onTransactComplete(): Promise { + // No-op + } + + async onSign(): Promise { + // No-op + } + + async onSignComplete(): Promise { + // No-op + } + + async onBroadcast(): Promise { + // No-op + } + + async onBroadcastComplete(): Promise { + // No-op + } + prompt(args: PromptArgs): Cancelable { if (args.title) { console.log(`[wharfkit] ${args.title}`) @@ -17,10 +79,21 @@ export class NonInteractiveConsoleUI extends ConsoleUserInterface { if (args.body) { console.log(args.body) } - return cancelable(Promise.resolve({} as PromptResponse), () => { - /* no cancellation work required */ + // No cancellation work required }) } + + status(message: string): void { + console.log(`[wharfkit] ${message}`) + } + + translate(key: string, options?: UserInterfaceTranslateOptions, _namespace?: string): string { + return String(options?.default ?? key) + } + + addTranslations(_translations: LocaleDefinitions): void { + // No-op + } } diff --git a/yarn.lock b/yarn.lock index 0dbfbae..04e39f5 100644 --- a/yarn.lock +++ b/yarn.lock @@ -255,17 +255,6 @@ resolved "https://registry.npmjs.org/@eslint/js/-/js-8.48.0.tgz" integrity sha512-ZSjtmelB7IJfWD2Fvb7+Z+ChTIKWq6kjda95fLcQKNS5aheVHn4IkfgRQE3sIIzTcSLwLcLZUD9UBt+V7+h+Pw== -"@greymass/eosio@^0.6.0", "@greymass/eosio@^0.6.9": - version "0.6.11" - resolved "https://registry.yarnpkg.com/@greymass/eosio/-/eosio-0.6.11.tgz#9bd1783f8467834563ac893d41524c01aba0228c" - integrity sha512-Ud6V7+vQJ+OLxqD7QcKTv/ik/Hd5uaRHyGY9Hzc+cJXezYU4sADX4TyQ8sn6XIr8ebVVNZFvWpc5JR0a9lBzKw== - dependencies: - bn.js "^4.11.9" - brorand "^1.1.0" - elliptic "^6.5.4" - hash.js "^1.0.0" - tslib "^2.0.3" - "@humanwhocodes/config-array@^0.11.10": version "0.11.10" resolved "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.11.10.tgz" @@ -701,16 +690,6 @@ dependencies: tslib "^2.1.0" -"@wharfkit/console-renderer@^0.1.1": - version "0.1.1" - resolved "https://registry.yarnpkg.com/@wharfkit/console-renderer/-/console-renderer-0.1.1.tgz#dc499c92e6097d230a38e81faff82f2f16fdfde2" - integrity sha512-a+eQBoOZFcfjqJLwBPVkfJCiuYJUqD79s6iTMs4zAz+4paogxUR3WHyVBeG1yVvCRBPpxECV53uO9A7mSyeE+A== - dependencies: - "@wharfkit/session" "0.3.1" - prompts "^2.4.2" - qrcode-terminal "^0.12.0" - tslib "^2.1.0" - "@wharfkit/contract@^1.1.4": version "1.1.4" resolved "https://registry.yarnpkg.com/@wharfkit/contract/-/contract-1.1.4.tgz#5d65a7effa02bb71c98a7493cfdca2472578e6d5" @@ -732,16 +711,6 @@ node-fetch "^2.6.1" tslib "^2.1.0" -"@wharfkit/session@0.3.1": - version "0.3.1" - resolved "https://registry.yarnpkg.com/@wharfkit/session/-/session-0.3.1.tgz#7aa8380481925806ba986025cca05bac8a14b773" - integrity sha512-ZipRLwPR2/kNDqM9kQvQyijeURJQ0mSiCtEYMOPRL17Chp39bZa3zTeBfFlTZMoY8nkn0M3k7+bkkOh9D+UwBQ== - dependencies: - "@greymass/eosio" "^0.6.9" - eosio-signing-request "^2.5.2" - pako "^2.0.4" - tslib "^2.1.0" - "@wharfkit/session@^1.0.0": version "1.0.0" resolved "https://registry.npmjs.org/@wharfkit/session/-/session-1.0.0.tgz" @@ -1231,14 +1200,6 @@ emoji-regex@^8.0.0: resolved "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz" integrity sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A== -eosio-signing-request@^2.5.2: - version "2.5.3" - resolved "https://registry.yarnpkg.com/eosio-signing-request/-/eosio-signing-request-2.5.3.tgz#4b15bcc67d2814393a83084c45e4473d638b16e1" - integrity sha512-jb4cKjQM+NI8+JwtGClYiCDX9sgSeUreRTE7YkIAga0fo21vtLTjTn0ZUH1zyfQU5b4Drdcs/yUMXdPUtqp9bg== - dependencies: - "@greymass/eosio" "^0.6.0" - tslib "^2.0.3" - es6-error@^4.0.1: version "4.1.1" resolved "https://registry.npmjs.org/es6-error/-/es6-error-4.1.1.tgz" @@ -1914,11 +1875,6 @@ keyv@^4.5.3: dependencies: json-buffer "3.0.1" -kleur@^3.0.3: - version "3.0.3" - resolved "https://registry.yarnpkg.com/kleur/-/kleur-3.0.3.tgz#a79c9ecc86ee1ce3fa6206d1216c501f147fc07e" - integrity sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w== - levn@^0.4.1: version "0.4.1" resolved "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz" @@ -2351,24 +2307,11 @@ process-on-spawn@^1.0.0: dependencies: fromentries "^1.2.0" -prompts@^2.4.2: - version "2.4.2" - resolved "https://registry.yarnpkg.com/prompts/-/prompts-2.4.2.tgz#7b57e73b3a48029ad10ebd44f74b01722a4cb069" - integrity sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q== - dependencies: - kleur "^3.0.3" - sisteransi "^1.0.5" - punycode@^2.1.0: version "2.3.0" resolved "https://registry.npmjs.org/punycode/-/punycode-2.3.0.tgz" integrity sha512-rRV+zQD8tVFys26lAGR9WUuS4iUAngJScM+ZRSKtvl5tKeZ2t5bvdNFdNHBW9FWR4guGHlgmsZ1G7BSm2wTbuA== -qrcode-terminal@^0.12.0: - version "0.12.0" - resolved "https://registry.yarnpkg.com/qrcode-terminal/-/qrcode-terminal-0.12.0.tgz#bb5b699ef7f9f0505092a3748be4464fe71b5819" - integrity sha512-EXtzRZmC+YGmGlDFbXKxQiMZNwCLEO6BANKXG4iCtSIM0yqc/pappSx3RIKr4r0uh5JsBckOXeKrB3Iz7mdQpQ== - queue-microtask@^1.2.2: version "1.2.3" resolved "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz" @@ -2549,11 +2492,6 @@ sinon@^15.2.0: nise "^5.1.4" supports-color "^7.2.0" -sisteransi@^1.0.5: - version "1.0.5" - resolved "https://registry.yarnpkg.com/sisteransi/-/sisteransi-1.0.5.tgz#134d681297756437cc05ca01370d3a7a571075ed" - integrity sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg== - skip-regex@^1.0.2: version "1.0.2" resolved "https://registry.npmjs.org/skip-regex/-/skip-regex-1.0.2.tgz" From 41aa6e3067cee2c41f47410cd38435d6fab4fb33 Mon Sep 17 00:00:00 2001 From: dafuga Date: Sun, 16 Nov 2025 18:47:06 -0800 Subject: [PATCH 08/36] fix: getting tests passing --- Makefile | 4 +- src/commands/wallet/transact.ts | 78 +++++++------------------------ src/index.ts | 6 ++- test/tests/e2e-workflow.ts | 82 ++++++++------------------------- 4 files changed, 43 insertions(+), 127 deletions(-) diff --git a/Makefile b/Makefile index 3cfe710..3b724a6 100644 --- a/Makefile +++ b/Makefile @@ -9,12 +9,12 @@ lib: ${SRC_FILES} package.json tsconfig.json node_modules rollup.config.js .PHONY: test test: node_modules - @TS_NODE_PROJECT='./test/tsconfig.json' MOCK_DIR='./test/data/requests' \ + @WHARFKIT_TEST=1 TS_NODE_PROJECT='./test/tsconfig.json' MOCK_DIR='./test/data/requests' \ ${BIN}/mocha ${MOCHA_OPTS} ${TEST_FILES} --no-timeout --grep '$(grep)' --exit .PHONY: ci-test ci-test: node_modules - @TS_NODE_PROJECT='./test/tsconfig.json' MOCK_DIR='./test/data/requests' \ + @WHARFKIT_TEST=1 TS_NODE_PROJECT='./test/tsconfig.json' MOCK_DIR='./test/data/requests' \ ${BIN}/nyc ${NYC_OPTS} --reporter=text \ ${BIN}/mocha ${MOCHA_OPTS} -R list ${TEST_FILES} --no-timeout --exit diff --git a/src/commands/wallet/transact.ts b/src/commands/wallet/transact.ts index a23705f..ad7cd4e 100644 --- a/src/commands/wallet/transact.ts +++ b/src/commands/wallet/transact.ts @@ -1,22 +1,13 @@ -import { - APIClient, - Checksum256, - FetchProvider, - SignedTransaction, - Transaction, -} from '@wharfkit/antelope' +import {Checksum256, SignedTransaction, Transaction} from '@wharfkit/antelope' import {log} from '../../utils' import {getKeyFromWallet, listWalletKeys} from './utils' import * as readline from 'readline' import * as fs from 'fs' -import fetch from 'node-fetch' -interface TransactOptions { +interface SignOptions { key?: string password?: boolean output?: string - broadcast?: boolean - url?: string } /** @@ -147,9 +138,12 @@ function selectKey(keyName?: string): string { } /** - * Transact a transaction (sign-only for now) + * Sign a transaction */ -export async function transactTransaction(transactionJson: string, options: TransactOptions): Promise { +export async function signTransaction( + transactionJson: string, + options: SignOptions +): Promise { try { // Load the transaction const transaction = loadTransaction(transactionJson) @@ -158,9 +152,6 @@ export async function transactTransaction(transactionJson: string, options: Tran log(JSON.stringify(transaction, null, 2), 'info') log('', 'info') - const shouldBroadcast = !!options.broadcast - const apiUrl = options.url || 'http://127.0.0.1:8888' - // Select the key to use const keyName = selectKey(options.key) log(`Using key: ${keyName}`, 'info') @@ -171,31 +162,14 @@ export async function transactTransaction(transactionJson: string, options: Tran // Load the private key const privateKey = getKeyFromWallet(keyName, password) - let client: APIClient | undefined - let chainId: Checksum256 - - if (shouldBroadcast) { - try { - client = new APIClient({ - provider: new FetchProvider(apiUrl, {fetch}), - }) - const info = await client.v1.chain.get_info() - chainId = Checksum256.from(String(info.chain_id)) - log(`Broadcast target: ${apiUrl}`, 'info') - log(`Chain ID: ${chainId.toString()}`, 'info') - log('', 'info') - } catch (error) { - log(`āŒ Failed to fetch chain info: ${(error as Error).message}`, 'info') - process.exit(1) - } - } else { - // Default placeholder chain IDs for offline signing - chainId = Checksum256.from( - transaction.ref_block_num - ? '73e4385a2708e6d7048834fbc1079f2fabb17b3c125b146af438971e90716c4d' - : '0000000000000000000000000000000000000000000000000000000000000000' - ) - } + // Create chain ID (you might want to make this configurable) + // For now, we'll use a placeholder. In a real scenario, this should come from + // the transaction data or be specified by the user + const chainId = Checksum256.from( + transaction.ref_block_num + ? '73e4385a2708e6d7048834fbc1079f2fabb17b3c125b146af438971e90716c4d' // EOS mainnet as default + : '0000000000000000000000000000000000000000000000000000000000000000' + ) // Sign the transaction const digest = transaction.signingDigest(chainId) @@ -215,7 +189,7 @@ export async function transactTransaction(transactionJson: string, options: Tran if (options.output) { // Save to file fs.writeFileSync(options.output, output, 'utf8') - log(`Transaction output saved to: ${options.output}`, 'info') + log(`Signed transaction saved to: ${options.output}`, 'info') } else { // Print to stdout log('Signed Transaction:', 'info') @@ -224,26 +198,8 @@ export async function transactTransaction(transactionJson: string, options: Tran log('', 'info') log(`Signature: ${signature.toString()}`, 'info') - - if (shouldBroadcast && client) { - try { - const result = await client.v1.chain.push_transaction(signedTransaction) - log('', 'info') - log('šŸš€ Transaction broadcast successfully!', 'info') - if (result.transaction_id) { - log(`Transaction ID: ${result.transaction_id}`, 'info') - } - const status = result.processed?.receipt?.status - if (status) { - log(`Status: ${status}`, 'info') - } - } catch (error) { - log(`āŒ Failed to broadcast transaction: ${(error as Error).message}`, 'info') - process.exit(1) - } - } } catch (error) { - log(`āŒ Failed to process transaction: ${(error as Error).message}`, 'info') + log(`āŒ Failed to sign transaction: ${(error as Error).message}`, 'info') process.exit(1) } } diff --git a/src/index.ts b/src/index.ts index 78c650a..adcaf07 100644 --- a/src/index.ts +++ b/src/index.ts @@ -4,7 +4,11 @@ import {version} from '../package.json' import {generateContractFromCommand} from './commands/contract' import {generateKeysFromCommand} from './commands/keys/index' import {createChainCommand} from './commands/chain/index' -import {createCompileCommand, createDeployCommand, createDevCommand} from './commands/wharfkit/index' +import { + createCompileCommand, + createDeployCommand, + createDevCommand, +} from './commands/wharfkit/index' import {createWalletCommand} from './commands/wallet/index' const program = new Command() diff --git a/test/tests/e2e-workflow.ts b/test/tests/e2e-workflow.ts index 9129595..4ac2e4f 100644 --- a/test/tests/e2e-workflow.ts +++ b/test/tests/e2e-workflow.ts @@ -18,7 +18,7 @@ suite('E2E Workflow', () => { let testWalletDir: string let originalHome: string - setup(function () { + suiteSetup(function () { // Create a temporary test directory testDir = path.join(os.tmpdir(), `wharfkit-e2e-test-${Date.now()}`) fs.mkdirSync(testDir, {recursive: true}) @@ -30,9 +30,11 @@ suite('E2E Workflow', () => { // Mock HOME to use test wallet directory originalHome = process.env.HOME || '' process.env.HOME = testDir + + execSync(`node ${cliPath} chain local start`, {encoding: 'utf8'}) }) - teardown(function () { + suiteTeardown(function () { // Restore original HOME process.env.HOME = originalHome @@ -40,6 +42,8 @@ suite('E2E Workflow', () => { if (fs.existsSync(testDir)) { fs.rmSync(testDir, {recursive: true, force: true}) } + + execSync(`node ${cliPath} chain local stop`, {encoding: 'utf8'}) }) suite('Wallet Key Management', () => { @@ -147,65 +151,16 @@ suite('E2E Workflow', () => { const saved = JSON.parse(fs.readFileSync(signedPath, 'utf8')) assert.isArray(saved.signatures, 'signed transaction should include signatures array') - assert.isAbove(saved.signatures.length, 0, 'signed transaction should contain at least one signature') + assert.isAbove( + saved.signatures.length, + 0, + 'signed transaction should contain at least one signature' + ) }) test('broadcasts transaction when --broadcast is provided', async function () { - const chainId = - 'b94d27b9934d3e08a52e52d7da7dabfade8882abff6b19413ababd9f146e6e1' const requests: Array<{path: string; method: string; body?: any}> = [] - const server = http.createServer((req, res) => { - if (!req.url || !req.method) { - res.writeHead(400) - res.end() - return - } - - if (req.method === 'GET' && req.url === '/v1/chain/get_info') { - requests.push({path: req.url, method: req.method}) - res.writeHead(200, {'Content-Type': 'application/json'}) - res.end(JSON.stringify({chain_id: chainId})) - return - } - - if (req.method === 'POST' && req.url === '/v1/chain/push_transaction') { - let body = '' - req.on('data', (chunk) => { - body += chunk - }) - req.on('end', () => { - requests.push({ - path: req.url as string, - method: req.method as string, - body: body ? JSON.parse(body) : undefined, - }) - res.writeHead(200, {'Content-Type': 'application/json'}) - res.end( - JSON.stringify({ - transaction_id: 'abcd1234ef567890', - processed: {receipt: {status: 'executed'}}, - }) - ) - }) - return - } - - res.writeHead(404) - res.end() - }) - - const port = await new Promise((resolve) => { - server.listen(0, () => { - const address = server.address() - if (typeof address === 'object' && address?.port) { - resolve(address.port) - } else { - resolve(0) - } - }) - }) - const txPath = path.join(testDir, 'transaction-broadcast.json') const transaction = { expiration: '2025-11-11T00:00:00', @@ -230,16 +185,18 @@ suite('E2E Workflow', () => { execSync(`node ${cliPath} wallet create --name broadcastkey`, {encoding: 'utf8'}) let output: string | undefined + try { - output = execSync( - `node ${cliPath} wallet transact ${txPath} --broadcast --url http://127.0.0.1:${port}`, - {encoding: 'utf8'} - ) - } finally { - await new Promise((resolve) => server.close(resolve)) + output = execSync(`node ${cliPath} wallet transact ${txPath} --broadcast`, { + encoding: 'utf8', + }) + } catch (error: any) { + console.error(error) + output = error.stdout } assert.isString(output) + console.log({output}) assert.include(output, 'šŸš€ Transaction broadcast successfully!') assert.include(output, 'Transaction ID: abcd1234ef567890') assert.include(output, 'Status: executed') @@ -378,4 +335,3 @@ class [[eosio::contract]] hello : public eosio::contract { }) }) }) - From afe61c72b5410115c38f25f139a50f8c0ad38aa6 Mon Sep 17 00:00:00 2001 From: dafuga Date: Mon, 17 Nov 2025 10:54:52 -0800 Subject: [PATCH 09/36] fix: getting tests passing --- src/commands/wallet/transact.ts | 112 +++++++++++++++++++++++++++++++- test/tests/e2e-workflow.ts | 88 +++++++++++++++---------- test/tests/wallet.ts | 11 +++- 3 files changed, 172 insertions(+), 39 deletions(-) diff --git a/src/commands/wallet/transact.ts b/src/commands/wallet/transact.ts index ad7cd4e..2b79f04 100644 --- a/src/commands/wallet/transact.ts +++ b/src/commands/wallet/transact.ts @@ -1,4 +1,6 @@ import {Checksum256, SignedTransaction, Transaction} from '@wharfkit/antelope' +import {APIClient} from '@wharfkit/antelope' +import {FetchProvider} from '@wharfkit/antelope' import {log} from '../../utils' import {getKeyFromWallet, listWalletKeys} from './utils' import * as readline from 'readline' @@ -10,6 +12,11 @@ interface SignOptions { output?: string } +interface TransactOptions extends SignOptions { + broadcast?: boolean + url?: string +} + /** * Prompt for password from stdin */ @@ -162,9 +169,7 @@ export async function signTransaction( // Load the private key const privateKey = getKeyFromWallet(keyName, password) - // Create chain ID (you might want to make this configurable) - // For now, we'll use a placeholder. In a real scenario, this should come from - // the transaction data or be specified by the user + // When not broadcasting, use EOS mainnet as default (user can modify transaction before broadcasting) const chainId = Checksum256.from( transaction.ref_block_num ? '73e4385a2708e6d7048834fbc1079f2fabb17b3c125b146af438971e90716c4d' // EOS mainnet as default @@ -203,3 +208,104 @@ export async function signTransaction( process.exit(1) } } + +/** + * Sign and optionally broadcast a transaction + */ +export async function transactTransaction( + transactionJson: string, + options: TransactOptions +): Promise { + try { + // Load the transaction + const transaction = loadTransaction(transactionJson) + + log('Transaction loaded:', 'info') + log(JSON.stringify(transaction, null, 2), 'info') + log('', 'info') + + // Select the key to use + const keyName = selectKey(options.key) + log(`Using key: ${keyName}`, 'info') + + // Get password if needed + const password = await getPassword(!!options.password) + + // Load the private key + const privateKey = getKeyFromWallet(keyName, password) + + // Get chain ID - fetch from blockchain if broadcasting, otherwise use a default + let chainId: Checksum256 + if (options.broadcast) { + const url = options.url || 'http://127.0.0.1:8888' + const client = new APIClient({ + provider: new FetchProvider(url, {fetch: globalThis.fetch}), + }) + const info = await client.v1.chain.get_info() + chainId = Checksum256.from(info.chain_id) + log(`Chain ID: ${chainId}`, 'info') + } else { + // When not broadcasting, use EOS mainnet as default (user can modify transaction before broadcasting) + chainId = Checksum256.from( + transaction.ref_block_num + ? '73e4385a2708e6d7048834fbc1079f2fabb17b3c125b146af438971e90716c4d' // EOS mainnet + : '0000000000000000000000000000000000000000000000000000000000000000' + ) + } + + // Sign the transaction + const digest = transaction.signingDigest(chainId) + const signature = privateKey.signDigest(digest) + + // Create signed transaction + const signedTransaction = SignedTransaction.from({ + ...transaction, + signatures: [signature], + }) + + log('āœ… Transaction signed successfully!', 'info') + log('', 'info') + + if (options.broadcast) { + // Broadcast the transaction + const url = options.url || 'http://127.0.0.1:8888' + log(`Broadcast target: ${url}`, 'info') + + try { + // Create API client + const client = new APIClient({ + provider: new FetchProvider(url, {fetch: globalThis.fetch}), + }) + + // Push the transaction + const result = await client.v1.chain.push_transaction(signedTransaction) + + log('šŸš€ Transaction broadcast successfully!', 'info') + log(`Transaction ID: ${result.transaction_id}`, 'info') + log(`Status: ${result.processed.receipt.status}`, 'info') + } catch (error: any) { + log(`āŒ Failed to broadcast transaction: ${error.message}`, 'info') + process.exit(1) + } + } else { + // Just output the signed transaction + const output = JSON.stringify(signedTransaction, null, 2) + + if (options.output) { + // Save to file + fs.writeFileSync(options.output, output, 'utf8') + log(`Signed transaction saved to: ${options.output}`, 'info') + } else { + // Print to stdout + log('Signed Transaction:', 'info') + log(output, 'info') + } + + log('', 'info') + log(`Signature: ${signature.toString()}`, 'info') + } + } catch (error) { + log(`āŒ Failed to transact: ${(error as Error).message}`, 'info') + process.exit(1) + } +} diff --git a/test/tests/e2e-workflow.ts b/test/tests/e2e-workflow.ts index 4ac2e4f..9496aa4 100644 --- a/test/tests/e2e-workflow.ts +++ b/test/tests/e2e-workflow.ts @@ -12,6 +12,16 @@ import * as http from 'http' * 3. Compile contracts * 4. Deploy contracts */ + +/** + * Get a transaction expiration date 1 hour from now + */ +function getTransactionExpiration(): string { + const now = new Date() + now.setHours(now.getHours() + 1) + return now.toISOString().slice(0, 19) // Remove milliseconds and timezone +} + suite('E2E Workflow', () => { const cliPath = path.join(__dirname, '../../lib/cli.js') let testDir: string @@ -31,6 +41,13 @@ suite('E2E Workflow', () => { originalHome = process.env.HOME || '' process.env.HOME = testDir + // Kill any existing processes on port 8888 + try { + execSync(`lsof -ti:8888 | xargs kill -9 2>/dev/null || true`, {encoding: 'utf8'}) + } catch (error) { + // Ignore errors if no process is running on port 8888 + } + execSync(`node ${cliPath} chain local start`, {encoding: 'utf8'}) }) @@ -86,7 +103,7 @@ suite('E2E Workflow', () => { // Create test transaction const transaction = { - expiration: '2025-11-11T00:00:00', + expiration: getTransactionExpiration(), ref_block_num: 12345, ref_block_prefix: 67890, max_net_usage_words: 0, @@ -119,7 +136,7 @@ suite('E2E Workflow', () => { execSync(`node ${cliPath} wallet create --name outputtest`, {encoding: 'utf8'}) const transaction = { - expiration: '2025-11-11T00:00:00', + expiration: getTransactionExpiration(), ref_block_num: 54321, ref_block_prefix: 98765, max_net_usage_words: 0, @@ -146,7 +163,7 @@ suite('E2E Workflow', () => { {encoding: 'utf8'} ) - assert.include(output, 'Transaction output saved to:') + assert.include(output, 'Signed transaction saved to:') assert.isTrue(fs.existsSync(signedPath)) const saved = JSON.parse(fs.readFileSync(signedPath, 'utf8')) @@ -158,55 +175,56 @@ suite('E2E Workflow', () => { ) }) - test('broadcasts transaction when --broadcast is provided', async function () { - const requests: Array<{path: string; method: string; body?: any}> = [] + test('broadcasts transaction when --broadcast is provided', function () { + // Get valid reference block info from the chain + const infoOutput = execSync('curl -s http://127.0.0.1:8888/v1/chain/get_info', { + encoding: 'utf8', + }) + const chainInfo = JSON.parse(infoOutput) + + // Calculate ref_block_num and ref_block_prefix from last_irreversible_block_num + const blockNum = chainInfo.last_irreversible_block_num + const blockOutput = execSync( + `curl -s -X POST http://127.0.0.1:8888/v1/chain/get_block -d '{"block_num_or_id":${blockNum}}'`, + {encoding: 'utf8'} + ) + const blockInfo = JSON.parse(blockOutput) const txPath = path.join(testDir, 'transaction-broadcast.json') + // Use buyram action - a core system action that's always available + // This buys 1 byte of RAM for eosio from eosio (essentially a no-op but valid) + // Data format for buyram: payer (name), receiver (name), quant (asset) + // Serialized: eosio (8 bytes), eosio (8 bytes), "0.0001 SYS" (asset) const transaction = { - expiration: '2025-11-11T00:00:00', - ref_block_num: 1111, - ref_block_prefix: 2222, + expiration: getTransactionExpiration(), + ref_block_num: blockNum & 0xffff, // Last 16 bits + ref_block_prefix: parseInt(blockInfo.ref_block_prefix), max_net_usage_words: 0, max_cpu_usage_ms: 0, delay_sec: 0, context_free_actions: [], actions: [ { - account: 'eosio.token', - name: 'transfer', - authorization: [{actor: 'broadcastacc', permission: 'active'}], - data: '0000000000ea305500000000487a2b9d010000000000000004535953000000000b62726f616463617374', + account: 'eosio', + name: 'buyram', + authorization: [{actor: 'eosio', permission: 'active'}], + data: '0000000000ea30550000000000ea30550100000000000000045359530000000000', }, ], transaction_extensions: [], } fs.writeFileSync(txPath, JSON.stringify(transaction)) - execSync(`node ${cliPath} wallet create --name broadcastkey`, {encoding: 'utf8'}) - - let output: string | undefined - - try { - output = execSync(`node ${cliPath} wallet transact ${txPath} --broadcast`, { - encoding: 'utf8', - }) - } catch (error: any) { - console.error(error) - output = error.stdout - } + // Test that the broadcast functionality works properly + // Use the 'dev' key which is automatically created by the local chain and has eosio authority + const output = execSync( + `node ${cliPath} wallet transact ${txPath} --broadcast --key dev`, + {encoding: 'utf8'} + ) - assert.isString(output) - console.log({output}) + // Should show broadcast success message assert.include(output, 'šŸš€ Transaction broadcast successfully!') - assert.include(output, 'Transaction ID: abcd1234ef567890') - assert.include(output, 'Status: executed') - - const broadcastRequest = requests.find( - (request) => request.path === '/v1/chain/push_transaction' - ) - assert.isDefined(broadcastRequest, 'push_transaction should be called') - assert.isArray(broadcastRequest?.body?.signatures) - assert.isAbove(broadcastRequest?.body?.signatures.length ?? 0, 0) + assert.include(output, 'Transaction ID:') }) }) diff --git a/test/tests/wallet.ts b/test/tests/wallet.ts index 3e502b0..e9633d5 100644 --- a/test/tests/wallet.ts +++ b/test/tests/wallet.ts @@ -11,6 +11,15 @@ import { getWalletDir, } from 'src/commands/wallet/utils' +/** + * Get a transaction expiration date 1 hour from now + */ +function getTransactionExpiration(): string { + const now = new Date() + now.setHours(now.getHours() + 1) + return now.toISOString().slice(0, 19) // Remove milliseconds and timezone +} + suite('Wallet Utils', () => { let testWalletDir: string @@ -215,7 +224,7 @@ suite('Wallet Utils', () => { // Create a test transaction const transaction = Transaction.from({ - expiration: '2025-11-11T00:00:00', + expiration: getTransactionExpiration(), ref_block_num: 12345, ref_block_prefix: 67890, max_net_usage_words: 0, From 011071eac68ca10180b938c5d0c8350cf99909d3 Mon Sep 17 00:00:00 2001 From: dafuga Date: Tue, 18 Nov 2025 23:13:41 -0700 Subject: [PATCH 10/36] fix: fixing wallet account create --- src/commands/wallet/account.ts | 138 ++++++++++++++++++-------------- src/commands/wharfkit/deploy.ts | 4 +- 2 files changed, 80 insertions(+), 62 deletions(-) diff --git a/src/commands/wallet/account.ts b/src/commands/wallet/account.ts index d8c1f2b..92fdb26 100644 --- a/src/commands/wallet/account.ts +++ b/src/commands/wallet/account.ts @@ -47,6 +47,19 @@ export async function createAccount(options: AccountCreateOptions): Promise String(a.name)) + hasSystemContract = + actionNames.includes('buyrambytes') && actionNames.includes('delegatebw') + } + } catch (e) { + // Ignore error, assume no system contract + } + // Create session with dev key const walletPlugin = new WalletPluginPrivateKey(devPrivateKey) walletPlugin.config.requiresChainSelect = false @@ -64,79 +77,84 @@ export async function createAccount(options: AccountCreateOptions): Promise Date: Wed, 19 Nov 2025 19:14:49 -0700 Subject: [PATCH 11/36] chore: better test coverage --- test/tests/e2e-workflow.ts | 61 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 61 insertions(+) diff --git a/test/tests/e2e-workflow.ts b/test/tests/e2e-workflow.ts index 9496aa4..30326a5 100644 --- a/test/tests/e2e-workflow.ts +++ b/test/tests/e2e-workflow.ts @@ -337,6 +337,67 @@ class [[eosio::contract]] hello : public eosio::contract { }) }) + suite('Integration: Account and Deployment', () => { + test('can create an account on the local chain', function () { + const accountName = 'acc' + Math.random().toString(36).substring(2, 8) + const output = execSync(`node ${cliPath} wallet account create --name ${accountName}`, { + encoding: 'utf8', + }) + + assert.include(output, 'āœ… Account created successfully!') + assert.include(output, `Account: ${accountName}`) + assert.include(output, 'Key stored in wallet') + }) + + test('can deploy a contract to the account', function () { + // Check if cdt-cpp is installed before running this test + try { + execSync('which cdt-cpp') + } catch (e) { + this.skip() + } + + // 1. Create an account + const accountName = 'deploy' + Math.random().toString(36).substring(2, 8) + execSync(`node ${cliPath} wallet account create --name ${accountName}`, { + encoding: 'utf8', + }) + + // 2. Create contract file + const contractCode = ` +#include +class [[eosio::contract]] hello : public eosio::contract { + public: + using eosio::contract::contract; + [[eosio::action]] + void hi(eosio::name user) { + print("Hello, ", user); + } +}; +` + const cppPath = path.join(testDir, 'hello.cpp') + const wasmPath = path.join(testDir, 'hello.wasm') + fs.writeFileSync(cppPath, contractCode) + + // 3. Compile contract + execSync(`node ${cliPath} compile`, { + encoding: 'utf8', + cwd: testDir, + }) + + assert.isTrue(fs.existsSync(wasmPath), 'WASM file should be generated') + + // 4. Deploy contract + const output = execSync(`node ${cliPath} deploy ${wasmPath} --account ${accountName}`, { + encoding: 'utf8', + cwd: testDir, + }) + + assert.include(output, 'āœ… Contract deployed successfully!') + assert.include(output, 'Transaction ID:') + }) + }) + suite('Integration: Wallet Key Storage', () => { test('created keys are persisted in wallet', function () { const keyName = `persistent-${Date.now()}` From 9cd6140bf61e8e47c4099b62265323f550debdd2 Mon Sep 17 00:00:00 2001 From: dafuga Date: Thu, 20 Nov 2025 00:59:54 -0700 Subject: [PATCH 12/36] chore: added table and account commands --- src/commands/chain/index.ts | 7 + src/commands/chain/interact.ts | 226 +++++++++++++++++++++++++++++++++ test/tests/chain-interact.ts | 154 ++++++++++++++++++++++ 3 files changed, 387 insertions(+) create mode 100644 src/commands/chain/interact.ts create mode 100644 test/tests/chain-interact.ts diff --git a/src/commands/chain/index.ts b/src/commands/chain/index.ts index 7a35875..91571b3 100644 --- a/src/commands/chain/index.ts +++ b/src/commands/chain/index.ts @@ -2,6 +2,7 @@ import {Command} from 'commander' import {showChainLogs, showChainStatus, startLocalChain, stopLocalChain} from './local' import {checkLeapInstallation} from './install' +import {addInteractCommands, addInteractSubcommands} from './interact' /** * Create the chain command with subcommands @@ -13,6 +14,9 @@ export function createChainCommand(): Command { // Local subcommand const local = chain.command('local').description('Manage local blockchain instance') + // Add interact commands to local + addInteractSubcommands(local, 'local') + // Local start local .command('start') @@ -115,6 +119,9 @@ export function createChainCommand(): Command { } }) + // Add dynamic chain commands + addInteractCommands(chain) + return chain } diff --git a/src/commands/chain/interact.ts b/src/commands/chain/interact.ts new file mode 100644 index 0000000..5575d43 --- /dev/null +++ b/src/commands/chain/interact.ts @@ -0,0 +1,226 @@ +/* eslint-disable no-console */ +import {APIClient, Name} from '@wharfkit/antelope' +import {Chains} from '@wharfkit/common' +import {Contract} from '@wharfkit/contract' +import {Command} from 'commander' +import fetch from 'node-fetch' + +interface ChainInteractOptions { + filter?: string + json?: boolean + scope?: string +} + +function getApiUrl(chainName: string): string { + // Check if it matches a known chain key from @wharfkit/common + // The keys in Chains are PascalCase (e.g. Jungle4, EOS, WAX) + // We should try to match case-insensitively + const knownChainKey = Object.keys(Chains).find( + (key) => key.toLowerCase() === chainName.toLowerCase() + ) + + if (knownChainKey) { + return (Chains as any)[knownChainKey].url + } + + switch (chainName) { + case 'local': + return 'http://127.0.0.1:8888' + // mainnet, jungle4 etc are now handled via Chains lookup above + // but keeping defaults/overrides if needed or for fallbacks + default: + if (chainName.startsWith('http')) { + return chainName + } + throw new Error(`Unknown chain: ${chainName}. Please provide a full URL or a known chain name.`) + } +} + +function createApiClient(url: string): APIClient { + return new APIClient({ + url, + fetch, + }) +} + +export async function lookupTable( + chainName: string, + tableNameInput: string, + options: ChainInteractOptions +): Promise { + const url = getApiUrl(chainName) + const api = createApiClient(url) + + let accountName: string + let tableName: string + + if (tableNameInput.includes('::')) { + const parts = tableNameInput.split('::') + accountName = parts[0] + tableName = parts[1] + } else { + if (!options.scope) { + throw new Error('Please specify contract in format contract::table or use --scope to specify contract') + } + accountName = options.scope + tableName = tableNameInput + } + + try { + const abiResponse = await api.v1.chain.get_abi(accountName) + if (!abiResponse.abi) { + throw new Error(`No ABI found for ${accountName}`) + } + + const contract = new Contract({ + client: api, + account: Name.from(accountName), + abi: abiResponse.abi, + }) + + const queryOptions: any = {} + if (options.filter) { + queryOptions.from = options.filter + queryOptions.limit = 10 + } + + // scope defaults to contract name if not provided + // If user provided --scope, we use it. + // Note: In our logic above, if :: is used, accountName is contract. + // If not, accountName is options.scope (contract). + // Table scope (the second arg to table()) is the data scope. + // It defaults to contract name. + // If user wants a different scope than contract, we might need another flag? + // But usually contract::table implies scope=contract? + // EOSIO tables: code, scope, table. + // Contract.table(name, scope) + // We will assume scope = contract name unless specified? + // options.scope is currently used for contract name if :: is missing. + // If :: is present, options.scope could be the data scope. + + let dataScope = accountName + if (tableNameInput.includes('::') && options.scope) { + dataScope = options.scope + } + + const tableInstance = contract.table(tableName, dataScope) + + const results = await tableInstance.query(queryOptions) + + if (options.json) { + console.log(JSON.stringify(results, null, 2)) + } else { + console.table(results) + } + + } catch (error: any) { + console.error(`Error fetching table data: ${error.message}`) + process.exit(1) + } +} + +export async function lookupAccount( + chainName: string, + accountName: string, + options: ChainInteractOptions +): Promise { + const url = getApiUrl(chainName) + const api = createApiClient(url) + + try { + const account = await api.v1.chain.get_account(accountName) + + if (options.json) { + console.log(JSON.stringify(account, null, 2)) + } else { + // Pretty print essential info + console.log(`Account: ${account.account_name}`) + console.log(`Created: ${account.created}`) + console.log(`Privileged: ${account.privileged}`) + console.log(`Last code update: ${account.last_code_update}`) + + if (account.core_liquid_balance) { + console.log(`Liquid Balance: ${account.core_liquid_balance}`) + } + + console.log('\nResources:') + console.log(` RAM: ${account.ram_usage} / ${account.ram_quota} bytes`) + if (account.net_limit) { + console.log(` NET: ${account.net_limit.used} / ${account.net_limit.max} bytes (${account.net_limit.available} available)`) + } + if (account.cpu_limit) { + console.log(` CPU: ${account.cpu_limit.used} / ${account.cpu_limit.max} us (${account.cpu_limit.available} available)`) + } + + if (account.permissions.length > 0) { + console.log('\nPermissions:') + for (const perm of account.permissions) { + console.log(` ${perm.perm_name} (${perm.parent}):`) + console.log(` Threshold: ${perm.required_auth.threshold}`) + for (const key of perm.required_auth.keys) { + console.log(` Key: ${key.key} (weight: ${key.weight})`) + } + for (const acc of perm.required_auth.accounts) { + console.log(` Account: ${acc.permission.actor}@${acc.permission.permission} (weight: ${acc.weight})`) + } + } + } + } + } catch (error: any) { + console.error(`Error fetching account: ${error.message}`) + process.exit(1) + } +} + +export function addInteractSubcommands(command: Command, fixedChainName?: string) { + command + .command('table ') + .description('Lookup table data (format: contract::table or use --scope)') + .option('--filter ', 'Filter the table data') + .option('--scope ', 'The contract/scope of the table') + .option('--json', 'Output as JSON') + .action(async (tableName, options, cmd) => { + const chainName = fixedChainName || cmd.parent?.args[0] + if (!chainName) { + console.error('Chain name is required') + process.exit(1) + } + await lookupTable(chainName, tableName, options) + }) + + command + .command('account ') + .description('Lookup account data') + .option('--json', 'Output as JSON') + .action(async (accountName, options, cmd) => { + const chainName = fixedChainName || cmd.parent?.args[0] + if (!chainName) { + console.error('Chain name is required') + process.exit(1) + } + await lookupAccount(chainName, accountName, options) + }) +} + +export function addInteractCommands(chain: Command) { + // Register known chains from @wharfkit/common as explicit subcommands + // This ensures they are discoverable and work without 'remote' prefix + const knownChains = Object.keys(Chains) + + for (const chainKey of knownChains) { + const chainName = chainKey.toLowerCase() // register as lowercase (jungle4, eos, etc) + + // Skip if it conflicts with existing commands (like 'local') - though 'local' isn't in Chains + if (chainName === 'local') continue + + const cmd = chain.command(chainName) + .description(`Interact with ${chainKey} chain`) + + addInteractSubcommands(cmd, chainKey) // Pass the PascalCase key or lowercase? getApiUrl handles both. + } + + // For arbitrary URLs, we still want a catch-all or 'remote' command. + const chainContext = chain.command('remote ') + .description('Interact with a custom chain URL') + addInteractSubcommands(chainContext) +} diff --git a/test/tests/chain-interact.ts b/test/tests/chain-interact.ts new file mode 100644 index 0000000..d0ee8b1 --- /dev/null +++ b/test/tests/chain-interact.ts @@ -0,0 +1,154 @@ +import {assert} from 'chai' +import {execSync} from 'child_process' +import * as fs from 'fs' +import * as path from 'path' +import * as os from 'os' + +suite('Chain Interaction', () => { + const cliPath = path.join(__dirname, '../../lib/cli.js') + let testDir: string + let originalHome: string + let contractAccount: string + + suiteSetup(function () { + this.timeout(120000) // Increase timeout for chain startup and deploy + + // Create a temporary test directory + testDir = path.join(os.tmpdir(), `wharfkit-interact-test-${Date.now()}`) + fs.mkdirSync(testDir, {recursive: true}) + + // Mock HOME to use test wallet directory + originalHome = process.env.HOME || '' + process.env.HOME = testDir + + // Kill any existing processes on port 8888 + try { + execSync(`lsof -ti:8888 | xargs kill -9 2>/dev/null || true`, {encoding: 'utf8'}) + } catch (error) { + // Ignore errors + } + + // Start local chain + execSync(`node ${cliPath} chain local start`, {encoding: 'utf8'}) + + // Wait for chain + execSync('sleep 5') + + // Check if cdt-cpp is installed + try { + execSync('which cdt-cpp') + + // Deploy a test contract + contractAccount = 'testcontract' + execSync(`node ${cliPath} wallet account create --name ${contractAccount}`, {encoding: 'utf8'}) + + const contractCode = ` + #include + class [[eosio::contract]] testcontract : public eosio::contract { + public: + using eosio::contract::contract; + + struct [[eosio::table]] item { + uint64_t id; + std::string name; + uint64_t primary_key() const { return id; } + }; + typedef eosio::multi_index<"items"_n, item> items_table; + + [[eosio::action]] + void add(uint64_t id, std::string name) { + items_table items(get_self(), get_self().value); + items.emplace(get_self(), [&](auto& row) { + row.id = id; + row.name = name; + }); + } + }; + ` + const cppPath = path.join(testDir, 'testcontract.cpp') + const wasmPath = path.join(testDir, 'testcontract.wasm') + fs.writeFileSync(cppPath, contractCode) + + execSync(`node ${cliPath} compile`, {encoding: 'utf8', cwd: testDir}) + execSync(`node ${cliPath} deploy ${wasmPath} --account ${contractAccount}`, {encoding: 'utf8', cwd: testDir}) + + } catch (e) { + console.log('Skipping contract deployment (cdt-cpp not found or failed)') + contractAccount = '' + } + }) + + suiteTeardown(function () { + this.timeout(30000) + process.env.HOME = originalHome + if (fs.existsSync(testDir)) { + fs.rmSync(testDir, {recursive: true, force: true}) + } + try { + execSync(`node ${cliPath} chain local stop`, {encoding: 'utf8'}) + } catch (e) { } + }) + + test('can lookup table data on deployed contract', function () { + if (!contractAccount) this.skip() + + const output = execSync(`node ${cliPath} chain local table ${contractAccount}::items`, { + encoding: 'utf8', + }) + assert.doesNotThrow(() => {}) + }) + + test('can lookup table data with scope option', function () { + if (!contractAccount) this.skip() + + const output = execSync(`node ${cliPath} chain local table items --scope ${contractAccount}`, { + encoding: 'utf8', + }) + }) + + test('can lookup single account', function () { + const output = execSync(`node ${cliPath} chain local account eosio`, { + encoding: 'utf8', + }) + + assert.include(output, 'Account: eosio') + assert.include(output, 'RAM:') + assert.include(output, 'Permissions:') + }) + + test('can lookup single account with --json', function () { + const output = execSync(`node ${cliPath} chain local account eosio --json`, { + encoding: 'utf8', + }) + + const account = JSON.parse(output) + assert.equal(account.account_name, 'eosio') + assert.property(account, 'permissions') + }) + + test('can access known remote chain (jungle4) directly for account', function () { + try { + execSync(`node ${cliPath} chain jungle4 account teamgreymass`, { + encoding: 'utf8' + }) + } catch (error: any) { + const output = (error.stderr || '').toString() + (error.stdout || '').toString() + if (output.includes('unknown command')) { + throw new Error('Commander failed to match jungle4: ' + output) + } + } + }) + + test('can access known remote chain (jungle4) directly for table', function () { + try { + execSync(`node ${cliPath} chain jungle4 table eosio::global`, { + encoding: 'utf8' + }) + } catch (error: any) { + const output = (error.stderr || '').toString() + (error.stdout || '').toString() + if (output.includes('unknown command')) { + throw new Error('Commander failed to match jungle4 for table command: ' + output) + } + } + }) +}) From 0cf192ba534ba17b8368eafd2704a75aab1e3312 Mon Sep 17 00:00:00 2001 From: dafuga Date: Thu, 20 Nov 2025 01:43:16 -0700 Subject: [PATCH 13/36] enhancement: cleaner table value displaying --- src/commands/chain/interact.ts | 121 +++++++++++++++++++++++++++++++-- 1 file changed, 114 insertions(+), 7 deletions(-) diff --git a/src/commands/chain/interact.ts b/src/commands/chain/interact.ts index 5575d43..84b20b6 100644 --- a/src/commands/chain/interact.ts +++ b/src/commands/chain/interact.ts @@ -9,6 +9,10 @@ interface ChainInteractOptions { filter?: string json?: boolean scope?: string + limit?: string + all?: boolean + columns?: boolean + fields?: string } function getApiUrl(chainName: string): string { @@ -81,9 +85,13 @@ export async function lookupTable( const queryOptions: any = {} if (options.filter) { queryOptions.from = options.filter - queryOptions.limit = 10 } + // Default limit to 4 rows unless specified + const userLimit = options.limit ? parseInt(options.limit, 10) : 4 + // Fetch one extra row to detect if there are more + queryOptions.limit = userLimit + 1 + // scope defaults to contract name if not provided // If user provided --scope, we use it. // Note: In our logic above, if :: is used, accountName is contract. @@ -105,12 +113,87 @@ export async function lookupTable( const tableInstance = contract.table(tableName, dataScope) - const results = await tableInstance.query(queryOptions) + const cursor = await tableInstance.query(queryOptions) + const allRows = await cursor.all() + + const hasMoreRows = allRows.length > userLimit + const rows = hasMoreRows ? allRows.slice(0, userLimit) : allRows if (options.json) { - console.log(JSON.stringify(results, null, 2)) + console.log(JSON.stringify(rows, null, 2)) + } else if (options.columns) { + // List available columns + if (rows.length > 0) { + const firstRow = rows[0] + const rowObj = (firstRow as any).toJSON ? (firstRow as any).toJSON() : firstRow + console.log('Available columns:') + Object.keys(rowObj).forEach(key => console.log(`- ${key}`)) + } else { + console.log('No data available to determine columns.') + } } else { - console.table(results) + // If rows are complex objects (like Wharf structs), they might need toJSON() + // But console.table handles objects well usually. + // Wharfkit structs have toJSON() which returns the plain object. + const plainRows = rows.map((r) => { + const row = (r as any).toJSON ? (r as any).toJSON() : r + // Recursively convert objects to strings for console.table friendliness + // This flattens BNs and other complex types + const flatten = (obj: any): any => { + if (obj && typeof obj === 'object') { + // If it looks like a BN or wrapper { value: ... } + if ('value' in obj && Object.keys(obj).length === 1) { + return String(obj.value) + } + // Recursively map object values + const newObj: any = {} + for (const key in obj) { + newObj[key] = flatten(obj[key]) + } + return newObj + } + return obj + } + return flatten(row) + }) + + // Limit columns displayed unless --all is used + const displayedRows = plainRows.map(row => { + if (options.all) return row + + const newRow: any = {} + const keys = Object.keys(row) + + // Filter by --fields if provided + if (options.fields) { + const selectedFields = options.fields.split(',').map(f => f.trim()) + selectedFields.forEach(key => { + if (key in row) { + newRow[key] = row[key] + } + }) + return newRow + } + + if (keys.length <= 5) return row + + const visibleKeys = keys.slice(0, 4) + visibleKeys.forEach(key => newRow[key] = row[key]) + + // Add summary of hidden columns + const hiddenCount = keys.length - 4 + newRow['...'] = `+${hiddenCount} more fields` + return newRow + }) + + console.table(displayedRows) + + if (hasMoreRows) { + console.log(`\n... and more rows (showing first ${userLimit}). Use --limit to see more.`) + } + if (!options.all && !options.fields && plainRows.length > 0 && Object.keys(plainRows[0]).length > 5) { + console.log(`\nSome columns hidden. Use --all to see all, --fields to select specific columns, or --columns to list them.`) + } } } catch (error: any) { @@ -174,18 +257,42 @@ export async function lookupAccount( export function addInteractSubcommands(command: Command, fixedChainName?: string) { command - .command('table ') + .command('table [extraFields...]') .description('Lookup table data (format: contract::table or use --scope)') .option('--filter ', 'Filter the table data') .option('--scope ', 'The contract/scope of the table') + .option('--limit ', 'Limit the number of rows displayed', '4') + .option('--all', 'Display all columns') + .option('--fields ', 'Comma-separated list of fields/columns to display') + .option('--columns', 'List available columns') .option('--json', 'Output as JSON') - .action(async (tableName, options, cmd) => { + .action(async (tableName, extraFields, options, cmd) => { + // Handle variadic args being shifted if extraFields is empty/present + // Commander passes (arg1, arg2..., options, cmd) + // If extraFields is provided, it's the second arg. + // If NOT provided, options is the second arg? NO, extraFields is an array, potentially empty. + // Wait, with [extraFields...], it is ALWAYS passed as an array (second arg). + // But we need to be careful if 'options' is the 3rd arg. + + let opts = options + let extras = extraFields + + // Commander 7+ usually guarantees order for defined args. + // (tableName, extras, options, cmd) + const chainName = fixedChainName || cmd.parent?.args[0] if (!chainName) { console.error('Chain name is required') process.exit(1) } - await lookupTable(chainName, tableName, options) + + // If user provided spaced fields like "--fields a, b", 'b' ends up in extras. + // We should append them to fields. + if (opts.fields && extras && extras.length > 0) { + opts.fields = [opts.fields, ...extras].join(' ') + } + + await lookupTable(chainName, tableName, opts) }) command From d9e263f4972d7506c960c0bc35c84e9446298f4c Mon Sep 17 00:00:00 2001 From: dafuga Date: Thu, 20 Nov 2025 15:11:31 -0700 Subject: [PATCH 14/36] refactor: using APIClient everywhere --- src/commands/wharfkit/deploy.ts | 147 +++++++++++++++++++++++++++++++- src/commands/wharfkit/index.ts | 2 + test.cpp | 32 +++++++ test/tests/e2e-workflow.ts | 59 +++++++------ 4 files changed, 210 insertions(+), 30 deletions(-) create mode 100644 test.cpp diff --git a/src/commands/wharfkit/deploy.ts b/src/commands/wharfkit/deploy.ts index c369642..e360e52 100644 --- a/src/commands/wharfkit/deploy.ts +++ b/src/commands/wharfkit/deploy.ts @@ -9,9 +9,110 @@ import fetch from 'node-fetch' import {NonInteractiveConsoleUI} from '../../utils/wharfkit-ui' import {getKeyFromWallet, listWalletKeys} from '../wallet/utils' +import {Chains} from '@wharfkit/common' +import {compileContract} from './compile' + interface DeployOptions { account?: string url?: string + force?: boolean + validate?: boolean +} + +/** + * Validate deployment safety (checks for orphaned tables with data) + */ +export async function validateDeploy( + accountName: string, + abiJson: any, + url: string, + force: boolean +): Promise { + const client = new APIClient({ + provider: new FetchProvider(url, {fetch}), + }) + + try { + const existingAbiResponse = await client.v1.chain.get_abi(accountName) + if (existingAbiResponse.abi) { + const oldAbi = existingAbiResponse.abi + const newAbi = ABI.from(abiJson) + + const oldTables = new Set(oldAbi.tables.map((t) => String(t.name))) + const newTables = new Set(newAbi.tables.map((t) => String(t.name))) + + const removedTables = [...oldTables].filter((t) => !newTables.has(t)) + + if (removedTables.length > 0) { + console.log( + `\nāš ļø Warning: The new ABI removes the following tables: ${removedTables.join( + ', ' + )}` + ) + console.log(` Checking for existing data in these tables...`) + + let hasData = false + for (const table of removedTables) { + try { + const rows = await client.v1.chain.get_table_rows({ + code: accountName, + scope: accountName, + table, + limit: 1, + }) + if (rows.rows.length > 0) { + console.log(` āŒ Table '${table}' contains data!`) + hasData = true + } else { + console.log(` āœ… Table '${table}' is empty.`) + } + } catch (e: any) { + // If check fails, ignore or warn? + // Often "table not found" error if using state history or other plugins if really gone? + // But if get_abi returned it, it was in ABI. + // We assume no data if error, or warn. + } + } + + if (hasData) { + if (force) { + console.log(` āš ļø Proceeding despite data loss warning (--force used).`) + } else { + throw new Error( + `Deployment would make existing table data inaccessible (orphaned).` + ) + } + } else { + console.log(` āœ… No data found in removed tables. Safe to proceed.`) + } + } else { + console.log(` āœ… No tables removed.`) + } + } else { + console.log(` āœ… No existing ABI found (new deployment).`) + } + } catch (error: any) { + if (error.message.includes('orphaned')) { + throw new Error( + `SAFETY CHECK FAILED: ${error.message}\nUse --force to override this check and deploy anyway.` + ) + } + // If validation fails due to network or other reasons, we might want to warn but proceed if not validating explicitly? + // If explicitly validating, we should error. + // If deploying, we usually proceed unless critical. + // But "safety check" implies we stop. + // However, if account doesn't exist, get_abi throws. + // We should catch that. + if (error.message.includes('Account not found') || error.message.includes('does not exist')) { + // New account, safe. + return + } + + // If it's a validation run, rethrow. + // If it's a deploy run, maybe warn? + // But we want strict safety. + throw error + } } /** @@ -24,7 +125,24 @@ export async function deployContract( options: DeployOptions ): Promise { // Determine the WASM file to deploy - const wasmPath = wasmFile ? resolve(wasmFile) : await findWasmFile() + let wasmPath: string + try { + wasmPath = wasmFile ? resolve(wasmFile) : await findWasmFile() + } catch (error: any) { + if (error.message.includes('No .wasm files found') && !wasmFile) { + console.log('No WASM file found. Attempting to compile contracts...') + try { + await compileContract(undefined, '.') + wasmPath = await findWasmFile() + } catch (compileError: any) { + throw new Error( + `Failed to auto-compile: ${compileError.message}\nPlease run 'wharfkit compile' manually.` + ) + } + } else { + throw error + } + } if (!existsSync(wasmPath)) { throw new Error(`WASM file not found: ${wasmPath}`) @@ -44,9 +162,21 @@ export async function deployContract( const accountName = options.account || basename(wasmPath, '.wasm') // Determine the blockchain URL - const url = options.url || 'http://127.0.0.1:8888' + let url = options.url || 'http://127.0.0.1:8888' + + // Check if URL is a known chain name + const knownChainKey = Object.keys(Chains).find( + (key) => key.toLowerCase() === url.toLowerCase() + ) + if (knownChainKey) { + url = (Chains as any)[knownChainKey].url + } - console.log(`Deploying contract...`) + if (options.validate) { + console.log(`Validating deployment for ${accountName}...`) + } else { + console.log(`Deploying contract...`) + } console.log(` WASM: ${wasmPath}`) console.log(` ABI: ${abiPath}`) console.log(` Account: ${accountName}`) @@ -57,6 +187,17 @@ export async function deployContract( const wasmCode = readFileSync(wasmPath) const abiJson = JSON.parse(readFileSync(abiPath, 'utf8')) + // Perform validation/safety check + // Only skip if force is used AND we are NOT explicitly validating? + // Actually, even with force, we might want to see warnings. + // But validateDeploy throws if unsafe and not forced. + await validateDeploy(accountName, abiJson, url, !!options.force) + + if (options.validate) { + console.log('\nāœ… Validation passed! Deployment appears safe.') + return + } + // Get private key from wallet for this account const privateKey = await getPrivateKeyForDeploy(accountName) diff --git a/src/commands/wharfkit/index.ts b/src/commands/wharfkit/index.ts index 35d3b08..d49414b 100644 --- a/src/commands/wharfkit/index.ts +++ b/src/commands/wharfkit/index.ts @@ -37,6 +37,8 @@ export function createDeployCommand(): Command { .argument('[wasm]', 'WASM file to deploy (auto-detects if not specified)') .option('-a, --account ', 'Contract account name (default: derived from filename)') .option('-u, --url ', 'Blockchain API URL (default: http://127.0.0.1:8888)') + .option('--force', 'Force deployment even if safety checks fail') + .option('--validate', 'Validate deployment safety without deploying') .action(async (wasm, options) => { try { await deployContract(wasm, options) diff --git a/test.cpp b/test.cpp new file mode 100644 index 0000000..07a0dd3 --- /dev/null +++ b/test.cpp @@ -0,0 +1,32 @@ +#include + +using namespace eosio; + +class [[eosio::contract]] test : public contract { + public: + using contract::contract; + + [[eosio::action]] + void hi(name user) { + print("Hello, ", user); + } + + struct [[eosio::table]] message { + uint64_t id; + std::string text; + + uint64_t primary_key() const { return id; } + }; + + using message_table = eosio::multi_index<"messages"_n, message>; + + [[eosio::action]] + void addmsg(uint64_t id, std::string text) { + message_table messages(get_self(), get_self().value); + messages.emplace(get_self(), [&](auto& row) { + row.id = id; + row.text = text; + }); + } +}; + diff --git a/test/tests/e2e-workflow.ts b/test/tests/e2e-workflow.ts index 30326a5..ae7ac2b 100644 --- a/test/tests/e2e-workflow.ts +++ b/test/tests/e2e-workflow.ts @@ -3,7 +3,8 @@ import {execSync} from 'child_process' import * as fs from 'fs' import * as path from 'path' import * as os from 'os' -import * as http from 'http' +import {APIClient, FetchProvider} from '@wharfkit/antelope' +import fetch from 'node-fetch' /** * E2E tests for the complete workflow: @@ -175,20 +176,16 @@ suite('E2E Workflow', () => { ) }) - test('broadcasts transaction when --broadcast is provided', function () { + test('broadcasts transaction when --broadcast is provided', async function () { // Get valid reference block info from the chain - const infoOutput = execSync('curl -s http://127.0.0.1:8888/v1/chain/get_info', { - encoding: 'utf8', + const client = new APIClient({ + provider: new FetchProvider('http://127.0.0.1:8888', {fetch}), }) - const chainInfo = JSON.parse(infoOutput) + const chainInfo = await client.v1.chain.get_info() // Calculate ref_block_num and ref_block_prefix from last_irreversible_block_num - const blockNum = chainInfo.last_irreversible_block_num - const blockOutput = execSync( - `curl -s -X POST http://127.0.0.1:8888/v1/chain/get_block -d '{"block_num_or_id":${blockNum}}'`, - {encoding: 'utf8'} - ) - const blockInfo = JSON.parse(blockOutput) + const blockNum = chainInfo.last_irreversible_block_num.toNumber() + const blockInfo = await client.v1.chain.get_block(blockNum) const txPath = path.join(testDir, 'transaction-broadcast.json') // Use buyram action - a core system action that's always available @@ -198,7 +195,7 @@ suite('E2E Workflow', () => { const transaction = { expiration: getTransactionExpiration(), ref_block_num: blockNum & 0xffff, // Last 16 bits - ref_block_prefix: parseInt(blockInfo.ref_block_prefix), + ref_block_prefix: blockInfo.ref_block_prefix.toNumber(), max_net_usage_words: 0, max_cpu_usage_ms: 0, delay_sec: 0, @@ -363,21 +360,29 @@ class [[eosio::contract]] hello : public eosio::contract { encoding: 'utf8', }) - // 2. Create contract file - const contractCode = ` -#include -class [[eosio::contract]] hello : public eosio::contract { - public: - using eosio::contract::contract; - [[eosio::action]] - void hi(eosio::name user) { - print("Hello, ", user); - } -}; -` - const cppPath = path.join(testDir, 'hello.cpp') - const wasmPath = path.join(testDir, 'hello.wasm') - fs.writeFileSync(cppPath, contractCode) + // 2. Use persistent contract file + // Copy test.cpp from root to testDir + const rootCppPath = path.join(__dirname, '../../test.cpp') + const cppPath = path.join(testDir, 'test.cpp') + const wasmPath = path.join(testDir, 'test.wasm') + + if (fs.existsSync(rootCppPath)) { + fs.copyFileSync(rootCppPath, cppPath) + } else { + // Fallback if root file missing (shouldn't happen if we just created it) + const contractCode = ` + #include + class [[eosio::contract]] hello : public eosio::contract { + public: + using eosio::contract::contract; + [[eosio::action]] + void hi(eosio::name user) { + print("Hello, ", user); + } + }; + ` + fs.writeFileSync(cppPath, contractCode) + } // 3. Compile contract execSync(`node ${cliPath} compile`, { From 84715a0f135bd75077b727d61fb045c698caead8 Mon Sep 17 00:00:00 2001 From: dafuga Date: Thu, 20 Nov 2025 23:04:22 -0700 Subject: [PATCH 15/36] refactor: adjusted the command locations --- src/commands/chain/install.ts | 3 +- src/commands/chain/interact.ts | 172 ++++++++------- src/commands/chain/local.ts | 6 +- src/commands/contract/deploy.ts | 354 +++++++++++++++++++++++++++++++ src/commands/contract/index.ts | 45 ++++ src/commands/wallet/account.ts | 3 +- src/commands/wallet/index.ts | 4 +- src/commands/wallet/transact.ts | 34 ++- src/commands/wharfkit/compile.ts | 5 - src/commands/wharfkit/dev.ts | 2 +- src/commands/wharfkit/index.ts | 25 --- src/index.ts | 12 +- src/types/wharfkit-session.d.ts | 1 - src/utils/wharfkit-ui.ts | 18 +- test/tests/chain-interact.ts | 48 +++-- test/tests/e2e-workflow.ts | 165 +++++++++++++- 16 files changed, 729 insertions(+), 168 deletions(-) create mode 100644 src/commands/contract/deploy.ts diff --git a/src/commands/chain/install.ts b/src/commands/chain/install.ts index 69f774b..73c7c16 100644 --- a/src/commands/chain/install.ts +++ b/src/commands/chain/install.ts @@ -52,7 +52,8 @@ export async function checkLeapInstallation(): Promise { } } - status.installed = status.nodeos && status.wharfkit.consoleRenderer && status.wharfkit.walletPlugin + status.installed = + status.nodeos && status.wharfkit.consoleRenderer && status.wharfkit.walletPlugin return status } diff --git a/src/commands/chain/interact.ts b/src/commands/chain/interact.ts index 84b20b6..bba0d91 100644 --- a/src/commands/chain/interact.ts +++ b/src/commands/chain/interact.ts @@ -2,7 +2,7 @@ import {APIClient, Name} from '@wharfkit/antelope' import {Chains} from '@wharfkit/common' import {Contract} from '@wharfkit/contract' -import {Command} from 'commander' +import type {Command} from 'commander' import fetch from 'node-fetch' interface ChainInteractOptions { @@ -36,7 +36,9 @@ function getApiUrl(chainName: string): string { if (chainName.startsWith('http')) { return chainName } - throw new Error(`Unknown chain: ${chainName}. Please provide a full URL or a known chain name.`) + throw new Error( + `Unknown chain: ${chainName}. Please provide a full URL or a known chain name.` + ) } } @@ -64,7 +66,9 @@ export async function lookupTable( tableName = parts[1] } else { if (!options.scope) { - throw new Error('Please specify contract in format contract::table or use --scope to specify contract') + throw new Error( + 'Please specify contract in format contract::table or use --scope to specify contract' + ) } accountName = options.scope tableName = tableNameInput @@ -86,12 +90,12 @@ export async function lookupTable( if (options.filter) { queryOptions.from = options.filter } - + // Default limit to 4 rows unless specified const userLimit = options.limit ? parseInt(options.limit, 10) : 4 // Fetch one extra row to detect if there are more queryOptions.limit = userLimit + 1 - + // scope defaults to contract name if not provided // If user provided --scope, we use it. // Note: In our logic above, if :: is used, accountName is contract. @@ -105,20 +109,20 @@ export async function lookupTable( // We will assume scope = contract name unless specified? // options.scope is currently used for contract name if :: is missing. // If :: is present, options.scope could be the data scope. - + let dataScope = accountName if (tableNameInput.includes('::') && options.scope) { dataScope = options.scope } - + const tableInstance = contract.table(tableName, dataScope) const cursor = await tableInstance.query(queryOptions) const allRows = await cursor.all() - + const hasMoreRows = allRows.length > userLimit const rows = hasMoreRows ? allRows.slice(0, userLimit) : allRows - + if (options.json) { console.log(JSON.stringify(rows, null, 2)) } else if (options.columns) { @@ -127,13 +131,13 @@ export async function lookupTable( const firstRow = rows[0] const rowObj = (firstRow as any).toJSON ? (firstRow as any).toJSON() : firstRow console.log('Available columns:') - Object.keys(rowObj).forEach(key => console.log(`- ${key}`)) + Object.keys(rowObj).forEach((key) => console.log(`- ${key}`)) } else { console.log('No data available to determine columns.') } } else { // If rows are complex objects (like Wharf structs), they might need toJSON() - // But console.table handles objects well usually. + // But console.table handles objects well usually. // Wharfkit structs have toJSON() which returns the plain object. const plainRows = rows.map((r) => { const row = (r as any).toJSON ? (r as any).toJSON() : r @@ -158,28 +162,28 @@ export async function lookupTable( }) // Limit columns displayed unless --all is used - const displayedRows = plainRows.map(row => { + const displayedRows = plainRows.map((row) => { if (options.all) return row - + const newRow: any = {} const keys = Object.keys(row) - + // Filter by --fields if provided if (options.fields) { - const selectedFields = options.fields.split(',').map(f => f.trim()) - selectedFields.forEach(key => { + const selectedFields = options.fields.split(',').map((f) => f.trim()) + selectedFields.forEach((key) => { if (key in row) { newRow[key] = row[key] } }) return newRow } - + if (keys.length <= 5) return row - + const visibleKeys = keys.slice(0, 4) - visibleKeys.forEach(key => newRow[key] = row[key]) - + visibleKeys.forEach((key) => (newRow[key] = row[key])) + // Add summary of hidden columns const hiddenCount = keys.length - 4 newRow['...'] = `+${hiddenCount} more fields` @@ -187,15 +191,23 @@ export async function lookupTable( }) console.table(displayedRows) - + if (hasMoreRows) { - console.log(`\n... and more rows (showing first ${userLimit}). Use --limit to see more.`) + console.log( + `\n... and more rows (showing first ${userLimit}). Use --limit to see more.` + ) } - if (!options.all && !options.fields && plainRows.length > 0 && Object.keys(plainRows[0]).length > 5) { - console.log(`\nSome columns hidden. Use --all to see all, --fields to select specific columns, or --columns to list them.`) + if ( + !options.all && + !options.fields && + plainRows.length > 0 && + Object.keys(plainRows[0]).length > 5 + ) { + console.log( + `\nSome columns hidden. Use --all to see all, --fields to select specific columns, or --columns to list them.` + ) } } - } catch (error: any) { console.error(`Error fetching table data: ${error.message}`) process.exit(1) @@ -221,31 +233,37 @@ export async function lookupAccount( console.log(`Created: ${account.created}`) console.log(`Privileged: ${account.privileged}`) console.log(`Last code update: ${account.last_code_update}`) - + if (account.core_liquid_balance) { console.log(`Liquid Balance: ${account.core_liquid_balance}`) } - + console.log('\nResources:') console.log(` RAM: ${account.ram_usage} / ${account.ram_quota} bytes`) if (account.net_limit) { - console.log(` NET: ${account.net_limit.used} / ${account.net_limit.max} bytes (${account.net_limit.available} available)`) + console.log( + ` NET: ${account.net_limit.used} / ${account.net_limit.max} bytes (${account.net_limit.available} available)` + ) } if (account.cpu_limit) { - console.log(` CPU: ${account.cpu_limit.used} / ${account.cpu_limit.max} us (${account.cpu_limit.available} available)`) + console.log( + ` CPU: ${account.cpu_limit.used} / ${account.cpu_limit.max} us (${account.cpu_limit.available} available)` + ) } - + if (account.permissions.length > 0) { console.log('\nPermissions:') for (const perm of account.permissions) { - console.log(` ${perm.perm_name} (${perm.parent}):`) - console.log(` Threshold: ${perm.required_auth.threshold}`) - for (const key of perm.required_auth.keys) { - console.log(` Key: ${key.key} (weight: ${key.weight})`) - } - for (const acc of perm.required_auth.accounts) { - console.log(` Account: ${acc.permission.actor}@${acc.permission.permission} (weight: ${acc.weight})`) - } + console.log(` ${perm.perm_name} (${perm.parent}):`) + console.log(` Threshold: ${perm.required_auth.threshold}`) + for (const key of perm.required_auth.keys) { + console.log(` Key: ${key.key} (weight: ${key.weight})`) + } + for (const acc of perm.required_auth.accounts) { + console.log( + ` Account: ${acc.permission.actor}@${acc.permission.permission} (weight: ${acc.weight})` + ) + } } } } @@ -267,32 +285,32 @@ export function addInteractSubcommands(command: Command, fixedChainName?: string .option('--columns', 'List available columns') .option('--json', 'Output as JSON') .action(async (tableName, extraFields, options, cmd) => { - // Handle variadic args being shifted if extraFields is empty/present - // Commander passes (arg1, arg2..., options, cmd) - // If extraFields is provided, it's the second arg. - // If NOT provided, options is the second arg? NO, extraFields is an array, potentially empty. - // Wait, with [extraFields...], it is ALWAYS passed as an array (second arg). - // But we need to be careful if 'options' is the 3rd arg. - - let opts = options - let extras = extraFields - - // Commander 7+ usually guarantees order for defined args. - // (tableName, extras, options, cmd) - - const chainName = fixedChainName || cmd.parent?.args[0] - if (!chainName) { - console.error('Chain name is required') - process.exit(1) - } - - // If user provided spaced fields like "--fields a, b", 'b' ends up in extras. - // We should append them to fields. - if (opts.fields && extras && extras.length > 0) { - opts.fields = [opts.fields, ...extras].join(' ') - } - - await lookupTable(chainName, tableName, opts) + // Handle variadic args being shifted if extraFields is empty/present + // Commander passes (arg1, arg2..., options, cmd) + // If extraFields is provided, it's the second arg. + // If NOT provided, options is the second arg? NO, extraFields is an array, potentially empty. + // Wait, with [extraFields...], it is ALWAYS passed as an array (second arg). + // But we need to be careful if 'options' is the 3rd arg. + + const opts = options + const extras = extraFields + + // Commander 7+ usually guarantees order for defined args. + // (tableName, extras, options, cmd) + + const chainName = fixedChainName || cmd.parent?.args[0] + if (!chainName) { + console.error('Chain name is required') + process.exit(1) + } + + // If user provided spaced fields like "--fields a, b", 'b' ends up in extras. + // We should append them to fields. + if (opts.fields && extras && extras.length > 0) { + opts.fields = [opts.fields, ...extras].join(' ') + } + + await lookupTable(chainName, tableName, opts) }) command @@ -300,12 +318,12 @@ export function addInteractSubcommands(command: Command, fixedChainName?: string .description('Lookup account data') .option('--json', 'Output as JSON') .action(async (accountName, options, cmd) => { - const chainName = fixedChainName || cmd.parent?.args[0] - if (!chainName) { - console.error('Chain name is required') - process.exit(1) - } - await lookupAccount(chainName, accountName, options) + const chainName = fixedChainName || cmd.parent?.args[0] + if (!chainName) { + console.error('Chain name is required') + process.exit(1) + } + await lookupAccount(chainName, accountName, options) }) } @@ -313,21 +331,21 @@ export function addInteractCommands(chain: Command) { // Register known chains from @wharfkit/common as explicit subcommands // This ensures they are discoverable and work without 'remote' prefix const knownChains = Object.keys(Chains) - + for (const chainKey of knownChains) { const chainName = chainKey.toLowerCase() // register as lowercase (jungle4, eos, etc) - + // Skip if it conflicts with existing commands (like 'local') - though 'local' isn't in Chains - if (chainName === 'local') continue + if (chainName === 'local') continue + + const cmd = chain.command(chainName).description(`Interact with ${chainKey} chain`) - const cmd = chain.command(chainName) - .description(`Interact with ${chainKey} chain`) - addInteractSubcommands(cmd, chainKey) // Pass the PascalCase key or lowercase? getApiUrl handles both. } // For arbitrary URLs, we still want a catch-all or 'remote' command. - const chainContext = chain.command('remote ') + const chainContext = chain + .command('remote ') .description('Interact with a custom chain URL') addInteractSubcommands(chainContext) } diff --git a/src/commands/chain/local.ts b/src/commands/chain/local.ts index 18d73d0..6a8d135 100644 --- a/src/commands/chain/local.ts +++ b/src/commands/chain/local.ts @@ -7,6 +7,7 @@ import * as path from 'path' import type {ChainStatus} from './utils' import { cleanDataDir, + createApiClientForPort, ensureDir, getConfigIni, getDefaultConfigDir, @@ -20,7 +21,6 @@ import { removePidFile, savePid, waitForChain, - createApiClientForPort, } from './utils' import {ensureLeapInstalled} from './install' import {addKeyToWallet, listWalletKeys} from '../wallet/utils' @@ -362,6 +362,8 @@ async function setupDevWallet(): Promise { } catch (error: any) { console.log(`Warning: Could not setup dev wallet: ${error.message}`) console.log('You can manually store the development key with:') - console.log(` wharfkit wallet keys add --name ${walletName} --private ${devKeys.privateKey}`) + console.log( + ` wharfkit wallet keys add --name ${walletName} --private ${devKeys.privateKey}` + ) } } diff --git a/src/commands/contract/deploy.ts b/src/commands/contract/deploy.ts new file mode 100644 index 0000000..65193a5 --- /dev/null +++ b/src/commands/contract/deploy.ts @@ -0,0 +1,354 @@ +/* eslint-disable no-console */ +import {existsSync, readdirSync, readFileSync} from 'fs' +import {basename, extname, resolve} from 'path' +import type {PrivateKey} from '@wharfkit/antelope' +import {ABI, APIClient, FetchProvider, Serializer} from '@wharfkit/antelope' +import {Session} from '@wharfkit/session' +import {WalletPluginPrivateKey} from '@wharfkit/wallet-plugin-privatekey' +import fetch from 'node-fetch' +import {NonInteractiveConsoleUI} from '../../utils/wharfkit-ui' +import {getKeyFromWallet, listWalletKeys} from '../wallet/utils' + +import {Chains} from '@wharfkit/common' +import {compileContract} from '../wharfkit/compile' + +interface DeployOptions { + account?: string + url?: string + force?: boolean + validate?: boolean +} + +/** + * Validate deployment safety (checks for orphaned tables with data) + */ +export async function validateDeploy( + accountName: string, + abiJson: any, + url: string, + force: boolean +): Promise { + const client = new APIClient({ + provider: new FetchProvider(url, {fetch}), + }) + + try { + const existingAbiResponse = await client.v1.chain.get_abi(accountName) + if (existingAbiResponse.abi) { + const oldAbi = existingAbiResponse.abi + const newAbi = ABI.from(abiJson) + + const oldTables = new Set(oldAbi.tables.map((t) => String(t.name))) + const newTables = new Set(newAbi.tables.map((t) => String(t.name))) + + const removedTables = [...oldTables].filter((t) => !newTables.has(t)) + + if (removedTables.length > 0) { + console.log( + `\nāš ļø Warning: The new ABI removes the following tables: ${removedTables.join( + ', ' + )}` + ) + console.log(` Checking for existing data in these tables...`) + + let hasData = false + for (const table of removedTables) { + try { + const rows = await client.v1.chain.get_table_rows({ + code: accountName, + scope: accountName, + table, + limit: 1, + }) + if (rows.rows.length > 0) { + console.log(` āŒ Table '${table}' contains data!`) + hasData = true + } else { + console.log(` āœ… Table '${table}' is empty.`) + } + } catch (e: any) { + // If check fails, ignore or warn? + // Often "table not found" error if using state history or other plugins if really gone? + // But if get_abi returned it, it was in ABI. + // We assume no data if error, or warn. + } + } + + if (hasData) { + if (force) { + console.log(` āš ļø Proceeding despite data loss warning (--force used).`) + } else { + throw new Error( + `Deployment would make existing table data inaccessible (orphaned).` + ) + } + } else { + console.log(` āœ… No data found in removed tables. Safe to proceed.`) + } + } else { + console.log(` āœ… No tables removed.`) + } + } else { + console.log(` āœ… No existing ABI found (new deployment).`) + } + } catch (error: any) { + if (error.message.includes('orphaned')) { + throw new Error( + `SAFETY CHECK FAILED: ${error.message}\nUse --force to override this check and deploy anyway.` + ) + } + // If validation fails due to network or other reasons, we might want to warn but proceed if not validating explicitly? + // If explicitly validating, we should error. + // If deploying, we usually proceed unless critical. + // But "safety check" implies we stop. + // However, if account doesn't exist, get_abi throws. + // We should catch that. + if ( + error.message.includes('Account not found') || + error.message.includes('does not exist') + ) { + // New account, safe. + return + } + + // If it's a validation run, rethrow. + // If it's a deploy run, maybe warn? + // But we want strict safety. + throw error + } +} + +/** + * Deploy a compiled contract to the blockchain + * @param wasmFile - Path to the WASM file to deploy + * @param options - Deployment options + */ +export async function deployContract( + wasmFile: string | undefined, + options: DeployOptions +): Promise { + // Determine the WASM file to deploy + let wasmPath: string + try { + wasmPath = wasmFile ? resolve(wasmFile) : await findWasmFile() + } catch (error: any) { + if (error.message.includes('No .wasm files found') && !wasmFile) { + console.log('No WASM file found. Attempting to compile contracts...') + try { + await compileContract(undefined, '.') + wasmPath = await findWasmFile() + } catch (compileError: any) { + throw new Error( + `Failed to auto-compile: ${compileError.message}\nPlease run 'wharfkit compile' manually.` + ) + } + } else { + throw error + } + } + + if (!existsSync(wasmPath)) { + throw new Error(`WASM file not found: ${wasmPath}`) + } + + if (extname(wasmPath) !== '.wasm') { + throw new Error(`File must be a .wasm file: ${wasmPath}`) + } + + // Find the ABI file + const abiPath = wasmPath.replace('.wasm', '.abi') + if (!existsSync(abiPath)) { + throw new Error(`ABI file not found: ${abiPath}`) + } + + // Determine the contract account name + const accountName = options.account || basename(wasmPath, '.wasm') + + // Determine the blockchain URL + let url = options.url || 'http://127.0.0.1:8888' + + // Check if URL is a known chain name + const knownChainKey = Object.keys(Chains).find((key) => key.toLowerCase() === url.toLowerCase()) + if (knownChainKey) { + url = (Chains as any)[knownChainKey].url + } + + if (options.validate) { + console.log(`Validating deployment for ${accountName}...`) + } else { + console.log(`Deploying contract...`) + } + console.log(` WASM: ${wasmPath}`) + console.log(` ABI: ${abiPath}`) + console.log(` Account: ${accountName}`) + console.log(` URL: ${url}`) + + try { + // Read WASM and ABI files + const wasmCode = readFileSync(wasmPath) + const abiJson = JSON.parse(readFileSync(abiPath, 'utf8')) + + // Perform validation/safety check + // Only skip if force is used AND we are NOT explicitly validating? + // Actually, even with force, we might want to see warnings. + // But validateDeploy throws if unsafe and not forced. + await validateDeploy(accountName, abiJson, url, !!options.force) + + if (options.validate) { + console.log('\nāœ… Validation passed! Deployment appears safe.') + return + } + + // Get private key from wallet for this account + const privateKey = await getPrivateKeyForDeploy(accountName) + + // Create API client + const client = new APIClient({ + provider: new FetchProvider(url, {fetch}), + }) + + // Create session with private key wallet plugin + const walletPlugin = new WalletPluginPrivateKey(privateKey) + walletPlugin.config.requiresChainSelect = false + walletPlugin.config.requiresPermissionSelect = false + walletPlugin.config.requiresPermissionEntry = false + + const session = new Session({ + chain: { + id: await getChainId(client), + url, + }, + actor: accountName, + permission: 'active', + walletPlugin, + ui: new NonInteractiveConsoleUI(), + }) + + console.log('\nšŸš€ Deploying contract...') + + // Create setcode action + const setcodeAction = { + account: 'eosio', + name: 'setcode', + authorization: [ + { + actor: accountName, + permission: 'active', + }, + ], + data: { + account: accountName, + vmtype: 0, + vmversion: 0, + code: wasmCode.toString('hex'), + }, + } + + // Create setabi action + const setabiAction = { + account: 'eosio', + name: 'setabi', + authorization: [ + { + actor: accountName, + permission: 'active', + }, + ], + data: { + account: accountName, + abi: Serializer.encode({object: ABI.from(abiJson), type: ABI}).hexString, + }, + } + + // Transact both actions + const result = await session.transact( + { + actions: [setcodeAction, setabiAction], + }, + { + broadcast: true, + } + ) + + console.log('\nāœ… Contract deployed successfully!') + console.log(`Transaction ID: ${result.resolved?.transaction.id}`) + } catch (error) { + const errorMessage = (error as Error).message + throw new Error( + `Failed to deploy contract: ${errorMessage}\n\n` + + `Make sure:\n` + + `1. The blockchain is running (wharfkit chain local start)\n` + + `2. The account "${accountName}" exists\n` + + `3. You have a wallet key with permissions for this account\n` + + `4. The ABI file exists alongside the WASM file` + ) + } +} + +/** + * Get private key for deployment based on account name + */ +async function getPrivateKeyForDeploy(accountName: string): Promise { + const keys = listWalletKeys() + + if (keys.length === 0) { + throw new Error('No keys found in wallet. Create one with: wharfkit wallet create') + } + + // Try to find a key with the same name as the account + const accountKey = keys.find((k) => k.name === accountName) + if (accountKey) { + console.log(`Using wallet key: ${accountKey.name}`) + return getKeyFromWallet(accountName) + } + + // Otherwise, try 'default' key + const defaultKey = keys.find((k) => k.name === 'default') + if (defaultKey) { + console.log(`Using wallet key: default`) + return getKeyFromWallet('default') + } + + // Use first available key + console.log(`Using wallet key: ${keys[0].name}`) + return getKeyFromWallet(keys[0].name) +} + +/** + * Get chain ID from the API + */ +async function getChainId(client: APIClient): Promise { + try { + const info = await client.v1.chain.get_info() + return String(info.chain_id) + } catch (error) { + // Default to local chain ID if we can't get it + return '8a34ec7df1b8cd06ff4a8abbaa7cc50300823350cadc59ab296cb00d104d2b8f' + } +} + +/** + * Find a WASM file in the current directory + */ +async function findWasmFile(): Promise { + const currentDir = process.cwd() + + const wasmFiles = readdirSync(currentDir) + .filter((file) => extname(file) === '.wasm') + .map((file) => resolve(currentDir, file)) + + if (wasmFiles.length === 0) { + throw new Error( + 'No .wasm files found in current directory. Please specify a file or compile first with: wharfkit compile' + ) + } + + if (wasmFiles.length > 1) { + throw new Error( + `Multiple .wasm files found: ${wasmFiles.map((f) => basename(f)).join(', ')}\n` + + `Please specify which file to deploy.` + ) + } + + return wasmFiles[0] +} diff --git a/src/commands/contract/index.ts b/src/commands/contract/index.ts index c22a733..63abdb7 100644 --- a/src/commands/contract/index.ts +++ b/src/commands/contract/index.ts @@ -5,9 +5,11 @@ import * as ts from 'typescript' import {ABI} from '@wharfkit/antelope' import {abiToBlob, ContractKit} from '@wharfkit/contract' +import {Command} from 'commander' import {log, makeClient} from '../../utils' import {generateContractClass} from './class' +import {deployContract} from './deploy' import {generateImportStatement, getCoreImports} from './helpers' import { generateActionNamesInterface, @@ -29,6 +31,49 @@ interface CommandOptions { eslintrc?: string } +export function createContractCommand(): Command { + const contract = new Command('contract') + contract.description('Contract management commands') + + contract + .command('deploy') + .description('Deploy a compiled contract to the blockchain') + .argument( + '[network]', + 'Network name or URL to deploy to (e.g. jungle4, http://localhost:8888)' + ) + .argument('[wasm]', 'WASM file to deploy (auto-detects if not specified)') + .option('-a, --account ', 'Contract account name (default: derived from filename)') + .option('-u, --url ', 'Blockchain API URL (override network argument)') + .option('--force', 'Force deployment even if safety checks fail') + .option('--validate', 'Validate deployment safety without deploying') + .action(async (networkOrWasm, wasmFile, options) => { + let network = networkOrWasm + let wasm = wasmFile + + // Handle ambiguity if first argument is a wasm file + if (network && (network.endsWith('.wasm') || network.includes('.wasm'))) { + wasm = network + network = undefined + } + + // If network is provided, it sets/overrides options.url if not explicitly set + if (network && !options.url) { + options.url = network + } + + try { + await deployContract(wasm, options) + } catch (error: any) { + // eslint-disable-next-line no-console + console.error(`Error: ${error.message}`) + process.exit(1) + } + }) + + return contract +} + export async function generateContractFromCommand( contractName: string | undefined, {url, file, json, eslintrc}: CommandOptions diff --git a/src/commands/wallet/account.ts b/src/commands/wallet/account.ts index 92fdb26..f9defde 100644 --- a/src/commands/wallet/account.ts +++ b/src/commands/wallet/account.ts @@ -172,7 +172,7 @@ export async function createAccount(options: AccountCreateOptions): Promise', 'Transaction JSON string or path to JSON file') .option('-k, --key ', 'Name or public key of the key to use for signing') .option('-p, --password', 'Prompt for password if key is encrypted with custom password') diff --git a/src/commands/wallet/transact.ts b/src/commands/wallet/transact.ts index 2b79f04..fec6a34 100644 --- a/src/commands/wallet/transact.ts +++ b/src/commands/wallet/transact.ts @@ -112,15 +112,16 @@ function loadTransaction(transactionJson: string): Transaction { } /** - * Select a key from the wallet + * Select a key from the wallet, optionally using transaction authorization to find a match */ -function selectKey(keyName?: string): string { +function selectKey(keyName?: string, transaction?: Transaction): string { const keys = listWalletKeys() if (keys.length === 0) { throw new Error('No keys found in wallet. Create one with: wharfkit wallet keys create') } + // 1. Use explicit key name if provided if (keyName) { const key = keys.find((k) => k.name === keyName || k.publicKey === keyName) if (!key) { @@ -129,18 +130,41 @@ function selectKey(keyName?: string): string { return key.name } - // If only one key, use it + // 2. Try to match based on transaction authorization + if (transaction && transaction.actions.length > 0) { + // Check authorizations of the first action + // (A more complex logic could check all actions, but usually the first one dictates the primary signer) + const auths = transaction.actions[0].authorization + for (const auth of auths) { + const actorName = String(auth.actor) + // Check if we have a key for this actor + const actorKey = keys.find((k) => k.name === actorName) + if (actorKey) { + log(`Auto-selected key for actor: ${actorName}`, 'info') + return actorKey.name + } else { + log( + `No key found for actor: ${actorName}. Available keys: ${keys + .map((k) => k.name) + .join(', ')}`, + 'info' + ) + } + } + } + + // 3. If only one key, use it if (keys.length === 1) { return keys[0].name } - // If multiple keys and no key specified, use 'default' if it exists + // 4. If multiple keys and no key specified, use 'default' if it exists const defaultKey = keys.find((k) => k.name === 'default') if (defaultKey) { return defaultKey.name } - // Otherwise, use the first key + // 5. Otherwise, use the first key return keys[0].name } diff --git a/src/commands/wharfkit/compile.ts b/src/commands/wharfkit/compile.ts index 40e0087..fe90a99 100644 --- a/src/commands/wharfkit/compile.ts +++ b/src/commands/wharfkit/compile.ts @@ -2,13 +2,8 @@ import {execSync} from 'child_process' import {existsSync, readdirSync} from 'fs' import {basename, extname, join, resolve} from 'path' -import {platform} from 'os' import {checkLeapInstallation} from '../chain/install' -interface CompileOptions { - output: string -} - /** * Compile a single C++ file or all .cpp files in the current directory * @param file - Optional file path to compile. If not provided, compiles all .cpp files in current directory diff --git a/src/commands/wharfkit/dev.ts b/src/commands/wharfkit/dev.ts index bf6948c..df7a64b 100644 --- a/src/commands/wharfkit/dev.ts +++ b/src/commands/wharfkit/dev.ts @@ -2,7 +2,7 @@ import {watch} from 'fs' import {extname} from 'path' import {compileContract} from './compile' -import {deployContract} from './deploy' +import {deployContract} from '../contract/deploy' import {getChainStatus, startLocalChain, stopLocalChain} from '../chain/local' interface DevOptions { diff --git a/src/commands/wharfkit/index.ts b/src/commands/wharfkit/index.ts index d49414b..77672a3 100644 --- a/src/commands/wharfkit/index.ts +++ b/src/commands/wharfkit/index.ts @@ -1,7 +1,6 @@ /* eslint-disable no-console */ import {Command} from 'commander' import {compileContract} from './compile' -import {deployContract} from './deploy' import {startDevMode} from './dev' /** @@ -27,30 +26,6 @@ export function createCompileCommand(): Command { return compile } -/** - * Create the deploy command - */ -export function createDeployCommand(): Command { - const deploy = new Command('deploy') - deploy - .description('Deploy a compiled contract to the blockchain') - .argument('[wasm]', 'WASM file to deploy (auto-detects if not specified)') - .option('-a, --account ', 'Contract account name (default: derived from filename)') - .option('-u, --url ', 'Blockchain API URL (default: http://127.0.0.1:8888)') - .option('--force', 'Force deployment even if safety checks fail') - .option('--validate', 'Validate deployment safety without deploying') - .action(async (wasm, options) => { - try { - await deployContract(wasm, options) - } catch (error: any) { - console.error(`Error: ${error.message}`) - process.exit(1) - } - }) - - return deploy -} - /** * Create the dev command */ diff --git a/src/index.ts b/src/index.ts index adcaf07..c5c0bd8 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,14 +1,10 @@ import {Command} from 'commander' import {version} from '../package.json' -import {generateContractFromCommand} from './commands/contract' +import {createContractCommand, generateContractFromCommand} from './commands/contract' import {generateKeysFromCommand} from './commands/keys/index' import {createChainCommand} from './commands/chain/index' -import { - createCompileCommand, - createDeployCommand, - createDevCommand, -} from './commands/wharfkit/index' +import {createCompileCommand, createDevCommand} from './commands/wharfkit/index' import {createWalletCommand} from './commands/wallet/index' const program = new Command() @@ -42,8 +38,8 @@ program.addCommand(createChainCommand()) // 4. Command to compile contracts program.addCommand(createCompileCommand()) -// 5. Command to deploy contracts -program.addCommand(createDeployCommand()) +// 5. Command to manage contracts (deploy, etc) +program.addCommand(createContractCommand()) // 6. Command for development mode program.addCommand(createDevCommand()) diff --git a/src/types/wharfkit-session.d.ts b/src/types/wharfkit-session.d.ts index b781a76..58fe540 100644 --- a/src/types/wharfkit-session.d.ts +++ b/src/types/wharfkit-session.d.ts @@ -9,4 +9,3 @@ declare module '@wharfkit/session' { ui?: UserInterface } } - diff --git a/src/utils/wharfkit-ui.ts b/src/utils/wharfkit-ui.ts index 9a68eb5..9f0f192 100644 --- a/src/utils/wharfkit-ui.ts +++ b/src/utils/wharfkit-ui.ts @@ -2,10 +2,7 @@ import { AbstractUserInterface, cancelable, type Cancelable, - type CreateAccountContext, - type LocaleDefinitions, type LoginContext, - type LoginOptions, type PromptArgs, type PromptResponse, type UserInterfaceAccountCreationResponse, @@ -27,12 +24,11 @@ export class NonInteractiveConsoleUI extends AbstractUserInterface { } async onError(error: Error): Promise { + // eslint-disable-next-line no-console console.error(`[wharfkit] ${error.message}`) } - async onAccountCreate( - _context: CreateAccountContext - ): Promise { + async onAccountCreate(): Promise { return {} } @@ -40,7 +36,7 @@ export class NonInteractiveConsoleUI extends AbstractUserInterface { // No-op } - async onLogin(_options?: LoginOptions): Promise { + async onLogin(): Promise { // No-op } @@ -74,9 +70,11 @@ export class NonInteractiveConsoleUI extends AbstractUserInterface { prompt(args: PromptArgs): Cancelable { if (args.title) { + // eslint-disable-next-line no-console console.log(`[wharfkit] ${args.title}`) } if (args.body) { + // eslint-disable-next-line no-console console.log(args.body) } return cancelable(Promise.resolve({} as PromptResponse), () => { @@ -85,15 +83,15 @@ export class NonInteractiveConsoleUI extends AbstractUserInterface { } status(message: string): void { + // eslint-disable-next-line no-console console.log(`[wharfkit] ${message}`) } - translate(key: string, options?: UserInterfaceTranslateOptions, _namespace?: string): string { + translate(key: string, options?: UserInterfaceTranslateOptions): string { return String(options?.default ?? key) } - addTranslations(_translations: LocaleDefinitions): void { + addTranslations(): void { // No-op } } - diff --git a/test/tests/chain-interact.ts b/test/tests/chain-interact.ts index d0ee8b1..ed5a704 100644 --- a/test/tests/chain-interact.ts +++ b/test/tests/chain-interact.ts @@ -12,7 +12,7 @@ suite('Chain Interaction', () => { suiteSetup(function () { this.timeout(120000) // Increase timeout for chain startup and deploy - + // Create a temporary test directory testDir = path.join(os.tmpdir(), `wharfkit-interact-test-${Date.now()}`) fs.mkdirSync(testDir, {recursive: true}) @@ -30,18 +30,20 @@ suite('Chain Interaction', () => { // Start local chain execSync(`node ${cliPath} chain local start`, {encoding: 'utf8'}) - + // Wait for chain execSync('sleep 5') // Check if cdt-cpp is installed try { execSync('which cdt-cpp') - + // Deploy a test contract contractAccount = 'testcontract' - execSync(`node ${cliPath} wallet account create --name ${contractAccount}`, {encoding: 'utf8'}) - + execSync(`node ${cliPath} wallet account create --name ${contractAccount}`, { + encoding: 'utf8', + }) + const contractCode = ` #include class [[eosio::contract]] testcontract : public eosio::contract { @@ -68,11 +70,14 @@ suite('Chain Interaction', () => { const cppPath = path.join(testDir, 'testcontract.cpp') const wasmPath = path.join(testDir, 'testcontract.wasm') fs.writeFileSync(cppPath, contractCode) - + execSync(`node ${cliPath} compile`, {encoding: 'utf8', cwd: testDir}) - execSync(`node ${cliPath} deploy ${wasmPath} --account ${contractAccount}`, {encoding: 'utf8', cwd: testDir}) - + execSync(`node ${cliPath} contract deploy ${wasmPath} --account ${contractAccount}`, { + encoding: 'utf8', + cwd: testDir, + }) } catch (e) { + // eslint-disable-next-line no-console console.log('Skipping contract deployment (cdt-cpp not found or failed)') contractAccount = '' } @@ -86,31 +91,32 @@ suite('Chain Interaction', () => { } try { execSync(`node ${cliPath} chain local stop`, {encoding: 'utf8'}) - } catch (e) { } + } catch (e) { + // Ignore error + } }) test('can lookup table data on deployed contract', function () { if (!contractAccount) this.skip() - - const output = execSync(`node ${cliPath} chain local table ${contractAccount}::items`, { + + execSync(`node ${cliPath} chain local table ${contractAccount}::items`, { encoding: 'utf8', }) - assert.doesNotThrow(() => {}) }) test('can lookup table data with scope option', function () { if (!contractAccount) this.skip() - - const output = execSync(`node ${cliPath} chain local table items --scope ${contractAccount}`, { + + execSync(`node ${cliPath} chain local table items --scope ${contractAccount}`, { encoding: 'utf8', }) }) - + test('can lookup single account', function () { const output = execSync(`node ${cliPath} chain local account eosio`, { encoding: 'utf8', }) - + assert.include(output, 'Account: eosio') assert.include(output, 'RAM:') assert.include(output, 'Permissions:') @@ -120,7 +126,7 @@ suite('Chain Interaction', () => { const output = execSync(`node ${cliPath} chain local account eosio --json`, { encoding: 'utf8', }) - + const account = JSON.parse(output) assert.equal(account.account_name, 'eosio') assert.property(account, 'permissions') @@ -129,12 +135,12 @@ suite('Chain Interaction', () => { test('can access known remote chain (jungle4) directly for account', function () { try { execSync(`node ${cliPath} chain jungle4 account teamgreymass`, { - encoding: 'utf8' + encoding: 'utf8', }) } catch (error: any) { const output = (error.stderr || '').toString() + (error.stdout || '').toString() if (output.includes('unknown command')) { - throw new Error('Commander failed to match jungle4: ' + output) + throw new Error('Commander failed to match jungle4: ' + output) } } }) @@ -142,12 +148,12 @@ suite('Chain Interaction', () => { test('can access known remote chain (jungle4) directly for table', function () { try { execSync(`node ${cliPath} chain jungle4 table eosio::global`, { - encoding: 'utf8' + encoding: 'utf8', }) } catch (error: any) { const output = (error.stderr || '').toString() + (error.stdout || '').toString() if (output.includes('unknown command')) { - throw new Error('Commander failed to match jungle4 for table command: ' + output) + throw new Error('Commander failed to match jungle4 for table command: ' + output) } } }) diff --git a/test/tests/e2e-workflow.ts b/test/tests/e2e-workflow.ts index ae7ac2b..cdf06c3 100644 --- a/test/tests/e2e-workflow.ts +++ b/test/tests/e2e-workflow.ts @@ -3,7 +3,7 @@ import {execSync} from 'child_process' import * as fs from 'fs' import * as path from 'path' import * as os from 'os' -import {APIClient, FetchProvider} from '@wharfkit/antelope' +import {ABI, APIClient, FetchProvider, Serializer} from '@wharfkit/antelope' import fetch from 'node-fetch' /** @@ -297,8 +297,8 @@ class [[eosio::contract]] hello : public eosio::contract { assert.include(output, 'Create a new account on the blockchain') }) - test('deploy command is at top level', function () { - const output = execSync(`node ${cliPath} deploy --help`, {encoding: 'utf8'}) + test('contract deploy command works', function () { + const output = execSync(`node ${cliPath} contract deploy --help`, {encoding: 'utf8'}) assert.include(output, 'Deploy a compiled contract') assert.include(output, '--account') @@ -327,7 +327,9 @@ class [[eosio::contract]] hello : public eosio::contract { test('deploy uses account-named key if available', function () { // This test verifies the key selection logic exists // Actual deployment would require a running chain - const deployHelp = execSync(`node ${cliPath} deploy --help`, {encoding: 'utf8'}) + const deployHelp = execSync(`node ${cliPath} contract deploy --help`, { + encoding: 'utf8', + }) // Verify --account option exists (used for key selection) assert.include(deployHelp, '--account') @@ -365,7 +367,7 @@ class [[eosio::contract]] hello : public eosio::contract { const rootCppPath = path.join(__dirname, '../../test.cpp') const cppPath = path.join(testDir, 'test.cpp') const wasmPath = path.join(testDir, 'test.wasm') - + if (fs.existsSync(rootCppPath)) { fs.copyFileSync(rootCppPath, cppPath) } else { @@ -393,14 +395,159 @@ class [[eosio::contract]] hello : public eosio::contract { assert.isTrue(fs.existsSync(wasmPath), 'WASM file should be generated') // 4. Deploy contract - const output = execSync(`node ${cliPath} deploy ${wasmPath} --account ${accountName}`, { - encoding: 'utf8', - cwd: testDir, - }) + const output = execSync( + `node ${cliPath} contract deploy ${wasmPath} --account ${accountName}`, + { + encoding: 'utf8', + cwd: testDir, + } + ) assert.include(output, 'āœ… Contract deployed successfully!') assert.include(output, 'Transaction ID:') }) + + test('validates table removal safety', async function () { + // Check if cdt-cpp is installed + try { + execSync('which cdt-cpp') + } catch (e) { + this.skip() + } + + const accountName = 'val' + Math.random().toString(36).substring(2, 8) + execSync(`node ${cliPath} wallet account create --name ${accountName}`, { + encoding: 'utf8', + }) + + // 1. Deploy contract V1 (with table) + const v1Code = ` + #include + using namespace eosio; + class [[eosio::contract]] v1 : public contract { + public: + using contract::contract; + struct [[eosio::table]] data { + uint64_t id; + std::string val; + uint64_t primary_key() const { return id; } + }; + typedef eosio::multi_index<"data"_n, data> data_table; + + [[eosio::action]] + void insert(uint64_t id, std::string val) { + data_table table(get_self(), get_self().value); + table.emplace(get_self(), [&](auto& row) { + row.id = id; + row.val = val; + }); + } + }; + ` + const cppPath = path.join(testDir, 'v1.cpp') + fs.writeFileSync(cppPath, v1Code) + + // Compile & Deploy V1 + execSync(`node ${cliPath} compile ${cppPath} --output ${testDir}`, {encoding: 'utf8'}) + execSync( + `node ${cliPath} contract deploy ${path.join( + testDir, + 'v1.wasm' + )} --account ${accountName}`, + {encoding: 'utf8', cwd: testDir} + ) + + // 2. Add data to the table + // Read ABI to serialize action data + const abiPath = path.join(testDir, 'v1.abi') + const abi = ABI.from(JSON.parse(fs.readFileSync(abiPath, 'utf8'))) + const actionData = { + id: 1, + val: 'unsafe to remove', + } + const hexData = Serializer.encode({object: actionData, abi, type: 'insert'}).hexString + + // Fetch chain info for valid TAPOS + const client = new APIClient({ + provider: new FetchProvider('http://127.0.0.1:8888', {fetch}), + }) + const chainInfo = await client.v1.chain.get_info() + const blockNum = chainInfo.last_irreversible_block_num.toNumber() + const blockInfo = await client.v1.chain.get_block(blockNum) + + // We need to push an action. We can use wallet transact. + const tx = { + expiration: getTransactionExpiration(), + ref_block_num: blockNum & 0xffff, + ref_block_prefix: blockInfo.ref_block_prefix.toNumber(), + actions: [ + { + account: accountName, + name: 'insert', + authorization: [{actor: accountName, permission: 'active'}], + data: hexData, + }, + ], + } + const txPath = path.join(testDir, 'insert_data.json') + fs.writeFileSync(txPath, JSON.stringify(tx)) + + // Use --broadcast to push to chain + // wallet transact should auto-detect the key from authorization + try { + execSync(`node ${cliPath} wallet transact ${txPath} --broadcast`, { + encoding: 'utf8', + }) + } catch (e: any) { + console.log('Transact failed:') + console.log(e.stdout) + console.log(e.stderr) + throw e + } + + // 3. Create contract V2 (WITHOUT table) + const v2Code = ` + #include + using namespace eosio; + class [[eosio::contract]] v2 : public contract { + public: + using contract::contract; + [[eosio::action]] + void hi() { print("hi"); } + }; + ` + const v2CppPath = path.join(testDir, 'v2.cpp') + fs.writeFileSync(v2CppPath, v2Code) + + // Compile V2 + execSync(`node ${cliPath} compile ${v2CppPath} --output ${testDir}`, {encoding: 'utf8'}) + const v2Wasm = path.join(testDir, 'v2.wasm') + + // 4. Try to deploy V2 - SHOULD FAIL due to safety check + try { + execSync(`node ${cliPath} contract deploy ${v2Wasm} --account ${accountName}`, { + encoding: 'utf8', + cwd: testDir, + stdio: 'pipe', // Capture stderr + }) + assert.fail('Should have failed validation') + } catch (error: any) { + const output = (error.stderr || '').toString() + (error.stdout || '').toString() + assert.include(output, 'SAFETY CHECK FAILED') + assert.include(output, "Table 'data' contains data") + } + + // 5. Try to deploy V2 with --force - SHOULD SUCCEED + const output = execSync( + `node ${cliPath} contract deploy ${v2Wasm} --account ${accountName} --force`, + { + encoding: 'utf8', + cwd: testDir, + } + ) + assert.include(output, 'Contract deployed successfully') + assert.include(output, 'Proceeding despite data loss warning') + }) }) suite('Integration: Wallet Key Storage', () => { From 11bd8d46b3308be4cf1a33983bf3dabe9b239796 Mon Sep 17 00:00:00 2001 From: dafuga Date: Thu, 20 Nov 2025 23:33:46 -0700 Subject: [PATCH 16/36] style: linted --- src/commands/wallet/account.ts | 3 ++- src/commands/wharfkit/deploy.ts | 17 +++++++++-------- test/tests/e2e-workflow.ts | 7 ++++--- 3 files changed, 15 insertions(+), 12 deletions(-) diff --git a/src/commands/wallet/account.ts b/src/commands/wallet/account.ts index f9defde..bee6668 100644 --- a/src/commands/wallet/account.ts +++ b/src/commands/wallet/account.ts @@ -172,7 +172,8 @@ export async function createAccount(options: AccountCreateOptions): Promise key.toLowerCase() === url.toLowerCase() - ) + const knownChainKey = Object.keys(Chains).find((key) => key.toLowerCase() === url.toLowerCase()) if (knownChainKey) { url = (Chains as any)[knownChainKey].url } diff --git a/test/tests/e2e-workflow.ts b/test/tests/e2e-workflow.ts index cdf06c3..cc0123d 100644 --- a/test/tests/e2e-workflow.ts +++ b/test/tests/e2e-workflow.ts @@ -5,6 +5,7 @@ import * as path from 'path' import * as os from 'os' import {ABI, APIClient, FetchProvider, Serializer} from '@wharfkit/antelope' import fetch from 'node-fetch' +import {log} from '../../src/utils' /** * E2E tests for the complete workflow: @@ -499,9 +500,9 @@ class [[eosio::contract]] hello : public eosio::contract { encoding: 'utf8', }) } catch (e: any) { - console.log('Transact failed:') - console.log(e.stdout) - console.log(e.stderr) + log('Transact failed:', 'info') + log(e.stdout, 'info') + log(e.stderr, 'info') throw e } From 421a136f169c8e315eb9bf08fc4f9ebe390e206a Mon Sep 17 00:00:00 2001 From: dafuga Date: Fri, 21 Nov 2025 00:29:32 -0700 Subject: [PATCH 17/36] cleanup: reusing test smart contract --- src/commands/contract/deploy.ts | 1 + test/tests/e2e-workflow.ts | 38 +++++---------------------------- test/tsconfig.json | 2 +- 3 files changed, 7 insertions(+), 34 deletions(-) diff --git a/src/commands/contract/deploy.ts b/src/commands/contract/deploy.ts index 65193a5..62564dc 100644 --- a/src/commands/contract/deploy.ts +++ b/src/commands/contract/deploy.ts @@ -1,4 +1,5 @@ /* eslint-disable no-console */ +/// import {existsSync, readdirSync, readFileSync} from 'fs' import {basename, extname, resolve} from 'path' import type {PrivateKey} from '@wharfkit/antelope' diff --git a/test/tests/e2e-workflow.ts b/test/tests/e2e-workflow.ts index cc0123d..b3ca75e 100644 --- a/test/tests/e2e-workflow.ts +++ b/test/tests/e2e-workflow.ts @@ -241,22 +241,10 @@ suite('E2E Workflow', () => { }) test('can compile a cpp file when cdt is installed', function () { - // Create a simple contract - const contractCode = ` -#include - -class [[eosio::contract]] hello : public eosio::contract { - public: - using eosio::contract::contract; - - [[eosio::action]] - void hi(eosio::name user) { - print("Hello, ", user); - } -}; -` - const cppPath = path.join(testDir, 'hello.cpp') - fs.writeFileSync(cppPath, contractCode) + const rootCppPath = path.join(__dirname, '../../test.cpp') + const cppPath = path.join(testDir, 'test.cpp') + + fs.copyFileSync(rootCppPath, cppPath) try { const output = execSync(`node ${cliPath} compile`, { @@ -369,23 +357,7 @@ class [[eosio::contract]] hello : public eosio::contract { const cppPath = path.join(testDir, 'test.cpp') const wasmPath = path.join(testDir, 'test.wasm') - if (fs.existsSync(rootCppPath)) { - fs.copyFileSync(rootCppPath, cppPath) - } else { - // Fallback if root file missing (shouldn't happen if we just created it) - const contractCode = ` - #include - class [[eosio::contract]] hello : public eosio::contract { - public: - using eosio::contract::contract; - [[eosio::action]] - void hi(eosio::name user) { - print("Hello, ", user); - } - }; - ` - fs.writeFileSync(cppPath, contractCode) - } + fs.copyFileSync(rootCppPath, cppPath) // 3. Compile contract execSync(`node ${cliPath} compile`, { diff --git a/test/tsconfig.json b/test/tsconfig.json index 9987f0e..acf397a 100644 --- a/test/tsconfig.json +++ b/test/tsconfig.json @@ -13,5 +13,5 @@ "$test/*": ["test/*"] } }, - "include": ["*.ts", "**/*.ts"] + "include": ["*.ts", "**/*.ts", "../src/**/*.ts", "../src/**/*.d.ts"] } From 596617e80ba1e80c8f982efcd841130d19096085 Mon Sep 17 00:00:00 2001 From: dafuga Date: Fri, 21 Nov 2025 14:36:52 -0700 Subject: [PATCH 18/36] fix: fixing type errors --- src/commands/contract/deploy.ts | 1 - src/commands/wallet/transact.ts | 4 ++-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/src/commands/contract/deploy.ts b/src/commands/contract/deploy.ts index 62564dc..65193a5 100644 --- a/src/commands/contract/deploy.ts +++ b/src/commands/contract/deploy.ts @@ -1,5 +1,4 @@ /* eslint-disable no-console */ -/// import {existsSync, readdirSync, readFileSync} from 'fs' import {basename, extname, resolve} from 'path' import type {PrivateKey} from '@wharfkit/antelope' diff --git a/src/commands/wallet/transact.ts b/src/commands/wallet/transact.ts index fec6a34..996e385 100644 --- a/src/commands/wallet/transact.ts +++ b/src/commands/wallet/transact.ts @@ -184,7 +184,7 @@ export async function signTransaction( log('', 'info') // Select the key to use - const keyName = selectKey(options.key) + const keyName = selectKey(options.key, transaction) log(`Using key: ${keyName}`, 'info') // Get password if needed @@ -249,7 +249,7 @@ export async function transactTransaction( log('', 'info') // Select the key to use - const keyName = selectKey(options.key) + const keyName = selectKey(options.key, transaction) log(`Using key: ${keyName}`, 'info') // Get password if needed From b9cd18fa9e6dec7df64b478f880c7c6f992a530e Mon Sep 17 00:00:00 2001 From: dafuga Date: Fri, 21 Nov 2025 19:26:14 -0700 Subject: [PATCH 19/36] fix: fixing another type error --- .gitignore | 1 + src/commands/contract/deploy.ts | 1 + src/types/{wharfkit-session.d.ts => wharfkit-session.ts} | 0 3 files changed, 2 insertions(+) rename src/types/{wharfkit-session.d.ts => wharfkit-session.ts} (100%) diff --git a/.gitignore b/.gitignore index 98eb6d2..5674b4f 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ node_modules/ lib/ ./yarn-error.log +.nyc_output/ diff --git a/src/commands/contract/deploy.ts b/src/commands/contract/deploy.ts index 65193a5..4cc8a5e 100644 --- a/src/commands/contract/deploy.ts +++ b/src/commands/contract/deploy.ts @@ -1,4 +1,5 @@ /* eslint-disable no-console */ +import '../../types/wharfkit-session' import {existsSync, readdirSync, readFileSync} from 'fs' import {basename, extname, resolve} from 'path' import type {PrivateKey} from '@wharfkit/antelope' diff --git a/src/types/wharfkit-session.d.ts b/src/types/wharfkit-session.ts similarity index 100% rename from src/types/wharfkit-session.d.ts rename to src/types/wharfkit-session.ts From 82c8500d6f96ca8888a7e584b99aa48b54fcd3de Mon Sep 17 00:00:00 2001 From: dafuga Date: Sat, 22 Nov 2025 00:57:37 -0700 Subject: [PATCH 20/36] fix: adjusting test script --- Makefile | 4 ++-- test/tests/e2e-workflow.ts | 15 ++++++++++++--- 2 files changed, 14 insertions(+), 5 deletions(-) diff --git a/Makefile b/Makefile index 3b724a6..df5000d 100644 --- a/Makefile +++ b/Makefile @@ -8,12 +8,12 @@ lib: ${SRC_FILES} package.json tsconfig.json node_modules rollup.config.js @${BIN}/rollup -c && touch lib .PHONY: test -test: node_modules +test: node_modules lib @WHARFKIT_TEST=1 TS_NODE_PROJECT='./test/tsconfig.json' MOCK_DIR='./test/data/requests' \ ${BIN}/mocha ${MOCHA_OPTS} ${TEST_FILES} --no-timeout --grep '$(grep)' --exit .PHONY: ci-test -ci-test: node_modules +ci-test: node_modules lib @WHARFKIT_TEST=1 TS_NODE_PROJECT='./test/tsconfig.json' MOCK_DIR='./test/data/requests' \ ${BIN}/nyc ${NYC_OPTS} --reporter=text \ ${BIN}/mocha ${MOCHA_OPTS} -R list ${TEST_FILES} --no-timeout --exit diff --git a/test/tests/e2e-workflow.ts b/test/tests/e2e-workflow.ts index b3ca75e..a0fef0d 100644 --- a/test/tests/e2e-workflow.ts +++ b/test/tests/e2e-workflow.ts @@ -24,6 +24,15 @@ function getTransactionExpiration(): string { return now.toISOString().slice(0, 19) // Remove milliseconds and timezone } +function getRandomName(prefix: string): string { + const chars = 'abcdefghijklmnopqrstuvwxyz12345' + let result = prefix + for (let i = 0; i < 6; i++) { + result += chars.charAt(Math.floor(Math.random() * chars.length)) + } + return result +} + suite('E2E Workflow', () => { const cliPath = path.join(__dirname, '../../lib/cli.js') let testDir: string @@ -327,7 +336,7 @@ suite('E2E Workflow', () => { suite('Integration: Account and Deployment', () => { test('can create an account on the local chain', function () { - const accountName = 'acc' + Math.random().toString(36).substring(2, 8) + const accountName = getRandomName('acc') const output = execSync(`node ${cliPath} wallet account create --name ${accountName}`, { encoding: 'utf8', }) @@ -346,7 +355,7 @@ suite('E2E Workflow', () => { } // 1. Create an account - const accountName = 'deploy' + Math.random().toString(36).substring(2, 8) + const accountName = getRandomName('deploy') execSync(`node ${cliPath} wallet account create --name ${accountName}`, { encoding: 'utf8', }) @@ -388,7 +397,7 @@ suite('E2E Workflow', () => { this.skip() } - const accountName = 'val' + Math.random().toString(36).substring(2, 8) + const accountName = getRandomName('val') execSync(`node ${cliPath} wallet account create --name ${accountName}`, { encoding: 'utf8', }) From 6aa35867433a7a3affdea21be4c1da5f091e040b Mon Sep 17 00:00:00 2001 From: dafuga Date: Sat, 22 Nov 2025 22:10:04 -0800 Subject: [PATCH 21/36] fix: fixing ci tests --- src/commands/chain/install.ts | 2 +- test.cpp | 1 + test/tests/chain-interact.ts | 104 +++++++++++++++++++++++++++++++++- test/tests/e2e-workflow.ts | 47 ++++++++++++++- 4 files changed, 151 insertions(+), 3 deletions(-) diff --git a/src/commands/chain/install.ts b/src/commands/chain/install.ts index 73c7c16..10c6a6a 100644 --- a/src/commands/chain/install.ts +++ b/src/commands/chain/install.ts @@ -120,7 +120,7 @@ async function installLeapLinux(): Promise { console.log('Adding AntelopeIO repository...') try { await executeCommand( - 'wget -qO - https://apt.antelope.io/repos/antelope.gpg.key | sudo apt-key add -' + 'wget -O - https://apt.antelope.io/repos/antelope.gpg.key | gpg --dearmor | sudo tee /etc/apt/trusted.gpg.d/antelope.gpg > /dev/null' ) await executeCommand( `echo "deb [arch=amd64] https://apt.antelope.io ${distro} ${version}" | sudo tee /etc/apt/sources.list.d/antelope.list` diff --git a/test.cpp b/test.cpp index 07a0dd3..9e0d0b2 100644 --- a/test.cpp +++ b/test.cpp @@ -30,3 +30,4 @@ class [[eosio::contract]] test : public contract { } }; + diff --git a/test/tests/chain-interact.ts b/test/tests/chain-interact.ts index ed5a704..1703b5f 100644 --- a/test/tests/chain-interact.ts +++ b/test/tests/chain-interact.ts @@ -23,11 +23,113 @@ suite('Chain Interaction', () => { // Kill any existing processes on port 8888 try { - execSync(`lsof -ti:8888 | xargs kill -9 2>/dev/null || true`, {encoding: 'utf8'}) + // Try to stop cleanly first + execSync(`node ${cliPath} chain local stop`, {encoding: 'utf8', stdio: 'ignore'}) + // Give it a moment to fully shut down + execSync('sleep 1', {encoding: 'utf8', stdio: 'ignore'}) } catch (error) { // Ignore errors } + // Also check for PID file and kill that process directly + try { + const pidFile = path.join(os.homedir(), '.wharfkit', 'chain', 'nodeos.pid') + if (fs.existsSync(pidFile)) { + const pid = parseInt(fs.readFileSync(pidFile, 'utf-8').trim(), 10) + if (!isNaN(pid) && pid > 0) { + try { + process.kill(pid, 'SIGKILL') + // Remove the PID file + fs.unlinkSync(pidFile) + // Give it a moment to fully shut down + execSync('sleep 0.5', {encoding: 'utf8', stdio: 'ignore'}) + } catch { + // Process might be gone already + } + } + } + } catch (error) { + // Ignore errors + } + + // Force kill only if it's nodeos (only check for LISTENING processes, not client connections) + try { + // Use -sTCP:LISTEN to only find processes LISTENING on the port, not clients + const pids = execSync(`lsof -ti:8888 -sTCP:LISTEN`, {encoding: 'utf8'}).trim().split('\n') + for (const pid of pids) { + if (!pid) continue + const pidNum = parseInt(pid) + if (isNaN(pidNum)) continue + + // Never kill our own process tree + if (pidNum === process.pid || pidNum === process.ppid) continue + + try { + // Check if process is nodeos + const cmd = execSync(`ps -p ${pidNum} -o command=`, {encoding: 'utf8'}).trim() + if (cmd.includes('nodeos')) { + // Only kill nodeos processes + process.kill(pidNum, 'SIGKILL') + } else { + // Log what we found but didn't kill + // eslint-disable-next-line no-console + console.log(`Found non-nodeos process ${pidNum} listening on port 8888: ${cmd}`) + } + } catch { + // Process might be gone already + } + } + } catch (error) { + // Ignore errors (lsof fails if no process found) + } + + // Wait for port 8888 to be free (only check for LISTENING processes) + const startTime = Date.now() + while (Date.now() - startTime < 20000) { + // Increased wait to 20s + try { + execSync('lsof -ti:8888 -sTCP:LISTEN', {encoding: 'utf8', stdio: 'ignore'}) + // Port is still in use, wait for it to be released + // If it's been more than 2 seconds, try stopping again + if (Date.now() - startTime > 2000) { + try { + execSync(`node ${cliPath} chain local stop`, {encoding: 'utf8', stdio: 'ignore'}) + } catch { + // Ignore errors + } + } + execSync('sleep 0.5') + } catch { + // lsof failed, meaning port is free + break + } + } + + // Final check - verify port is free (only LISTENING processes) + try { + const remainingPids = execSync('lsof -ti:8888 -sTCP:LISTEN', {encoding: 'utf8'}).trim() + if (remainingPids) { + // Check what's still holding the port + const pids = remainingPids.split('\n') + const processes: string[] = [] + for (const pid of pids) { + if (!pid) continue + try { + const cmd = execSync(`ps -p ${pid} -o command=`, {encoding: 'utf8'}).trim() + processes.push(`${pid}: ${cmd}`) + } catch { + processes.push(`${pid}: (unknown)`) + } + } + throw new Error( + `Port 8888 is still in use after cleanup by: ${processes.join(', ')}` + ) + } + } catch (e: any) { + if (e.message && e.message.includes('Port 8888 is still in use')) throw e + // lsof failed, meaning port is free - this is what we want + } + // Start local chain execSync(`node ${cliPath} chain local start`, {encoding: 'utf8'}) diff --git a/test/tests/e2e-workflow.ts b/test/tests/e2e-workflow.ts index a0fef0d..051b9b2 100644 --- a/test/tests/e2e-workflow.ts +++ b/test/tests/e2e-workflow.ts @@ -54,11 +54,56 @@ suite('E2E Workflow', () => { // Kill any existing processes on port 8888 try { - execSync(`lsof -ti:8888 | xargs kill -9 2>/dev/null || true`, {encoding: 'utf8'}) + // Try to stop cleanly first + execSync(`node ${cliPath} chain local stop`, {encoding: 'utf8', stdio: 'ignore'}) + } catch (error) { + // Ignore errors + } + + // Force kill only if it's nodeos (only check for LISTENING processes, not client connections) + try { + // Use -sTCP:LISTEN to only find processes LISTENING on the port, not clients + const pids = execSync(`lsof -ti:8888 -sTCP:LISTEN`, {encoding: 'utf8'}).trim().split('\n') + for (const pid of pids) { + if (!pid) continue + const pidNum = parseInt(pid) + if (isNaN(pidNum)) continue + + // Never kill our own process tree + if (pidNum === process.pid || pidNum === process.ppid) continue + + try { + // Check if process is nodeos + const cmd = execSync(`ps -p ${pidNum} -o command=`, {encoding: 'utf8'}).trim() + if (cmd.includes('nodeos')) { + // Only kill nodeos processes + process.kill(pidNum, 'SIGKILL') + } else { + // Log what we found but didn't kill + // eslint-disable-next-line no-console + console.log(`Found non-nodeos process ${pidNum} listening on port 8888: ${cmd}`) + } + } catch { + // Process might be gone already + } + } } catch (error) { // Ignore errors if no process is running on port 8888 } + // Wait for port 8888 to be free (only check for LISTENING processes) + const startTime = Date.now() + while (Date.now() - startTime < 10000) { + try { + execSync('lsof -ti:8888 -sTCP:LISTEN', {encoding: 'utf8', stdio: 'ignore'}) + // Port is still in use, wait + execSync('sleep 0.5') + } catch { + // lsof failed, meaning port is free + break + } + } + execSync(`node ${cliPath} chain local start`, {encoding: 'utf8'}) }) From 681faba2e26f638e330002cdd326b6cbc7a5ce14 Mon Sep 17 00:00:00 2001 From: dafuga Date: Sat, 22 Nov 2025 22:32:14 -0800 Subject: [PATCH 22/36] fix: getting tests passing --- src/commands/chain/utils.ts | 3 +- test/tests/chain-interact.ts | 4 +- test/tests/e2e-workflow.ts | 87 ++++++++++++++++++++++++++++++++---- 3 files changed, 82 insertions(+), 12 deletions(-) diff --git a/src/commands/chain/utils.ts b/src/commands/chain/utils.ts index 0da3033..733d7cf 100644 --- a/src/commands/chain/utils.ts +++ b/src/commands/chain/utils.ts @@ -127,7 +127,8 @@ export async function removePidFile(): Promise { */ export async function isPortAvailable(port: number): Promise { try { - const {stdout} = await execAsync(`lsof -i :${port} || echo "free"`) + // Only check for LISTENING processes, not client connections + const {stdout} = await execAsync(`lsof -ti:${port} -sTCP:LISTEN || echo "free"`) return stdout.includes('free') } catch { return true diff --git a/test/tests/chain-interact.ts b/test/tests/chain-interact.ts index 1703b5f..9a517d8 100644 --- a/test/tests/chain-interact.ts +++ b/test/tests/chain-interact.ts @@ -38,7 +38,7 @@ suite('Chain Interaction', () => { const pid = parseInt(fs.readFileSync(pidFile, 'utf-8').trim(), 10) if (!isNaN(pid) && pid > 0) { try { - process.kill(pid, 'SIGKILL') + execSync(`kill -9 ${pid}`, {encoding: 'utf8', stdio: 'ignore'}) // Remove the PID file fs.unlinkSync(pidFile) // Give it a moment to fully shut down @@ -69,7 +69,7 @@ suite('Chain Interaction', () => { const cmd = execSync(`ps -p ${pidNum} -o command=`, {encoding: 'utf8'}).trim() if (cmd.includes('nodeos')) { // Only kill nodeos processes - process.kill(pidNum, 'SIGKILL') + execSync(`kill -9 ${pidNum}`, {encoding: 'utf8', stdio: 'ignore'}) } else { // Log what we found but didn't kill // eslint-disable-next-line no-console diff --git a/test/tests/e2e-workflow.ts b/test/tests/e2e-workflow.ts index 051b9b2..28c6385 100644 --- a/test/tests/e2e-workflow.ts +++ b/test/tests/e2e-workflow.ts @@ -56,6 +56,29 @@ suite('E2E Workflow', () => { try { // Try to stop cleanly first execSync(`node ${cliPath} chain local stop`, {encoding: 'utf8', stdio: 'ignore'}) + // Give it a moment to fully shut down + execSync('sleep 1', {encoding: 'utf8', stdio: 'ignore'}) + } catch (error) { + // Ignore errors + } + + // Also check for PID file and kill that process directly + try { + const pidFile = path.join(os.homedir(), '.wharfkit', 'chain', 'nodeos.pid') + if (fs.existsSync(pidFile)) { + const pid = parseInt(fs.readFileSync(pidFile, 'utf-8').trim(), 10) + if (!isNaN(pid) && pid > 0) { + try { + execSync(`kill -9 ${pid}`, {encoding: 'utf8', stdio: 'ignore'}) + // Remove the PID file + fs.unlinkSync(pidFile) + // Give it a moment to fully shut down + execSync('sleep 0.5', {encoding: 'utf8', stdio: 'ignore'}) + } catch { + // Process might be gone already + } + } + } } catch (error) { // Ignore errors } @@ -63,40 +86,56 @@ suite('E2E Workflow', () => { // Force kill only if it's nodeos (only check for LISTENING processes, not client connections) try { // Use -sTCP:LISTEN to only find processes LISTENING on the port, not clients - const pids = execSync(`lsof -ti:8888 -sTCP:LISTEN`, {encoding: 'utf8'}).trim().split('\n') + const pids = execSync(`lsof -ti:8888 -sTCP:LISTEN`, {encoding: 'utf8'}) + .trim() + .split('\n') for (const pid of pids) { if (!pid) continue const pidNum = parseInt(pid) if (isNaN(pidNum)) continue - + // Never kill our own process tree if (pidNum === process.pid || pidNum === process.ppid) continue - + try { // Check if process is nodeos const cmd = execSync(`ps -p ${pidNum} -o command=`, {encoding: 'utf8'}).trim() if (cmd.includes('nodeos')) { // Only kill nodeos processes - process.kill(pidNum, 'SIGKILL') + execSync(`kill -9 ${pidNum}`, {encoding: 'utf8', stdio: 'ignore'}) } else { // Log what we found but didn't kill // eslint-disable-next-line no-console - console.log(`Found non-nodeos process ${pidNum} listening on port 8888: ${cmd}`) + console.log( + `Found non-nodeos process ${pidNum} listening on port 8888: ${cmd}` + ) } } catch { // Process might be gone already } } } catch (error) { - // Ignore errors if no process is running on port 8888 + // Ignore errors (lsof fails if no process found) } // Wait for port 8888 to be free (only check for LISTENING processes) const startTime = Date.now() - while (Date.now() - startTime < 10000) { + while (Date.now() - startTime < 20000) { + // Increased wait to 20s try { execSync('lsof -ti:8888 -sTCP:LISTEN', {encoding: 'utf8', stdio: 'ignore'}) - // Port is still in use, wait + // Port is still in use, wait for it to be released + // If it's been more than 2 seconds, try stopping again + if (Date.now() - startTime > 2000) { + try { + execSync(`node ${cliPath} chain local stop`, { + encoding: 'utf8', + stdio: 'ignore', + }) + } catch { + // Ignore errors + } + } execSync('sleep 0.5') } catch { // lsof failed, meaning port is free @@ -104,10 +143,36 @@ suite('E2E Workflow', () => { } } + // Final check - verify port is free (only LISTENING processes) + try { + const remainingPids = execSync('lsof -ti:8888 -sTCP:LISTEN', {encoding: 'utf8'}).trim() + if (remainingPids) { + // Check what's still holding the port + const pids = remainingPids.split('\n') + const processes: string[] = [] + for (const pid of pids) { + if (!pid) continue + try { + const cmd = execSync(`ps -p ${pid} -o command=`, {encoding: 'utf8'}).trim() + processes.push(`${pid}: ${cmd}`) + } catch { + processes.push(`${pid}: (unknown)`) + } + } + throw new Error( + `Port 8888 is still in use after cleanup by: ${processes.join(', ')}` + ) + } + } catch (e: any) { + if (e.message && e.message.includes('Port 8888 is still in use')) throw e + // lsof failed, meaning port is free - this is what we want + } + execSync(`node ${cliPath} chain local start`, {encoding: 'utf8'}) }) suiteTeardown(function () { + this.timeout(30000) // Restore original HOME process.env.HOME = originalHome @@ -116,7 +181,11 @@ suite('E2E Workflow', () => { fs.rmSync(testDir, {recursive: true, force: true}) } - execSync(`node ${cliPath} chain local stop`, {encoding: 'utf8'}) + try { + execSync(`node ${cliPath} chain local stop`, {encoding: 'utf8'}) + } catch (e) { + // Ignore error + } }) suite('Wallet Key Management', () => { From 6e04ccd79bad6a6012c3a8d52c11d252ab0e98a7 Mon Sep 17 00:00:00 2001 From: dafuga Date: Sat, 22 Nov 2025 22:50:53 -0800 Subject: [PATCH 23/36] cleanup: cleaning up the tests --- test/tests/chain-interact.ts | 38 ++--------- test/tests/e2e-workflow.ts | 123 +++-------------------------------- test/utils/test-helpers.ts | 31 +++++++++ 3 files changed, 46 insertions(+), 146 deletions(-) create mode 100644 test/utils/test-helpers.ts diff --git a/test/tests/chain-interact.ts b/test/tests/chain-interact.ts index 9a517d8..43c013f 100644 --- a/test/tests/chain-interact.ts +++ b/test/tests/chain-interact.ts @@ -3,6 +3,7 @@ import {execSync} from 'child_process' import * as fs from 'fs' import * as path from 'path' import * as os from 'os' +import {killProcessAtPort} from '../utils/test-helpers' suite('Chain Interaction', () => { const cliPath = path.join(__dirname, '../../lib/cli.js') @@ -52,36 +53,8 @@ suite('Chain Interaction', () => { // Ignore errors } - // Force kill only if it's nodeos (only check for LISTENING processes, not client connections) - try { - // Use -sTCP:LISTEN to only find processes LISTENING on the port, not clients - const pids = execSync(`lsof -ti:8888 -sTCP:LISTEN`, {encoding: 'utf8'}).trim().split('\n') - for (const pid of pids) { - if (!pid) continue - const pidNum = parseInt(pid) - if (isNaN(pidNum)) continue - - // Never kill our own process tree - if (pidNum === process.pid || pidNum === process.ppid) continue - - try { - // Check if process is nodeos - const cmd = execSync(`ps -p ${pidNum} -o command=`, {encoding: 'utf8'}).trim() - if (cmd.includes('nodeos')) { - // Only kill nodeos processes - execSync(`kill -9 ${pidNum}`, {encoding: 'utf8', stdio: 'ignore'}) - } else { - // Log what we found but didn't kill - // eslint-disable-next-line no-console - console.log(`Found non-nodeos process ${pidNum} listening on port 8888: ${cmd}`) - } - } catch { - // Process might be gone already - } - } - } catch (error) { - // Ignore errors (lsof fails if no process found) - } + // Force kill any nodeos processes on port 8888 + killProcessAtPort(8888) // Wait for port 8888 to be free (only check for LISTENING processes) const startTime = Date.now() @@ -93,7 +66,10 @@ suite('Chain Interaction', () => { // If it's been more than 2 seconds, try stopping again if (Date.now() - startTime > 2000) { try { - execSync(`node ${cliPath} chain local stop`, {encoding: 'utf8', stdio: 'ignore'}) + execSync(`node ${cliPath} chain local stop`, { + encoding: 'utf8', + stdio: 'ignore', + }) } catch { // Ignore errors } diff --git a/test/tests/e2e-workflow.ts b/test/tests/e2e-workflow.ts index 28c6385..d1069aa 100644 --- a/test/tests/e2e-workflow.ts +++ b/test/tests/e2e-workflow.ts @@ -6,6 +6,7 @@ import * as os from 'os' import {ABI, APIClient, FetchProvider, Serializer} from '@wharfkit/antelope' import fetch from 'node-fetch' import {log} from '../../src/utils' +import {killProcessAtPort} from '../utils/test-helpers' /** * E2E tests for the complete workflow: @@ -52,121 +53,17 @@ suite('E2E Workflow', () => { originalHome = process.env.HOME || '' process.env.HOME = testDir - // Kill any existing processes on port 8888 + // Stop any existing chain before starting + // Try chain local stop first (works if chain was started with same HOME) try { - // Try to stop cleanly first execSync(`node ${cliPath} chain local stop`, {encoding: 'utf8', stdio: 'ignore'}) - // Give it a moment to fully shut down execSync('sleep 1', {encoding: 'utf8', stdio: 'ignore'}) - } catch (error) { - // Ignore errors + } catch { + // Ignore errors - chain might not be running or was started with different HOME } - // Also check for PID file and kill that process directly - try { - const pidFile = path.join(os.homedir(), '.wharfkit', 'chain', 'nodeos.pid') - if (fs.existsSync(pidFile)) { - const pid = parseInt(fs.readFileSync(pidFile, 'utf-8').trim(), 10) - if (!isNaN(pid) && pid > 0) { - try { - execSync(`kill -9 ${pid}`, {encoding: 'utf8', stdio: 'ignore'}) - // Remove the PID file - fs.unlinkSync(pidFile) - // Give it a moment to fully shut down - execSync('sleep 0.5', {encoding: 'utf8', stdio: 'ignore'}) - } catch { - // Process might be gone already - } - } - } - } catch (error) { - // Ignore errors - } - - // Force kill only if it's nodeos (only check for LISTENING processes, not client connections) - try { - // Use -sTCP:LISTEN to only find processes LISTENING on the port, not clients - const pids = execSync(`lsof -ti:8888 -sTCP:LISTEN`, {encoding: 'utf8'}) - .trim() - .split('\n') - for (const pid of pids) { - if (!pid) continue - const pidNum = parseInt(pid) - if (isNaN(pidNum)) continue - - // Never kill our own process tree - if (pidNum === process.pid || pidNum === process.ppid) continue - - try { - // Check if process is nodeos - const cmd = execSync(`ps -p ${pidNum} -o command=`, {encoding: 'utf8'}).trim() - if (cmd.includes('nodeos')) { - // Only kill nodeos processes - execSync(`kill -9 ${pidNum}`, {encoding: 'utf8', stdio: 'ignore'}) - } else { - // Log what we found but didn't kill - // eslint-disable-next-line no-console - console.log( - `Found non-nodeos process ${pidNum} listening on port 8888: ${cmd}` - ) - } - } catch { - // Process might be gone already - } - } - } catch (error) { - // Ignore errors (lsof fails if no process found) - } - - // Wait for port 8888 to be free (only check for LISTENING processes) - const startTime = Date.now() - while (Date.now() - startTime < 20000) { - // Increased wait to 20s - try { - execSync('lsof -ti:8888 -sTCP:LISTEN', {encoding: 'utf8', stdio: 'ignore'}) - // Port is still in use, wait for it to be released - // If it's been more than 2 seconds, try stopping again - if (Date.now() - startTime > 2000) { - try { - execSync(`node ${cliPath} chain local stop`, { - encoding: 'utf8', - stdio: 'ignore', - }) - } catch { - // Ignore errors - } - } - execSync('sleep 0.5') - } catch { - // lsof failed, meaning port is free - break - } - } - - // Final check - verify port is free (only LISTENING processes) - try { - const remainingPids = execSync('lsof -ti:8888 -sTCP:LISTEN', {encoding: 'utf8'}).trim() - if (remainingPids) { - // Check what's still holding the port - const pids = remainingPids.split('\n') - const processes: string[] = [] - for (const pid of pids) { - if (!pid) continue - try { - const cmd = execSync(`ps -p ${pid} -o command=`, {encoding: 'utf8'}).trim() - processes.push(`${pid}: ${cmd}`) - } catch { - processes.push(`${pid}: (unknown)`) - } - } - throw new Error( - `Port 8888 is still in use after cleanup by: ${processes.join(', ')}` - ) - } - } catch (e: any) { - if (e.message && e.message.includes('Port 8888 is still in use')) throw e - // lsof failed, meaning port is free - this is what we want - } + // Also check port 8888 directly in case chain was started by another test with different HOME + killProcessAtPort(8888) execSync(`node ${cliPath} chain local start`, {encoding: 'utf8'}) }) @@ -181,11 +78,7 @@ suite('E2E Workflow', () => { fs.rmSync(testDir, {recursive: true, force: true}) } - try { - execSync(`node ${cliPath} chain local stop`, {encoding: 'utf8'}) - } catch (e) { - // Ignore error - } + execSync(`node ${cliPath} chain local stop`, {encoding: 'utf8'}) }) suite('Wallet Key Management', () => { diff --git a/test/utils/test-helpers.ts b/test/utils/test-helpers.ts new file mode 100644 index 0000000..9ac1397 --- /dev/null +++ b/test/utils/test-helpers.ts @@ -0,0 +1,31 @@ +import {execSync} from 'child_process' + +/** + * Kill any nodeos processes listening on the specified port + * @param port - The port number to check + */ +export function killProcessAtPort(port: number): void { + try { + const pids = execSync(`lsof -ti:${port} -sTCP:LISTEN`, {encoding: 'utf8'}) + .trim() + .split('\n') + for (const pid of pids) { + if (!pid) continue + const pidNum = parseInt(pid) + if (isNaN(pidNum) || pidNum === process.pid || pidNum === process.ppid) continue + try { + const cmd = execSync(`ps -p ${pidNum} -o command=`, {encoding: 'utf8'}).trim() + if (cmd.includes('nodeos')) { + execSync(`kill -9 ${pidNum}`, {encoding: 'utf8', stdio: 'ignore'}) + } + } catch { + // Process might be gone already + } + } + // Give it a moment to fully shut down + execSync('sleep 0.5', {encoding: 'utf8', stdio: 'ignore'}) + } catch { + // Port is free or lsof failed + } +} + From f9e9ac533bea18ff58f6e06b7f020843505d01c0 Mon Sep 17 00:00:00 2001 From: dafuga Date: Sat, 22 Nov 2025 22:53:24 -0800 Subject: [PATCH 24/36] style: linted --- test/utils/test-helpers.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/test/utils/test-helpers.ts b/test/utils/test-helpers.ts index 9ac1397..796f516 100644 --- a/test/utils/test-helpers.ts +++ b/test/utils/test-helpers.ts @@ -28,4 +28,3 @@ export function killProcessAtPort(port: number): void { // Port is free or lsof failed } } - From 6780419f5f74441a0d49654e42f30e40fa6430b7 Mon Sep 17 00:00:00 2001 From: dafuga Date: Sat, 22 Nov 2025 22:59:40 -0800 Subject: [PATCH 25/36] fix: skipping e2e tests on gh ci --- src/commands/chain/install.ts | 13 +++++++++++++ test/tests/chain-interact.ts | 10 +++++++++- test/tests/e2e-workflow.ts | 10 +++++++++- test/utils/test-helpers.ts | 12 ++++++++++++ 4 files changed, 43 insertions(+), 2 deletions(-) diff --git a/src/commands/chain/install.ts b/src/commands/chain/install.ts index 10c6a6a..ed61feb 100644 --- a/src/commands/chain/install.ts +++ b/src/commands/chain/install.ts @@ -188,6 +188,19 @@ export async function ensureLeapInstalled(): Promise { return } + // In test mode, skip auto-installation and just check if nodeos is available + if (process.env.WHARFKIT_TEST) { + if (!status.nodeos) { + throw new Error( + 'LEAP is not installed and auto-installation is disabled in test mode. ' + + 'Please install LEAP manually or ensure nodeos is available in PATH.' + ) + } + // If nodeos is available, continue even if other components are missing + console.log(`LEAP nodeos is available (version: ${status.version || 'unknown'})`) + return + } + console.log('LEAP is not installed, installing automatically...') if (!status.nodeos) { diff --git a/test/tests/chain-interact.ts b/test/tests/chain-interact.ts index 43c013f..bbe2cd7 100644 --- a/test/tests/chain-interact.ts +++ b/test/tests/chain-interact.ts @@ -3,7 +3,7 @@ import {execSync} from 'child_process' import * as fs from 'fs' import * as path from 'path' import * as os from 'os' -import {killProcessAtPort} from '../utils/test-helpers' +import {killProcessAtPort, isNodeosAvailable} from '../utils/test-helpers' suite('Chain Interaction', () => { const cliPath = path.join(__dirname, '../../lib/cli.js') @@ -12,6 +12,14 @@ suite('Chain Interaction', () => { let contractAccount: string suiteSetup(function () { + // Skip suite if nodeos is not available + if (!isNodeosAvailable()) { + // eslint-disable-next-line no-console + console.log('Skipping Chain Interaction tests: nodeos is not available') + this.skip() + return + } + this.timeout(120000) // Increase timeout for chain startup and deploy // Create a temporary test directory diff --git a/test/tests/e2e-workflow.ts b/test/tests/e2e-workflow.ts index d1069aa..bf04e77 100644 --- a/test/tests/e2e-workflow.ts +++ b/test/tests/e2e-workflow.ts @@ -6,7 +6,7 @@ import * as os from 'os' import {ABI, APIClient, FetchProvider, Serializer} from '@wharfkit/antelope' import fetch from 'node-fetch' import {log} from '../../src/utils' -import {killProcessAtPort} from '../utils/test-helpers' +import {killProcessAtPort, isNodeosAvailable} from '../utils/test-helpers' /** * E2E tests for the complete workflow: @@ -41,6 +41,14 @@ suite('E2E Workflow', () => { let originalHome: string suiteSetup(function () { + // Skip suite if nodeos is not available + if (!isNodeosAvailable()) { + // eslint-disable-next-line no-console + console.log('Skipping E2E Workflow tests: nodeos is not available') + this.skip() + return + } + // Create a temporary test directory testDir = path.join(os.tmpdir(), `wharfkit-e2e-test-${Date.now()}`) fs.mkdirSync(testDir, {recursive: true}) diff --git a/test/utils/test-helpers.ts b/test/utils/test-helpers.ts index 796f516..c2508c4 100644 --- a/test/utils/test-helpers.ts +++ b/test/utils/test-helpers.ts @@ -1,5 +1,17 @@ import {execSync} from 'child_process' +/** + * Check if nodeos is available in PATH + */ +export function isNodeosAvailable(): boolean { + try { + execSync('which nodeos', {encoding: 'utf8', stdio: 'ignore'}) + return true + } catch { + return false + } +} + /** * Kill any nodeos processes listening on the specified port * @param port - The port number to check From b996c19ec2348a7b65e9bd83c2fdb24c852ecb78 Mon Sep 17 00:00:00 2001 From: dafuga Date: Sun, 23 Nov 2025 00:00:17 -0800 Subject: [PATCH 26/36] style: linted --- test/tests/chain-interact.ts | 2 +- test/tests/e2e-workflow.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/test/tests/chain-interact.ts b/test/tests/chain-interact.ts index bbe2cd7..f4c041e 100644 --- a/test/tests/chain-interact.ts +++ b/test/tests/chain-interact.ts @@ -3,7 +3,7 @@ import {execSync} from 'child_process' import * as fs from 'fs' import * as path from 'path' import * as os from 'os' -import {killProcessAtPort, isNodeosAvailable} from '../utils/test-helpers' +import {isNodeosAvailable, killProcessAtPort} from '../utils/test-helpers' suite('Chain Interaction', () => { const cliPath = path.join(__dirname, '../../lib/cli.js') diff --git a/test/tests/e2e-workflow.ts b/test/tests/e2e-workflow.ts index bf04e77..8ad829e 100644 --- a/test/tests/e2e-workflow.ts +++ b/test/tests/e2e-workflow.ts @@ -6,7 +6,7 @@ import * as os from 'os' import {ABI, APIClient, FetchProvider, Serializer} from '@wharfkit/antelope' import fetch from 'node-fetch' import {log} from '../../src/utils' -import {killProcessAtPort, isNodeosAvailable} from '../utils/test-helpers' +import {isNodeosAvailable, killProcessAtPort} from '../utils/test-helpers' /** * E2E tests for the complete workflow: From 488b3f693ccd8b4dddb88471f9f5abf5f77d8220 Mon Sep 17 00:00:00 2001 From: dafuga Date: Sun, 23 Nov 2025 16:06:51 -0800 Subject: [PATCH 27/36] fix: minor rollbar tweaks --- rollup.config.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/rollup.config.js b/rollup.config.js index 6199ddc..0827bfa 100644 --- a/rollup.config.js +++ b/rollup.config.js @@ -7,8 +7,10 @@ import pkg from './package.json' const external = ['fs', ...Object.keys(pkg.dependencies)] // Add shebang + disable experimental fetch warning +// Use env -S with explicit PATH to ensure node can be found in restricted environments (e.g., Make) +// Note: macOS env -S only supports ${VARNAME} syntax, not $VARNAME const banner = ` -#!/usr/bin/env node +#!/usr/bin/env -S PATH="/opt/homebrew/bin:/usr/local/bin:\${PATH}" node process.removeAllListeners('warning') `.trim() From d5fbbf553654cf62fc2523828bebc6d26453deef Mon Sep 17 00:00:00 2001 From: dafuga Date: Sun, 23 Nov 2025 16:38:10 -0800 Subject: [PATCH 28/36] fix: fixing installing of leap --- Makefile | 6 +++--- src/commands/chain/install.ts | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/Makefile b/Makefile index df5000d..6c57526 100644 --- a/Makefile +++ b/Makefile @@ -5,16 +5,16 @@ MOCHA_OPTS := -u tdd -r ts-node/register -r tsconfig-paths/register --extension BIN := ./node_modules/.bin lib: ${SRC_FILES} package.json tsconfig.json node_modules rollup.config.js - @${BIN}/rollup -c && touch lib + @GITHUB_CI=false ${BIN}/rollup -c && touch lib .PHONY: test test: node_modules lib - @WHARFKIT_TEST=1 TS_NODE_PROJECT='./test/tsconfig.json' MOCK_DIR='./test/data/requests' \ + @GITHUB_CI=false TS_NODE_PROJECT='./test/tsconfig.json' MOCK_DIR='./test/data/requests' \ ${BIN}/mocha ${MOCHA_OPTS} ${TEST_FILES} --no-timeout --grep '$(grep)' --exit .PHONY: ci-test ci-test: node_modules lib - @WHARFKIT_TEST=1 TS_NODE_PROJECT='./test/tsconfig.json' MOCK_DIR='./test/data/requests' \ + @GITHUB_CI=true TS_NODE_PROJECT='./test/tsconfig.json' MOCK_DIR='./test/data/requests' \ ${BIN}/nyc ${NYC_OPTS} --reporter=text \ ${BIN}/mocha ${MOCHA_OPTS} -R list ${TEST_FILES} --no-timeout --exit diff --git a/src/commands/chain/install.ts b/src/commands/chain/install.ts index ed61feb..58b17ee 100644 --- a/src/commands/chain/install.ts +++ b/src/commands/chain/install.ts @@ -188,11 +188,11 @@ export async function ensureLeapInstalled(): Promise { return } - // In test mode, skip auto-installation and just check if nodeos is available - if (process.env.WHARFKIT_TEST) { + // In CI mode, skip auto-installation and just check if nodeos is available + if (process.env.GITHUB_CI) { if (!status.nodeos) { throw new Error( - 'LEAP is not installed and auto-installation is disabled in test mode. ' + + 'LEAP is not installed and auto-installation is disabled in CI mode. ' + 'Please install LEAP manually or ensure nodeos is available in PATH.' ) } From 86061293069e4fa1edb4def38883ece549c2eff3 Mon Sep 17 00:00:00 2001 From: dafuga Date: Sun, 23 Nov 2025 17:37:32 -0800 Subject: [PATCH 29/36] fix: fixing account create command --- src/commands/wallet/account.ts | 277 +++++++++++++-------------------- src/commands/wallet/index.ts | 8 +- 2 files changed, 115 insertions(+), 170 deletions(-) diff --git a/src/commands/wallet/account.ts b/src/commands/wallet/account.ts index bee6668..a6318ff 100644 --- a/src/commands/wallet/account.ts +++ b/src/commands/wallet/account.ts @@ -1,190 +1,113 @@ -/* eslint-disable no-console */ -import {APIClient, FetchProvider, KeyType, PrivateKey} from '@wharfkit/antelope' -import {Session} from '@wharfkit/session' -import {WalletPluginPrivateKey} from '@wharfkit/wallet-plugin-privatekey' +import type {PublicKeyType} from '@wharfkit/antelope' +import {KeyType, type NameType, PrivateKey} from '@wharfkit/antelope' +import {type ChainDefinition, type ChainIndices, Chains} from '@wharfkit/common' import fetch from 'node-fetch' -import {getDevKeys} from '../chain/utils' -import {NonInteractiveConsoleUI} from '../../utils/wharfkit-ui' -import {addKeyToWallet} from './utils' +import {log, makeClient} from '../../utils' interface AccountCreateOptions { - name?: string - url?: string + key?: PublicKeyType | string + name?: NameType | string + chain?: ChainIndices | string } -export async function createAccount(options: AccountCreateOptions): Promise { - const url = options.url || 'http://127.0.0.1:8888' - - // Generate account name if not provided - const accountName = options.name || generateRandomAccountName() +const supportedChains = ['Jungle4', 'KylinTestnet'] - // Validate account name - if (accountName.length > 12 || accountName.length < 3) { - console.error('Account name must be between 3 and 12 characters long') - process.exit(1) +export async function createAccount(options: AccountCreateOptions): Promise { + let publicKey + let privateKey + + // Convert chain option to ChainIndices format (PascalCase) + let chainIndex: ChainIndices = 'Jungle4' + if (options.chain) { + const chainStr = String(options.chain) + // Convert to PascalCase (e.g., "jungle4" -> "Jungle4") + const pascalCaseChain = chainStr.charAt(0).toUpperCase() + chainStr.slice(1).toLowerCase() + if (supportedChains.includes(pascalCaseChain)) { + chainIndex = pascalCaseChain as ChainIndices + } else { + log( + `Unsupported chain "${options.chain}". Supported chains are: ${supportedChains.join( + ', ' + )}`, + 'info' + ) + return + } } - console.log('Creating account on local chain...') - console.log(` Account: ${accountName}`) - console.log(` URL: ${url}`) + const chainDefinition: ChainDefinition = Chains[chainIndex] - try { - // Generate a new key pair for the account - const newPrivateKey = PrivateKey.generate(KeyType.K1) - const newPublicKey = newPrivateKey.toPublic() + // Default to "jungle4" if no chain option is provided + const chainUrl = chainDefinition + ? chainDefinition.url + : `http://${chainIndex.toLowerCase()}.greymass.com` - console.log(` Public Key: ${newPublicKey.toString()}`) + if (options.name) { + if (!String(options.name).endsWith('.gm')) { + log('Account name must end with ".gm"', 'info') + return + } + if (options.name && (String(options.name).length > 12 || String(options.name).length < 3)) { + log('Account name must be between 3 and 12 characters long', 'info') + return + } + const accountNameExists = + options.name && (await checkAccountNameExists(options.name, chainUrl)) - // Get dev keys for signing the newaccount action - const devKeys = getDevKeys() - const devPrivateKey = PrivateKey.from(devKeys.privateKey) + if (accountNameExists) { + log( + `Account name "${options.name}" is already taken. Please choose another name.`, + 'info' + ) + return + } + } - // Create API client - const client = new APIClient({ - provider: new FetchProvider(url, {fetch}), - }) + // Generate a random account name if not provided + const accountName = options.name || generateRandomAccountName() - // Get chain info - const info = await client.v1.chain.get_info() - - // Check if system contract is deployed (for buyram/delegatebw) - let hasSystemContract = false - try { - const abiResponse = await client.v1.chain.get_abi('eosio') - if (abiResponse.abi) { - const actionNames = abiResponse.abi.actions.map((a) => String(a.name)) - hasSystemContract = - actionNames.includes('buyrambytes') && actionNames.includes('delegatebw') - } - } catch (e) { - // Ignore error, assume no system contract + try { + // Check if a public key is provided in the options + if (options.key) { + publicKey = String(options.key) + } else { + // Generate a new private key if none is provided + privateKey = PrivateKey.generate(KeyType.K1) + // Derive the corresponding public key + publicKey = String(privateKey.toPublic()) } - // Create session with dev key - const walletPlugin = new WalletPluginPrivateKey(devPrivateKey) - walletPlugin.config.requiresChainSelect = false - walletPlugin.config.requiresPermissionSelect = false - walletPlugin.config.requiresPermissionEntry = false + // Prepare the data for the POST request + const data = { + accountName: accountName, + activeKey: publicKey, + ownerKey: publicKey, + network: chainDefinition.id, + } - const session = new Session({ - chain: { - id: String(info.chain_id), - url, + // Make the POST request to create the account + const response = await fetch(`${chainUrl}/account/create`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', }, - actor: 'eosio', - permission: 'active', - walletPlugin, - ui: new NonInteractiveConsoleUI(), + body: JSON.stringify(data), }) - const actions: any[] = [ - { - account: 'eosio', - name: 'newaccount', - authorization: [ - { - actor: 'eosio', - permission: 'active', - }, - ], - data: { - creator: 'eosio', - name: accountName, - owner: { - threshold: 1, - keys: [ - { - key: newPublicKey, - weight: 1, - }, - ], - accounts: [], - waits: [], - }, - active: { - threshold: 1, - keys: [ - { - key: newPublicKey, - weight: 1, - }, - ], - accounts: [], - waits: [], - }, - }, - }, - ] - - if (hasSystemContract) { - actions.push({ - account: 'eosio', - name: 'buyrambytes', - authorization: [ - { - actor: 'eosio', - permission: 'active', - }, - ], - data: { - payer: 'eosio', - receiver: accountName, - bytes: 8192, - }, - }) - actions.push({ - account: 'eosio', - name: 'delegatebw', - authorization: [ - { - actor: 'eosio', - permission: 'active', - }, - ], - data: { - from: 'eosio', - receiver: accountName, - stake_net_quantity: '1.0000 SYS', - stake_cpu_quantity: '1.0000 SYS', - transfer: false, - }, - }) - } - - // Create newaccount action - const result = await session.transact( - { - actions, - }, - { - broadcast: true, + if (response.status === 201) { + log('Account created successfully!', 'info') + log(`Account Name: ${accountName}`, 'info') + if (privateKey) { + // Only print the private key if it was generated + log(`Private Key: ${privateKey.toString()}`, 'info') } - ) - - console.log('\nāœ… Account created successfully!') - console.log(`Account: ${accountName}`) - console.log(`Private Key: ${newPrivateKey.toString()}`) - console.log(`Public Key: ${newPublicKey.toString()}`) - console.log(`Transaction ID: ${result.resolved?.transaction.id}`) - - // Store the key in wallet with account name - try { - addKeyToWallet(newPrivateKey, accountName) - console.log(`\nšŸ” Key stored in wallet as: ${accountName}`) - console.log( - 'You can now deploy contracts with: wharfkit contract deploy --account ' + - accountName - ) - } catch (error) { - console.log( - '\nāš ļø Could not store key in wallet (may already exist): ' + - (error as Error).message - ) + log(`Public Key: ${publicKey}`, 'info') + } else { + const responseData = await response.json() + log(`Failed to create account: ${responseData.message || responseData.reason}`, 'info') } - } catch (error) { - console.error(`\nāŒ Failed to create account: ${(error as Error).message}`) - console.error('\nMake sure the local chain is running: wharfkit chain local start') - process.exit(1) + } catch (error: unknown) { + log(`Error during account creation: ${(error as {message: string}).message}`, 'info') } } @@ -192,8 +115,26 @@ function generateRandomAccountName(): string { // Generate a random 12-character account name using the allowed characters for Antelope accounts const characters = 'abcdefghijklmnopqrstuvwxyz12345' let result = '' - for (let i = 0; i < 12; i++) { + for (let i = 0; i < 9; i++) { result += characters.charAt(Math.floor(Math.random() * characters.length)) } - return result + return `${result}.gm` +} + +async function checkAccountNameExists(accountName: NameType, chainUrl: string): Promise { + const client = makeClient(chainUrl) + + try { + const account = await client.v1.chain.get_account(accountName) + return !!account?.account_name + } catch (error: unknown) { + const errorMessage = (error as {message: string}).message + if ( + errorMessage.includes('Account not found') || + errorMessage.includes('Account Query Exception') + ) { + return false + } + throw Error(`Error checking if account name exists: ${errorMessage}`) + } } diff --git a/src/commands/wallet/index.ts b/src/commands/wallet/index.ts index 9ae2ade..55684da 100644 --- a/src/commands/wallet/index.ts +++ b/src/commands/wallet/index.ts @@ -50,8 +50,12 @@ export function createWalletCommand(): Command { accountCommand .command('create') .description('Create a new account on the blockchain') - .option('-n, --name ', 'Account name (default: auto-generated)') - .option('-u, --url ', 'Blockchain API URL (default: http://127.0.0.1:8888)') + .option('-n, --name ', 'Account name (default: auto-generated, must end with .gm)') + .option('-k, --key ', 'Public key to use (default: auto-generated)') + .option( + '-c, --chain ', + 'Chain to create account on (Jungle4 or KylinTestnet, default: Jungle4)' + ) .action(async (options) => { await createAccount(options) }) From e20ea86ce7501b3ab451c39e210dc601a21af8bd Mon Sep 17 00:00:00 2001 From: dafuga Date: Sun, 23 Nov 2025 18:12:45 -0800 Subject: [PATCH 30/36] fix: fixing tests --- src/commands/{wharfkit => }/compile.ts | 26 +- src/commands/contract/deploy.ts | 11 +- src/commands/{wharfkit => }/dev.ts | 32 ++- src/commands/wallet/account.ts | 301 +++++++++++++++++---- src/commands/wallet/index.ts | 9 +- src/commands/wharfkit/deploy.ts | 354 ------------------------- src/commands/wharfkit/index.ts | 54 ---- src/index.ts | 3 +- test/tests/e2e-workflow.ts | 44 +-- 9 files changed, 344 insertions(+), 490 deletions(-) rename src/commands/{wharfkit => }/compile.ts (82%) rename src/commands/{wharfkit => }/dev.ts (81%) delete mode 100644 src/commands/wharfkit/deploy.ts delete mode 100644 src/commands/wharfkit/index.ts diff --git a/src/commands/wharfkit/compile.ts b/src/commands/compile.ts similarity index 82% rename from src/commands/wharfkit/compile.ts rename to src/commands/compile.ts index fe90a99..ffbac62 100644 --- a/src/commands/wharfkit/compile.ts +++ b/src/commands/compile.ts @@ -1,8 +1,9 @@ /* eslint-disable no-console */ +import {Command} from 'commander' import {execSync} from 'child_process' import {existsSync, readdirSync} from 'fs' import {basename, extname, join, resolve} from 'path' -import {checkLeapInstallation} from '../chain/install' +import {checkLeapInstallation} from './chain/install' /** * Compile a single C++ file or all .cpp files in the current directory @@ -128,3 +129,26 @@ async function compileSingleFile(filePath: string, outputDir: string): Promise', 'Output directory for compiled WASM files', '.') + .action(async (file, options) => { + try { + await compileContract(file, options.output) + } catch (error: any) { + console.error(`Error: ${error.message}`) + process.exit(1) + } + }) + + return compile +} diff --git a/src/commands/contract/deploy.ts b/src/commands/contract/deploy.ts index 4cc8a5e..e91be11 100644 --- a/src/commands/contract/deploy.ts +++ b/src/commands/contract/deploy.ts @@ -11,7 +11,7 @@ import {NonInteractiveConsoleUI} from '../../utils/wharfkit-ui' import {getKeyFromWallet, listWalletKeys} from '../wallet/utils' import {Chains} from '@wharfkit/common' -import {compileContract} from '../wharfkit/compile' +import {compileContract} from '../compile' interface DeployOptions { account?: string @@ -68,10 +68,11 @@ export async function validateDeploy( console.log(` āœ… Table '${table}' is empty.`) } } catch (e: any) { - // If check fails, ignore or warn? - // Often "table not found" error if using state history or other plugins if really gone? - // But if get_abi returned it, it was in ABI. - // We assume no data if error, or warn. + console.log( + ` āš ļø Warning: Could not check table '${table}' for data: ${ + e.message || String(e) + }` + ) } } diff --git a/src/commands/wharfkit/dev.ts b/src/commands/dev.ts similarity index 81% rename from src/commands/wharfkit/dev.ts rename to src/commands/dev.ts index df7a64b..177c7ab 100644 --- a/src/commands/wharfkit/dev.ts +++ b/src/commands/dev.ts @@ -1,9 +1,10 @@ /* eslint-disable no-console */ +import {Command} from 'commander' import {watch} from 'fs' import {extname} from 'path' import {compileContract} from './compile' -import {deployContract} from '../contract/deploy' -import {getChainStatus, startLocalChain, stopLocalChain} from '../chain/local' +import {deployContract} from './contract/deploy' +import {getChainStatus, startLocalChain, stopLocalChain} from './chain/local' interface DevOptions { account?: string @@ -154,3 +155,30 @@ process.on('SIGINT', async () => { process.on('SIGTERM', async () => { await stopDevMode() }) + +/** + * Create the dev command + */ +export function createDevCommand(): Command { + const dev = new Command('dev') + dev.description( + 'Start local chain and watch for changes (auto-compile and auto-deploy on file changes)' + ) + .option('-a, --account ', 'Contract account name (default: derived from filename)') + .option('-p, --port ', 'Port for local blockchain', '8888') + .option('-c, --clean', 'Start with a clean blockchain state') + .action(async (options) => { + try { + await startDevMode({ + account: options.account, + port: parseInt(options.port), + clean: options.clean, + }) + } catch (error: any) { + console.error(`Error: ${error.message}`) + process.exit(1) + } + }) + + return dev +} diff --git a/src/commands/wallet/account.ts b/src/commands/wallet/account.ts index a6318ff..761102d 100644 --- a/src/commands/wallet/account.ts +++ b/src/commands/wallet/account.ts @@ -1,13 +1,19 @@ import type {PublicKeyType} from '@wharfkit/antelope' -import {KeyType, type NameType, PrivateKey} from '@wharfkit/antelope' +import {APIClient, FetchProvider, KeyType, type NameType, PrivateKey} from '@wharfkit/antelope' import {type ChainDefinition, type ChainIndices, Chains} from '@wharfkit/common' +import {Session} from '@wharfkit/session' +import {WalletPluginPrivateKey} from '@wharfkit/wallet-plugin-privatekey' import fetch from 'node-fetch' +import {getDevKeys} from '../chain/utils' +import {NonInteractiveConsoleUI} from '../../utils/wharfkit-ui' +import {addKeyToWallet} from './utils' import {log, makeClient} from '../../utils' interface AccountCreateOptions { key?: PublicKeyType | string name?: NameType | string chain?: ChainIndices | string + url?: string } const supportedChains = ['Jungle4', 'KylinTestnet'] @@ -16,41 +22,56 @@ export async function createAccount(options: AccountCreateOptions): Promise "Jungle4") - const pascalCaseChain = chainStr.charAt(0).toUpperCase() + chainStr.slice(1).toLowerCase() - if (supportedChains.includes(pascalCaseChain)) { - chainIndex = pascalCaseChain as ChainIndices - } else { - log( - `Unsupported chain "${options.chain}". Supported chains are: ${supportedChains.join( - ', ' - )}`, - 'info' - ) - return - } - } + // Determine chain URL + let chainUrl: string + let chainDefinition: ChainDefinition | undefined + let isLocalChain = false - const chainDefinition: ChainDefinition = Chains[chainIndex] + if (options.url) { + // Use provided URL (could be local or remote) + chainUrl = options.url + isLocalChain = chainUrl.includes('127.0.0.1') || chainUrl.includes('localhost') + } else { + // Convert chain option to ChainIndices format (PascalCase) + let chainIndex: ChainIndices = 'Jungle4' + if (options.chain) { + const chainStr = String(options.chain) + // Convert to PascalCase (e.g., "jungle4" -> "Jungle4") + const pascalCaseChain = + chainStr.charAt(0).toUpperCase() + chainStr.slice(1).toLowerCase() + if (supportedChains.includes(pascalCaseChain)) { + chainIndex = pascalCaseChain as ChainIndices + } else { + log( + `Unsupported chain "${ + options.chain + }". Supported chains are: ${supportedChains.join(', ')}`, + 'info' + ) + return + } + } - // Default to "jungle4" if no chain option is provided - const chainUrl = chainDefinition - ? chainDefinition.url - : `http://${chainIndex.toLowerCase()}.greymass.com` + chainDefinition = Chains[chainIndex] + chainUrl = chainDefinition + ? chainDefinition.url + : `http://${chainIndex.toLowerCase()}.greymass.com` + } - if (options.name) { + // For local chains, don't require .gm suffix + if (options.name && !isLocalChain) { if (!String(options.name).endsWith('.gm')) { log('Account name must end with ".gm"', 'info') return } - if (options.name && (String(options.name).length > 12 || String(options.name).length < 3)) { - log('Account name must be between 3 and 12 characters long', 'info') - return - } + } + + if (options.name && (String(options.name).length > 12 || String(options.name).length < 3)) { + log('Account name must be between 3 and 12 characters long', 'info') + return + } + + if (options.name && !isLocalChain) { const accountNameExists = options.name && (await checkAccountNameExists(options.name, chainUrl)) @@ -64,7 +85,9 @@ export async function createAccount(options: AccountCreateOptions): Promise { + const newPublicKey = privateKey.toPublic() + + // Get dev keys for signing the newaccount action + const devKeys = getDevKeys() + const devPrivateKey = PrivateKey.from(devKeys.privateKey) + + // Create API client + const client = new APIClient({ + provider: new FetchProvider(chainUrl, {fetch}), + }) + + // Get chain info + const info = await client.v1.chain.get_info() + + // Check if system contract is deployed (for buyram/delegatebw) + let hasSystemContract = false + try { + const abiResponse = await client.v1.chain.get_abi('eosio') + if (abiResponse.abi) { + const actionNames = abiResponse.abi.actions.map((a) => String(a.name)) + hasSystemContract = + actionNames.includes('buyrambytes') && actionNames.includes('delegatebw') + } + } catch (e) { + // Ignore error, assume no system contract + } + + // Create session with dev key + const walletPlugin = new WalletPluginPrivateKey(devPrivateKey) + walletPlugin.config.requiresChainSelect = false + walletPlugin.config.requiresPermissionSelect = false + walletPlugin.config.requiresPermissionEntry = false + + const session = new Session({ + chain: { + id: String(info.chain_id), + url: chainUrl, + }, + actor: 'eosio', + permission: 'active', + walletPlugin, + ui: new NonInteractiveConsoleUI(), + }) + + const actions: any[] = [ + { + account: 'eosio', + name: 'newaccount', + authorization: [ + { + actor: 'eosio', + permission: 'active', + }, + ], + data: { + creator: 'eosio', + name: accountName, + owner: { + threshold: 1, + keys: [ + { + key: newPublicKey, + weight: 1, + }, + ], + accounts: [], + waits: [], + }, + active: { + threshold: 1, + keys: [ + { + key: newPublicKey, + weight: 1, + }, + ], + accounts: [], + waits: [], + }, + }, + }, + ] + + if (hasSystemContract) { + actions.push({ + account: 'eosio', + name: 'buyrambytes', + authorization: [ + { + actor: 'eosio', + permission: 'active', + }, + ], + data: { + payer: 'eosio', + receiver: accountName, + bytes: 8192, + }, + }) + actions.push({ + account: 'eosio', + name: 'delegatebw', + authorization: [ + { + actor: 'eosio', + permission: 'active', + }, + ], + data: { + from: 'eosio', + receiver: accountName, + stake_net_quantity: '1.0000 SYS', + stake_cpu_quantity: '1.0000 SYS', + transfer: false, + }, + }) + } + + // Create newaccount action + const result = await session.transact( + { + actions, + }, + { + broadcast: true, + } + ) + + log('Account created successfully!', 'info') + log(`Account Name: ${accountName}`, 'info') + log(`Private Key: ${privateKey.toString()}`, 'info') + log(`Public Key: ${publicKey}`, 'info') + log(`Transaction ID: ${result.resolved?.transaction.id}`, 'info') + + // Store the key in wallet with account name + try { + addKeyToWallet(privateKey, accountName) + log(`Key stored in wallet as: ${accountName}`, 'info') + } catch (error) { + log( + `Could not store key in wallet (may already exist): ${(error as Error).message}`, + 'info' + ) + } +} + function generateRandomAccountName(): string { // Generate a random 12-character account name using the allowed characters for Antelope accounts const characters = 'abcdefghijklmnopqrstuvwxyz12345' @@ -121,6 +302,16 @@ function generateRandomAccountName(): string { return `${result}.gm` } +function generateRandomLocalAccountName(): string { + // Generate a random 12-character account name for local chains (no .gm suffix required) + const characters = 'abcdefghijklmnopqrstuvwxyz12345' + let result = '' + for (let i = 0; i < 12; i++) { + result += characters.charAt(Math.floor(Math.random() * characters.length)) + } + return result +} + async function checkAccountNameExists(accountName: NameType, chainUrl: string): Promise { const client = makeClient(chainUrl) diff --git a/src/commands/wallet/index.ts b/src/commands/wallet/index.ts index 55684da..cead438 100644 --- a/src/commands/wallet/index.ts +++ b/src/commands/wallet/index.ts @@ -50,12 +50,19 @@ export function createWalletCommand(): Command { accountCommand .command('create') .description('Create a new account on the blockchain') - .option('-n, --name ', 'Account name (default: auto-generated, must end with .gm)') + .option( + '-n, --name ', + 'Account name (default: auto-generated, must end with .gm for remote chains)' + ) .option('-k, --key ', 'Public key to use (default: auto-generated)') .option( '-c, --chain ', 'Chain to create account on (Jungle4 or KylinTestnet, default: Jungle4)' ) + .option( + '-u, --url ', + 'Blockchain API URL (for local chains, e.g., http://127.0.0.1:8888)' + ) .action(async (options) => { await createAccount(options) }) diff --git a/src/commands/wharfkit/deploy.ts b/src/commands/wharfkit/deploy.ts deleted file mode 100644 index 9492f57..0000000 --- a/src/commands/wharfkit/deploy.ts +++ /dev/null @@ -1,354 +0,0 @@ -/* eslint-disable no-console */ -import {existsSync, readdirSync, readFileSync} from 'fs' -import {basename, extname, resolve} from 'path' -import type {PrivateKey} from '@wharfkit/antelope' -import {ABI, APIClient, FetchProvider, Serializer} from '@wharfkit/antelope' -import {Session} from '@wharfkit/session' -import {WalletPluginPrivateKey} from '@wharfkit/wallet-plugin-privatekey' -import fetch from 'node-fetch' -import {NonInteractiveConsoleUI} from '../../utils/wharfkit-ui' -import {getKeyFromWallet, listWalletKeys} from '../wallet/utils' - -import {Chains} from '@wharfkit/common' -import {compileContract} from './compile' - -interface DeployOptions { - account?: string - url?: string - force?: boolean - validate?: boolean -} - -/** - * Validate deployment safety (checks for orphaned tables with data) - */ -export async function validateDeploy( - accountName: string, - abiJson: any, - url: string, - force: boolean -): Promise { - const client = new APIClient({ - provider: new FetchProvider(url, {fetch}), - }) - - try { - const existingAbiResponse = await client.v1.chain.get_abi(accountName) - if (existingAbiResponse.abi) { - const oldAbi = existingAbiResponse.abi - const newAbi = ABI.from(abiJson) - - const oldTables = new Set(oldAbi.tables.map((t) => String(t.name))) - const newTables = new Set(newAbi.tables.map((t) => String(t.name))) - - const removedTables = [...oldTables].filter((t) => !newTables.has(t)) - - if (removedTables.length > 0) { - console.log( - `\nāš ļø Warning: The new ABI removes the following tables: ${removedTables.join( - ', ' - )}` - ) - console.log(` Checking for existing data in these tables...`) - - let hasData = false - for (const table of removedTables) { - try { - const rows = await client.v1.chain.get_table_rows({ - code: accountName, - scope: accountName, - table, - limit: 1, - }) - if (rows.rows.length > 0) { - console.log(` āŒ Table '${table}' contains data!`) - hasData = true - } else { - console.log(` āœ… Table '${table}' is empty.`) - } - } catch (e: any) { - // If check fails, ignore or warn? - // Often "table not found" error if using state history or other plugins if really gone? - // But if get_abi returned it, it was in ABI. - // We assume no data if error, or warn. - } - } - - if (hasData) { - if (force) { - console.log(` āš ļø Proceeding despite data loss warning (--force used).`) - } else { - throw new Error( - `Deployment would make existing table data inaccessible (orphaned).` - ) - } - } else { - console.log(` āœ… No data found in removed tables. Safe to proceed.`) - } - } else { - console.log(` āœ… No tables removed.`) - } - } else { - console.log(` āœ… No existing ABI found (new deployment).`) - } - } catch (error: any) { - if (error.message.includes('orphaned')) { - throw new Error( - `SAFETY CHECK FAILED: ${error.message}\nUse --force to override this check and deploy anyway.` - ) - } - // If validation fails due to network or other reasons, we might want to warn but proceed if not validating explicitly? - // If explicitly validating, we should error. - // If deploying, we usually proceed unless critical. - // But "safety check" implies we stop. - // However, if account doesn't exist, get_abi throws. - // We should catch that. - if ( - error.message.includes('Account not found') || - error.message.includes('does not exist') - ) { - // New account, safe. - return - } - - // If it's a validation run, rethrow. - // If it's a deploy run, maybe warn? - // But we want strict safety. - throw error - } -} - -/** - * Deploy a compiled contract to the blockchain - * @param wasmFile - Path to the WASM file to deploy - * @param options - Deployment options - */ -export async function deployContract( - wasmFile: string | undefined, - options: DeployOptions -): Promise { - // Determine the WASM file to deploy - let wasmPath: string - try { - wasmPath = wasmFile ? resolve(wasmFile) : await findWasmFile() - } catch (error: any) { - if (error.message.includes('No .wasm files found') && !wasmFile) { - console.log('No WASM file found. Attempting to compile contracts...') - try { - await compileContract(undefined, '.') - wasmPath = await findWasmFile() - } catch (compileError: any) { - throw new Error( - `Failed to auto-compile: ${compileError.message}\nPlease run 'wharfkit compile' manually.` - ) - } - } else { - throw error - } - } - - if (!existsSync(wasmPath)) { - throw new Error(`WASM file not found: ${wasmPath}`) - } - - if (extname(wasmPath) !== '.wasm') { - throw new Error(`File must be a .wasm file: ${wasmPath}`) - } - - // Find the ABI file - const abiPath = wasmPath.replace('.wasm', '.abi') - if (!existsSync(abiPath)) { - throw new Error(`ABI file not found: ${abiPath}`) - } - - // Determine the contract account name - const accountName = options.account || basename(wasmPath, '.wasm') - - // Determine the blockchain URL - let url = options.url || 'http://127.0.0.1:8888' - - // Check if URL is a known chain name - const knownChainKey = Object.keys(Chains).find((key) => key.toLowerCase() === url.toLowerCase()) - if (knownChainKey) { - url = (Chains as any)[knownChainKey].url - } - - if (options.validate) { - console.log(`Validating deployment for ${accountName}...`) - } else { - console.log(`Deploying contract...`) - } - console.log(` WASM: ${wasmPath}`) - console.log(` ABI: ${abiPath}`) - console.log(` Account: ${accountName}`) - console.log(` URL: ${url}`) - - try { - // Read WASM and ABI files - const wasmCode = readFileSync(wasmPath) - const abiJson = JSON.parse(readFileSync(abiPath, 'utf8')) - - // Perform validation/safety check - // Only skip if force is used AND we are NOT explicitly validating? - // Actually, even with force, we might want to see warnings. - // But validateDeploy throws if unsafe and not forced. - await validateDeploy(accountName, abiJson, url, !!options.force) - - if (options.validate) { - console.log('\nāœ… Validation passed! Deployment appears safe.') - return - } - - // Get private key from wallet for this account - const privateKey = await getPrivateKeyForDeploy(accountName) - - // Create API client - const client = new APIClient({ - provider: new FetchProvider(url, {fetch}), - }) - - // Create session with private key wallet plugin - const walletPlugin = new WalletPluginPrivateKey(privateKey) - walletPlugin.config.requiresChainSelect = false - walletPlugin.config.requiresPermissionSelect = false - walletPlugin.config.requiresPermissionEntry = false - - const session = new Session({ - chain: { - id: await getChainId(client), - url, - }, - actor: accountName, - permission: 'active', - walletPlugin, - ui: new NonInteractiveConsoleUI(), - }) - - console.log('\nšŸš€ Deploying contract...') - - // Create setcode action - const setcodeAction = { - account: 'eosio', - name: 'setcode', - authorization: [ - { - actor: accountName, - permission: 'active', - }, - ], - data: { - account: accountName, - vmtype: 0, - vmversion: 0, - code: wasmCode.toString('hex'), - }, - } - - // Create setabi action - const setabiAction = { - account: 'eosio', - name: 'setabi', - authorization: [ - { - actor: accountName, - permission: 'active', - }, - ], - data: { - account: accountName, - abi: Serializer.encode({object: ABI.from(abiJson), type: ABI}).hexString, - }, - } - - // Transact both actions - const result = await session.transact( - { - actions: [setcodeAction, setabiAction], - }, - { - broadcast: true, - } - ) - - console.log('\nāœ… Contract deployed successfully!') - console.log(`Transaction ID: ${result.resolved?.transaction.id}`) - } catch (error) { - const errorMessage = (error as Error).message - throw new Error( - `Failed to deploy contract: ${errorMessage}\n\n` + - `Make sure:\n` + - `1. The blockchain is running (wharfkit chain local start)\n` + - `2. The account "${accountName}" exists\n` + - `3. You have a wallet key with permissions for this account\n` + - `4. The ABI file exists alongside the WASM file` - ) - } -} - -/** - * Get private key for deployment based on account name - */ -async function getPrivateKeyForDeploy(accountName: string): Promise { - const keys = listWalletKeys() - - if (keys.length === 0) { - throw new Error('No keys found in wallet. Create one with: wharfkit wallet create') - } - - // Try to find a key with the same name as the account - const accountKey = keys.find((k) => k.name === accountName) - if (accountKey) { - console.log(`Using wallet key: ${accountKey.name}`) - return getKeyFromWallet(accountName) - } - - // Otherwise, try 'default' key - const defaultKey = keys.find((k) => k.name === 'default') - if (defaultKey) { - console.log(`Using wallet key: default`) - return getKeyFromWallet('default') - } - - // Use first available key - console.log(`Using wallet key: ${keys[0].name}`) - return getKeyFromWallet(keys[0].name) -} - -/** - * Get chain ID from the API - */ -async function getChainId(client: APIClient): Promise { - try { - const info = await client.v1.chain.get_info() - return String(info.chain_id) - } catch (error) { - // Default to local chain ID if we can't get it - return '8a34ec7df1b8cd06ff4a8abbaa7cc50300823350cadc59ab296cb00d104d2b8f' - } -} - -/** - * Find a WASM file in the current directory - */ -async function findWasmFile(): Promise { - const currentDir = process.cwd() - - const wasmFiles = readdirSync(currentDir) - .filter((file) => extname(file) === '.wasm') - .map((file) => resolve(currentDir, file)) - - if (wasmFiles.length === 0) { - throw new Error( - 'No .wasm files found in current directory. Please specify a file or compile first with: wharfkit compile' - ) - } - - if (wasmFiles.length > 1) { - throw new Error( - `Multiple .wasm files found: ${wasmFiles.map((f) => basename(f)).join(', ')}\n` + - `Please specify which file to deploy.` - ) - } - - return wasmFiles[0] -} diff --git a/src/commands/wharfkit/index.ts b/src/commands/wharfkit/index.ts deleted file mode 100644 index 77672a3..0000000 --- a/src/commands/wharfkit/index.ts +++ /dev/null @@ -1,54 +0,0 @@ -/* eslint-disable no-console */ -import {Command} from 'commander' -import {compileContract} from './compile' -import {startDevMode} from './dev' - -/** - * Create the compile command - */ -export function createCompileCommand(): Command { - const compile = new Command('compile') - compile - .description( - 'Compile C++ contract files (single file or all .cpp files in current directory)' - ) - .argument('[file]', 'Optional file to compile (compiles all .cpp files if not specified)') - .option('-o, --output ', 'Output directory for compiled WASM files', '.') - .action(async (file, options) => { - try { - await compileContract(file, options.output) - } catch (error: any) { - console.error(`Error: ${error.message}`) - process.exit(1) - } - }) - - return compile -} - -/** - * Create the dev command - */ -export function createDevCommand(): Command { - const dev = new Command('dev') - dev.description( - 'Start local chain and watch for changes (auto-compile and auto-deploy on file changes)' - ) - .option('-a, --account ', 'Contract account name (default: derived from filename)') - .option('-p, --port ', 'Port for local blockchain', '8888') - .option('-c, --clean', 'Start with a clean blockchain state') - .action(async (options) => { - try { - await startDevMode({ - account: options.account, - port: parseInt(options.port), - clean: options.clean, - }) - } catch (error: any) { - console.error(`Error: ${error.message}`) - process.exit(1) - } - }) - - return dev -} diff --git a/src/index.ts b/src/index.ts index c5c0bd8..90ed33c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -4,7 +4,8 @@ import {version} from '../package.json' import {createContractCommand, generateContractFromCommand} from './commands/contract' import {generateKeysFromCommand} from './commands/keys/index' import {createChainCommand} from './commands/chain/index' -import {createCompileCommand, createDevCommand} from './commands/wharfkit/index' +import {createCompileCommand} from './commands/compile' +import {createDevCommand} from './commands/dev' import {createWalletCommand} from './commands/wallet/index' const program = new Command() diff --git a/test/tests/e2e-workflow.ts b/test/tests/e2e-workflow.ts index 8ad829e..f6b4460 100644 --- a/test/tests/e2e-workflow.ts +++ b/test/tests/e2e-workflow.ts @@ -25,10 +25,12 @@ function getTransactionExpiration(): string { return now.toISOString().slice(0, 19) // Remove milliseconds and timezone } -function getRandomName(prefix: string): string { +function getRandomLocalAccountName(prefix: string): string { const chars = 'abcdefghijklmnopqrstuvwxyz12345' let result = prefix - for (let i = 0; i < 6; i++) { + // Generate up to 12 chars total (no .gm suffix for local chains) + const remaining = 12 - prefix.length + for (let i = 0; i < remaining; i++) { result += chars.charAt(Math.floor(Math.random() * chars.length)) } return result @@ -351,14 +353,16 @@ suite('E2E Workflow', () => { suite('Integration: Account and Deployment', () => { test('can create an account on the local chain', function () { - const accountName = getRandomName('acc') - const output = execSync(`node ${cliPath} wallet account create --name ${accountName}`, { - encoding: 'utf8', - }) + const accountName = getRandomLocalAccountName('acc') + const output = execSync( + `node ${cliPath} wallet account create --name ${accountName} --url http://127.0.0.1:8888`, + { + encoding: 'utf8', + } + ) - assert.include(output, 'āœ… Account created successfully!') - assert.include(output, `Account: ${accountName}`) - assert.include(output, 'Key stored in wallet') + assert.include(output, 'Account created successfully!') + assert.include(output, `Account Name: ${accountName}`) }) test('can deploy a contract to the account', function () { @@ -370,10 +374,13 @@ suite('E2E Workflow', () => { } // 1. Create an account - const accountName = getRandomName('deploy') - execSync(`node ${cliPath} wallet account create --name ${accountName}`, { - encoding: 'utf8', - }) + const accountName = getRandomLocalAccountName('deploy') + execSync( + `node ${cliPath} wallet account create --name ${accountName} --url http://127.0.0.1:8888`, + { + encoding: 'utf8', + } + ) // 2. Use persistent contract file // Copy test.cpp from root to testDir @@ -412,10 +419,13 @@ suite('E2E Workflow', () => { this.skip() } - const accountName = getRandomName('val') - execSync(`node ${cliPath} wallet account create --name ${accountName}`, { - encoding: 'utf8', - }) + const accountName = getRandomLocalAccountName('val') + execSync( + `node ${cliPath} wallet account create --name ${accountName} --url http://127.0.0.1:8888`, + { + encoding: 'utf8', + } + ) // 1. Deploy contract V1 (with table) const v1Code = ` From 13ca7a151bdf78edbdcd4941e25557b82b9d956c Mon Sep 17 00:00:00 2001 From: dafuga Date: Sun, 23 Nov 2025 18:46:50 -0800 Subject: [PATCH 31/36] chore: added ability import keys --- src/commands/contract/deploy.ts | 67 +++++++- src/commands/contract/index.ts | 4 + src/commands/wallet/index.ts | 13 +- src/commands/wallet/keys.ts | 51 ++++++ test/tests/wallet-account.ts | 270 ++++++++++++++++++++++++++++++++ 5 files changed, 399 insertions(+), 6 deletions(-) create mode 100644 test/tests/wallet-account.ts diff --git a/src/commands/contract/deploy.ts b/src/commands/contract/deploy.ts index e91be11..06a8edd 100644 --- a/src/commands/contract/deploy.ts +++ b/src/commands/contract/deploy.ts @@ -2,8 +2,7 @@ import '../../types/wharfkit-session' import {existsSync, readdirSync, readFileSync} from 'fs' import {basename, extname, resolve} from 'path' -import type {PrivateKey} from '@wharfkit/antelope' -import {ABI, APIClient, FetchProvider, Serializer} from '@wharfkit/antelope' +import {ABI, APIClient, FetchProvider, PrivateKey, Serializer} from '@wharfkit/antelope' import {Session} from '@wharfkit/session' import {WalletPluginPrivateKey} from '@wharfkit/wallet-plugin-privatekey' import fetch from 'node-fetch' @@ -18,6 +17,7 @@ interface DeployOptions { url?: string force?: boolean validate?: boolean + key?: string } /** @@ -202,7 +202,7 @@ export async function deployContract( } // Get private key from wallet for this account - const privateKey = await getPrivateKeyForDeploy(accountName) + const privateKey = await getPrivateKeyForDeploy(accountName, options) // Create API client const client = new APIClient({ @@ -288,9 +288,66 @@ export async function deployContract( } /** - * Get private key for deployment based on account name + * Check if a string looks like a private key */ -async function getPrivateKeyForDeploy(accountName: string): Promise { +function isPrivateKeyString(str: string): boolean { + return str.startsWith('PVT_') || str.startsWith('5') || /^[A-Za-z0-9]{51}$/.test(str) +} + +/** + * Get private key for deployment based on account name, options, and environment + */ +async function getPrivateKeyForDeploy( + accountName: string, + options?: DeployOptions +): Promise { + // 1. Check if --key option is provided + if (options?.key) { + // Check if it's a private key string + if (isPrivateKeyString(options.key)) { + try { + console.log('Using private key from --key option') + return PrivateKey.from(options.key) + } catch (error) { + throw new Error(`Invalid private key format: ${(error as Error).message}`) + } + } + + // Otherwise, treat it as a key name + const keys = listWalletKeys() + const key = keys.find((k) => k.name === options.key || k.publicKey === options.key) + if (key) { + console.log(`Using wallet key: ${key.name}`) + return getKeyFromWallet(key.name) + } + throw new Error(`Key "${options.key}" not found in wallet`) + } + + // 2. Check environment variable + const envKey = process.env.WHARFKIT_DEPLOY_KEY + if (envKey) { + if (isPrivateKeyString(envKey)) { + try { + console.log('Using private key from WHARFKIT_DEPLOY_KEY environment variable') + return PrivateKey.from(envKey) + } catch (error) { + throw new Error( + `Invalid private key format in WHARFKIT_DEPLOY_KEY: ${(error as Error).message}` + ) + } + } + + // Otherwise, treat it as a key name + const keys = listWalletKeys() + const key = keys.find((k) => k.name === envKey || k.publicKey === envKey) + if (key) { + console.log(`Using wallet key from environment: ${key.name}`) + return getKeyFromWallet(key.name) + } + throw new Error(`Key "${envKey}" from WHARFKIT_DEPLOY_KEY not found in wallet`) + } + + // 3. Fallback to existing logic: try account name, then default, then first key const keys = listWalletKeys() if (keys.length === 0) { diff --git a/src/commands/contract/index.ts b/src/commands/contract/index.ts index 63abdb7..67443e5 100644 --- a/src/commands/contract/index.ts +++ b/src/commands/contract/index.ts @@ -45,6 +45,10 @@ export function createContractCommand(): Command { .argument('[wasm]', 'WASM file to deploy (auto-detects if not specified)') .option('-a, --account ', 'Contract account name (default: derived from filename)') .option('-u, --url ', 'Blockchain API URL (override network argument)') + .option( + '-k, --key ', + 'Private key or wallet key name to use for deployment (overrides WHARFKIT_DEPLOY_KEY env var)' + ) .option('--force', 'Force deployment even if safety checks fail') .option('--validate', 'Validate deployment safety without deploying') .action(async (networkOrWasm, wasmFile, options) => { diff --git a/src/commands/wallet/index.ts b/src/commands/wallet/index.ts index cead438..a719078 100644 --- a/src/commands/wallet/index.ts +++ b/src/commands/wallet/index.ts @@ -1,7 +1,7 @@ import {Command} from 'commander' import {createAccount} from './account' import {createWalletKey} from './create' -import {createKey, listKeys} from './keys' +import {addKey, createKey, listKeys} from './keys' import {transactTransaction} from './transact' /** @@ -40,6 +40,17 @@ export function createWalletCommand(): Command { await createKey(options) }) + // wallet keys add - Add an existing private key + keysCommand + .command('add') + .description('Add an existing private key to the wallet') + .argument('', 'Private key to import (e.g., PVT_K1_...)') + .option('-n, --name ', 'Name for the key (default: auto-generated)') + .option('-p, --password', 'Prompt for a password to encrypt the key') + .action(async (privateKey, options) => { + await addKey(options, privateKey) + }) + walletCommand.addCommand(keysCommand) // wallet account - Manage accounts diff --git a/src/commands/wallet/keys.ts b/src/commands/wallet/keys.ts index 1954d3e..5a8bbdc 100644 --- a/src/commands/wallet/keys.ts +++ b/src/commands/wallet/keys.ts @@ -8,6 +8,11 @@ interface KeysCreateOptions { password?: boolean } +interface KeysAddOptions { + name?: string + password?: boolean +} + /** * Prompt for password from stdin */ @@ -147,3 +152,49 @@ export async function createKey(options: KeysCreateOptions): Promise { process.exit(1) } } + +/** + * Add an existing private key to the wallet + */ +export async function addKey(options: KeysAddOptions, privateKeyString: string): Promise { + try { + // Validate and parse the private key + let privateKey: PrivateKey + try { + privateKey = PrivateKey.from(privateKeyString) + } catch (error) { + throw new Error(`Invalid private key format: ${(error as Error).message}`) + } + + const publicKey = privateKey.toPublic() + + // Get password if requested + const password = await getPassword(!!options.password) + + // Determine key name + const keyName = options.name || generateDefaultKeyName() + + // Store the key + addKeyToWallet(privateKey, keyName, password) + + log('āœ… Key added successfully!', 'info') + log(`Name: ${keyName}`, 'info') + log(`Public Key: ${publicKey.toString()}`, 'info') + log('', 'info') + + if (!options.password) { + log( + 'āš ļø Note: Key is encrypted with default password. Use --password flag for custom password.', + 'info' + ) + } else { + log('šŸ”’ Key is encrypted with your custom password.', 'info') + } + + log('', 'info') + log('šŸ’” To view all keys: wharfkit wallet keys', 'info') + } catch (error) { + log(`āŒ Failed to add key: ${(error as Error).message}`, 'info') + process.exit(1) + } +} diff --git a/test/tests/wallet-account.ts b/test/tests/wallet-account.ts new file mode 100644 index 0000000..9a8c368 --- /dev/null +++ b/test/tests/wallet-account.ts @@ -0,0 +1,270 @@ +import {assert} from 'chai' +import sinon from 'sinon' +import * as nodeFetch from 'node-fetch' +import {Chains} from '@wharfkit/common' + +import {createAccount} from 'src/commands/wallet/account' +import * as utils from 'src/utils' + +suite('Wallet Account Create', () => { + let sandbox: sinon.SinonSandbox + let fetchStub: sinon.SinonStub + let makeClientStub: sinon.SinonStub + let logStub: sinon.SinonStub + + setup(function () { + sandbox = sinon.createSandbox() + fetchStub = sandbox.stub() + makeClientStub = sandbox.stub() + logStub = sandbox.stub() + + // Mock fetch from node-fetch + sandbox.stub(nodeFetch, 'default').callsFake(fetchStub as any) + + // Mock makeClient and log from utils + sandbox.stub(utils, 'makeClient').callsFake(makeClientStub as any) + sandbox.stub(utils, 'log').callsFake(logStub as any) + }) + + teardown(function () { + sandbox.restore() + }) + + test('creates account with auto-generated name on default chain (Jungle4)', async function () { + const mockResponse = { + status: 201, + json: sandbox.stub().resolves({}), + } + fetchStub.resolves(mockResponse) + + await createAccount({}) + + assert.isTrue(fetchStub.calledOnce) + const callArgs = fetchStub.getCall(0).args + assert.equal(callArgs[0], `${Chains.Jungle4.url}/account/create`) + assert.equal(callArgs[1].method, 'POST') + assert.equal(callArgs[1].headers['Content-Type'], 'application/json') + + const body = JSON.parse(callArgs[1].body) + assert.isString(body.accountName) + assert.isTrue(body.accountName.endsWith('.gm')) + assert.isString(body.activeKey) + assert.isString(body.ownerKey) + assert.equal(body.activeKey, body.ownerKey) + assert.equal(body.network, Chains.Jungle4.id) + + assert.isTrue(logStub.calledWith('Account created successfully!', 'info')) + }) + + test('creates account with custom name', async function () { + const accountName = 'testaccount.gm' + const mockResponse = { + status: 201, + json: sandbox.stub().resolves({}), + } + fetchStub.resolves(mockResponse) + + // Mock account check - account doesn't exist + const mockClient = { + v1: { + chain: { + get_account: sandbox.stub().rejects(new Error('Account not found')), + }, + }, + } + makeClientStub.returns(mockClient) + + await createAccount({name: accountName}) + + assert.isTrue(makeClientStub.calledOnce) + assert.isTrue(fetchStub.calledOnce) + + const body = JSON.parse(fetchStub.getCall(0).args[1].body) + assert.equal(body.accountName, accountName) + assert.isTrue(logStub.calledWith(`Account Name: ${accountName}`, 'info')) + }) + + test('creates account with custom public key', async function () { + const publicKey = 'PUB_K1_5TXDWwucfSa9Ghh49di3vxthzUcLSDE5yuxEMCJvw29Jpjq4mp' + const mockResponse = { + status: 201, + json: sandbox.stub().resolves({}), + } + fetchStub.resolves(mockResponse) + + await createAccount({key: publicKey}) + + const body = JSON.parse(fetchStub.getCall(0).args[1].body) + assert.equal(body.activeKey, publicKey) + assert.equal(body.ownerKey, publicKey) + + // Should not log private key when key is provided + const logCalls = logStub.getCalls().map((call) => call.args[0]) + assert.isFalse(logCalls.some((msg) => msg.includes('Private Key'))) + }) + + test('generates private key when not provided', async function () { + const mockResponse = { + status: 201, + json: sandbox.stub().resolves({}), + } + fetchStub.resolves(mockResponse) + + await createAccount({}) + + const body = JSON.parse(fetchStub.getCall(0).args[1].body) + assert.isString(body.activeKey) + assert.include(body.activeKey, 'PUB_K1_') + + // Should log private key when it was generated + const logCalls = logStub.getCalls().map((call) => call.args[0]) + assert.isTrue(logCalls.some((msg) => msg.includes('Private Key'))) + }) + + test('creates account on KylinTestnet chain', async function () { + const mockResponse = { + status: 201, + json: sandbox.stub().resolves({}), + } + fetchStub.resolves(mockResponse) + + await createAccount({chain: 'KylinTestnet'}) + + const callArgs = fetchStub.getCall(0).args + assert.equal(callArgs[0], `${Chains.KylinTestnet.url}/account/create`) + + const body = JSON.parse(callArgs[1].body) + assert.equal(body.network, Chains.KylinTestnet.id) + }) + + test('handles lowercase chain name', async function () { + const mockResponse = { + status: 201, + json: sandbox.stub().resolves({}), + } + fetchStub.resolves(mockResponse) + + await createAccount({chain: 'jungle4'}) + + const callArgs = fetchStub.getCall(0).args + assert.equal(callArgs[0], `${Chains.Jungle4.url}/account/create`) + }) + + test('rejects unsupported chain', async function () { + await createAccount({chain: 'EOS'}) + + assert.isTrue( + logStub.calledWith( + sinon.match(/Unsupported chain.*Supported chains are: Jungle4, KylinTestnet/), + 'info' + ) + ) + assert.isFalse(fetchStub.called) + }) + + test('rejects account name without .gm suffix', async function () { + await createAccount({name: 'testaccount'}) + + assert.isTrue(logStub.calledWith('Account name must end with ".gm"', 'info')) + assert.isFalse(fetchStub.called) + }) + + test('accepts valid short account name', async function () { + const accountName = 'ab.gm' + const mockResponse = { + status: 201, + json: sandbox.stub().resolves({}), + } + fetchStub.resolves(mockResponse) + + // Mock account check - account doesn't exist + const mockClient = { + v1: { + chain: { + get_account: sandbox.stub().rejects(new Error('Account not found')), + }, + }, + } + makeClientStub.returns(mockClient) + + // "ab.gm" is valid (5 chars total, which is >= 3) + await createAccount({name: accountName}) + + // Should proceed to account creation since it's valid length + assert.isTrue(fetchStub.called) + const body = JSON.parse(fetchStub.getCall(0).args[1].body) + assert.equal(body.accountName, accountName) + }) + + test('rejects account name that is too long', async function () { + await createAccount({name: 'toolongaccountname.gm'}) + + assert.isTrue( + logStub.calledWith('Account name must be between 3 and 12 characters long', 'info') + ) + assert.isFalse(fetchStub.called) + }) + + test('rejects account name that already exists', async function () { + const accountName = 'existing.gm' + const mockClient = { + v1: { + chain: { + get_account: sandbox.stub().resolves({ + account_name: accountName, + }), + }, + }, + } + makeClientStub.returns(mockClient) + + await createAccount({name: accountName}) + + assert.isTrue( + logStub.calledWith( + `Account name "${accountName}" is already taken. Please choose another name.`, + 'info' + ) + ) + assert.isFalse(fetchStub.called) + }) + + test('handles account creation failure', async function () { + const mockResponse = { + status: 400, + json: sandbox.stub().resolves({ + message: 'Account creation failed', + }), + } + fetchStub.resolves(mockResponse) + + await createAccount({}) + + assert.isTrue( + logStub.calledWith('Failed to create account: Account creation failed', 'info') + ) + }) + + test('handles fetch error', async function () { + fetchStub.rejects(new Error('Network error')) + + await createAccount({}) + + assert.isTrue(logStub.calledWith(sinon.match(/Error during account creation/), 'info')) + }) + + test('generates random account name ending with .gm', async function () { + const mockResponse = { + status: 201, + json: sandbox.stub().resolves({}), + } + fetchStub.resolves(mockResponse) + + await createAccount({}) + + const body = JSON.parse(fetchStub.getCall(0).args[1].body) + assert.isTrue(body.accountName.endsWith('.gm')) + // Should be 9 chars + .gm = 12 chars total + assert.equal(body.accountName.length, 12) + }) +}) From a4ace5865f69b0c13c8d079114e7da4f71875a5f Mon Sep 17 00:00:00 2001 From: dafuga Date: Sun, 23 Nov 2025 18:51:31 -0800 Subject: [PATCH 32/36] chore: automatically importing chain key --- src/commands/chain/index.ts | 5 ++ src/commands/chain/local.ts | 93 +++++++++++++++++++++++++++------ src/commands/chain/utils.ts | 11 ++-- src/commands/contract/deploy.ts | 47 +++++++++++++++++ src/commands/dev.ts | 7 +++ 5 files changed, 144 insertions(+), 19 deletions(-) diff --git a/src/commands/chain/index.ts b/src/commands/chain/index.ts index 91571b3..2e8dd01 100644 --- a/src/commands/chain/index.ts +++ b/src/commands/chain/index.ts @@ -23,11 +23,16 @@ export function createChainCommand(): Command { .description('Start a local LEAP blockchain (installs LEAP automatically if needed)') .option('-p, --port ', 'Port for the HTTP server', '8888') .option('-c, --clean', 'Clean blockchain data before starting', false) + .option( + '-k, --key ', + 'Private key to automatically import into wallet (overrides WHARFKIT_CHAIN_KEY env var)' + ) .action(async (options) => { try { await startLocalChain({ port: parseInt(options.port, 10), clean: options.clean, + key: options.key, }) } catch (error: any) { console.error(`Error: ${error.message}`) diff --git a/src/commands/chain/local.ts b/src/commands/chain/local.ts index 6a8d135..4f1e3ec 100644 --- a/src/commands/chain/local.ts +++ b/src/commands/chain/local.ts @@ -1,5 +1,5 @@ /* eslint-disable no-console */ -import {PrivateKey} from '@wharfkit/antelope' +import {KeyType, PrivateKey} from '@wharfkit/antelope' import {WalletPluginPrivateKey} from '@wharfkit/wallet-plugin-privatekey' import {spawn} from 'child_process' import * as fs from 'fs' @@ -29,6 +29,7 @@ import {NonInteractiveConsoleUI} from '../../utils/wharfkit-ui' export interface LocalStartOptions { port: number clean: boolean + key?: string } /** @@ -72,17 +73,36 @@ export async function startLocalChain(options: LocalStartOptions): Promise ) } + // Determine which key to use: provided, env var, or generate new one + let chainPrivateKey: PrivateKey + let chainPublicKey: string + const providedKey = options.key || process.env.WHARFKIT_CHAIN_KEY + + if (providedKey) { + // Use provided key + chainPrivateKey = PrivateKey.from(providedKey) + chainPublicKey = chainPrivateKey.toPublic().toString() + console.log('Using provided private key for chain') + } else { + // Generate a new random key + chainPrivateKey = PrivateKey.generate(KeyType.K1) + chainPublicKey = chainPrivateKey.toPublic().toString() + console.log('Generated new random private key for chain') + console.log(` Public Key: ${chainPublicKey}`) + console.log(` Private Key: ${chainPrivateKey.toString()}`) + } + // Create config files const configFile = path.join(configDir, 'config.ini') const genesisFile = path.join(configDir, 'genesis.json') - // Write config.ini - const configContent = getConfigIni(options.port) + // Write config.ini with the determined key + const configContent = getConfigIni(options.port, chainPublicKey, chainPrivateKey.toString()) await fs.promises.writeFile(configFile, configContent) // Write genesis.json if it doesn't exist or if cleaning if (options.clean || !fs.existsSync(genesisFile)) { - const genesisContent = getGenesisJson() + const genesisContent = getGenesisJson(chainPublicKey) await fs.promises.writeFile(genesisFile, genesisContent) } @@ -138,17 +158,16 @@ export async function startLocalChain(options: LocalStartOptions): Promise console.log('Chain is ready!') - // Setup dev wallet - await setupDevWallet() + // Setup dev wallet (import the key used for the chain) + await setupDevWallet(chainPrivateKey.toString()) console.log('\nāœ… Local LEAP blockchain is running!') console.log(` URL: http://127.0.0.1:${options.port}`) console.log(` Data directory: ${dataDir}`) console.log(` Config directory: ${configDir}`) - console.log('\nšŸ“ Development keys:') - const devKeys = getDevKeys() - console.log(` Public: ${devKeys.publicKey}`) - console.log(` Private: ${devKeys.privateKey}`) + console.log('\nšŸ“ Chain keys:') + console.log(` Public: ${chainPublicKey}`) + console.log(` Private: ${chainPrivateKey.toString()}`) console.log('\nšŸ›‘ To stop: wharfkit chain local stop') } @@ -327,9 +346,16 @@ export async function showChainLogs(options: {follow: boolean; errors: boolean}) } /** - * Setup development wallet with default keys + * Check if a string looks like a private key + */ +function isPrivateKeyString(str: string): boolean { + return str.startsWith('PVT_') || str.startsWith('5') || /^[A-Za-z0-9]{51}$/.test(str) +} + +/** + * Setup development wallet with default keys and optionally import custom key */ -async function setupDevWallet(): Promise { +async function setupDevWallet(customKey?: string): Promise { console.log('Setting up development wallet...') const walletName = 'dev' @@ -337,6 +363,7 @@ async function setupDevWallet(): Promise { const devPrivateKey = PrivateKey.from(devKeys.privateKey) try { + // First, import the default dev key const existingKeys = listWalletKeys() const existingEntry = existingKeys.find( (key) => key.name === walletName || key.publicKey === devKeys.publicKey @@ -353,6 +380,44 @@ async function setupDevWallet(): Promise { console.log('Development key already stored') } + // If a custom key is provided, import it automatically + if (customKey) { + try { + if (isPrivateKeyString(customKey)) { + const customPrivateKey = PrivateKey.from(customKey) + const customPublicKey = customPrivateKey.toPublic().toString() + + // Check if this key already exists + const existingCustomKey = existingKeys.find( + (key) => key.publicKey === customPublicKey + ) + + if (!existingCustomKey) { + // Use 'default' if no default exists, otherwise use 'chain-key' + const hasDefault = existingKeys.some((k) => k.name === 'default') + const customKeyName = hasDefault ? 'chain-key' : 'default' + addKeyToWallet(customPrivateKey, customKeyName) + console.log( + `āœ… Automatically imported chain key into wallet as "${customKeyName}"` + ) + console.log(` Public Key: ${customPublicKey}`) + } else { + console.log( + `Chain key already exists in wallet as "${existingCustomKey.name}"` + ) + } + } else { + console.log( + `Warning: Provided key "${customKey}" does not appear to be a valid private key format` + ) + } + } catch (error: any) { + console.log(`Warning: Could not import chain key: ${error.message}`) + console.log('You can manually import it with:') + console.log(` wharfkit wallet keys add "${customKey}"`) + } + } + const walletPlugin = new WalletPluginPrivateKey(devPrivateKey) const renderer = new NonInteractiveConsoleUI() renderer.status('WharfKit wallet plugin initialized for local development') @@ -362,8 +427,6 @@ async function setupDevWallet(): Promise { } catch (error: any) { console.log(`Warning: Could not setup dev wallet: ${error.message}`) console.log('You can manually store the development key with:') - console.log( - ` wharfkit wallet keys add --name ${walletName} --private ${devKeys.privateKey}` - ) + console.log(` wharfkit wallet keys add --name ${walletName} ${devKeys.privateKey}`) } } diff --git a/src/commands/chain/utils.ts b/src/commands/chain/utils.ts index 733d7cf..50d851c 100644 --- a/src/commands/chain/utils.ts +++ b/src/commands/chain/utils.ts @@ -180,14 +180,15 @@ export function getDevKeys(): {publicKey: string; privateKey: string} { /** * Create default genesis.json */ -export function getGenesisJson(): string { +export function getGenesisJson(publicKey?: string): string { const devKeys = getDevKeys() + const initialKey = publicKey || devKeys.publicKey // Use a fixed timestamp for deterministic blockchain const timestamp = '2018-12-05T08:55:00.000' return JSON.stringify( { initial_timestamp: timestamp, - initial_key: devKeys.publicKey, + initial_key: initialKey, initial_configuration: { max_block_net_usage: 1048576, target_block_net_usage_pct: 1000, @@ -216,8 +217,10 @@ export function getGenesisJson(): string { /** * Create default config.ini */ -export function getConfigIni(port: number): string { +export function getConfigIni(port: number, publicKey?: string, privateKey?: string): string { const devKeys = getDevKeys() + const producerPublicKey = publicKey || devKeys.publicKey + const producerPrivateKey = privateKey || devKeys.privateKey return `# Plugins plugin = eosio::chain_api_plugin plugin = eosio::chain_plugin @@ -244,7 +247,7 @@ resource-monitor-not-shutdown-on-threshold-exceeded = true # Producer settings producer-name = eosio -signature-provider = ${devKeys.publicKey}=KEY:${devKeys.privateKey} +signature-provider = ${producerPublicKey}=KEY:${producerPrivateKey} enable-stale-production = true pause-on-startup = false ` diff --git a/src/commands/contract/deploy.ts b/src/commands/contract/deploy.ts index 06a8edd..5e05142 100644 --- a/src/commands/contract/deploy.ts +++ b/src/commands/contract/deploy.ts @@ -209,6 +209,42 @@ export async function deployContract( provider: new FetchProvider(url, {fetch}), }) + // Verify the key matches the account's active permission + try { + const accountInfo = await client.v1.chain.get_account(accountName) + const activePermission = accountInfo.permissions.find( + (p) => String(p.perm_name) === 'active' + ) + if (activePermission) { + const publicKey = privateKey.toPublic().toString() + const keyMatches = activePermission.required_auth.keys.some( + (k) => String(k.key) === publicKey + ) + if (!keyMatches) { + const accountKeys = activePermission.required_auth.keys.map((k) => k.key) + throw new Error( + `The selected key (${publicKey}) does not match account "${accountName}"'s active permission.\n` + + `Account's active permission keys: ${accountKeys.join(', ')}\n\n` + + `To deploy, you need a key that matches the account's active permission.\n` + + `Options:\n` + + ` 1. Import the correct key: wharfkit wallet keys add \n` + + ` 2. Specify the key: wharfkit contract deploy --key \n` + + ` 3. Set WHARFKIT_DEPLOY_KEY environment variable` + ) + } + } + } catch (error: any) { + // If account doesn't exist or we can't fetch permissions, let the deployment attempt proceed + // (it will fail with a clearer error from the blockchain) + if (!error.message.includes('does not match')) { + console.log( + `Warning: Could not verify key matches account permission: ${error.message}` + ) + } else { + throw error + } + } + // Create session with private key wallet plugin const walletPlugin = new WalletPluginPrivateKey(privateKey) walletPlugin.config.requiresChainSelect = false @@ -368,6 +404,17 @@ async function getPrivateKeyForDeploy( return getKeyFromWallet('default') } + // Try 'chain-key' (imported when chain starts) + // Note: This only works if the account's active permission matches this key + const chainKey = keys.find((k) => k.name === 'chain-key') + if (chainKey) { + console.log(`Using wallet key: chain-key`) + console.log( + `Note: This will only work if account "${accountName}"'s active permission matches this key` + ) + return getKeyFromWallet('chain-key') + } + // Use first available key console.log(`Using wallet key: ${keys[0].name}`) return getKeyFromWallet(keys[0].name) diff --git a/src/commands/dev.ts b/src/commands/dev.ts index 177c7ab..6fab91e 100644 --- a/src/commands/dev.ts +++ b/src/commands/dev.ts @@ -10,6 +10,7 @@ interface DevOptions { account?: string port?: number clean?: boolean + key?: string } let isCompiling = false @@ -35,6 +36,7 @@ export async function startDevMode(options: DevOptions): Promise { await startLocalChain({ port, clean: options.clean || false, + key: options.key, }) console.log('āœ… Local blockchain started\n') @@ -167,12 +169,17 @@ export function createDevCommand(): Command { .option('-a, --account ', 'Contract account name (default: derived from filename)') .option('-p, --port ', 'Port for local blockchain', '8888') .option('-c, --clean', 'Start with a clean blockchain state') + .option( + '-k, --key ', + 'Private key to automatically import into wallet (overrides WHARFKIT_CHAIN_KEY env var)' + ) .action(async (options) => { try { await startDevMode({ account: options.account, port: parseInt(options.port), clean: options.clean, + key: options.key, }) } catch (error: any) { console.error(`Error: ${error.message}`) From 932a0f70f0d6320036705c8e533e14bf3d9b3068 Mon Sep 17 00:00:00 2001 From: dafuga Date: Sun, 23 Nov 2025 19:05:09 -0800 Subject: [PATCH 33/36] fix: fixing tests --- src/commands/wallet/account.ts | 32 +++++++++++++++++++------------- test/tests/e2e-workflow.ts | 2 +- test/tests/wallet-account.ts | 3 ++- 3 files changed, 22 insertions(+), 15 deletions(-) diff --git a/src/commands/wallet/account.ts b/src/commands/wallet/account.ts index 761102d..c359de4 100644 --- a/src/commands/wallet/account.ts +++ b/src/commands/wallet/account.ts @@ -1,3 +1,4 @@ +import '../../types/wharfkit-session' import type {PublicKeyType} from '@wharfkit/antelope' import {APIClient, FetchProvider, KeyType, type NameType, PrivateKey} from '@wharfkit/antelope' import {type ChainDefinition, type ChainIndices, Chains} from '@wharfkit/common' @@ -36,19 +37,24 @@ export async function createAccount(options: AccountCreateOptions): Promise "Jungle4") - const pascalCaseChain = - chainStr.charAt(0).toUpperCase() + chainStr.slice(1).toLowerCase() - if (supportedChains.includes(pascalCaseChain)) { - chainIndex = pascalCaseChain as ChainIndices + // Try exact match first (handles PascalCase like "KylinTestnet") + if (supportedChains.includes(chainStr)) { + chainIndex = chainStr as ChainIndices } else { - log( - `Unsupported chain "${ - options.chain - }". Supported chains are: ${supportedChains.join(', ')}`, - 'info' - ) - return + // Convert to PascalCase (e.g., "jungle4" -> "Jungle4", "kylintestnet" -> "Kylintestnet") + const pascalCaseChain = + chainStr.charAt(0).toUpperCase() + chainStr.slice(1).toLowerCase() + if (supportedChains.includes(pascalCaseChain)) { + chainIndex = pascalCaseChain as ChainIndices + } else { + log( + `Unsupported chain "${ + options.chain + }". Supported chains are: ${supportedChains.join(', ')}`, + 'info' + ) + return + } } } @@ -102,7 +108,7 @@ export async function createAccount(options: AccountCreateOptions): Promise { assert.include(output, 'Deploy a compiled contract') assert.include(output, '--account') assert.include(output, '--url') - assert.notInclude(output, '--key') + assert.include(output, '--key') }) test('dev command is at top level', function () { diff --git a/test/tests/wallet-account.ts b/test/tests/wallet-account.ts index 9a8c368..f315fa5 100644 --- a/test/tests/wallet-account.ts +++ b/test/tests/wallet-account.ts @@ -57,7 +57,7 @@ suite('Wallet Account Create', () => { }) test('creates account with custom name', async function () { - const accountName = 'testaccount.gm' + const accountName = 'testacc.gm' const mockResponse = { status: 201, json: sandbox.stub().resolves({}), @@ -130,6 +130,7 @@ suite('Wallet Account Create', () => { await createAccount({chain: 'KylinTestnet'}) + assert.isTrue(fetchStub.calledOnce) const callArgs = fetchStub.getCall(0).args assert.equal(callArgs[0], `${Chains.KylinTestnet.url}/account/create`) From e5deaca2c2b8e19a7658cac6a451041c6a0f5b7f Mon Sep 17 00:00:00 2001 From: dafuga Date: Sun, 23 Nov 2025 19:20:02 -0800 Subject: [PATCH 34/36] enhancement: added default key --- src/commands/chain/local.ts | 36 +++++++++++++------------- src/commands/contract/deploy.ts | 5 ++-- src/commands/wallet/account.ts | 29 +++++++++++++++++---- test/tests/chain-interact.ts | 8 +++--- test/tests/e2e-workflow.ts | 45 ++++++++++++++++++++++++++++----- test/utils/test-helpers.ts | 33 ++++++++++++++++++++++++ 6 files changed, 119 insertions(+), 37 deletions(-) diff --git a/src/commands/chain/local.ts b/src/commands/chain/local.ts index 4f1e3ec..9f6aec8 100644 --- a/src/commands/chain/local.ts +++ b/src/commands/chain/local.ts @@ -1,5 +1,5 @@ /* eslint-disable no-console */ -import {KeyType, PrivateKey} from '@wharfkit/antelope' +import {PrivateKey} from '@wharfkit/antelope' import {WalletPluginPrivateKey} from '@wharfkit/wallet-plugin-privatekey' import {spawn} from 'child_process' import * as fs from 'fs' @@ -73,7 +73,7 @@ export async function startLocalChain(options: LocalStartOptions): Promise ) } - // Determine which key to use: provided, env var, or generate new one + // Determine which key to use: provided, env var, or genesis key (default) let chainPrivateKey: PrivateKey let chainPublicKey: string const providedKey = options.key || process.env.WHARFKIT_CHAIN_KEY @@ -84,12 +84,12 @@ export async function startLocalChain(options: LocalStartOptions): Promise chainPublicKey = chainPrivateKey.toPublic().toString() console.log('Using provided private key for chain') } else { - // Generate a new random key - chainPrivateKey = PrivateKey.generate(KeyType.K1) - chainPublicKey = chainPrivateKey.toPublic().toString() - console.log('Generated new random private key for chain') + // Use genesis key by default (always the same key for consistency) + const devKeys = getDevKeys() + chainPrivateKey = PrivateKey.from(devKeys.privateKey) + chainPublicKey = devKeys.publicKey + console.log('Using genesis key for chain (default)') console.log(` Public Key: ${chainPublicKey}`) - console.log(` Private Key: ${chainPrivateKey.toString()}`) } // Create config files @@ -381,30 +381,28 @@ async function setupDevWallet(customKey?: string): Promise { } // If a custom key is provided, import it automatically + // Store genesis key as 'eosio' for predictable account creation if (customKey) { try { if (isPrivateKeyString(customKey)) { const customPrivateKey = PrivateKey.from(customKey) const customPublicKey = customPrivateKey.toPublic().toString() - // Check if this key already exists - const existingCustomKey = existingKeys.find( - (key) => key.publicKey === customPublicKey - ) + // Check if 'default' key already exists + const existingDefaultKey = existingKeys.find((k) => k.name === 'default') - if (!existingCustomKey) { - // Use 'default' if no default exists, otherwise use 'chain-key' - const hasDefault = existingKeys.some((k) => k.name === 'default') - const customKeyName = hasDefault ? 'chain-key' : 'default' - addKeyToWallet(customPrivateKey, customKeyName) + if (existingDefaultKey) { + // 'default' key already exists - reuse it console.log( - `āœ… Automatically imported chain key into wallet as "${customKeyName}"` + 'Genesis key already exists in wallet as "default" - reusing it' ) - console.log(` Public Key: ${customPublicKey}`) } else { + // 'default' doesn't exist - create it + addKeyToWallet(customPrivateKey, 'default') console.log( - `Chain key already exists in wallet as "${existingCustomKey.name}"` + 'āœ… Automatically imported genesis key into wallet as "default"' ) + console.log(` Public Key: ${customPublicKey}`) } } else { console.log( diff --git a/src/commands/contract/deploy.ts b/src/commands/contract/deploy.ts index 5e05142..cfb775a 100644 --- a/src/commands/contract/deploy.ts +++ b/src/commands/contract/deploy.ts @@ -107,9 +107,10 @@ export async function validateDeploy( // We should catch that. if ( error.message.includes('Account not found') || - error.message.includes('does not exist') + error.message.includes('does not exist') || + error.message.includes('Account Query Exception') ) { - // New account, safe. + // New account or account doesn't exist yet, safe to proceed return } diff --git a/src/commands/wallet/account.ts b/src/commands/wallet/account.ts index c359de4..90aabdc 100644 --- a/src/commands/wallet/account.ts +++ b/src/commands/wallet/account.ts @@ -7,7 +7,7 @@ import {WalletPluginPrivateKey} from '@wharfkit/wallet-plugin-privatekey' import fetch from 'node-fetch' import {getDevKeys} from '../chain/utils' import {NonInteractiveConsoleUI} from '../../utils/wharfkit-ui' -import {addKeyToWallet} from './utils' +import {addKeyToWallet, getKeyFromWallet, listWalletKeys} from './utils' import {log, makeClient} from '../../utils' interface AccountCreateOptions { @@ -154,9 +154,28 @@ async function createAccountOnLocalChain( ): Promise { const newPublicKey = privateKey.toPublic() - // Get dev keys for signing the newaccount action + // Try to get eosio account key from wallet (default, chain-key, dev, or hardcoded) + // This ensures we use the correct key that matches the chain's eosio account permission + let eosioPrivateKey: PrivateKey + const walletKeys = listWalletKeys() const devKeys = getDevKeys() - const devPrivateKey = PrivateKey.from(devKeys.privateKey) + + // Try to find a key that might be the chain's eosio key + // Priority: default -> chain-key -> dev -> hardcoded dev keys + const defaultKey = walletKeys.find((k) => k.name === 'default') + const chainKey = walletKeys.find((k) => k.name === 'chain-key') + const devKey = walletKeys.find((k) => k.name === 'dev') + + if (defaultKey) { + eosioPrivateKey = getKeyFromWallet('default') + } else if (chainKey) { + eosioPrivateKey = getKeyFromWallet('chain-key') + } else if (devKey) { + eosioPrivateKey = getKeyFromWallet('dev') + } else { + // Fall back to hardcoded dev keys (for backward compatibility) + eosioPrivateKey = PrivateKey.from(devKeys.privateKey) + } // Create API client const client = new APIClient({ @@ -179,8 +198,8 @@ async function createAccountOnLocalChain( // Ignore error, assume no system contract } - // Create session with dev key - const walletPlugin = new WalletPluginPrivateKey(devPrivateKey) + // Create session with eosio account key + const walletPlugin = new WalletPluginPrivateKey(eosioPrivateKey) walletPlugin.config.requiresChainSelect = false walletPlugin.config.requiresPermissionSelect = false walletPlugin.config.requiresPermissionEntry = false diff --git a/test/tests/chain-interact.ts b/test/tests/chain-interact.ts index f4c041e..64c8196 100644 --- a/test/tests/chain-interact.ts +++ b/test/tests/chain-interact.ts @@ -3,7 +3,7 @@ import {execSync} from 'child_process' import * as fs from 'fs' import * as path from 'path' import * as os from 'os' -import {isNodeosAvailable, killProcessAtPort} from '../utils/test-helpers' +import {isNodeosAvailable, killProcessAtPort, waitForChainReady} from '../utils/test-helpers' suite('Chain Interaction', () => { const cliPath = path.join(__dirname, '../../lib/cli.js') @@ -11,7 +11,7 @@ suite('Chain Interaction', () => { let originalHome: string let contractAccount: string - suiteSetup(function () { + suiteSetup(async function () { // Skip suite if nodeos is not available if (!isNodeosAvailable()) { // eslint-disable-next-line no-console @@ -117,8 +117,8 @@ suite('Chain Interaction', () => { // Start local chain execSync(`node ${cliPath} chain local start`, {encoding: 'utf8'}) - // Wait for chain - execSync('sleep 5') + // Wait for chain to be ready + await waitForChainReady('http://127.0.0.1:8888', 30000) // Check if cdt-cpp is installed try { diff --git a/test/tests/e2e-workflow.ts b/test/tests/e2e-workflow.ts index e5b57e6..53abb0c 100644 --- a/test/tests/e2e-workflow.ts +++ b/test/tests/e2e-workflow.ts @@ -6,7 +6,7 @@ import * as os from 'os' import {ABI, APIClient, FetchProvider, Serializer} from '@wharfkit/antelope' import fetch from 'node-fetch' import {log} from '../../src/utils' -import {isNodeosAvailable, killProcessAtPort} from '../utils/test-helpers' +import {isNodeosAvailable, killProcessAtPort, waitForChainReady} from '../utils/test-helpers' /** * E2E tests for the complete workflow: @@ -42,7 +42,9 @@ suite('E2E Workflow', () => { let testWalletDir: string let originalHome: string - suiteSetup(function () { + suiteSetup(async function () { + this.timeout(60000) // Increase timeout for chain startup + // Skip suite if nodeos is not available if (!isNodeosAvailable()) { // eslint-disable-next-line no-console @@ -75,7 +77,11 @@ suite('E2E Workflow', () => { // Also check port 8888 directly in case chain was started by another test with different HOME killProcessAtPort(8888) + // Start the chain execSync(`node ${cliPath} chain local start`, {encoding: 'utf8'}) + + // Wait for chain to be ready + await waitForChainReady('http://127.0.0.1:8888', 30000) }) suiteTeardown(function () { @@ -240,11 +246,36 @@ suite('E2E Workflow', () => { fs.writeFileSync(txPath, JSON.stringify(transaction)) // Test that the broadcast functionality works properly - // Use the 'dev' key which is automatically created by the local chain and has eosio authority - const output = execSync( - `node ${cliPath} wallet transact ${txPath} --broadcast --key dev`, - {encoding: 'utf8'} - ) + // Try to use the chain's key (chain-key, default, or dev) which has eosio authority + // The chain key is automatically imported when the chain starts + let output: string + try { + // Try chain-key first (if chain uses random key) + output = execSync( + `node ${cliPath} wallet transact ${txPath} --broadcast --key chain-key`, + { + encoding: 'utf8', + } + ) + } catch { + try { + // Try default (if chain key was imported as default) + output = execSync( + `node ${cliPath} wallet transact ${txPath} --broadcast --key default`, + { + encoding: 'utf8', + } + ) + } catch { + // Fall back to dev key (if chain uses dev keys) + output = execSync( + `node ${cliPath} wallet transact ${txPath} --broadcast --key dev`, + { + encoding: 'utf8', + } + ) + } + } // Should show broadcast success message assert.include(output, 'šŸš€ Transaction broadcast successfully!') diff --git a/test/utils/test-helpers.ts b/test/utils/test-helpers.ts index c2508c4..e50f688 100644 --- a/test/utils/test-helpers.ts +++ b/test/utils/test-helpers.ts @@ -1,4 +1,6 @@ import {execSync} from 'child_process' +import {APIClient, FetchProvider} from '@wharfkit/antelope' +import fetch from 'node-fetch' /** * Check if nodeos is available in PATH @@ -12,6 +14,37 @@ export function isNodeosAvailable(): boolean { } } +/** + * Wait for the chain to be ready by checking the API + * @param url - Chain API URL (default: http://127.0.0.1:8888) + * @param timeoutMs - Maximum time to wait in milliseconds (default: 30000) + * @returns Promise that resolves when chain is ready, rejects on timeout + */ +export async function waitForChainReady( + url: string = 'http://127.0.0.1:8888', + timeoutMs: number = 30000 +): Promise { + const client = new APIClient({ + provider: new FetchProvider(url, {fetch}), + }) + + const startTime = Date.now() + + while (Date.now() - startTime < timeoutMs) { + try { + const info = await client.v1.chain.get_info() + if (Number(info.head_block_num) >= 0) { + return + } + } catch { + // Chain not ready yet, continue waiting + } + await new Promise((resolve) => setTimeout(resolve, 500)) + } + + throw new Error(`Chain at ${url} did not become ready within ${timeoutMs}ms`) +} + /** * Kill any nodeos processes listening on the specified port * @param port - The port number to check From e6bbd6e58f9af3cfb6589ea3a8cad927af7b212d Mon Sep 17 00:00:00 2001 From: dafuga Date: Sun, 23 Nov 2025 19:29:35 -0800 Subject: [PATCH 35/36] chore: added chain genesis tests --- test/tests/chain-genesis-key.ts | 243 ++++++++++++++++++++++++++++++++ 1 file changed, 243 insertions(+) create mode 100644 test/tests/chain-genesis-key.ts diff --git a/test/tests/chain-genesis-key.ts b/test/tests/chain-genesis-key.ts new file mode 100644 index 0000000..694b640 --- /dev/null +++ b/test/tests/chain-genesis-key.ts @@ -0,0 +1,243 @@ +import {assert} from 'chai' +import * as fs from 'fs' +import * as path from 'path' +import * as os from 'os' +import {KeyType, PrivateKey} from '@wharfkit/antelope' +import { + getDevKeys, + getDefaultWalletDir, + getDefaultDataDir, + getDefaultConfigDir, +} from '../../src/commands/chain/utils' +import { + addKeyToWallet, + listWalletKeys, + getKeyFromWallet, + removeKeyFromWallet, + getWalletFilePath, + loadWalletData, +} from '../../src/commands/wallet/utils' + +suite('Chain Genesis Key Storage', () => { + let testWalletDir: string + let testDataDir: string + let testConfigDir: string + let originalHome: string + let originalWalletDir: string | undefined + + setup(function () { + // Create temporary test directories + const testBaseDir = path.join(os.tmpdir(), `wharfkit-genesis-test-${Date.now()}`) + testWalletDir = path.join(testBaseDir, '.wharfkit', 'wallet') + testDataDir = path.join(testBaseDir, '.wharfkit', 'chain') + testConfigDir = path.join(testBaseDir, '.wharfkit', 'config') + + fs.mkdirSync(testWalletDir, {recursive: true}) + fs.mkdirSync(testDataDir, {recursive: true}) + fs.mkdirSync(testConfigDir, {recursive: true}) + + // Mock HOME to use test directories + originalHome = process.env.HOME || '' + process.env.HOME = testBaseDir + + // Clear wallet file if it exists + const walletFile = getWalletFilePath() + if (fs.existsSync(walletFile)) { + fs.unlinkSync(walletFile) + } + }) + + teardown(function () { + // Restore original HOME + process.env.HOME = originalHome + + // Clean up test directories + if (fs.existsSync(testWalletDir)) { + const walletFile = getWalletFilePath() + if (fs.existsSync(walletFile)) { + fs.unlinkSync(walletFile) + } + fs.rmSync(path.dirname(testWalletDir), {recursive: true, force: true}) + } + }) + + test('genesis key is stored as default when wallet is empty', function () { + const devKeys = getDevKeys() + const genesisPrivateKey = PrivateKey.from(devKeys.privateKey) + + // Verify wallet is empty + const initialKeys = listWalletKeys() + assert.equal(initialKeys.length, 0) + + // Add genesis key as 'default' (simulating what setupDevWallet does) + addKeyToWallet(genesisPrivateKey, 'default') + + // Verify key was stored + const keys = listWalletKeys() + assert.equal(keys.length, 1) + assert.equal(keys[0].name, 'default') + // Verify the stored key matches genesis key by comparing public keys + const storedKey = getKeyFromWallet('default') + const expectedGenesisKey = PrivateKey.from(devKeys.privateKey) + assert.equal(storedKey.toPublic().toString(), expectedGenesisKey.toPublic().toString()) + + // Verify we can retrieve it + const retrievedKey = getKeyFromWallet('default') + assert.equal(retrievedKey.toPublic().toString(), expectedGenesisKey.toPublic().toString()) + }) + + test('genesis key is reused when default already exists', function () { + const devKeys = getDevKeys() + const genesisPrivateKey = PrivateKey.from(devKeys.privateKey) + + // First, add the genesis key as 'default' + addKeyToWallet(genesisPrivateKey, 'default') + + // Verify it exists + const firstKeys = listWalletKeys() + assert.equal(firstKeys.length, 1) + assert.equal(firstKeys[0].name, 'default') + + // Try to add it again (should not create duplicate) + // This simulates what happens when chain starts multiple times + const existingKeys = listWalletKeys() + const existingDefaultKey = existingKeys.find((k) => k.name === 'default') + + if (existingDefaultKey) { + // Key already exists - should reuse it + // Compare by retrieving the key and checking public key + const retrievedKey = getKeyFromWallet('default') + const expectedGenesisKey = PrivateKey.from(devKeys.privateKey) + assert.equal( + retrievedKey.toPublic().toString(), + expectedGenesisKey.toPublic().toString() + ) + } else { + // Should not reach here, but if it does, add the key + addKeyToWallet(genesisPrivateKey, 'default') + } + + // Verify still only one key + const finalKeys = listWalletKeys() + assert.equal(finalKeys.length, 1) + assert.equal(finalKeys[0].name, 'default') + // Verify it matches genesis key by comparing public keys + const storedKey = getKeyFromWallet('default') + const expectedGenesisKey = PrivateKey.from(devKeys.privateKey) + assert.equal(storedKey.toPublic().toString(), expectedGenesisKey.toPublic().toString()) + }) + + test('genesis key matches dev keys', function () { + const devKeys = getDevKeys() + + // Verify the keys match expected values + assert.equal(devKeys.publicKey, 'EOS6MRyAjQq8ud7hVNYcfnVPJqcVpscN5So8BhtHuGYqET5GDW5CV') + assert.equal(devKeys.privateKey, '5KQwrPbwdL6PhXujxW37FSSQZ1JiwsST4cqQzDeyXtP79zkvFD3') + + // Verify private key can be converted to public key + const privateKey = PrivateKey.from(devKeys.privateKey) + const publicKey = privateKey.toPublic() + // Public key should be in PUB_K1 format + assert.include(publicKey.toString(), 'PUB_K1_') + // Verify it matches the expected public key (convert EOS format to compare) + const expectedPublicKey = PrivateKey.from(devKeys.privateKey).toPublic() + assert.equal(publicKey.toString(), expectedPublicKey.toString()) + }) + + test('default key can be retrieved for account creation', function () { + const devKeys = getDevKeys() + const genesisPrivateKey = PrivateKey.from(devKeys.privateKey) + + // Store genesis key as 'default' + addKeyToWallet(genesisPrivateKey, 'default') + + // Simulate account creation key lookup (priority: default -> chain-key -> dev -> hardcoded) + const walletKeys = listWalletKeys() + const defaultKey = walletKeys.find((k) => k.name === 'default') + + assert.isDefined(defaultKey, 'default key should exist') + // Verify the stored key matches genesis key by comparing public keys + const storedKey = getKeyFromWallet('default') + const expectedGenesisKey = PrivateKey.from(devKeys.privateKey) + assert.equal(storedKey.toPublic().toString(), expectedGenesisKey.toPublic().toString()) + + // Retrieve the key and verify it matches genesis key + const eosioPrivateKey = getKeyFromWallet('default') + assert.equal( + eosioPrivateKey.toPublic().toString(), + expectedGenesisKey.toPublic().toString() + ) + }) + + test('default key is prioritized over other keys', function () { + const devKeys = getDevKeys() + const genesisPrivateKey = PrivateKey.from(devKeys.privateKey) + + // Create some other keys first + const otherKey1 = PrivateKey.generate(KeyType.K1) + const otherKey2 = PrivateKey.generate(KeyType.K1) + + addKeyToWallet(otherKey1, 'chain-key') + addKeyToWallet(otherKey2, 'dev') + + // Now add genesis key as 'default' + addKeyToWallet(genesisPrivateKey, 'default') + + // Simulate account creation key lookup priority + const walletKeys = listWalletKeys() + const defaultKey = walletKeys.find((k) => k.name === 'default') + const chainKey = walletKeys.find((k) => k.name === 'chain-key') + const devKey = walletKeys.find((k) => k.name === 'dev') + + // 'default' should be found first + assert.isDefined(defaultKey) + // Verify it's the genesis key by comparing public keys + const defaultStoredKey = getKeyFromWallet('default') + const expectedGenesisKey = PrivateKey.from(devKeys.privateKey) + assert.equal( + defaultStoredKey.toPublic().toString(), + expectedGenesisKey.toPublic().toString() + ) + + // Verify priority order: default should be used + let selectedKey: PrivateKey + if (defaultKey) { + selectedKey = getKeyFromWallet('default') + } else if (chainKey) { + selectedKey = getKeyFromWallet('chain-key') + } else if (devKey) { + selectedKey = getKeyFromWallet('dev') + } else { + selectedKey = PrivateKey.from(devKeys.privateKey) + } + + // Compare public keys (format may differ) + const expectedKey = PrivateKey.from(devKeys.privateKey) + assert.equal(selectedKey.toPublic().toString(), expectedKey.toPublic().toString()) + }) + + test('genesis key is not duplicated when stored multiple times', function () { + const devKeys = getDevKeys() + const genesisPrivateKey = PrivateKey.from(devKeys.privateKey) + + // Add genesis key as 'default' + addKeyToWallet(genesisPrivateKey, 'default') + + // Try to add it again (should fail because key already exists) + try { + addKeyToWallet(genesisPrivateKey, 'default') + assert.fail('Should throw error when adding duplicate key name') + } catch (error: any) { + assert.include(error.message, 'already exists') + } + + // Verify still only one key + const keys = listWalletKeys() + assert.equal(keys.length, 1) + assert.equal(keys[0].name, 'default') + // Verify it's the genesis key by comparing public keys + const storedKey = getKeyFromWallet('default') + const expectedKey = PrivateKey.from(devKeys.privateKey) + assert.equal(storedKey.toPublic().toString(), expectedKey.toPublic().toString()) + }) +}) From 82a0f3aaaa3abcf8af66e297eef148b3589ff4f9 Mon Sep 17 00:00:00 2001 From: dafuga Date: Sun, 23 Nov 2025 20:02:48 -0800 Subject: [PATCH 36/36] refactor: simplified the wallet commands fix: got tests passing --- package.json | 5 +- src/commands/chain/local.ts | 267 +++++++++++++++++++++++++------- src/commands/wallet/account.ts | 83 ++++++---- src/commands/wallet/utils.ts | 4 + test/tests/chain-genesis-key.ts | 89 ++++------- test/tests/e2e-workflow.ts | 4 +- 6 files changed, 310 insertions(+), 142 deletions(-) diff --git a/package.json b/package.json index a336db0..66d36b2 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,10 @@ "homepage": "https://github.com/wharfkit/cli#readme", "description": "Command line utilities for Wharf", "scripts": { - "prepare": "make" + "prepare": "make", + "check": "tsc --noEmit", + "test": "make test", + "lint": "make check" }, "engines": { "node": ">=18.0.0" diff --git a/src/commands/chain/local.ts b/src/commands/chain/local.ts index 9f6aec8..d0e314f 100644 --- a/src/commands/chain/local.ts +++ b/src/commands/chain/local.ts @@ -1,5 +1,6 @@ /* eslint-disable no-console */ -import {PrivateKey} from '@wharfkit/antelope' +import {PrivateKey, PublicKey} from '@wharfkit/antelope' +import {Session} from '@wharfkit/session' import {WalletPluginPrivateKey} from '@wharfkit/wallet-plugin-privatekey' import {spawn} from 'child_process' import * as fs from 'fs' @@ -23,7 +24,7 @@ import { waitForChain, } from './utils' import {ensureLeapInstalled} from './install' -import {addKeyToWallet, listWalletKeys} from '../wallet/utils' +import {addKeyToWallet, DEFAULT_KEY_NAME, listWalletKeys} from '../wallet/utils' import {NonInteractiveConsoleUI} from '../../utils/wharfkit-ui' export interface LocalStartOptions { @@ -158,6 +159,8 @@ export async function startLocalChain(options: LocalStartOptions): Promise console.log('Chain is ready!') + await ensureEosioPermissionsMatchChainKey(options.port, chainPrivateKey) + // Setup dev wallet (import the key used for the chain) await setupDevWallet(chainPrivateKey.toString()) @@ -358,62 +361,15 @@ function isPrivateKeyString(str: string): boolean { async function setupDevWallet(customKey?: string): Promise { console.log('Setting up development wallet...') - const walletName = 'dev' + const walletName = DEFAULT_KEY_NAME const devKeys = getDevKeys() const devPrivateKey = PrivateKey.from(devKeys.privateKey) try { - // First, import the default dev key - const existingKeys = listWalletKeys() - const existingEntry = existingKeys.find( - (key) => key.name === walletName || key.publicKey === devKeys.publicKey - ) + ensureDevelopmentKeyStored(devPrivateKey, walletName, devKeys.publicKey) - if (!existingEntry) { - addKeyToWallet(devPrivateKey, walletName) - console.log(`Stored development key in WharfKit wallet as "${walletName}"`) - } else if (existingEntry.name !== walletName) { - console.log( - `Development key already stored as "${existingEntry.name}", keeping existing entry` - ) - } else { - console.log('Development key already stored') - } - - // If a custom key is provided, import it automatically - // Store genesis key as 'eosio' for predictable account creation if (customKey) { - try { - if (isPrivateKeyString(customKey)) { - const customPrivateKey = PrivateKey.from(customKey) - const customPublicKey = customPrivateKey.toPublic().toString() - - // Check if 'default' key already exists - const existingDefaultKey = existingKeys.find((k) => k.name === 'default') - - if (existingDefaultKey) { - // 'default' key already exists - reuse it - console.log( - 'Genesis key already exists in wallet as "default" - reusing it' - ) - } else { - // 'default' doesn't exist - create it - addKeyToWallet(customPrivateKey, 'default') - console.log( - 'āœ… Automatically imported genesis key into wallet as "default"' - ) - console.log(` Public Key: ${customPublicKey}`) - } - } else { - console.log( - `Warning: Provided key "${customKey}" does not appear to be a valid private key format` - ) - } - } catch (error: any) { - console.log(`Warning: Could not import chain key: ${error.message}`) - console.log('You can manually import it with:') - console.log(` wharfkit wallet keys add "${customKey}"`) - } + ensureChainKeyStored(customKey) } const walletPlugin = new WalletPluginPrivateKey(devPrivateKey) @@ -428,3 +384,210 @@ async function setupDevWallet(customKey?: string): Promise { console.log(` wharfkit wallet keys add --name ${walletName} ${devKeys.privateKey}`) } } + +function ensureDevelopmentKeyStored( + devPrivateKey: PrivateKey, + walletName: string, + devPublicKey: string +): void { + const walletKeys = listWalletKeys() + const existingByPublic = walletKeys.find((key) => key.publicKey === devPublicKey) + + if (existingByPublic) { + if (existingByPublic.name === walletName) { + console.log('Development key already stored') + } else { + console.log( + `Development key already stored as "${existingByPublic.name}", keeping existing entry` + ) + } + return + } + + const conflictingByName = walletKeys.find((key) => key.name === walletName) + if (conflictingByName) { + console.log( + `Warning: Wallet already contains a key named "${walletName}" with a different public key.` + ) + console.log(' Skipping automatic import of the development key to avoid conflicts.') + console.log( + ` You can remove or rename the existing entry and rerun "wharfkit chain local start".` + ) + return + } + + addKeyToWallet(devPrivateKey, walletName) + console.log(`Stored development key in WharfKit wallet as "${walletName}"`) +} + +function ensureChainKeyStored(customKey: string): void { + if (!isPrivateKeyString(customKey)) { + console.log( + `Warning: Provided key "${customKey}" does not appear to be a valid private key format` + ) + return + } + + try { + const customPrivateKey = PrivateKey.from(customKey) + const customPublicKey = customPrivateKey.toPublic().toString() + const walletKeys = listWalletKeys() + const existingEntry = walletKeys.find((key) => key.publicKey === customPublicKey) + + if (existingEntry) { + console.log( + `Genesis key already stored in wallet as "${existingEntry.name}" - reusing it` + ) + return + } + + // Try to use default name + const defaultKey = walletKeys.find((key) => key.name === DEFAULT_KEY_NAME) + if (!defaultKey) { + addKeyToWallet(customPrivateKey, DEFAULT_KEY_NAME) + console.log( + `āœ… Automatically imported genesis key into wallet as "${DEFAULT_KEY_NAME}"` + ) + console.log(` Public Key: ${customPublicKey}`) + return + } + + // If default already exists, warn user + console.log( + `Warning: Key "${DEFAULT_KEY_NAME}" already exists. Skipping automatic import of genesis key.` + ) + console.log('You can manually import it with:') + console.log(` wharfkit wallet keys add "${customKey}"`) + } catch (error: any) { + console.log(`Warning: Could not import chain key: ${error.message}`) + console.log('You can manually import it with:') + console.log(` wharfkit wallet keys add "${customKey}"`) + } +} + +async function ensureEosioPermissionsMatchChainKey( + port: number, + chainPrivateKey: PrivateKey +): Promise { + try { + const client = createApiClientForPort(port) + const [account, info] = await Promise.all([ + client.v1.chain.get_account('eosio'), + client.v1.chain.get_info(), + ]) + const targetPublicKey = chainPrivateKey.toPublic() + const permissions: any[] = account.permissions ?? [] + const ownerPermission = permissions.find((perm) => perm.perm_name === 'owner') + const activePermission = permissions.find((perm) => perm.perm_name === 'active') + + const ownerMatches = permissionIncludesKey(ownerPermission, targetPublicKey) + const activeMatches = permissionIncludesKey(activePermission, targetPublicKey) + + if (ownerMatches && activeMatches) { + console.log('eosio account permissions already match the configured chain key') + return + } + + console.log('Aligning eosio account permissions with the configured chain key...') + + const walletPlugin = new WalletPluginPrivateKey(chainPrivateKey) + walletPlugin.config.requiresChainSelect = false + walletPlugin.config.requiresPermissionSelect = false + walletPlugin.config.requiresPermissionEntry = false + + const session = new Session({ + chain: { + id: String(info.chain_id), + url: `http://127.0.0.1:${port}`, + }, + actor: 'eosio', + permission: 'owner', + walletPlugin, + ui: new NonInteractiveConsoleUI(), + }) + + const actions: any[] = [] + if (!ownerMatches) { + actions.push(buildUpdateAuthAction('owner', targetPublicKey)) + } + if (!activeMatches) { + actions.push(buildUpdateAuthAction('active', targetPublicKey)) + } + + if (actions.length === 0) { + return + } + + await session.transact( + { + actions, + }, + { + broadcast: true, + } + ) + + console.log('Updated eosio account permissions to use the configured chain key') + } catch (error: any) { + console.log( + `Warning: Could not verify or update eosio permissions: ${error?.message ?? error}` + ) + console.log('You can manually verify with:') + console.log( + ' curl -s http://127.0.0.1:8888/v1/chain/get_account -X POST -d \'{"account_name":"eosio"}\'' + ) + } +} + +function permissionIncludesKey(permission: any, targetPublicKey: PublicKey): boolean { + if (!permission || !permission.required_auth) { + return false + } + + const targetVariants = new Set([ + targetPublicKey.toString(), + targetPublicKey.toLegacyString(), + ]) + + return (permission.required_auth.keys ?? []).some((entry: any) => { + if (!entry?.key) { + return false + } + + try { + const parsedKey = PublicKey.from(entry.key) + return parsedKey.equals(targetPublicKey) + } catch { + return targetVariants.has(entry.key) + } + }) +} + +function buildUpdateAuthAction(permission: 'owner' | 'active', publicKey: PublicKey): any { + return { + account: 'eosio', + name: 'updateauth', + authorization: [ + { + actor: 'eosio', + permission: 'owner', + }, + ], + data: { + account: 'eosio', + permission, + parent: permission === 'owner' ? '' : 'owner', + auth: { + threshold: 1, + keys: [ + { + key: publicKey.toString(), + weight: 1, + }, + ], + accounts: [], + waits: [], + }, + }, + } +} diff --git a/src/commands/wallet/account.ts b/src/commands/wallet/account.ts index 90aabdc..05c914d 100644 --- a/src/commands/wallet/account.ts +++ b/src/commands/wallet/account.ts @@ -1,13 +1,27 @@ import '../../types/wharfkit-session' import type {PublicKeyType} from '@wharfkit/antelope' -import {APIClient, FetchProvider, KeyType, type NameType, PrivateKey} from '@wharfkit/antelope' +import { + APIClient, + FetchProvider, + KeyType, + type NameType, + PrivateKey, + PublicKey, +} from '@wharfkit/antelope' import {type ChainDefinition, type ChainIndices, Chains} from '@wharfkit/common' import {Session} from '@wharfkit/session' import {WalletPluginPrivateKey} from '@wharfkit/wallet-plugin-privatekey' import fetch from 'node-fetch' import {getDevKeys} from '../chain/utils' import {NonInteractiveConsoleUI} from '../../utils/wharfkit-ui' -import {addKeyToWallet, getKeyFromWallet, listWalletKeys} from './utils' +import { + addKeyToWallet, + DEFAULT_KEY_NAME, + EOSIO_KEY_PREFERRED_NAMES, + getKeyFromWallet, + listWalletKeys, + type StoredKey, +} from './utils' import {log, makeClient} from '../../utils' interface AccountCreateOptions { @@ -108,7 +122,7 @@ export async function createAccount(options: AccountCreateOptions): Promise { - const newPublicKey = privateKey.toPublic() + // Parse the public key string into a PublicKey object + // If we have a private key, derive from it; otherwise use the provided public key string + const newPublicKey = privateKey ? privateKey.toPublic() : PublicKey.from(publicKey) - // Try to get eosio account key from wallet (default, chain-key, dev, or hardcoded) + // Try to get eosio account key from wallet (default or hardcoded) // This ensures we use the correct key that matches the chain's eosio account permission let eosioPrivateKey: PrivateKey const walletKeys = listWalletKeys() const devKeys = getDevKeys() - // Try to find a key that might be the chain's eosio key - // Priority: default -> chain-key -> dev -> hardcoded dev keys - const defaultKey = walletKeys.find((k) => k.name === 'default') - const chainKey = walletKeys.find((k) => k.name === 'chain-key') - const devKey = walletKeys.find((k) => k.name === 'dev') - - if (defaultKey) { - eosioPrivateKey = getKeyFromWallet('default') - } else if (chainKey) { - eosioPrivateKey = getKeyFromWallet('chain-key') - } else if (devKey) { - eosioPrivateKey = getKeyFromWallet('dev') + // Try to find the default key + const preferredKeyName = resolveEosioWalletKeyName(walletKeys) + const defaultKey = walletKeys.find((k) => k.name === DEFAULT_KEY_NAME) + + if (preferredKeyName) { + eosioPrivateKey = getKeyFromWallet(preferredKeyName) + } else if (defaultKey) { + eosioPrivateKey = getKeyFromWallet(DEFAULT_KEY_NAME) } else { // Fall back to hardcoded dev keys (for backward compatibility) eosioPrivateKey = PrivateKey.from(devKeys.privateKey) @@ -301,19 +313,25 @@ async function createAccountOnLocalChain( log('Account created successfully!', 'info') log(`Account Name: ${accountName}`, 'info') - log(`Private Key: ${privateKey.toString()}`, 'info') + if (privateKey) { + log(`Private Key: ${privateKey.toString()}`, 'info') + } log(`Public Key: ${publicKey}`, 'info') log(`Transaction ID: ${result.resolved?.transaction.id}`, 'info') - // Store the key in wallet with account name - try { - addKeyToWallet(privateKey, accountName) - log(`Key stored in wallet as: ${accountName}`, 'info') - } catch (error) { - log( - `Could not store key in wallet (may already exist): ${(error as Error).message}`, - 'info' - ) + // Store the key in wallet with account name (only if we have a private key) + if (privateKey) { + try { + addKeyToWallet(privateKey, accountName) + log(`Key stored in wallet as: ${accountName}`, 'info') + } catch (error) { + log( + `Could not store key in wallet (may already exist): ${(error as Error).message}`, + 'info' + ) + } + } else { + log('Note: No private key available to store in wallet (public key was provided)', 'info') } } @@ -337,6 +355,15 @@ function generateRandomLocalAccountName(): string { return result } +function resolveEosioWalletKeyName(walletKeys: StoredKey[]): string | undefined { + for (const preferredName of EOSIO_KEY_PREFERRED_NAMES) { + if (walletKeys.some((key) => key.name === preferredName)) { + return preferredName + } + } + return undefined +} + async function checkAccountNameExists(accountName: NameType, chainUrl: string): Promise { const client = makeClient(chainUrl) diff --git a/src/commands/wallet/utils.ts b/src/commands/wallet/utils.ts index c9928df..c42b0d8 100644 --- a/src/commands/wallet/utils.ts +++ b/src/commands/wallet/utils.ts @@ -4,6 +4,10 @@ import * as path from 'path' import * as os from 'os' import {PrivateKey} from '@wharfkit/antelope' +export const DEFAULT_KEY_NAME = 'default' +// Use default for all eosio account keys +export const EOSIO_KEY_PREFERRED_NAMES = [DEFAULT_KEY_NAME] as const + // Default password for encryption when user doesn't provide one const DEFAULT_PASSWORD = 'wharfkit-default-encryption-key-do-not-use-in-production' diff --git a/test/tests/chain-genesis-key.ts b/test/tests/chain-genesis-key.ts index 694b640..fc2feae 100644 --- a/test/tests/chain-genesis-key.ts +++ b/test/tests/chain-genesis-key.ts @@ -3,38 +3,27 @@ import * as fs from 'fs' import * as path from 'path' import * as os from 'os' import {KeyType, PrivateKey} from '@wharfkit/antelope' -import { - getDevKeys, - getDefaultWalletDir, - getDefaultDataDir, - getDefaultConfigDir, -} from '../../src/commands/chain/utils' +import {getDevKeys} from '../../src/commands/chain/utils' import { addKeyToWallet, - listWalletKeys, + DEFAULT_KEY_NAME, getKeyFromWallet, - removeKeyFromWallet, getWalletFilePath, - loadWalletData, + listWalletKeys, } from '../../src/commands/wallet/utils' suite('Chain Genesis Key Storage', () => { let testWalletDir: string - let testDataDir: string - let testConfigDir: string let originalHome: string - let originalWalletDir: string | undefined setup(function () { // Create temporary test directories const testBaseDir = path.join(os.tmpdir(), `wharfkit-genesis-test-${Date.now()}`) testWalletDir = path.join(testBaseDir, '.wharfkit', 'wallet') - testDataDir = path.join(testBaseDir, '.wharfkit', 'chain') - testConfigDir = path.join(testBaseDir, '.wharfkit', 'config') fs.mkdirSync(testWalletDir, {recursive: true}) - fs.mkdirSync(testDataDir, {recursive: true}) - fs.mkdirSync(testConfigDir, {recursive: true}) + fs.mkdirSync(path.join(testBaseDir, '.wharfkit', 'chain'), {recursive: true}) + fs.mkdirSync(path.join(testBaseDir, '.wharfkit', 'config'), {recursive: true}) // Mock HOME to use test directories originalHome = process.env.HOME || '' @@ -70,19 +59,19 @@ suite('Chain Genesis Key Storage', () => { assert.equal(initialKeys.length, 0) // Add genesis key as 'default' (simulating what setupDevWallet does) - addKeyToWallet(genesisPrivateKey, 'default') + addKeyToWallet(genesisPrivateKey, DEFAULT_KEY_NAME) // Verify key was stored const keys = listWalletKeys() assert.equal(keys.length, 1) - assert.equal(keys[0].name, 'default') + assert.equal(keys[0].name, DEFAULT_KEY_NAME) // Verify the stored key matches genesis key by comparing public keys - const storedKey = getKeyFromWallet('default') + const storedKey = getKeyFromWallet(DEFAULT_KEY_NAME) const expectedGenesisKey = PrivateKey.from(devKeys.privateKey) assert.equal(storedKey.toPublic().toString(), expectedGenesisKey.toPublic().toString()) // Verify we can retrieve it - const retrievedKey = getKeyFromWallet('default') + const retrievedKey = getKeyFromWallet(DEFAULT_KEY_NAME) assert.equal(retrievedKey.toPublic().toString(), expectedGenesisKey.toPublic().toString()) }) @@ -91,22 +80,22 @@ suite('Chain Genesis Key Storage', () => { const genesisPrivateKey = PrivateKey.from(devKeys.privateKey) // First, add the genesis key as 'default' - addKeyToWallet(genesisPrivateKey, 'default') + addKeyToWallet(genesisPrivateKey, DEFAULT_KEY_NAME) // Verify it exists const firstKeys = listWalletKeys() assert.equal(firstKeys.length, 1) - assert.equal(firstKeys[0].name, 'default') + assert.equal(firstKeys[0].name, DEFAULT_KEY_NAME) // Try to add it again (should not create duplicate) // This simulates what happens when chain starts multiple times const existingKeys = listWalletKeys() - const existingDefaultKey = existingKeys.find((k) => k.name === 'default') + const existingDefaultKey = existingKeys.find((k) => k.name === DEFAULT_KEY_NAME) if (existingDefaultKey) { // Key already exists - should reuse it // Compare by retrieving the key and checking public key - const retrievedKey = getKeyFromWallet('default') + const retrievedKey = getKeyFromWallet(DEFAULT_KEY_NAME) const expectedGenesisKey = PrivateKey.from(devKeys.privateKey) assert.equal( retrievedKey.toPublic().toString(), @@ -114,15 +103,15 @@ suite('Chain Genesis Key Storage', () => { ) } else { // Should not reach here, but if it does, add the key - addKeyToWallet(genesisPrivateKey, 'default') + addKeyToWallet(genesisPrivateKey, DEFAULT_KEY_NAME) } // Verify still only one key const finalKeys = listWalletKeys() assert.equal(finalKeys.length, 1) - assert.equal(finalKeys[0].name, 'default') + assert.equal(finalKeys[0].name, DEFAULT_KEY_NAME) // Verify it matches genesis key by comparing public keys - const storedKey = getKeyFromWallet('default') + const storedKey = getKeyFromWallet(DEFAULT_KEY_NAME) const expectedGenesisKey = PrivateKey.from(devKeys.privateKey) assert.equal(storedKey.toPublic().toString(), expectedGenesisKey.toPublic().toString()) }) @@ -149,20 +138,20 @@ suite('Chain Genesis Key Storage', () => { const genesisPrivateKey = PrivateKey.from(devKeys.privateKey) // Store genesis key as 'default' - addKeyToWallet(genesisPrivateKey, 'default') + addKeyToWallet(genesisPrivateKey, DEFAULT_KEY_NAME) - // Simulate account creation key lookup (priority: default -> chain-key -> dev -> hardcoded) + // Simulate account creation key lookup (priority: default -> hardcoded) const walletKeys = listWalletKeys() - const defaultKey = walletKeys.find((k) => k.name === 'default') + const defaultKey = walletKeys.find((k) => k.name === DEFAULT_KEY_NAME) assert.isDefined(defaultKey, 'default key should exist') // Verify the stored key matches genesis key by comparing public keys - const storedKey = getKeyFromWallet('default') + const storedKey = getKeyFromWallet(DEFAULT_KEY_NAME) const expectedGenesisKey = PrivateKey.from(devKeys.privateKey) assert.equal(storedKey.toPublic().toString(), expectedGenesisKey.toPublic().toString()) // Retrieve the key and verify it matches genesis key - const eosioPrivateKey = getKeyFromWallet('default') + const eosioPrivateKey = getKeyFromWallet(DEFAULT_KEY_NAME) assert.equal( eosioPrivateKey.toPublic().toString(), expectedGenesisKey.toPublic().toString() @@ -177,43 +166,25 @@ suite('Chain Genesis Key Storage', () => { const otherKey1 = PrivateKey.generate(KeyType.K1) const otherKey2 = PrivateKey.generate(KeyType.K1) - addKeyToWallet(otherKey1, 'chain-key') + addKeyToWallet(otherKey1, 'aux-key') addKeyToWallet(otherKey2, 'dev') // Now add genesis key as 'default' - addKeyToWallet(genesisPrivateKey, 'default') + addKeyToWallet(genesisPrivateKey, DEFAULT_KEY_NAME) // Simulate account creation key lookup priority const walletKeys = listWalletKeys() - const defaultKey = walletKeys.find((k) => k.name === 'default') - const chainKey = walletKeys.find((k) => k.name === 'chain-key') - const devKey = walletKeys.find((k) => k.name === 'dev') + const defaultKey = walletKeys.find((k) => k.name === DEFAULT_KEY_NAME) - // 'default' should be found first + // Default key should be found first assert.isDefined(defaultKey) // Verify it's the genesis key by comparing public keys - const defaultStoredKey = getKeyFromWallet('default') + const defaultStoredKey = getKeyFromWallet(DEFAULT_KEY_NAME) const expectedGenesisKey = PrivateKey.from(devKeys.privateKey) assert.equal( defaultStoredKey.toPublic().toString(), expectedGenesisKey.toPublic().toString() ) - - // Verify priority order: default should be used - let selectedKey: PrivateKey - if (defaultKey) { - selectedKey = getKeyFromWallet('default') - } else if (chainKey) { - selectedKey = getKeyFromWallet('chain-key') - } else if (devKey) { - selectedKey = getKeyFromWallet('dev') - } else { - selectedKey = PrivateKey.from(devKeys.privateKey) - } - - // Compare public keys (format may differ) - const expectedKey = PrivateKey.from(devKeys.privateKey) - assert.equal(selectedKey.toPublic().toString(), expectedKey.toPublic().toString()) }) test('genesis key is not duplicated when stored multiple times', function () { @@ -221,11 +192,11 @@ suite('Chain Genesis Key Storage', () => { const genesisPrivateKey = PrivateKey.from(devKeys.privateKey) // Add genesis key as 'default' - addKeyToWallet(genesisPrivateKey, 'default') + addKeyToWallet(genesisPrivateKey, DEFAULT_KEY_NAME) // Try to add it again (should fail because key already exists) try { - addKeyToWallet(genesisPrivateKey, 'default') + addKeyToWallet(genesisPrivateKey, DEFAULT_KEY_NAME) assert.fail('Should throw error when adding duplicate key name') } catch (error: any) { assert.include(error.message, 'already exists') @@ -234,9 +205,9 @@ suite('Chain Genesis Key Storage', () => { // Verify still only one key const keys = listWalletKeys() assert.equal(keys.length, 1) - assert.equal(keys[0].name, 'default') + assert.equal(keys[0].name, DEFAULT_KEY_NAME) // Verify it's the genesis key by comparing public keys - const storedKey = getKeyFromWallet('default') + const storedKey = getKeyFromWallet(DEFAULT_KEY_NAME) const expectedKey = PrivateKey.from(devKeys.privateKey) assert.equal(storedKey.toPublic().toString(), expectedKey.toPublic().toString()) }) diff --git a/test/tests/e2e-workflow.ts b/test/tests/e2e-workflow.ts index 53abb0c..e636e79 100644 --- a/test/tests/e2e-workflow.ts +++ b/test/tests/e2e-workflow.ts @@ -77,8 +77,8 @@ suite('E2E Workflow', () => { // Also check port 8888 directly in case chain was started by another test with different HOME killProcessAtPort(8888) - // Start the chain - execSync(`node ${cliPath} chain local start`, {encoding: 'utf8'}) + // Start the chain with --clean to ensure fresh state and genesis key is used + execSync(`node ${cliPath} chain local start --clean`, {encoding: 'utf8'}) // Wait for chain to be ready await waitForChainReady('http://127.0.0.1:8888', 30000)