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/Makefile b/Makefile index 25db953..6c57526 100644 --- a/Makefile +++ b/Makefile @@ -5,18 +5,18 @@ 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 - @TS_NODE_PROJECT='./test/tsconfig.json' MOCK_DIR='./test/data/requests' \ - ${BIN}/mocha ${MOCHA_OPTS} ${TEST_FILES} --no-timeout --grep '$(grep)' +test: node_modules lib + @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 - @TS_NODE_PROJECT='./test/tsconfig.json' MOCK_DIR='./test/data/requests' \ +ci-test: node_modules lib + @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 + ${BIN}/mocha ${MOCHA_OPTS} -R list ${TEST_FILES} --no-timeout --exit .PHONY: test_generate test_generate: node_modules clean lib diff --git a/README.md b/README.md index 0c2e458..05ea20d 100644 --- a/README.md +++ b/README.md @@ -34,10 +34,165 @@ Options: -h, --help display help for command Commands: + keys Generate a new set of public and private keys generate [options] Generate Contract Kit code for the named smart contract + chain Manage local LEAP blockchain + 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, creating accounts, and signing transactions locally. + +The `wallet` command is your central hub for all key and account management. + +#### 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 transact transaction.json + +# Sign with specific key + wharfkit wallet transact transaction.json --key mykey + +# Sign with password-protected key + wharfkit wallet transact transaction.json --key production --password + +# 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: +- 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. @@ -66,6 +221,265 @@ 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 compile mycontract.cpp +``` + +**Compile all .cpp files in the current directory:** +```bash +wharfkit compile +``` + +**Specify output directory:** +```bash +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 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 +``` + +### 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 WharfKit sessions 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. + +#### 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) 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/package.json b/package.json index 165124b..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" @@ -28,6 +31,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/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() 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/chain/index.ts b/src/commands/chain/index.ts new file mode 100644 index 0000000..2e8dd01 --- /dev/null +++ b/src/commands/chain/index.ts @@ -0,0 +1,139 @@ +/* eslint-disable no-console */ +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 + */ +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') + + // Add interact commands to local + addInteractSubcommands(local, 'local') + + // 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) + .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}`) + 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( + ` 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.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) { + console.error(`Error: ${error.message}`) + process.exit(1) + } + }) + + // Add dynamic chain commands + addInteractCommands(chain) + + 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..58b17ee --- /dev/null +++ b/src/commands/chain/install.ts @@ -0,0 +1,236 @@ +/* eslint-disable no-console */ +import {WalletPluginPrivateKey} from '@wharfkit/wallet-plugin-privatekey' +import {executeCommand, getDevKeys, getPlatform} from './utils' +import {NonInteractiveConsoleUI} from '../../utils/wharfkit-ui' + +export interface InstallationStatus { + installed: boolean + nodeos: boolean + nodeosPath?: string + version?: string + wharfkit: { + consoleRenderer: boolean + walletPlugin: boolean + } +} + +/** + * Check if LEAP is installed + */ +export async function checkLeapInstallation(): Promise { + const status: InstallationStatus = { + installed: false, + nodeos: false, + wharfkit: { + consoleRenderer: false, + walletPlugin: false, + }, + } + + // Check nodeos + try { + const {stdout} = await executeCommand('which nodeos') + status.nodeosPath = stdout.trim() + status.nodeos = true + } catch { + // nodeos not found + } + + status.wharfkit.consoleRenderer = checkConsoleRenderer() + status.wharfkit.walletPlugin = checkWalletPlugin() + + // 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.wharfkit.consoleRenderer && status.wharfkit.walletPlugin + + 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 -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` + ) + } 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 + } + + // 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 CI 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) { + console.log(' - nodeos is not installed') + } + if (!status.wharfkit.consoleRenderer) { + console.log(' - WharfKit console renderer is unavailable') + } + if (!status.wharfkit.walletPlugin) { + console.log(' - WharfKit private key wallet plugin is unavailable') + } + + await installLeap() +} + +function checkConsoleRenderer(): boolean { + try { + new NonInteractiveConsoleUI() + 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/interact.ts b/src/commands/chain/interact.ts new file mode 100644 index 0000000..bba0d91 --- /dev/null +++ b/src/commands/chain/interact.ts @@ -0,0 +1,351 @@ +/* eslint-disable no-console */ +import {APIClient, Name} from '@wharfkit/antelope' +import {Chains} from '@wharfkit/common' +import {Contract} from '@wharfkit/contract' +import type {Command} from 'commander' +import fetch from 'node-fetch' + +interface ChainInteractOptions { + filter?: string + json?: boolean + scope?: string + limit?: string + all?: boolean + columns?: boolean + fields?: 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 + } + + // 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. + // 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 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) { + // 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 { + // 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) { + 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 [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, 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. + + 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 + .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/src/commands/chain/local.ts b/src/commands/chain/local.ts new file mode 100644 index 0000000..d0e314f --- /dev/null +++ b/src/commands/chain/local.ts @@ -0,0 +1,593 @@ +/* eslint-disable no-console */ +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' +import * as path from 'path' +import type {ChainStatus} from './utils' +import { + cleanDataDir, + createApiClientForPort, + ensureDir, + getConfigIni, + getDefaultConfigDir, + getDefaultDataDir, + getDefaultWalletDir, + getDevKeys, + getGenesisJson, + isPortAvailable, + isProcessRunning, + readPid, + removePidFile, + savePid, + waitForChain, +} from './utils' +import {ensureLeapInstalled} from './install' +import {addKeyToWallet, DEFAULT_KEY_NAME, listWalletKeys} from '../wallet/utils' +import {NonInteractiveConsoleUI} from '../../utils/wharfkit-ui' + +export interface LocalStartOptions { + port: number + clean: boolean + key?: string +} + +/** + * 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.` + ) + } + + // 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 + + if (providedKey) { + // Use provided key + chainPrivateKey = PrivateKey.from(providedKey) + chainPublicKey = chainPrivateKey.toPublic().toString() + console.log('Using provided private key for chain') + } else { + // 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}`) + } + + // Create config files + const configFile = path.join(configDir, 'config.ini') + const genesisFile = path.join(configDir, 'genesis.json') + + // 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(chainPublicKey) + 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!') + + await ensureEosioPermissionsMatchChainKey(options.port, chainPrivateKey) + + // 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📝 Chain keys:') + console.log(` Public: ${chainPublicKey}`) + console.log(` Private: ${chainPrivateKey.toString()}`) + 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 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' + } + } + + 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}`) + } +} + +/** + * 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(customKey?: string): Promise { + console.log('Setting up development wallet...') + + const walletName = DEFAULT_KEY_NAME + const devKeys = getDevKeys() + const devPrivateKey = PrivateKey.from(devKeys.privateKey) + + try { + ensureDevelopmentKeyStored(devPrivateKey, walletName, devKeys.publicKey) + + if (customKey) { + ensureChainKeyStored(customKey) + } + + const walletPlugin = new WalletPluginPrivateKey(devPrivateKey) + const renderer = new NonInteractiveConsoleUI() + 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 store the development key with:') + 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/chain/utils.ts b/src/commands/chain/utils.ts new file mode 100644 index 0000000..50d851c --- /dev/null +++ b/src/commands/chain/utils.ts @@ -0,0 +1,282 @@ +/* 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' +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 { + // 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 + } +} + +/** + * 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(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: initialKey, + 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, 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 +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 = ${producerPublicKey}=KEY:${producerPrivateKey} +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() + const client = createApiClientForPort(port) + + while (Date.now() - startTime < timeoutMs) { + try { + const info = await client.v1.chain.get_info() + if (Number(info.head_block_num) >= 0) { + return true + } + } catch { + // Chain not ready yet + } + await new Promise((resolve) => setTimeout(resolve, 500)) + } + + 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/compile.ts b/src/commands/compile.ts new file mode 100644 index 0000000..ffbac62 --- /dev/null +++ b/src/commands/compile.ts @@ -0,0 +1,154 @@ +/* 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' + +/** + * 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.` + ) + } +} + +/** + * 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 +} diff --git a/src/commands/contract/deploy.ts b/src/commands/contract/deploy.ts new file mode 100644 index 0000000..cfb775a --- /dev/null +++ b/src/commands/contract/deploy.ts @@ -0,0 +1,461 @@ +/* eslint-disable no-console */ +import '../../types/wharfkit-session' +import {existsSync, readdirSync, readFileSync} from 'fs' +import {basename, extname, resolve} from 'path' +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' +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 + key?: string +} + +/** + * 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) { + console.log( + ` ⚠️ Warning: Could not check table '${table}' for data: ${ + e.message || String(e) + }` + ) + } + } + + 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') || + error.message.includes('Account Query Exception') + ) { + // New account or account doesn't exist yet, safe to proceed + 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, options) + + // Create API client + const client = new APIClient({ + 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 + 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` + ) + } +} + +/** + * 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) +} + +/** + * 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) { + 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') + } + + // 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) +} + +/** + * 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..67443e5 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,53 @@ 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( + '-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) => { + 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/dev.ts b/src/commands/dev.ts new file mode 100644 index 0000000..6fab91e --- /dev/null +++ b/src/commands/dev.ts @@ -0,0 +1,191 @@ +/* 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' + +interface DevOptions { + account?: string + port?: number + clean?: boolean + key?: string +} + +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, + key: options.key, + }) + 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() +}) + +/** + * 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') + .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}`) + process.exit(1) + } + }) + + return dev +} diff --git a/src/commands/wallet/account.ts b/src/commands/wallet/account.ts new file mode 100644 index 0000000..05c914d --- /dev/null +++ b/src/commands/wallet/account.ts @@ -0,0 +1,383 @@ +import '../../types/wharfkit-session' +import type {PublicKeyType} 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, + DEFAULT_KEY_NAME, + EOSIO_KEY_PREFERRED_NAMES, + getKeyFromWallet, + listWalletKeys, + type StoredKey, +} from './utils' +import {log, makeClient} from '../../utils' + +interface AccountCreateOptions { + key?: PublicKeyType | string + name?: NameType | string + chain?: ChainIndices | string + url?: string +} + +const supportedChains = ['Jungle4', 'KylinTestnet'] + +export async function createAccount(options: AccountCreateOptions): Promise { + let publicKey + let privateKey + + // Determine chain URL + let chainUrl: string + let chainDefinition: ChainDefinition | undefined + let isLocalChain = false + + 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) + // Try exact match first (handles PascalCase like "KylinTestnet") + if (supportedChains.includes(chainStr)) { + chainIndex = chainStr as ChainIndices + } else { + // 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 + } + } + } + + chainDefinition = Chains[chainIndex] + chainUrl = chainDefinition + ? chainDefinition.url + : `http://${chainIndex.toLowerCase()}.greymass.com` + } + + // 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 && !isLocalChain) { + 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 || + (isLocalChain ? generateRandomLocalAccountName() : 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()) + } + + if (isLocalChain) { + // Use Session Kit for local chain + await createAccountOnLocalChain(String(accountName), publicKey, privateKey, chainUrl) + } else { + // Use POST endpoint for remote chains + const data = { + accountName: accountName, + activeKey: publicKey, + ownerKey: publicKey, + network: chainDefinition?.id || 'eos', + } + + 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) { + 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') + } +} + +async function createAccountOnLocalChain( + accountName: string, + publicKey: string, + privateKey: PrivateKey | undefined, + chainUrl: string +): Promise { + // 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 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 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) + } + + // 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 eosio account key + const walletPlugin = new WalletPluginPrivateKey(eosioPrivateKey) + 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') + 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 (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') + } +} + +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` +} + +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 +} + +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) + + 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/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..a719078 --- /dev/null +++ b/src/commands/wallet/index.ts @@ -0,0 +1,100 @@ +import {Command} from 'commander' +import {createAccount} from './account' +import {createWalletKey} from './create' +import {addKey, createKey, listKeys} from './keys' +import {transactTransaction} from './transact' + +/** + * 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) + }) + + // 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 + 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, 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) + }) + + walletCommand.addCommand(accountCommand) + + // wallet transact - Sign a transaction + walletCommand + .command('transact') + .description( + 'Transact (sign and optionally broadcast) 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)') + .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) + }) + + return walletCommand +} diff --git a/src/commands/wallet/keys.ts b/src/commands/wallet/keys.ts new file mode 100644 index 0000000..5a8bbdc --- /dev/null +++ b/src/commands/wallet/keys.ts @@ -0,0 +1,200 @@ +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 +} + +interface KeysAddOptions { + 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) + } +} + +/** + * 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/src/commands/wallet/transact.ts b/src/commands/wallet/transact.ts new file mode 100644 index 0000000..996e385 --- /dev/null +++ b/src/commands/wallet/transact.ts @@ -0,0 +1,335 @@ +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' +import * as fs from 'fs' + +interface SignOptions { + key?: string + password?: boolean + output?: string +} + +interface TransactOptions extends SignOptions { + broadcast?: boolean + url?: 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, optionally using transaction authorization to find a match + */ +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) { + throw new Error(`Key "${keyName}" not found in wallet`) + } + return key.name + } + + // 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 + } + + // 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 + } + + // 5. 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, transaction) + log(`Using key: ${keyName}`, 'info') + + // Get password if needed + const password = await getPassword(!!options.password) + + // Load the private key + const privateKey = getKeyFromWallet(keyName, password) + + // 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 + : '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) + } +} + +/** + * 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, transaction) + 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/src/commands/wallet/utils.ts b/src/commands/wallet/utils.ts new file mode 100644 index 0000000..c42b0d8 --- /dev/null +++ b/src/commands/wallet/utils.ts @@ -0,0 +1,232 @@ +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' + +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' + +// 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 51c43e0..90ed33c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,9 +1,12 @@ 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 {createAccountFromCommand} from './commands/account/index' +import {createChainCommand} from './commands/chain/index' +import {createCompileCommand} from './commands/compile' +import {createDevCommand} from './commands/dev' +import {createWalletCommand} from './commands/wallet/index' const program = new Command() @@ -15,22 +18,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') @@ -45,4 +33,19 @@ program ) .action(generateContractFromCommand) +// 3. Command to manage local blockchain +program.addCommand(createChainCommand()) + +// 4. Command to compile contracts +program.addCommand(createCompileCommand()) + +// 5. Command to manage contracts (deploy, etc) +program.addCommand(createContractCommand()) + +// 6. Command for development mode +program.addCommand(createDevCommand()) + +// 7. Command to manage wallet (includes account creation) +program.addCommand(createWalletCommand()) + program.parse(process.argv) diff --git a/src/types/wharfkit-session.ts b/src/types/wharfkit-session.ts new file mode 100644 index 0000000..58fe540 --- /dev/null +++ b/src/types/wharfkit-session.ts @@ -0,0 +1,11 @@ +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..9f0f192 --- /dev/null +++ b/src/utils/wharfkit-ui.ts @@ -0,0 +1,97 @@ +import { + AbstractUserInterface, + cancelable, + type Cancelable, + type LoginContext, + type PromptArgs, + type PromptResponse, + type UserInterfaceAccountCreationResponse, + type UserInterfaceLoginResponse, + type UserInterfaceTranslateOptions, +} from '@wharfkit/session' + +/** + * Non-interactive console UI for CLI usage. + * Avoids stdin listeners that would prevent process exit. + */ +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 { + // eslint-disable-next-line no-console + console.error(`[wharfkit] ${error.message}`) + } + + async onAccountCreate(): Promise { + return {} + } + + async onAccountCreateComplete(): Promise { + // No-op + } + + async onLogin(): 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) { + // 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), () => { + // No cancellation work required + }) + } + + status(message: string): void { + // eslint-disable-next-line no-console + console.log(`[wharfkit] ${message}`) + } + + translate(key: string, options?: UserInterfaceTranslateOptions): string { + return String(options?.default ?? key) + } + + addTranslations(): void { + // No-op + } +} diff --git a/test.cpp b/test.cpp new file mode 100644 index 0000000..9e0d0b2 --- /dev/null +++ b/test.cpp @@ -0,0 +1,33 @@ +#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/chain-genesis-key.ts b/test/tests/chain-genesis-key.ts new file mode 100644 index 0000000..fc2feae --- /dev/null +++ b/test/tests/chain-genesis-key.ts @@ -0,0 +1,214 @@ +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} from '../../src/commands/chain/utils' +import { + addKeyToWallet, + DEFAULT_KEY_NAME, + getKeyFromWallet, + getWalletFilePath, + listWalletKeys, +} from '../../src/commands/wallet/utils' + +suite('Chain Genesis Key Storage', () => { + let testWalletDir: string + let originalHome: string + + setup(function () { + // Create temporary test directories + const testBaseDir = path.join(os.tmpdir(), `wharfkit-genesis-test-${Date.now()}`) + testWalletDir = path.join(testBaseDir, '.wharfkit', 'wallet') + + fs.mkdirSync(testWalletDir, {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 || '' + 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_KEY_NAME) + + // Verify key was stored + const keys = listWalletKeys() + assert.equal(keys.length, 1) + assert.equal(keys[0].name, DEFAULT_KEY_NAME) + // Verify the stored key matches genesis key by comparing public keys + 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_KEY_NAME) + 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_KEY_NAME) + + // Verify it exists + const firstKeys = listWalletKeys() + assert.equal(firstKeys.length, 1) + 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_KEY_NAME) + + if (existingDefaultKey) { + // Key already exists - should reuse it + // Compare by retrieving the key and checking public key + const retrievedKey = getKeyFromWallet(DEFAULT_KEY_NAME) + 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_KEY_NAME) + } + + // Verify still only one key + const finalKeys = listWalletKeys() + assert.equal(finalKeys.length, 1) + assert.equal(finalKeys[0].name, DEFAULT_KEY_NAME) + // Verify it matches genesis key by comparing public keys + const storedKey = getKeyFromWallet(DEFAULT_KEY_NAME) + 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_KEY_NAME) + + // Simulate account creation key lookup (priority: default -> hardcoded) + const walletKeys = listWalletKeys() + 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_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_KEY_NAME) + 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, 'aux-key') + addKeyToWallet(otherKey2, 'dev') + + // Now add genesis key as 'default' + addKeyToWallet(genesisPrivateKey, DEFAULT_KEY_NAME) + + // Simulate account creation key lookup priority + const walletKeys = listWalletKeys() + const defaultKey = walletKeys.find((k) => k.name === DEFAULT_KEY_NAME) + + // Default key should be found first + assert.isDefined(defaultKey) + // Verify it's the genesis key by comparing public keys + const defaultStoredKey = getKeyFromWallet(DEFAULT_KEY_NAME) + const expectedGenesisKey = PrivateKey.from(devKeys.privateKey) + assert.equal( + defaultStoredKey.toPublic().toString(), + expectedGenesisKey.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_KEY_NAME) + + // Try to add it again (should fail because key already exists) + try { + addKeyToWallet(genesisPrivateKey, DEFAULT_KEY_NAME) + 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_KEY_NAME) + // Verify it's the genesis key by comparing public keys + 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/chain-install.ts b/test/tests/chain-install.ts new file mode 100644 index 0000000..6ade37c --- /dev/null +++ b/test/tests/chain-install.ts @@ -0,0 +1,59 @@ +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, 'wharfkit') + assert.isBoolean(status.installed) + assert.isBoolean(status.nodeos) + 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 () { + this.timeout(5000) + + const status = await checkLeapInstallation() + + if (status.nodeos) { + assert.property(status, 'nodeosPath') + assert.isString(status.nodeosPath) + } + }) + + 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.wharfkit.consoleRenderer) + assert.isTrue(status.wharfkit.walletPlugin) + } + }) + }) +}) diff --git a/test/tests/chain-interact.ts b/test/tests/chain-interact.ts new file mode 100644 index 0000000..64c8196 --- /dev/null +++ b/test/tests/chain-interact.ts @@ -0,0 +1,246 @@ +import {assert} from 'chai' +import {execSync} from 'child_process' +import * as fs from 'fs' +import * as path from 'path' +import * as os from 'os' +import {isNodeosAvailable, killProcessAtPort, waitForChainReady} from '../utils/test-helpers' + +suite('Chain Interaction', () => { + const cliPath = path.join(__dirname, '../../lib/cli.js') + let testDir: string + let originalHome: string + let contractAccount: string + + suiteSetup(async 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 + 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 { + // 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 + } + + // 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() + 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'}) + + // Wait for chain to be ready + await waitForChainReady('http://127.0.0.1:8888', 30000) + + // 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} 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 = '' + } + }) + + 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) { + // Ignore error + } + }) + + test('can lookup table data on deployed contract', function () { + if (!contractAccount) this.skip() + + execSync(`node ${cliPath} chain local table ${contractAccount}::items`, { + encoding: 'utf8', + }) + }) + + test('can lookup table data with scope option', function () { + if (!contractAccount) this.skip() + + 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) + } + } + }) +}) 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()) + }) + }) +}) diff --git a/test/tests/e2e-workflow.ts b/test/tests/e2e-workflow.ts new file mode 100644 index 0000000..e636e79 --- /dev/null +++ b/test/tests/e2e-workflow.ts @@ -0,0 +1,606 @@ +import {assert} from 'chai' +import {execSync} from 'child_process' +import * as fs from 'fs' +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' +import {isNodeosAvailable, killProcessAtPort, waitForChainReady} from '../utils/test-helpers' + +/** + * E2E tests for the complete workflow: + * 1. Create wallet keys + * 2. Create accounts + * 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 +} + +function getRandomLocalAccountName(prefix: string): string { + const chars = 'abcdefghijklmnopqrstuvwxyz12345' + let result = prefix + // 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 +} + +suite('E2E Workflow', () => { + const cliPath = path.join(__dirname, '../../lib/cli.js') + let testDir: string + let testWalletDir: string + let originalHome: string + + 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 + 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}) + + // 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 + + // Stop any existing chain before starting + // Try chain local stop first (works if chain was started with same HOME) + try { + execSync(`node ${cliPath} chain local stop`, {encoding: 'utf8', stdio: 'ignore'}) + execSync('sleep 1', {encoding: 'utf8', stdio: 'ignore'}) + } catch { + // Ignore errors - chain might not be running or was started with different HOME + } + + // Also check port 8888 directly in case chain was started by another test with different HOME + killProcessAtPort(8888) + + // 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) + }) + + suiteTeardown(function () { + this.timeout(30000) + // Restore original HOME + process.env.HOME = originalHome + + // Clean up test directory + if (fs.existsSync(testDir)) { + fs.rmSync(testDir, {recursive: true, force: true}) + } + + execSync(`node ${cliPath} chain local stop`, {encoding: 'utf8'}) + }) + + 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 Transacting', () => { + test('can transact (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: getTransactionExpiration(), + 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)) + + // 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: getTransactionExpiration(), + 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, 'Signed transaction 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' + ) + }) + + test('broadcasts transaction when --broadcast is provided', async function () { + // Get valid reference block info from the chain + const client = new APIClient({ + provider: new FetchProvider('http://127.0.0.1:8888', {fetch}), + }) + 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.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 + // 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: getTransactionExpiration(), + ref_block_num: blockNum & 0xffff, // Last 16 bits + ref_block_prefix: blockInfo.ref_block_prefix.toNumber(), + max_net_usage_words: 0, + max_cpu_usage_ms: 0, + delay_sec: 0, + context_free_actions: [], + actions: [ + { + account: 'eosio', + name: 'buyram', + authorization: [{actor: 'eosio', permission: 'active'}], + data: '0000000000ea30550000000000ea30550100000000000000045359530000000000', + }, + ], + transaction_extensions: [], + } + fs.writeFileSync(txPath, JSON.stringify(transaction)) + + // Test that the broadcast functionality works properly + // 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!') + assert.include(output, 'Transaction ID:') + }) + }) + + 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 () { + 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`, { + 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, 'transact') + }) + + 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('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') + assert.include(output, '--url') + assert.include(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} contract deploy --help`, { + encoding: 'utf8', + }) + + // Verify --account option exists (used for key selection) + assert.include(deployHelp, '--account') + }) + }) + + suite('Integration: Account and Deployment', () => { + test('can create an account on the local chain', function () { + 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 Name: ${accountName}`) + }) + + 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 = 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 + const rootCppPath = path.join(__dirname, '../../test.cpp') + const cppPath = path.join(testDir, 'test.cpp') + const wasmPath = path.join(testDir, 'test.wasm') + + fs.copyFileSync(rootCppPath, cppPath) + + // 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} 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 = 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 = ` + #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) { + log('Transact failed:', 'info') + log(e.stdout, 'info') + log(e.stderr, 'info') + 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', () => { + 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-account.ts b/test/tests/wallet-account.ts new file mode 100644 index 0000000..f315fa5 --- /dev/null +++ b/test/tests/wallet-account.ts @@ -0,0 +1,271 @@ +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 = 'testacc.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'}) + + assert.isTrue(fetchStub.calledOnce) + 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) + }) +}) diff --git a/test/tests/wallet.ts b/test/tests/wallet.ts new file mode 100644 index 0000000..e9633d5 --- /dev/null +++ b/test/tests/wallet.ts @@ -0,0 +1,264 @@ +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' + +/** + * 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 + + 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: getTransactionExpiration(), + 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/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"] } diff --git a/test/utils/test-helpers.ts b/test/utils/test-helpers.ts new file mode 100644 index 0000000..e50f688 --- /dev/null +++ b/test/utils/test-helpers.ts @@ -0,0 +1,75 @@ +import {execSync} from 'child_process' +import {APIClient, FetchProvider} from '@wharfkit/antelope' +import fetch from 'node-fetch' + +/** + * Check if nodeos is available in PATH + */ +export function isNodeosAvailable(): boolean { + try { + execSync('which nodeos', {encoding: 'utf8', stdio: 'ignore'}) + return true + } catch { + return false + } +} + +/** + * 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 + */ +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 + } +} 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"