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..c041bbd 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 @@ -69,3 +69,8 @@ clean: .PHONY: distclean distclean: clean rm -rf node_modules/ + +.PHONY: publish-next +publish-next: lib + yarn version --minor --no-git-tag-version + yarn publish --tag next 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..a6bb2e0 100644 --- a/package.json +++ b/package.json @@ -1,11 +1,14 @@ { "name": "@wharfkit/cli", - "version": "2.11.0", + "version": "2.12.0", "license": "BSD-3-Clause", "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,10 +31,13 @@ "@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", "prettier": "^2.2.1", + "qrcode-terminal": "^0.12.0", "typescript": "^4.9.5" }, "resolutions": { @@ -50,6 +56,7 @@ "@types/chai": "^4.3.1", "@types/mocha": "^9.0.0", "@types/node": "^18.7.18", + "@types/qrcode-terminal": "^0.12.2", "@typescript-eslint/eslint-plugin": "^6.6.0", "@typescript-eslint/parser": "^6.6.0", "@wharfkit/mock-data": "^1.0.0", 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.ts b/src/commands/account.ts new file mode 100644 index 0000000..35f404f --- /dev/null +++ b/src/commands/account.ts @@ -0,0 +1,30 @@ +/* eslint-disable no-console */ +import {Command} from 'commander' +import {lookupAccount} from './chain/interact' +import {getDefaultChain} from './chain/utils' + +/** + * Create the account command + */ +export function createAccountCommand(): Command { + const accountCommand = new Command('account') + accountCommand.description('Account management commands') + + accountCommand + .command('info') + .description('Display information about an account') + .argument('', 'Account name to lookup') + .option('-c, --chain ', 'Chain to query (default: local or configured default)') + .option('--json', 'Output as JSON') + .action(async (accountName, options) => { + try { + const chainName = options.chain || (await getDefaultChain()) + await lookupAccount(chainName, accountName, options) + } catch (error: any) { + console.error(`Error: ${error.message}`) + process.exit(1) + } + }) + + return accountCommand +} 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/action/index.ts b/src/commands/action/index.ts new file mode 100644 index 0000000..ef6ecb5 --- /dev/null +++ b/src/commands/action/index.ts @@ -0,0 +1,31 @@ +import {Command} from 'commander' +import {createActionRequest} from './request' + +/** + * Create the sign command with subcommands + */ +export function createSignCommand(): Command { + const signCommand = new Command('sign') + signCommand.description('Create signing requests (ESR) for blockchain actions') + + // sign request - Create a signing request (ESR) and display QR code + signCommand + .command('request') + .description('Create a signing request (ESR) and display QR code for any action') + .argument('', 'Contract and action in format "contract::action"') + .argument('', 'Action data as JSON file, JSON string, or key=value pairs') + .option( + '-c, --chain ', + 'Chain name or API URL (e.g., local, Jungle4, EOS, https://...)', + 'local' + ) + .option( + '-a, --auth ', + 'Authorization in format "account@permission" (default: wallet placeholder)' + ) + .action(async (contractAction, data, options) => { + await createActionRequest(contractAction, data, options) + }) + + return signCommand +} diff --git a/src/commands/action/request.ts b/src/commands/action/request.ts new file mode 100644 index 0000000..a7617e9 --- /dev/null +++ b/src/commands/action/request.ts @@ -0,0 +1,285 @@ +/* eslint-disable no-console */ +import * as fs from 'fs' +import {ABI, APIClient, FetchProvider} from '@wharfkit/antelope' +import {Chains} from '@wharfkit/common' +import {PlaceholderName, PlaceholderPermission, SigningRequest} from '@wharfkit/signing-request' +import fetch from 'node-fetch' +import {displayQRCode} from '../../utils' + +export interface ActionRequestOptions { + chain?: string + auth?: string +} + +/** + * Parse authorization string (e.g., "account@permission" or "account") + * Returns undefined for actor/permission to use placeholders + */ +export function parseAuthorization(authString?: string): { + actor: string | null + permission: string +} { + if (!authString) { + return {actor: null, permission: 'active'} + } + + if (authString.includes('@')) { + const [actor, permission] = authString.split('@') + return {actor, permission: permission || 'active'} + } + + return {actor: authString, permission: 'active'} +} + +/** + * Parse action data from string (JSON file, JSON string, or key=value pairs) + */ +export function parseActionData(dataString: string): Record { + // Try to read as file first + try { + if (fs.existsSync(dataString)) { + const fileContent = fs.readFileSync(dataString, 'utf8') + return JSON.parse(fileContent) + } + } catch { + // Not a valid file or couldn't parse, continue with other methods + } + + // Try JSON string + try { + return JSON.parse(dataString) + } catch { + // Try key=value format + const data: Record = {} + const pairs = dataString.split(',').map((p) => p.trim()) + + for (const pair of pairs) { + const [key, ...valueParts] = pair.split('=') + if (key && valueParts.length > 0) { + const value = valueParts.join('=').trim() + // Try to parse as number or boolean + if (value === 'true') { + data[key.trim()] = true + } else if (value === 'false') { + data[key.trim()] = false + } else if (!isNaN(Number(value)) && value !== '') { + data[key.trim()] = Number(value) + } else { + data[key.trim()] = value + } + } + } + + if (Object.keys(data).length === 0) { + throw new Error( + `Invalid action data format. Use a JSON file path, JSON string (e.g., '{"key": "value"}'), or key=value pairs (e.g., 'key1=value1,key2=value2')` + ) + } + + return data + } +} + +/** + * Parse contract::action format and validate + */ +export function parseContractAction(contractAction: string): { + contractAccount: string + actionName: string +} { + if (!contractAction.includes('::')) { + throw new Error( + `Invalid format. Use contract::action format (e.g., "eosio.token::transfer")` + ) + } + + const [contractAccount, actionName] = contractAction.split('::') + + if (!contractAccount || !actionName) { + throw new Error( + `Invalid format. Use contract::action format (e.g., "eosio.token::transfer")` + ) + } + + return {contractAccount, actionName} +} + +/** + * Get the API URL for a chain name or URL + */ +export function getApiUrl(chainOrUrl: string): string { + // Check if it's already a URL + if (chainOrUrl.startsWith('http://') || chainOrUrl.startsWith('https://')) { + return chainOrUrl + } + + // Check if it matches a known chain key from @wharfkit/common + const knownChainKey = Object.keys(Chains).find( + (key) => key.toLowerCase() === chainOrUrl.toLowerCase() + ) + + if (knownChainKey) { + return (Chains as Record)[knownChainKey].url + } + + // Default to local + if (chainOrUrl === 'local') { + return 'http://127.0.0.1:8888' + } + + throw new Error( + `Unknown chain: ${chainOrUrl}. Use a full URL (http://...) or a known chain name (EOS, Jungle4, WAX, etc.)` + ) +} + +/** + * Create an ESR (EOSIO Signing Request) for any action + */ +export async function createActionESR( + client: APIClient, + contractAccount: string, + actionName: string, + actionData: Record, + auth: {actor: string | null; permission: string} +): Promise<{uri: string; encodedUri: string}> { + const info = await client.v1.chain.get_info() + const chainId = String(info.chain_id) + + // Fetch the contract ABI for serialization + const abiResponse = await client.v1.chain.get_abi(contractAccount) + if (!abiResponse.abi) { + throw new Error(`Could not fetch ABI for contract: ${contractAccount}`) + } + const contractAbi = ABI.from(abiResponse.abi) + + // Verify the action exists in the ABI + const actionDef = contractAbi.actions.find((a) => String(a.name) === actionName) + if (!actionDef) { + const availableActions = contractAbi.actions.map((a) => String(a.name)).join(', ') + throw new Error( + `Action "${actionName}" not found in contract "${contractAccount}". Available actions: ${availableActions}` + ) + } + + // Use placeholders if no specific actor is provided + const actor = auth.actor || PlaceholderName + const permission = auth.actor ? auth.permission : PlaceholderPermission + + // Replace placeholder tokens in data with actual placeholders + const processedData = processDataPlaceholders(actionData, auth.actor) + + // Create the signing request + const request = await SigningRequest.create( + { + action: { + account: contractAccount, + name: actionName, + authorization: [ + { + actor, + permission, + }, + ], + data: processedData, + }, + chainId, + }, + { + abiProvider: { + getAbi: async () => contractAbi, + }, + } + ) + + const encodedUri = request.encode() + // Normalize to esr:// format + let uri = encodedUri + if (!uri.startsWith('esr://')) { + if (uri.startsWith('esr:')) { + uri = `esr://${uri.slice(4)}` + } + } + + return {uri, encodedUri} +} + +/** + * Process data to replace special placeholder tokens + * Replaces $signer with the PlaceholderName for ESR + */ +function processDataPlaceholders( + data: Record, + specificActor: string | null +): Record { + const processed: Record = {} + + for (const [key, value] of Object.entries(data)) { + if (typeof value === 'string' && value === '$signer') { + processed[key] = specificActor || PlaceholderName + } else if (typeof value === 'object' && value !== null && !Array.isArray(value)) { + processed[key] = processDataPlaceholders( + value as Record, + specificActor + ) + } else { + processed[key] = value + } + } + + return processed +} + +/** + * Create a signing request and display QR code + */ +export async function createActionRequest( + contractAction: string, + dataString: string, + options: ActionRequestOptions +): Promise { + // Parse contract::action format + const {contractAccount, actionName} = parseContractAction(contractAction) + + // Parse action data + const actionData = parseActionData(dataString) + + // Parse authorization + const auth = parseAuthorization(options.auth) + + // Get the API URL + const url = getApiUrl(options.chain || 'local') + + // Format data for display (show placeholder info) + const displayData = JSON.stringify(actionData, null, 2).replace( + /"\$signer"/g, + '""' + ) + + console.log('Creating signing request...') + console.log(` Contract: ${contractAccount}`) + console.log(` Action: ${actionName}`) + console.log(` Chain: ${options.chain || 'local'} (${url})`) + console.log(` Data: ${displayData}`) + + if (auth.actor) { + console.log(` Authorization: ${auth.actor}@${auth.permission}`) + } else { + console.log(` Authorization: @`) + } + + try { + const client = new APIClient({ + provider: new FetchProvider(url, {fetch}), + }) + + const {uri} = await createActionESR(client, contractAccount, actionName, actionData, auth) + + displayQRCode(uri, `📝 ${contractAccount}::${actionName}`) + + console.log(`\n✅ Signing request created!`) + console.log(` Scan the QR code with a compatible wallet to sign and broadcast.`) + } catch (error) { + console.error(`\n❌ Failed to create signing request: ${(error as Error).message}`) + process.exit(1) + } +} diff --git a/src/commands/chain/index.ts b/src/commands/chain/index.ts new file mode 100644 index 0000000..a2f2d3c --- /dev/null +++ b/src/commands/chain/index.ts @@ -0,0 +1,154 @@ +/* eslint-disable no-console */ +import {Command} from 'commander' +import {showChainLogs, showChainStatus, startLocalChain, stopLocalChain} from './local' +import {checkLeapInstallation} from './install' +import {addInteractCommands, addInteractSubcommands} from './interact' +import {setDefaultChain} from './utils' + +/** + * 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) + } + }) + + // Set default chain + chain + .command('set ') + .description('Set the default chain for account lookups') + .action(async (chainName) => { + try { + await setDefaultChain(chainName) + console.log(`Default chain set to: ${chainName}`) + } 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..05e3146 --- /dev/null +++ b/src/commands/chain/install.ts @@ -0,0 +1,457 @@ +/* eslint-disable no-console */ +import {WalletPluginPrivateKey} from '@wharfkit/wallet-plugin-privatekey' +import {executeCommand, getDevKeys, getPlatform} from './utils' +import {NonInteractiveConsoleUI} from '../../utils/wharfkit-ui' +import * as os from 'os' +import * as path from 'path' +import * as fs from 'fs' + +const LEAP_VERSION = 'v5.0.3' +const LEAP_REPO = 'https://github.com/AntelopeIO/leap' + +export interface InstallationStatus { + installed: boolean + nodeos: boolean + nodeosPath?: string + version?: string + wharfkit: { + consoleRenderer: boolean + walletPlugin: boolean + } +} + +/** + * Get the directory where LEAP will be cloned and built + */ +function getLeapBuildDir(): string { + return path.join(os.homedir(), '.wharfkit', 'leap-build') +} + +/** + * 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 +} + +/** + * Ensure directory exists + */ +async function ensureBuildDir(dir: string): Promise { + if (!fs.existsSync(dir)) { + await fs.promises.mkdir(dir, {recursive: true}) + } +} + +/** + * Clone and checkout LEAP repository + */ +async function cloneLeapRepo(buildDir: string): Promise { + const leapDir = path.join(buildDir, 'leap') + + // Check if already cloned + if (fs.existsSync(path.join(leapDir, '.git'))) { + console.log('LEAP repository already cloned, updating...') + try { + await executeCommand(`cd ${leapDir} && git fetch --all --tags`) + await executeCommand(`cd ${leapDir} && git checkout ${LEAP_VERSION}`) + await executeCommand(`cd ${leapDir} && git pull || true`) + await executeCommand(`cd ${leapDir} && git submodule update --init --recursive`) + } catch (error: unknown) { + const message = error instanceof Error ? error.message : String(error) + throw new Error(`Failed to update LEAP repository: ${message}`) + } + } else { + console.log('Cloning LEAP repository...') + try { + await executeCommand(`git clone --recursive ${LEAP_REPO} ${leapDir}`) + await executeCommand(`cd ${leapDir} && git fetch --all --tags`) + await executeCommand(`cd ${leapDir} && git checkout ${LEAP_VERSION}`) + await executeCommand(`cd ${leapDir} && git submodule update --init --recursive`) + } catch (error: unknown) { + const message = error instanceof Error ? error.message : String(error) + throw new Error(`Failed to clone LEAP repository: ${message}`) + } + } +} + +/** + * Build LEAP from source + */ +async function buildLeap(buildDir: string, numJobs?: number): Promise { + const leapDir = path.join(buildDir, 'leap') + const leapBuildDir = path.join(leapDir, 'build') + + // Create build directory + await ensureBuildDir(leapBuildDir) + + const jobs = numJobs || Math.max(1, Math.floor(os.cpus().length / 2)) + console.log(`Building LEAP with ${jobs} parallel jobs (this may take a while)...`) + + try { + const {os: platform} = getPlatform() + + if (platform === 'darwin') { + // macOS build - use llvm from Homebrew + const llvmPrefix = await getLlvmPrefix() + await executeCommand( + `cd ${leapBuildDir} && cmake -DCMAKE_BUILD_TYPE=Release -DCMAKE_PREFIX_PATH=${llvmPrefix} ..` + ) + } else { + // Linux build + await executeCommand( + `cd ${leapBuildDir} && cmake -DCMAKE_BUILD_TYPE=Release -DCMAKE_PREFIX_PATH=/usr/lib/llvm-11 ..` + ) + } + + await executeCommand(`cd ${leapBuildDir} && make -j ${jobs}`) + } catch (error: unknown) { + const message = error instanceof Error ? error.message : String(error) + throw new Error(`Failed to build LEAP: ${message}`) + } +} + +/** + * Get LLVM prefix path on macOS + */ +async function getLlvmPrefix(): Promise { + try { + const {stdout} = await executeCommand('brew --prefix llvm@11') + return stdout.trim() + } catch { + // Try llvm without version + try { + const {stdout} = await executeCommand('brew --prefix llvm') + return stdout.trim() + } catch { + return '/usr/local/opt/llvm' + } + } +} + +/** + * Install built LEAP binaries + */ +async function installBuiltLeap(buildDir: string): Promise { + const leapDir = path.join(buildDir, 'leap') + const leapBuildDir = path.join(leapDir, 'build') + const binDir = path.join(leapBuildDir, 'bin') + + console.log('Installing LEAP binaries...') + + const binaries = ['nodeos', 'cleos', 'keosd', 'leap-util'] + const targetDir = '/usr/local/bin' + + try { + const {os: platform} = getPlatform() + + // First, try to copy binaries directly (works if user owns /usr/local/bin) + try { + for (const binary of binaries) { + const src = path.join(binDir, binary) + const dest = path.join(targetDir, binary) + if (fs.existsSync(src)) { + await fs.promises.copyFile(src, dest) + await fs.promises.chmod(dest, 0o755) + } + } + console.log('Binaries copied to /usr/local/bin') + return + } catch { + // Direct copy failed, try sudo methods + console.log('Direct copy failed, trying with elevated permissions...') + } + + if (platform === 'darwin') { + // On macOS, use make install with sudo + await executeCommand(`cd ${leapBuildDir} && sudo make install`) + } else { + // On Linux, install the .deb package if available, otherwise make install + try { + const {stdout} = await executeCommand( + `ls ${leapBuildDir}/leap*.deb 2>/dev/null | head -1` + ) + if (stdout.trim()) { + await executeCommand(`sudo apt-get install -y ${stdout.trim()}`) + } else { + await executeCommand(`cd ${leapBuildDir} && sudo make install`) + } + } catch { + await executeCommand(`cd ${leapBuildDir} && sudo make install`) + } + } + } catch (error: unknown) { + const message = error instanceof Error ? error.message : String(error) + throw new Error(`Failed to install LEAP: ${message}`) + } +} + +/** + * Install LEAP on macOS by building from source + */ +async function installLeapMacOS(): Promise { + console.log('Installing LEAP on macOS by building from source...') + + // 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.' + ) + } + + // Install build dependencies + console.log('Installing build dependencies...') + try { + await executeCommand('brew install cmake git llvm@11 gmp curl python3 numpy || true') + } catch (error: unknown) { + const message = error instanceof Error ? error.message : String(error) + throw new Error(`Failed to install dependencies: ${message}`) + } + + const buildDir = getLeapBuildDir() + await ensureBuildDir(buildDir) + + // Clone repository + await cloneLeapRepo(buildDir) + + // Build from source + await buildLeap(buildDir) + + // Install + await installBuiltLeap(buildDir) + + console.log('LEAP installed successfully!') +} + +/** + * Get Ubuntu version for determining LLVM version + */ +async function getUbuntuVersion(): Promise { + try { + const {stdout} = await executeCommand('lsb_release -rs') + return stdout.trim() + } catch { + return '22.04' + } +} + +/** + * Install LEAP on Linux by building from source + */ +async function installLeapLinux(): Promise { + console.log('Installing LEAP on Linux by building from source...') + + const ubuntuVersion = await getUbuntuVersion() + const majorVersion = parseInt(ubuntuVersion.split('.')[0], 10) + + // Update package list + console.log('Updating package list...') + try { + await executeCommand('sudo apt-get update') + } catch (error: unknown) { + const message = error instanceof Error ? error.message : String(error) + throw new Error(`Failed to update package list: ${message}`) + } + + // Install build dependencies + console.log('Installing build dependencies...') + try { + await executeCommand(`sudo apt-get install -y \ + build-essential \ + cmake \ + git \ + libcurl4-openssl-dev \ + libgmp-dev \ + llvm-11-dev \ + python3-numpy \ + file \ + zlib1g-dev`) + + // On Ubuntu 20.04, install gcc-10 for C++20 support + if (majorVersion === 20) { + await executeCommand('sudo apt-get install -y g++-10') + } + } catch (error: unknown) { + const message = error instanceof Error ? error.message : String(error) + throw new Error(`Failed to install dependencies: ${message}`) + } + + const buildDir = getLeapBuildDir() + await ensureBuildDir(buildDir) + + // Clone repository + await cloneLeapRepo(buildDir) + + // Build from source (with Ubuntu 20.04 specific compiler flags) + await buildLeapLinux(buildDir, majorVersion) + + // Install + await installBuiltLeap(buildDir) + + console.log('LEAP installed successfully!') +} + +/** + * Build LEAP on Linux with version-specific settings + */ +async function buildLeapLinux(buildDir: string, ubuntuMajorVersion: number): Promise { + const leapDir = path.join(buildDir, 'leap') + const leapBuildDir = path.join(leapDir, 'build') + + // Create build directory + await ensureBuildDir(leapBuildDir) + + const jobs = Math.max(1, Math.floor(os.cpus().length / 2)) + console.log(`Building LEAP with ${jobs} parallel jobs (this may take a while)...`) + + try { + if (ubuntuMajorVersion === 20) { + // Ubuntu 20.04 needs gcc-10 specified + await executeCommand( + `cd ${leapBuildDir} && cmake \ + -DCMAKE_C_COMPILER=gcc-10 \ + -DCMAKE_CXX_COMPILER=g++-10 \ + -DCMAKE_BUILD_TYPE=Release \ + -DCMAKE_PREFIX_PATH=/usr/lib/llvm-11 ..` + ) + } else { + // Ubuntu 22.04+ has gcc-11 by default + await executeCommand( + `cd ${leapBuildDir} && cmake \ + -DCMAKE_BUILD_TYPE=Release \ + -DCMAKE_PREFIX_PATH=/usr/lib/llvm-11 ..` + ) + } + + await executeCommand(`cd ${leapBuildDir} && make -j ${jobs}`) + } catch (error: unknown) { + const message = error instanceof Error ? error.message : String(error) + throw new Error(`Failed to build LEAP: ${message}`) + } +} + +/** + * 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..eea28ec --- /dev/null +++ b/src/commands/chain/utils.ts @@ -0,0 +1,313 @@ +/* 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}) +} + +/** + * Get the config file path for storing default chain preference + */ +export function getConfigFilePath(): string { + return path.join(getDefaultConfigDir(), 'default-chain.json') +} + +/** + * Get the default chain name (defaults to 'local') + */ +export async function getDefaultChain(): Promise { + const configFile = getConfigFilePath() + try { + const content = await fs.promises.readFile(configFile, 'utf-8') + const config = JSON.parse(content) + return config.chain || 'local' + } catch { + return 'local' + } +} + +/** + * Set the default chain name + */ +export async function setDefaultChain(chainName: string): Promise { + const configFile = getConfigFilePath() + await ensureDir(path.dirname(configFile)) + const config = {chain: chainName} + await fs.promises.writeFile(configFile, JSON.stringify(config, null, 2)) +} diff --git a/src/commands/compile.ts b/src/commands/compile.ts new file mode 100644 index 0000000..2c0fe58 --- /dev/null +++ b/src/commands/compile.ts @@ -0,0 +1,399 @@ +/* eslint-disable no-console */ +import {Command} from 'commander' +import {execSync} from 'child_process' +import {existsSync, mkdirSync, readdirSync, readFileSync, statSync} from 'fs' +import {basename, dirname, extname, isAbsolute, join, normalize, relative, 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 + } + + // Resolve output directory to absolute path, handling both relative and absolute paths + const absoluteOutputDir = isAbsolute(outputDir) + ? normalize(outputDir) + : normalize(resolve(outputDir)) + ensureOutputDirectory(absoluteOutputDir) + + // Ensure cdt-cpp is installed + await ensureCdtCppInstalled() + + console.log(`Compiling ${files.length} file(s)...\n`) + + for (const filePath of files) { + await compileSingleFile(filePath, currentDir, absoluteOutputDir) + } + + console.log('\nCompilation complete!') +} + +/** + * Recursively find all .cpp files in a directory + */ +function findCppFilesRecursive(dir: string, files: string[] = []): string[] { + try { + const entries = readdirSync(dir, {withFileTypes: true}) + + for (const entry of entries) { + const fullPath = join(dir, entry.name) + + // Skip hidden directories and common build/output directories + if (entry.isDirectory()) { + if ( + entry.name.startsWith('.') || + entry.name === 'node_modules' || + entry.name === 'build' || + entry.name === 'dist' || + entry.name === 'lib' + ) { + continue + } + findCppFilesRecursive(fullPath, files) + } else if (entry.isFile() && extname(entry.name) === '.cpp') { + files.push(fullPath) + } + } + } catch { + // Ignore permission errors and other filesystem errors + } + + return files +} + +/** + * Get list of files to compile + */ +export 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] + } + + // Recursively find all .cpp files in current directory and subdirectories + const files = findCppFilesRecursive(currentDir) + + return files +} + +/** + * Ensure output directory exists, create it if it doesn't + */ +function ensureOutputDirectory(dir: string): void { + if (!existsSync(dir)) { + mkdirSync(dir, {recursive: true}) + } +} + +/** + * 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 + } +} + +/** + * Find the contract root directory by walking up from the source file + * Looks for common contract directory structures + */ +export function findContractRoot(sourceFile: string): string { + let currentDir = dirname(resolve(sourceFile)) + const root = resolve('/') + + // Walk up the directory tree looking for contract structure indicators + while (currentDir !== root) { + // Check for common contract directory patterns + const hasInclude = existsSync(join(currentDir, 'include')) + const hasSrc = existsSync(join(currentDir, 'src')) + const hasInc = existsSync(join(currentDir, 'inc')) + const hasHeaders = existsSync(join(currentDir, 'headers')) + + // If we find include directories or src directory, this might be the contract root + if (hasInclude || hasSrc || hasInc || hasHeaders) { + return currentDir + } + + // Also check if current directory contains .cpp files (might be contract root) + try { + const files = readdirSync(currentDir) + const hasCppFiles = files.some((f) => extname(f) === '.cpp') + if (hasCppFiles && (hasInclude || hasInc || hasHeaders)) { + return currentDir + } + } catch { + // Ignore permission errors + } + + currentDir = dirname(currentDir) + } + + // If no contract root found, return the source file's directory + return dirname(resolve(sourceFile)) +} + +/** + * Auto-detect include directories + */ +export function detectIncludeDirectories(sourceFile: string, contractRoot: string): string[] { + const sourceDir = dirname(resolve(sourceFile)) + const includeDirs: string[] = [] + + // Common include directory patterns + const patterns = [ + join(contractRoot, 'include'), + join(contractRoot, 'inc'), + join(contractRoot, 'headers'), + join(sourceDir, 'include'), + join(sourceDir, 'inc'), + join(sourceDir, 'headers'), + ] + + for (const dir of patterns) { + if (existsSync(dir) && statSync(dir).isDirectory()) { + const normalized = resolve(dir) + if (!includeDirs.includes(normalized)) { + includeDirs.push(normalized) + } + } + } + + return includeDirs +} + +/** + * Auto-detect resource paths (for quoted includes like #include "actions/commit.cpp") + */ +export function detectResourcePaths(sourceFile: string, contractRoot: string): string[] { + const sourceDir = dirname(resolve(sourceFile)) + const resourcePaths: string[] = [] + + // Common resource directory patterns + const patterns = [ + join(contractRoot, 'src'), + join(sourceDir, 'src'), + contractRoot, // Root itself might contain resources + sourceDir, // Source directory might contain resources + ] + + for (const dir of patterns) { + if (existsSync(dir) && statSync(dir).isDirectory()) { + const normalized = resolve(dir) + if (!resourcePaths.includes(normalized)) { + resourcePaths.push(normalized) + } + } + } + + return resourcePaths +} + +/** + * Parse #include statements from source file + */ +export function parseIncludes(sourceFile: string): {angleBracket: string[]; quoted: string[]} { + const content = readFileSync(sourceFile, 'utf8') + const angleBracket: string[] = [] + const quoted: string[] = [] + + // Match #include <...> and #include "..." + const includeRegex = /#include\s+[<"]([^>"]+)[>"]/g + let match + + while ((match = includeRegex.exec(content)) !== null) { + const includePath = match[1] + if (match[0].includes('<')) { + angleBracket.push(includePath) + } else { + quoted.push(includePath) + } + } + + return {angleBracket, quoted} +} + +/** + * Auto-detect compilation flags based on source file and directory structure + */ +export function autoDetectCompileFlags(sourceFile: string): string[] { + const contractRoot = findContractRoot(sourceFile) + const includeDirs = detectIncludeDirectories(sourceFile, contractRoot) + const resourcePaths = detectResourcePaths(sourceFile, contractRoot) + const includes = parseIncludes(sourceFile) + + const flags: string[] = [] + + // Add include directories (-I flag) if they exist + // These are needed for angle-bracket includes like + if (includeDirs.length > 0) { + for (const dir of includeDirs) { + flags.push(`-I${dir}`) + } + } + + // Add resource paths (-R flag) for quoted includes like "actions/commit.cpp" + // Only add if there are actually quoted includes in the source + if (resourcePaths.length > 0 && includes.quoted.length > 0) { + for (const path of resourcePaths) { + flags.push(`-R${path}`) + } + } + + return flags +} + +/** + * Compile a single C++ file to WASM using cdt-cpp + */ +async function compileSingleFile( + filePath: string, + currentDir: string, + outputDir: string +): Promise { + const fileName = basename(filePath, '.cpp') + const contractRoot = findContractRoot(filePath) + const contractRootSrc = join(contractRoot, 'src') + const sourceFileAbsolute = resolve(filePath) + + // Check if source file is inside a src/ directory relative to contract root + // If so, strip the src/ prefix from output path + let relativePath: string + if (existsSync(contractRootSrc) && sourceFileAbsolute.startsWith(resolve(contractRootSrc))) { + // File is in src/ directory, calculate path relative to contract root + const pathFromContractRoot = relative(contractRoot, filePath) + // Strip 'src/' prefix if present + if (pathFromContractRoot.startsWith('src/')) { + relativePath = pathFromContractRoot.substring(4) // Remove 'src/' prefix + } else { + relativePath = pathFromContractRoot + } + } else { + // Use path relative to current directory (preserve structure) + // But if the relative path goes outside currentDir (starts with ..), + // just use the filename to avoid path issues + const relPath = relative(currentDir, filePath) + if (relPath.startsWith('..')) { + // If path goes outside current directory, just use filename + relativePath = basename(filePath, '.cpp') + } else { + relativePath = relPath + } + } + + const relativeDir = dirname(relativePath) + + // Calculate output path + let wasmOutput: string + if (relativeDir === '.' || relativeDir === '') { + // File should be output directly in output directory + wasmOutput = join(outputDir, `${fileName}.wasm`) + } else { + // File is in a subdirectory, preserve the structure (but without src/ prefix if stripped) + // Normalize the path to prevent issues with relative paths containing '..' + const outputSubDir = normalize(join(outputDir, relativeDir)) + // Ensure the output subdirectory exists + if (!existsSync(outputSubDir)) { + mkdirSync(outputSubDir, {recursive: true}) + } + wasmOutput = join(outputSubDir, `${fileName}.wasm`) + } + + // Calculate ABI output path (same location as WASM, but with .abi extension) + const abiOutput = wasmOutput.replace(/\.wasm$/, '.abi') + + console.log(`Compiling: ${filePath}`) + console.log(`Output: ${wasmOutput}`) + + // Auto-detect compilation flags + const autoFlags = autoDetectCompileFlags(filePath) + if (autoFlags.length > 0) { + console.log(`Auto-detected flags: ${autoFlags.join(' ')}`) + } + + try { + const flagsStr = autoFlags.length > 0 ? `${autoFlags.join(' ')} ` : '' + const command = `cdt-cpp -abigen -abigen_output="${abiOutput}" ${flagsStr}-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/contract-utils.ts b/src/commands/contract/contract-utils.ts new file mode 100644 index 0000000..5c9479b --- /dev/null +++ b/src/commands/contract/contract-utils.ts @@ -0,0 +1,58 @@ +/** + * Shared utility functions for contract code generation. + * These are extracted to avoid circular dependencies between helpers.ts and finders.ts. + */ + +const decorators = ['?', '[]'] + +export function extractDecorator(type: string): {type: string; decorator?: string} { + for (const decorator of decorators) { + if (type.includes(decorator)) { + type = type.replace(decorator, '') + + return {type, decorator} + } + } + + return {type} +} + +export function parseType(type: string): string { + type = type.replace('$', '') + + if (type === 'String') { + return 'string' + } + + if (type === 'String[]') { + return 'string[]' + } + + if (type === 'Boolean') { + return 'boolean' + } + + if (type === 'Boolean[]') { + return 'boolean[]' + } + + return type +} + +export function trim(string: string) { + return string.replace(/\s/g, '') +} + +export function capitalize(string: string) { + if (typeof string !== 'string' || string.length === 0) { + return '' + } + + return string.charAt(0).toUpperCase() + string.slice(1) +} + +export function cleanupType(type: string): string { + return extractDecorator(parseType(trim(type))).type +} + + diff --git a/src/commands/contract/deploy-utils.ts b/src/commands/contract/deploy-utils.ts new file mode 100644 index 0000000..1bf17b7 --- /dev/null +++ b/src/commands/contract/deploy-utils.ts @@ -0,0 +1,583 @@ +/* eslint-disable no-console */ +import * as readline from 'readline' +import type {APIClient} from '@wharfkit/antelope' +import {ABI, Asset, Struct} from '@wharfkit/antelope' +import {PlaceholderName, PlaceholderPermission, SigningRequest} from '@wharfkit/signing-request' + +/** + * RAM market row structure + */ +@Struct.type('rammarket') +export class RamMarketRow extends Struct { + @Struct.field(Asset) supply!: Asset + @Struct.field(Asset) base!: Asset + @Struct.field(Asset) quote!: Asset +} + +/** + * Connector structure for RAM market + */ +@Struct.type('connector') +export class Connector extends Struct { + @Struct.field(Asset) balance!: Asset + @Struct.field('float64') weight!: number +} + +/** + * Exchange state structure + */ +@Struct.type('exchange_state') +export class ExchangeState extends Struct { + @Struct.field(Asset) supply!: Asset + @Struct.field(Connector) base!: Connector + @Struct.field(Connector) quote!: Connector +} + +export interface RamInfo { + pricePerByte: number + ramBytesNeeded: number + existingContractRam: number // RAM used by existing contract code (will be freed on update) + deltaRamNeeded: number // Actual new RAM needed (ramBytesNeeded - existingContractRam) + costInTokens: Asset + currentRamBytes: number + currentRamAvailable: number + tokenBalance: Asset + hasEnoughRam: boolean + hasEnoughTokens: boolean + ramToBuy: number + hasSystemContract: boolean // Whether the chain has full system contracts (RAM market) + isUpdate: boolean // Whether this is an update to existing contract +} + +export interface AccountResources { + ramQuota: number + ramUsage: number + ramAvailable: number + coreBalance: Asset +} + +/** + * Calculate RAM needed for contract deployment + * setcode requires approximately 10x the WASM size + * setabi requires approximately the ABI size + */ +export function calculateRamNeeded(wasmSize: number, abiSize: number): number { + // setcode action requires roughly 10x the WASM file size + const setcodeRam = wasmSize * 10 + // setabi action requires roughly the ABI file size + const setabiRam = abiSize + // Add a 1% buffer for overhead + const buffer = Math.ceil((setcodeRam + setabiRam) * 0.01) + return setcodeRam + setabiRam + buffer +} + +/** + * Get existing contract RAM usage + * When updating a contract, the existing code RAM will be freed and replaced + * Returns 0 if no contract exists + */ +export async function getExistingContractRam( + client: APIClient, + accountName: string +): Promise { + try { + // Get the API URL from the client's provider + const baseUrl = (client.provider as {url?: string}).url + + if (!baseUrl) { + return 0 + } + + // Use fetch to call get_raw_code_and_abi endpoint directly + // as wharfkit APIClient doesn't have this method built-in + const response = await fetch(`${baseUrl}/v1/chain/get_raw_code_and_abi`, { + method: 'POST', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({account_name: accountName}), + }) + + if (!response.ok) { + return 0 + } + + const data = (await response.json()) as {wasm?: string; abi?: string} + + // Check if there's existing code + if (!data.wasm || data.wasm.length === 0) { + return 0 + } + + // Decode base64 to get actual sizes + const wasmBytes = Buffer.from(data.wasm, 'base64') + const abiBytes = data.abi ? Buffer.from(data.abi, 'base64') : Buffer.alloc(0) + + // Calculate RAM used by existing contract using same formula + return calculateRamNeeded(wasmBytes.length, abiBytes.length) + } catch { + // Account might not exist or have no code + return 0 + } +} + +/** + * Get the core token symbol for a chain + */ +export async function getCoreSymbol(client: APIClient): Promise { + try { + // Try to get from rammarket which has the quote symbol + const rammarket = await client.v1.chain.get_table_rows({ + code: 'eosio', + scope: 'eosio', + table: 'rammarket', + limit: 1, + }) + if (rammarket.rows.length > 0) { + const state = rammarket.rows[0] + // Extract symbol from quote balance (e.g., "1000.0000 EOS") + const quoteStr = state.quote?.balance || state.quote + if (typeof quoteStr === 'string') { + const parts = quoteStr.split(' ') + if (parts.length === 2) { + return parts[1] + } + } + } + } catch (e) { + // Ignore errors, use default + } + return 'EOS' +} + +/** + * Get RAM price from the rammarket table using Bancor algorithm + * Falls back to default values for local chains without system contracts + */ +export async function getRamPrice( + client: APIClient +): Promise<{pricePerByte: number; symbol: string; hasSystemContract: boolean}> { + try { + const rammarket = await client.v1.chain.get_table_rows({ + code: 'eosio', + scope: 'eosio', + table: 'rammarket', + limit: 1, + }) + + if (rammarket.rows.length === 0) { + // No RAM market data, this is a simple local chain + return {pricePerByte: 0.00000001, symbol: 'SYS', hasSystemContract: false} + } + + const state = rammarket.rows[0] + + // Parse base (RAM) and quote (tokens) from the market + // Base is RAM bytes, Quote is the token (e.g., EOS) + let baseBalance: number + let quoteBalance: number + let symbol = 'EOS' + + // Handle different response formats + if (state.base?.balance) { + // Format: { balance: "123456789 RAM", weight: "0.50000000000000000" } + const baseStr = state.base.balance + baseBalance = parseFloat(baseStr.split(' ')[0]) + const quoteStr = state.quote.balance + const quoteParts = quoteStr.split(' ') + quoteBalance = parseFloat(quoteParts[0]) + symbol = quoteParts[1] || 'EOS' + } else { + // Simpler format + baseBalance = parseFloat(state.base) + quoteBalance = parseFloat(state.quote) + } + + // Bancor formula: price = quote_balance / base_balance + const pricePerByte = quoteBalance / baseBalance + + return {pricePerByte, symbol, hasSystemContract: true} + } catch { + // RAM market might not exist on local chains, use default values + // This allows deployment to proceed on simple local chains + return {pricePerByte: 0.00000001, symbol: 'SYS', hasSystemContract: false} + } +} + +/** + * Get account resources (RAM and token balance) + */ +export async function getAccountResources( + client: APIClient, + accountName: string, + symbol: string +): Promise { + try { + const accountInfo = await client.v1.chain.get_account(accountName) + + const ramQuota = Number(accountInfo.ram_quota) + const ramUsage = Number(accountInfo.ram_usage) + + // Get core token balance + let coreBalance: Asset + try { + const balances = await client.v1.chain.get_currency_balance('eosio.token', accountName) + const matchingBalance = balances.find((b) => String(b).includes(symbol)) + coreBalance = matchingBalance || Asset.from(`0.0000 ${symbol}`) + } catch { + // eosio.token might not exist on local chains, default to zero balance + coreBalance = Asset.from(`0.0000 ${symbol}`) + } + + return { + ramQuota, + ramUsage, + ramAvailable: ramQuota - ramUsage, + coreBalance, + } + } catch { + // Account might not exist yet or other API errors + return { + ramQuota: 0, + ramUsage: 0, + ramAvailable: 0, + coreBalance: Asset.from(`0.0000 ${symbol}`), + } + } +} + +/** + * Calculate total RAM cost for a given number of bytes + */ +export function calculateRamCost(bytesNeeded: number, pricePerByte: number, symbol: string): Asset { + // Add 0.5% fee for RAM purchase + const ramCostRaw = bytesNeeded * pricePerByte * 1.005 + // Round up to 4 decimal places + const ramCost = Math.ceil(ramCostRaw * 10000) / 10000 + return Asset.from(`${ramCost.toFixed(4)} ${symbol}`) +} + +/** + * Analyze RAM requirements for deployment + * When updating an existing contract, calculates the delta RAM needed + * (existing contract RAM will be freed when replaced) + */ +export async function analyzeRamRequirements( + client: APIClient, + accountName: string, + wasmSize: number, + abiSize: number +): Promise { + const ramBytesNeeded = calculateRamNeeded(wasmSize, abiSize) + const {pricePerByte, symbol, hasSystemContract} = await getRamPrice(client) + const resources = await getAccountResources(client, accountName, symbol) + + // Check for existing contract - its RAM will be freed when we update + const existingContractRam = await getExistingContractRam(client, accountName) + const isUpdate = existingContractRam > 0 + + // Calculate actual delta RAM needed (new - existing, minimum 0) + // When updating, the existing contract RAM is freed and replaced + const deltaRamNeeded = Math.max(0, ramBytesNeeded - existingContractRam) + + // Only need to buy RAM for the delta beyond what's available + const ramToBuy = Math.max(0, deltaRamNeeded - resources.ramAvailable) + const costInTokens = calculateRamCost(ramToBuy, pricePerByte, symbol) + + // On chains without system contracts, RAM is essentially free/unlimited + const hasEnoughRam = !hasSystemContract || resources.ramAvailable >= deltaRamNeeded + const hasEnoughTokens = hasEnoughRam || resources.coreBalance.value >= costInTokens.value + + return { + pricePerByte, + ramBytesNeeded, + existingContractRam, + deltaRamNeeded, + costInTokens, + currentRamBytes: resources.ramQuota, + currentRamAvailable: resources.ramAvailable, + tokenBalance: resources.coreBalance, + hasEnoughRam, + hasEnoughTokens, + ramToBuy, + hasSystemContract, + isUpdate, + } +} + +/** + * Format bytes to human-readable string + */ +export function formatBytes(bytes: number): string { + if (bytes < 1024) return `${bytes} bytes` + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(2)} KB` + return `${(bytes / (1024 * 1024)).toFixed(2)} MB` +} + +/** + * Prompt user for confirmation + */ +export async function promptConfirmation(message: string): Promise { + return new Promise((resolve) => { + const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout, + }) + + rl.question(`${message} (y/n): `, (answer) => { + rl.close() + resolve(answer.toLowerCase() === 'y' || answer.toLowerCase() === 'yes') + }) + }) +} + +/** + * Create an ESR (EOSIO Signing Request) for transferring tokens + */ +export async function createTransferESR( + client: APIClient, + toAccount: string, + amount: Asset, + memo: string +): Promise<{uri: string; encodedUri: string}> { + const info = await client.v1.chain.get_info() + const chainId = String(info.chain_id) + + // Fetch the eosio.token ABI for serialization + const tokenAbiResponse = await client.v1.chain.get_abi('eosio.token') + if (!tokenAbiResponse.abi) { + throw new Error('Could not fetch eosio.token ABI') + } + const tokenAbi = ABI.from(tokenAbiResponse.abi) + + // Create the signing request with placeholders for the signing wallet + const request = await SigningRequest.create( + { + action: { + account: 'eosio.token', + name: 'transfer', + authorization: [ + { + actor: PlaceholderName, + permission: PlaceholderPermission, + }, + ], + data: { + from: PlaceholderName, + to: toAccount, + quantity: String(amount), + memo, + }, + }, + chainId, + }, + { + abiProvider: { + getAbi: async () => tokenAbi, + }, + } + ) + + const encodedUri = request.encode() + // Normalize to esr:// format + let uri = encodedUri + if (uri.startsWith('esr://')) { + // Already correct format + } else if (uri.startsWith('esr:')) { + uri = `esr://${uri.slice(4)}` + } + + return {uri, encodedUri} +} + +/** + * Create an ESR for buying RAM + */ +export async function createBuyRamESR( + client: APIClient, + receiver: string, + amount: Asset +): Promise<{uri: string; encodedUri: string}> { + const info = await client.v1.chain.get_info() + const chainId = String(info.chain_id) + + // Get eosio ABI for buyram + const eosioAbiResponse = await client.v1.chain.get_abi('eosio') + if (!eosioAbiResponse.abi) { + throw new Error('Could not fetch eosio ABI') + } + const eosioAbi = ABI.from(eosioAbiResponse.abi) + + // Create the signing request with placeholders + const request = await SigningRequest.create( + { + action: { + account: 'eosio', + name: 'buyram', + authorization: [ + { + actor: PlaceholderName, + permission: PlaceholderPermission, + }, + ], + data: { + payer: PlaceholderName, + receiver, + quant: String(amount), + }, + }, + chainId, + }, + { + abiProvider: { + getAbi: async () => eosioAbi, + }, + } + ) + + const encodedUri = request.encode() + // Normalize to esr:// format + let uri = encodedUri + if (uri.startsWith('esr://')) { + // Already correct format + } else if (uri.startsWith('esr:')) { + uri = `esr://${uri.slice(4)}` + } + + return {uri, encodedUri} +} + +/** + * Wait for account balance to reach a target + */ +export async function waitForBalance( + client: APIClient, + accountName: string, + targetBalance: Asset, + pollInterval: number = 5000, + timeout: number = 300000 // 5 minutes +): Promise { + const startTime = Date.now() + const symbol = String(targetBalance).split(' ')[1] + + console.log(`\n⏳ Waiting for funds... (polling every ${pollInterval / 1000}s)`) + console.log(` Needed: ${targetBalance}`) + console.log(' Press Ctrl+C to cancel\n') + + while (Date.now() - startTime < timeout) { + try { + const balances = await client.v1.chain.get_currency_balance('eosio.token', accountName) + const currentBalance = balances.find((b) => String(b).includes(symbol)) + + if (currentBalance && Asset.from(currentBalance).value >= targetBalance.value) { + console.log(`\n✅ Funds received! Current balance: ${currentBalance}`) + return true + } + + process.stdout.write( + `\r Current balance: ${currentBalance || `0.0000 ${symbol}`} | ` + + `Elapsed: ${Math.floor((Date.now() - startTime) / 1000)}s` + ) + } catch (e) { + // Account might not exist yet, continue polling + } + + await sleep(pollInterval) + } + + console.log('\n\n⏱️ Timeout waiting for funds') + return false +} + +/** + * Wait for account RAM to reach a target + */ +export async function waitForRam( + client: APIClient, + accountName: string, + targetRamBytes: number, + pollInterval: number = 5000, + timeout: number = 300000 // 5 minutes +): Promise { + const startTime = Date.now() + + console.log(`\n⏳ Waiting for RAM... (polling every ${pollInterval / 1000}s)`) + console.log(` Target: ${formatBytes(targetRamBytes)} available`) + console.log(' Press Ctrl+C to cancel\n') + + while (Date.now() - startTime < timeout) { + try { + const accountInfo = await client.v1.chain.get_account(accountName) + const ramAvailable = Number(accountInfo.ram_quota) - Number(accountInfo.ram_usage) + + if (ramAvailable >= targetRamBytes) { + console.log(`\n✅ RAM available! Current: ${formatBytes(ramAvailable)}`) + return true + } + + process.stdout.write( + `\r Current RAM available: ${formatBytes(ramAvailable)} | ` + + `Elapsed: ${Math.floor((Date.now() - startTime) / 1000)}s` + ) + } catch (e) { + // Account might not exist yet + } + + await sleep(pollInterval) + } + + console.log('\n\n⏱️ Timeout waiting for RAM') + return false +} + +/** + * Sleep helper + */ +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)) +} + +/** + * Display RAM analysis summary + */ +export function displayRamAnalysis(ramInfo: RamInfo, accountName: string): void { + console.log('\n📊 RAM Analysis') + console.log('─'.repeat(50)) + console.log(`Account: ${accountName}`) + + if (ramInfo.isUpdate) { + console.log(`📦 Updating existing contract`) + console.log(` New contract RAM: ${formatBytes(ramInfo.ramBytesNeeded)}`) + console.log(` Existing contract RAM: ${formatBytes(ramInfo.existingContractRam)}`) + console.log(` Delta RAM needed: ${formatBytes(ramInfo.deltaRamNeeded)}`) + } else { + console.log(`📦 New contract deployment`) + console.log(` RAM needed: ${formatBytes(ramInfo.ramBytesNeeded)}`) + } + + console.log(`Current RAM available: ${formatBytes(ramInfo.currentRamAvailable)}`) + + if (!ramInfo.hasSystemContract) { + console.log('─'.repeat(50)) + console.log('ℹ️ Local chain without system contracts - RAM management not required') + return + } + + if (ramInfo.ramToBuy > 0) { + console.log(`RAM to purchase: ${formatBytes(ramInfo.ramToBuy)}`) + console.log(`Estimated cost: ${ramInfo.costInTokens}`) + } + console.log(`Current balance: ${ramInfo.tokenBalance}`) + console.log( + `Price per KB: ${(ramInfo.pricePerByte * 1024).toFixed(4)} ${ + String(ramInfo.costInTokens).split(' ')[1] + }` + ) + console.log('─'.repeat(50)) + + if (ramInfo.hasEnoughRam) { + console.log('✅ Account has sufficient RAM for deployment') + } else if (ramInfo.hasEnoughTokens) { + console.log('✅ Account has sufficient tokens to purchase required RAM') + } else { + console.log('❌ Account needs more tokens to purchase required RAM') + } +} diff --git a/src/commands/contract/deploy.ts b/src/commands/contract/deploy.ts new file mode 100644 index 0000000..0080b83 --- /dev/null +++ b/src/commands/contract/deploy.ts @@ -0,0 +1,592 @@ +/* eslint-disable no-console */ +import '../../types/wharfkit-session' +import {existsSync, readdirSync, readFileSync, statSync} from 'fs' +import {basename, extname, resolve} from 'path' +import {ABI, APIClient, Asset, 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' +import {displayQRCode} from '../../utils' +import { + analyzeRamRequirements, + createTransferESR, + displayRamAnalysis, + formatBytes, + promptConfirmation, + waitForBalance, +} from './deploy-utils' + +interface DeployOptions { + account?: string + url?: string + force?: boolean + validate?: boolean + key?: string + yes?: boolean // Skip confirmation prompts +} + +/** + * 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 + } + + // Create API client for RAM analysis + const analysisClient = new APIClient({ + provider: new FetchProvider(url, {fetch}), + }) + + // Get file sizes for RAM calculation + const wasmSize = statSync(wasmPath).size + const abiSize = statSync(abiPath).size + + // Analyze RAM requirements + console.log('\n📊 Analyzing RAM requirements...') + let ramInfo = await analyzeRamRequirements(analysisClient, accountName, wasmSize, abiSize) + displayRamAnalysis(ramInfo, accountName) + + // Handle insufficient resources (only on chains with system contracts) + if (ramInfo.hasSystemContract && !ramInfo.hasEnoughRam && !ramInfo.hasEnoughTokens) { + // Need to acquire tokens first + const tokensNeeded = Asset.from(ramInfo.costInTokens) + const symbol = String(tokensNeeded).split(' ')[1] + const currentBalance = ramInfo.tokenBalance.value || 0 + const shortfall = tokensNeeded.value - currentBalance + const amountToSend = Asset.from(`${(shortfall * 1.01).toFixed(4)} ${symbol}`) + + console.log( + `\n❌ Insufficient funds! Need approximately ${amountToSend} more ${symbol}` + ) + + try { + const {uri} = await createTransferESR( + analysisClient, + accountName, + amountToSend, + `RAM for contract deployment` + ) + + displayQRCode(uri, `💰 Send ${amountToSend} to ${accountName}`) + + // Poll for balance - only wait for the actual amount needed (not the buffered amount) + const targetBalance = Asset.from(`${tokensNeeded.value.toFixed(4)} ${symbol}`) + const received = await waitForBalance( + analysisClient, + accountName, + targetBalance, + 5000, + 300000 + ) + + if (!received) { + throw new Error('Deployment cancelled: Funds not received within timeout') + } + + // Re-analyze RAM after receiving funds + ramInfo = await analyzeRamRequirements( + analysisClient, + accountName, + wasmSize, + abiSize + ) + } catch (esrError) { + // ESR creation might fail on local chains without proper setup + console.log( + `\n⚠️ Could not create payment request: ${(esrError as Error).message}` + ) + console.log( + `\n💡 Please manually send at least ${ramInfo.costInTokens} to ${accountName}` + ) + throw new Error('Insufficient funds for deployment') + } + } + + // Check if we need to buy RAM (only on chains with system contracts) + if (ramInfo.hasSystemContract && !ramInfo.hasEnoughRam && ramInfo.hasEnoughTokens) { + console.log(`\n💡 Account needs to purchase ${formatBytes(ramInfo.ramToBuy)} of RAM`) + console.log(` Estimated cost: ${ramInfo.costInTokens}`) + + if (!options.yes) { + const ramAmount = formatBytes(ramInfo.ramToBuy) + const proceed = await promptConfirmation( + `\nPurchase ${ramAmount} of RAM for ~${ramInfo.costInTokens}?` + ) + + if (!proceed) { + console.log('Deployment cancelled by user.') + return + } + } + } else if (!options.yes) { + // Confirm deployment even if RAM is sufficient + const proceed = await promptConfirmation( + `\nProceed with deployment? (RAM needed: ${formatBytes(ramInfo.ramBytesNeeded)})` + ) + + if (!proceed) { + console.log('Deployment cancelled by user.') + 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...') + + // Build actions array + const actions: Array<{ + account: string + name: string + authorization: Array<{actor: string; permission: string}> + data: Record + }> = [] + + // Add buyrambytes action if needed (only on chains with system contracts) + if (ramInfo.hasSystemContract && !ramInfo.hasEnoughRam && ramInfo.ramToBuy > 0) { + console.log(` 📦 Buying ${formatBytes(ramInfo.ramToBuy)} of RAM...`) + actions.push({ + account: 'eosio', + name: 'buyrambytes', + authorization: [ + { + actor: accountName, + permission: 'active', + }, + ], + data: { + payer: accountName, + receiver: accountName, + bytes: ramInfo.ramToBuy, + }, + }) + } + + // 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'), + }, + } + actions.push(setcodeAction) + + // 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, + }, + } + actions.push(setabiAction) + + // Transact all actions + const result = await session.transact( + { + actions, + }, + { + 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}`) + } +} + +/** + * 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/finders.ts b/src/commands/contract/finders.ts index eb58937..4abc0cd 100644 --- a/src/commands/contract/finders.ts +++ b/src/commands/contract/finders.ts @@ -1,6 +1,6 @@ import * as Antelope from '@wharfkit/antelope' import type {ABI} from '@wharfkit/antelope' -import {capitalize, extractDecorator, formatInternalType, parseType, trim} from './helpers' +import {capitalize, extractDecorator, parseType, trim} from './contract-utils' import {formatClassName} from '../../utils' const ANTELOPE_CLASSES: string[] = [] @@ -124,14 +124,3 @@ export function findCoreClass(type: string): string | undefined { ) ) } - -export function findInternalType( - type: string, - typeNamespace: string | undefined, - abi: ABI.Def -): string { - const {type: typeString, decorator} = findType(type, abi, typeNamespace) - - // TODO: inside findType, namespace is prefixed, but format internal is doing the same - return formatInternalType(typeString, typeNamespace, abi, decorator) -} diff --git a/src/commands/contract/helpers.ts b/src/commands/contract/helpers.ts index 131ab12..571da5d 100644 --- a/src/commands/contract/helpers.ts +++ b/src/commands/contract/helpers.ts @@ -1,9 +1,13 @@ import type {ABI} from '@wharfkit/antelope' import * as ts from 'typescript' import {formatClassName} from '../../utils' +import {capitalize, extractDecorator} from './contract-utils' import {findAbiType, findAliasFromType, findCoreClass, findCoreType, findVariant} from './finders' import type {TypeInterfaceDeclaration} from './interfaces' +// Re-export utilities for backwards compatibility +export {capitalize, extractDecorator, parseType, trim, cleanupType} from './contract-utils' + export function getCoreImports(abi: ABI.Def) { const coreImports: string[] = [] const coreTypes: string[] = [] @@ -211,55 +215,15 @@ export function formatInternalType( return `${type}${decorator}` } -const decorators = ['?', '[]'] -export function extractDecorator(type: string): {type: string; decorator?: string} { - for (const decorator of decorators) { - if (type.includes(decorator)) { - type = type.replace(decorator, '') - - return {type, decorator} - } - } - - return {type} -} - -export function cleanupType(type: string): string { - return extractDecorator(parseType(trim(type))).type -} - -export function parseType(type: string): string { - type = type.replace('$', '') - - if (type === 'String') { - return 'string' - } - - if (type === 'String[]') { - return 'string[]' - } - - if (type === 'Boolean') { - return 'boolean' - } - - if (type === 'Boolean[]') { - return 'boolean[]' - } - - return type -} - -export function trim(string: string) { - return string.replace(/\s/g, '') -} - -export function capitalize(string) { - if (typeof string !== 'string' || string.length === 0) { - return '' - } +export function findInternalType( + type: string, + typeNamespace: string | undefined, + abi: ABI.Def +): string { + const {type: typeString, decorator} = findAbiType(type, abi, typeNamespace) - return string.charAt(0).toUpperCase() + string.slice(1) + // TODO: inside findAbiType, namespace is prefixed, but formatInternalType is doing the same + return formatInternalType(typeString, typeNamespace, abi, decorator) } export function removeDuplicateInterfaces( diff --git a/src/commands/contract/index.ts b/src/commands/contract/index.ts index c22a733..ca8c275 100644 --- a/src/commands/contract/index.ts +++ b/src/commands/contract/index.ts @@ -5,9 +5,13 @@ 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 {lookupContractInfo} from './info' +import {getDefaultChain} from '../chain/utils' import {generateImportStatement, getCoreImports} from './helpers' import { generateActionNamesInterface, @@ -29,6 +33,71 @@ 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') + .option('-y, --yes', 'Skip confirmation prompts') + .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) + } + }) + + contract + .command('info') + .description('Display information about a deployed contract') + .argument('', 'The account name where the contract is deployed') + .option('-c, --chain ', 'Chain to query (default: local or configured default)') + .option('--json', 'Output as JSON') + .action(async (accountName, options) => { + try { + const chainName = options.chain || (await getDefaultChain()) + await lookupContractInfo(chainName, accountName, 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/contract/info.ts b/src/commands/contract/info.ts new file mode 100644 index 0000000..09451ca --- /dev/null +++ b/src/commands/contract/info.ts @@ -0,0 +1,187 @@ +/* eslint-disable no-console */ +import {APIClient} from '@wharfkit/antelope' +import {Chains} from '@wharfkit/common' +import fetch from 'node-fetch' + +interface ApiClientLike { + v1: { + chain: { + get_account: (name: string) => Promise<{ + last_code_update: string + ram_usage: {toNumber?: () => number} | number + ram_quota: {toNumber?: () => number} | number + core_liquid_balance?: {toString: () => string} + }> + get_abi: (name: string) => Promise<{ + abi?: { + actions?: { + name: {toString: () => string} + type: string + ricardian_contract?: string + }[] + tables?: { + name: {toString: () => string} + type: string + key_names?: string[] + index_type?: string + }[] + structs?: {name: string; fields?: {name: string; type: string}[]}[] + action_results?: {name: string; result_type: string}[] + variants?: {name: string; types?: string[]}[] + } + }> + } + } +} + +interface ContractInfoOptions { + chain?: string + json?: boolean + _apiClient?: ApiClientLike // For testing purposes +} + +export function getApiUrl(chainName: string): string { + 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' + default: + if (chainName.startsWith('http')) { + return chainName + } + throw new Error( + `Unknown chain: ${chainName}. Please provide a full URL or a known chain name.` + ) + } +} + +export function createApiClient(url: string): APIClient { + return new APIClient({ + url, + fetch, + }) +} + +export async function lookupContractInfo( + chainName: string, + accountName: string, + options: ContractInfoOptions +): Promise { + const url = getApiUrl(chainName) + const api = options._apiClient || createApiClient(url) + + try { + // Get account info + const account = await api.v1.chain.get_account(accountName) + + // Get ABI + const abiResponse = await api.v1.chain.get_abi(accountName) + + if (options.json) { + const result = { + account: accountName, + chain: chainName, + hasCode: !!abiResponse.abi, + lastCodeUpdate: account.last_code_update, + ram: { + used: Number(account.ram_usage), + quota: Number(account.ram_quota), + }, + balance: account.core_liquid_balance?.toString() || '0', + actions: abiResponse.abi?.actions?.map((a) => a.name.toString()) || [], + tables: abiResponse.abi?.tables?.map((t) => t.name.toString()) || [], + structs: abiResponse.abi?.structs?.map((s) => s.name) || [], + } + console.log(JSON.stringify(result, null, 2)) + return + } + + // Pretty print + console.log(`Contract: ${accountName}`) + console.log(`Chain: ${chainName}`) + + if (!abiResponse.abi) { + console.log('\n❌ No contract deployed on this account') + return + } + + console.log(`Last code update: ${account.last_code_update}`) + console.log(`RAM: ${account.ram_usage} / ${account.ram_quota} bytes`) + + if (account.core_liquid_balance) { + console.log(`Balance: ${account.core_liquid_balance}`) + } + + // Actions + const actions = abiResponse.abi.actions || [] + if (actions.length > 0) { + console.log('\nActions:') + for (const action of actions) { + const actionStruct = abiResponse.abi.structs?.find((s) => s.name === action.type) + const params = actionStruct?.fields?.map((f) => `${f.name}: ${f.type}`).join(', ') + console.log(` ${action.name}(${params || ''})`) + } + } else { + console.log('\nActions: none') + } + + // Tables + const tables = abiResponse.abi.tables || [] + if (tables.length > 0) { + console.log('\nTables:') + for (const table of tables) { + const tableStruct = abiResponse.abi.structs?.find((s) => s.name === table.type) + const fields = tableStruct?.fields?.length || 0 + console.log( + ` ${table.name} (${fields} fields, key: ${ + table.key_names?.[0] || table.index_type || 'primary' + })` + ) + } + } else { + console.log('\nTables: none') + } + + // Ricardian contracts (if any) + const hasRicardian = actions.some( + (a) => a.ricardian_contract && a.ricardian_contract.length > 0 + ) + if (hasRicardian) { + console.log('\n✓ Has Ricardian contracts') + } + + // Action results (if any) + const actionResults = abiResponse.abi.action_results || [] + if (actionResults.length > 0) { + console.log('\nAction Results:') + for (const result of actionResults) { + console.log(` ${result.name} -> ${result.result_type}`) + } + } + + // Variants (if any) + const variants = abiResponse.abi.variants || [] + if (variants.length > 0) { + console.log('\nVariants:') + for (const variant of variants) { + console.log(` ${variant.name}: ${variant.types?.join(' | ')}`) + } + } + } catch (error: any) { + if (error.message?.includes('Account not found')) { + console.error(`Error: Account "${accountName}" not found on ${chainName}`) + } else { + console.error(`Error fetching contract info: ${error.message}`) + } + process.exit(1) + } +} + + diff --git a/src/commands/contract/structs.ts b/src/commands/contract/structs.ts index 48871ae..7ed4dff 100644 --- a/src/commands/contract/structs.ts +++ b/src/commands/contract/structs.ts @@ -1,8 +1,7 @@ import type {ABI} from '@wharfkit/antelope' import ts from 'typescript' -import {extractDecorator, parseType} from './helpers' +import {extractDecorator, findInternalType, parseType} from './helpers' import {formatClassName} from '../../utils' -import {findInternalType} from './finders' interface FieldType { name: string 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/table/index.ts b/src/commands/table/index.ts new file mode 100644 index 0000000..f8a1317 --- /dev/null +++ b/src/commands/table/index.ts @@ -0,0 +1,36 @@ +/* eslint-disable no-console */ +import {Command} from 'commander' +import {getDefaultChain} from '../chain/utils' +import {lookupTable} from '../chain/interact' + +/** + * Create the table command that uses the default chain + */ +export function createTableCommand(): Command { + const table = new Command('table') + table + .description( + 'Lookup table data using the default chain (set with: wharfkit chain set )' + ) + .argument('', 'Table to lookup (format: contract::table)') + .argument('[extraFields...]', 'Additional fields') + .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) => { + const chainName = await getDefaultChain() + + // If user provided spaced fields like "--fields a, b", 'b' ends up in extras. + if (options.fields && extraFields && extraFields.length > 0) { + options.fields = [options.fields, ...extraFields].join(' ') + } + + await lookupTable(chainName, tableName, options) + }) + + return table +} diff --git a/src/commands/wallet/account.ts b/src/commands/wallet/account.ts new file mode 100644 index 0000000..bfad2a9 --- /dev/null +++ b/src/commands/wallet/account.ts @@ -0,0 +1,400 @@ +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 { + // Check if "local" chain is specified + if (options.chain && String(options.chain).toLowerCase() === 'local') { + chainUrl = 'http://127.0.0.1:8888' + isLocalChain = true + } 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(', ')}, local`, + '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') + log(`Public Key: ${publicKey}`, 'info') + + // Import the private key into the wallet + if (privateKey) { + try { + addKeyToWallet(privateKey, String(accountName)) + log(`✅ Private key imported into wallet as: ${accountName}`, 'info') + } catch (error) { + log( + `⚠️ Could not import key into wallet (may already exist): ${ + (error as Error).message + }`, + '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') + log(`Public Key: ${publicKey}`, 'info') + log(`Transaction ID: ${result.resolved?.transaction.id}`, 'info') + + // Import the private key into the wallet (only if we have a private key) + if (privateKey) { + try { + addKeyToWallet(privateKey, accountName) + log(`✅ Private key imported into wallet as: ${accountName}`, 'info') + } catch (error) { + log( + `⚠️ Could not import key into wallet (may already exist): ${ + (error as Error).message + }`, + 'info' + ) + } + } else { + log('Note: No private key available to import (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..11e8604 --- /dev/null +++ b/src/commands/wallet/transact.ts @@ -0,0 +1,447 @@ +import {ABI, Action, 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 +} + +// Cache for ABIs to avoid fetching the same ABI multiple times +const abiCache: Map = new Map() + +/** + * 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 +} + +/** + * Fetch ABI for a contract account + */ +async function fetchAbi(client: APIClient, account: string): Promise { + // Check cache first + const cached = abiCache.get(account) + if (cached) { + return cached + } + + const abiResponse = await client.v1.chain.get_abi(account) + if (!abiResponse.abi) { + throw new Error(`Could not fetch ABI for contract: ${account}`) + } + + const abi = ABI.from(abiResponse.abi) + abiCache.set(account, abi) + return abi +} + +/** + * Check if action data needs ABI-based serialization + * Returns true if data is an object (untyped), false if it's already serialized + */ +function needsAbiSerialization(actionData: unknown): boolean { + // If data is a plain object (not Bytes/Uint8Array), it needs serialization + return ( + typeof actionData === 'object' && + actionData !== null && + !Array.isArray(actionData) && + !(actionData instanceof Uint8Array) && + // Check if it's a plain object, not a special antelope type + Object.getPrototypeOf(actionData) === Object.prototype + ) +} + +/** + * Load transaction from JSON file or string, fetching ABIs as needed + */ +async function loadTransaction(transactionJson: string, apiUrl?: string): Promise { + let transactionData: { + actions?: Array<{ + account: string + name: string + authorization: Array<{actor: string; permission: string}> + data: unknown + }> + [key: string]: unknown + } + + 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.' + ) + } + + // Check if any action has untyped data that needs ABI serialization + const actions = transactionData.actions || [] + const needsAbi = actions.some((action) => needsAbiSerialization(action.data)) + + if (needsAbi) { + // We need to fetch ABIs to serialize the action data + const url = apiUrl || 'http://127.0.0.1:8888' + const client = new APIClient({ + provider: new FetchProvider(url, {fetch: globalThis.fetch}), + }) + + // Get unique contract accounts that need ABI fetching + const accountsNeedingAbi = new Set() + for (const action of actions) { + if (needsAbiSerialization(action.data)) { + accountsNeedingAbi.add(action.account) + } + } + + // Fetch all needed ABIs + log(`Fetching ABIs for: ${Array.from(accountsNeedingAbi).join(', ')}`, 'info') + for (const account of accountsNeedingAbi) { + await fetchAbi(client, account) + } + + // Create properly serialized actions + const serializedActions: Action[] = [] + for (const action of actions) { + if (needsAbiSerialization(action.data)) { + const abi = abiCache.get(action.account)! + const serializedAction = Action.from( + { + account: action.account, + name: action.name, + authorization: action.authorization, + data: action.data, + }, + abi + ) + serializedActions.push(serializedAction) + } else { + serializedActions.push(Action.from(action)) + } + } + + // Build the transaction with serialized actions + const txData = { + ...transactionData, + actions: serializedActions, + } + + // If transaction doesn't have header fields, we need to fetch them + if (!transactionData.expiration || !transactionData.ref_block_num) { + const info = await client.v1.chain.get_info() + const header = info.getTransactionHeader() + return Transaction.from({ + ...header, + ...txData, + }) + } + + return Transaction.from(txData) + } + + // No ABI needed, try to parse directly + 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 = await 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 (pass URL so it can fetch ABIs if needed) + const transaction = await loadTransaction(transactionJson, options.url) + + 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..368c3ed 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,9 +1,15 @@ 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' +import {createAccountCommand} from './commands/account' +import {createTableCommand} from './commands/table' +import {createSignCommand} from './commands/action' const program = new Command() @@ -15,22 +21,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 +36,28 @@ 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()) + +// 8. Command to lookup account data +program.addCommand(createAccountCommand()) + +// 9. Command to lookup table data (uses default chain) +program.addCommand(createTableCommand()) + +// 10. Command to create signing requests +program.addCommand(createSignCommand()) + 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.ts b/src/utils.ts index 6bc450f..0de61aa 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -1,6 +1,8 @@ +/* eslint-disable no-console */ import {APIClient, FetchProvider} from '@wharfkit/antelope' import {capitalize} from '@wharfkit/contract' import fetch from 'node-fetch' +import * as qrcode from 'qrcode-terminal' type logLevel = 'info' | 'debug' @@ -25,3 +27,15 @@ export function capitalizeName(text: string) { export function formatClassName(name: string) { return name.split(/[.]/).join('') } + +/** + * Display QR code and link in terminal + */ +export function displayQRCode(uri: string, title: string): void { + console.log(`\n${title}`) + console.log('─'.repeat(60)) + console.log(`\nLink: ${uri}`) + console.log('\nScan this QR code with your wallet app:\n') + qrcode.generate(uri, {small: true}) + console.log('─'.repeat(60)) +} 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/action-request.ts b/test/tests/action-request.ts new file mode 100644 index 0000000..d4957d9 --- /dev/null +++ b/test/tests/action-request.ts @@ -0,0 +1,199 @@ +import {assert} from 'chai' +import { + getApiUrl, + parseActionData, + parseAuthorization, + parseContractAction, +} from '../../src/commands/action/request' + +suite('action-request', function () { + suite('parseContractAction', function () { + test('parses valid contract::action format', function () { + const result = parseContractAction('eosio.token::transfer') + assert.equal(result.contractAccount, 'eosio.token') + assert.equal(result.actionName, 'transfer') + }) + + test('parses contract with dots in name', function () { + const result = parseContractAction('payroll.boid::setpayroll') + assert.equal(result.contractAccount, 'payroll.boid') + assert.equal(result.actionName, 'setpayroll') + }) + + test('rejects format without double colon', function () { + try { + parseContractAction('eosio.token') + assert.fail('Should have thrown an error') + } catch (error) { + assert.include((error as Error).message, 'contract::action') + } + }) + + test('rejects empty contract name', function () { + try { + parseContractAction('::transfer') + assert.fail('Should have thrown an error') + } catch (error) { + assert.include((error as Error).message, 'contract::action') + } + }) + + test('rejects empty action name', function () { + try { + parseContractAction('eosio.token::') + assert.fail('Should have thrown an error') + } catch (error) { + assert.include((error as Error).message, 'contract::action') + } + }) + + test('rejects single colon format', function () { + try { + parseContractAction('eosio.token:transfer') + assert.fail('Should have thrown an error') + } catch (error) { + assert.include((error as Error).message, 'contract::action') + } + }) + }) + + suite('parseActionData', function () { + test('parses valid JSON object', function () { + const result = parseActionData('{"from": "alice", "to": "bob"}') + assert.deepEqual(result, {from: 'alice', to: 'bob'}) + }) + + test('parses JSON with nested objects', function () { + const result = parseActionData('{"user": {"name": "alice", "age": 30}}') + assert.deepEqual(result, {user: {name: 'alice', age: 30}}) + }) + + test('parses JSON with arrays', function () { + const result = parseActionData('{"values": [1, 2, 3]}') + assert.deepEqual(result, {values: [1, 2, 3]}) + }) + + test('parses simple key=value pairs', function () { + const result = parseActionData('from=alice,to=bob') + assert.deepEqual(result, {from: 'alice', to: 'bob'}) + }) + + test('parses key=value with spaces', function () { + const result = parseActionData('from = alice, to = bob') + assert.deepEqual(result, {from: 'alice', to: 'bob'}) + }) + + test('parses key=value with numeric values', function () { + const result = parseActionData('amount=100,count=5') + assert.deepEqual(result, {amount: 100, count: 5}) + }) + + test('parses key=value with boolean values', function () { + const result = parseActionData('active=true,disabled=false') + assert.deepEqual(result, {active: true, disabled: false}) + }) + + test('handles values with equals sign', function () { + const result = parseActionData('key=value=with=equals') + assert.deepEqual(result, {key: 'value=with=equals'}) + }) + + test('rejects invalid format', function () { + try { + parseActionData('not valid json or pairs') + assert.fail('Should have thrown an error') + } catch (error) { + assert.include((error as Error).message, 'Invalid action data format') + } + }) + + test('rejects empty string', function () { + try { + parseActionData('') + assert.fail('Should have thrown an error') + } catch (error) { + // Empty string throws from JSON.parse or key=value parser + assert.exists(error) + } + }) + }) + + suite('parseAuthorization', function () { + test('returns placeholder with no input', function () { + const result = parseAuthorization() + assert.isNull(result.actor) + assert.equal(result.permission, 'active') + }) + + test('returns placeholder with undefined', function () { + const result = parseAuthorization(undefined) + assert.isNull(result.actor) + assert.equal(result.permission, 'active') + }) + + test('parses account@permission format', function () { + const result = parseAuthorization('alice@active') + assert.equal(result.actor, 'alice') + assert.equal(result.permission, 'active') + }) + + test('parses account@owner format', function () { + const result = parseAuthorization('alice@owner') + assert.equal(result.actor, 'alice') + assert.equal(result.permission, 'owner') + }) + + test('parses account only (defaults to active)', function () { + const result = parseAuthorization('alice') + assert.equal(result.actor, 'alice') + assert.equal(result.permission, 'active') + }) + + test('parses account with trailing @', function () { + const result = parseAuthorization('alice@') + assert.equal(result.actor, 'alice') + assert.equal(result.permission, 'active') + }) + + test('parses account with dots', function () { + const result = parseAuthorization('payroll.boid@active') + assert.equal(result.actor, 'payroll.boid') + assert.equal(result.permission, 'active') + }) + }) + + suite('getApiUrl', function () { + test('returns URL as-is for http://', function () { + const url = 'http://my-node.example.com:8888' + assert.equal(getApiUrl(url), url) + }) + + test('returns URL as-is for https://', function () { + const url = 'https://my-node.example.com' + assert.equal(getApiUrl(url), url) + }) + + test('resolves local to localhost', function () { + assert.equal(getApiUrl('local'), 'http://127.0.0.1:8888') + }) + + test('resolves known chain names (case insensitive)', function () { + // These should resolve to their respective URLs + const jungle4Url = getApiUrl('Jungle4') + assert.include(jungle4Url, 'http') + assert.isString(jungle4Url) + + const jungle4UrlLower = getApiUrl('jungle4') + assert.equal(jungle4Url, jungle4UrlLower) + }) + + test('throws for unknown chain name', function () { + try { + getApiUrl('unknownchain') + assert.fail('Should have thrown an error') + } catch (error) { + assert.include((error as Error).message, 'Unknown chain') + } + }) + }) +}) 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..c093767 --- /dev/null +++ b/test/tests/chain-interact.ts @@ -0,0 +1,248 @@ +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 - skip contract tests if not + try { + execSync('which cdt-cpp', {stdio: 'ignore'}) + } catch { + // eslint-disable-next-line no-console + console.log('Skipping contract deployment tests: cdt-cpp not installed') + contractAccount = '' + return + } + + // Deploy a test contract + contractAccount = 'testcontract' + execSync( + `node ${cliPath} wallet account create --name ${contractAccount} --url http://127.0.0.1:8888`, + {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} --yes`, { + encoding: 'utf8', + cwd: testDir, + }) + }) + + 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/compile-auto-detect.ts b/test/tests/compile-auto-detect.ts new file mode 100644 index 0000000..103735d --- /dev/null +++ b/test/tests/compile-auto-detect.ts @@ -0,0 +1,599 @@ +import {assert} from 'chai' +import fs from 'fs' +import path from 'path' +import {tmpdir} from 'os' +import { + autoDetectCompileFlags, + detectIncludeDirectories, + detectResourcePaths, + findContractRoot, + getFilesToCompile, + parseIncludes, +} from '../../src/commands/compile' + +suite('Compile Auto-Detection', function () { + function createTestDir(): string { + return fs.mkdtempSync(path.join(tmpdir(), 'wharfkit-compile-test-')) + } + + function cleanupTestDir(testDir: string): void { + if (fs.existsSync(testDir)) { + fs.rmSync(testDir, {recursive: true, force: true}) + } + } + + suite('parseIncludes', function () { + test('parses angle-bracket includes', function () { + const testDir = createTestDir() + try { + const cppFile = path.join(testDir, 'test.cpp') + fs.writeFileSync( + cppFile, + `#include +#include +#include "actions/commit.cpp" +` + ) + + const result = parseIncludes(cppFile) + assert.deepEqual(result.angleBracket, [ + 'randomrng/randomrng.hpp', + 'eosio/eosio.hpp', + ]) + assert.deepEqual(result.quoted, ['actions/commit.cpp']) + } finally { + cleanupTestDir(testDir) + } + }) + + test('parses quoted includes', function () { + const testDir = createTestDir() + try { + const cppFile = path.join(testDir, 'test.cpp') + fs.writeFileSync( + cppFile, + `#include "actions/commit.cpp" +#include "actions/reveal.cpp" +#include "actions/cleanup.cpp" +` + ) + + const result = parseIncludes(cppFile) + assert.deepEqual(result.angleBracket, []) + assert.deepEqual(result.quoted, [ + 'actions/commit.cpp', + 'actions/reveal.cpp', + 'actions/cleanup.cpp', + ]) + } finally { + cleanupTestDir(testDir) + } + }) + + test('handles empty file', function () { + const testDir = createTestDir() + try { + const cppFile = path.join(testDir, 'test.cpp') + fs.writeFileSync(cppFile, '') + + const result = parseIncludes(cppFile) + assert.deepEqual(result.angleBracket, []) + assert.deepEqual(result.quoted, []) + } finally { + cleanupTestDir(testDir) + } + }) + + test('handles mixed includes', function () { + const testDir = createTestDir() + try { + const cppFile = path.join(testDir, 'test.cpp') + fs.writeFileSync( + cppFile, + `#include +#include "local.hpp" +#include +#include "utils/helper.cpp" +` + ) + + const result = parseIncludes(cppFile) + assert.deepEqual(result.angleBracket, ['eosio/eosio.hpp', 'contract/header.hpp']) + assert.deepEqual(result.quoted, ['local.hpp', 'utils/helper.cpp']) + } finally { + cleanupTestDir(testDir) + } + }) + }) + + suite('findContractRoot', function () { + test('finds contract root with include directory', function () { + const testDir = createTestDir() + try { + const contractRoot = path.join(testDir, 'contract') + const includeDir = path.join(contractRoot, 'include') + const srcDir = path.join(contractRoot, 'src') + const cppFile = path.join(srcDir, 'test.cpp') + + fs.mkdirSync(includeDir, {recursive: true}) + fs.mkdirSync(srcDir, {recursive: true}) + fs.writeFileSync(cppFile, '') + + const result = findContractRoot(cppFile) + assert.equal(result, contractRoot) + } finally { + cleanupTestDir(testDir) + } + }) + + test('finds contract root with inc directory', function () { + const testDir = createTestDir() + try { + const contractRoot = path.join(testDir, 'contract') + const incDir = path.join(contractRoot, 'inc') + const cppFile = path.join(contractRoot, 'test.cpp') + + fs.mkdirSync(incDir, {recursive: true}) + fs.writeFileSync(cppFile, '') + + const result = findContractRoot(cppFile) + assert.equal(result, contractRoot) + } finally { + cleanupTestDir(testDir) + } + }) + + test('finds contract root with headers directory', function () { + const testDir = createTestDir() + try { + const contractRoot = path.join(testDir, 'contract') + const headersDir = path.join(contractRoot, 'headers') + const cppFile = path.join(contractRoot, 'test.cpp') + + fs.mkdirSync(headersDir, {recursive: true}) + fs.writeFileSync(cppFile, '') + + const result = findContractRoot(cppFile) + assert.equal(result, contractRoot) + } finally { + cleanupTestDir(testDir) + } + }) + + test('returns source directory if no contract root found', function () { + const testDir = createTestDir() + try { + const cppFile = path.join(testDir, 'test.cpp') + fs.writeFileSync(cppFile, '') + + const result = findContractRoot(cppFile) + assert.equal(result, testDir) + } finally { + cleanupTestDir(testDir) + } + }) + + test('walks up directory tree to find contract root', function () { + const testDir = createTestDir() + try { + const contractRoot = path.join(testDir, 'contract') + const includeDir = path.join(contractRoot, 'include') + const nestedDir = path.join(contractRoot, 'nested', 'deep', 'path') + const cppFile = path.join(nestedDir, 'test.cpp') + + fs.mkdirSync(includeDir, {recursive: true}) + fs.mkdirSync(nestedDir, {recursive: true}) + fs.writeFileSync(cppFile, '') + + const result = findContractRoot(cppFile) + assert.equal(result, contractRoot) + } finally { + cleanupTestDir(testDir) + } + }) + }) + + suite('detectIncludeDirectories', function () { + test('detects include directory in contract root', function () { + const testDir = createTestDir() + try { + const contractRoot = path.join(testDir, 'contract') + const includeDir = path.join(contractRoot, 'include') + const cppFile = path.join(contractRoot, 'test.cpp') + + fs.mkdirSync(includeDir, {recursive: true}) + fs.writeFileSync(cppFile, '') + + const result = detectIncludeDirectories(cppFile, contractRoot) + assert.include(result, includeDir) + } finally { + cleanupTestDir(testDir) + } + }) + + test('detects inc directory', function () { + const testDir = createTestDir() + try { + const contractRoot = path.join(testDir, 'contract') + const incDir = path.join(contractRoot, 'inc') + const cppFile = path.join(contractRoot, 'test.cpp') + + fs.mkdirSync(incDir, {recursive: true}) + fs.writeFileSync(cppFile, '') + + const result = detectIncludeDirectories(cppFile, contractRoot) + assert.include(result, incDir) + } finally { + cleanupTestDir(testDir) + } + }) + + test('detects headers directory', function () { + const testDir = createTestDir() + try { + const contractRoot = path.join(testDir, 'contract') + const headersDir = path.join(contractRoot, 'headers') + const cppFile = path.join(contractRoot, 'test.cpp') + + fs.mkdirSync(headersDir, {recursive: true}) + fs.writeFileSync(cppFile, '') + + const result = detectIncludeDirectories(cppFile, contractRoot) + assert.include(result, headersDir) + } finally { + cleanupTestDir(testDir) + } + }) + + test('detects include directory in source directory', function () { + const testDir = createTestDir() + try { + const contractRoot = path.join(testDir, 'contract') + const srcDir = path.join(contractRoot, 'src') + const srcIncludeDir = path.join(srcDir, 'include') + const cppFile = path.join(srcDir, 'test.cpp') + + fs.mkdirSync(srcIncludeDir, {recursive: true}) + fs.writeFileSync(cppFile, '') + + const result = detectIncludeDirectories(cppFile, contractRoot) + assert.include(result, srcIncludeDir) + } finally { + cleanupTestDir(testDir) + } + }) + + test('returns empty array if no include directories found', function () { + const testDir = createTestDir() + try { + const contractRoot = path.join(testDir, 'contract') + const cppFile = path.join(contractRoot, 'test.cpp') + + fs.mkdirSync(contractRoot, {recursive: true}) + fs.writeFileSync(cppFile, '') + + const result = detectIncludeDirectories(cppFile, contractRoot) + assert.deepEqual(result, []) + } finally { + cleanupTestDir(testDir) + } + }) + + test('does not duplicate directories', function () { + const testDir = createTestDir() + try { + const contractRoot = path.join(testDir, 'contract') + const includeDir = path.join(contractRoot, 'include') + const cppFile = path.join(contractRoot, 'test.cpp') + + fs.mkdirSync(includeDir, {recursive: true}) + fs.writeFileSync(cppFile, '') + + const result = detectIncludeDirectories(cppFile, contractRoot) + assert.equal(result.length, 1) + assert.include(result, includeDir) + } finally { + cleanupTestDir(testDir) + } + }) + }) + + suite('detectResourcePaths', function () { + test('detects src directory in contract root', function () { + const testDir = createTestDir() + try { + const contractRoot = path.join(testDir, 'contract') + const srcDir = path.join(contractRoot, 'src') + const cppFile = path.join(srcDir, 'test.cpp') + + fs.mkdirSync(srcDir, {recursive: true}) + fs.writeFileSync(cppFile, '') + + const result = detectResourcePaths(cppFile, contractRoot) + assert.include(result, srcDir) + } finally { + cleanupTestDir(testDir) + } + }) + + test('includes contract root as resource path', function () { + const testDir = createTestDir() + try { + const contractRoot = path.join(testDir, 'contract') + const cppFile = path.join(contractRoot, 'test.cpp') + + fs.mkdirSync(contractRoot, {recursive: true}) + fs.writeFileSync(cppFile, '') + + const result = detectResourcePaths(cppFile, contractRoot) + assert.include(result, contractRoot) + } finally { + cleanupTestDir(testDir) + } + }) + + test('includes source directory as resource path', function () { + const testDir = createTestDir() + try { + const contractRoot = path.join(testDir, 'contract') + const srcDir = path.join(contractRoot, 'src') + const cppFile = path.join(srcDir, 'test.cpp') + + fs.mkdirSync(srcDir, {recursive: true}) + fs.writeFileSync(cppFile, '') + + const result = detectResourcePaths(cppFile, contractRoot) + assert.include(result, srcDir) + } finally { + cleanupTestDir(testDir) + } + }) + + test('does not duplicate paths', function () { + const testDir = createTestDir() + try { + const contractRoot = path.join(testDir, 'contract') + const cppFile = path.join(contractRoot, 'test.cpp') + + fs.mkdirSync(contractRoot, {recursive: true}) + fs.writeFileSync(cppFile, '') + + const result = detectResourcePaths(cppFile, contractRoot) + // Should have contractRoot and sourceDir (which is same as contractRoot in this case) + assert.isAtLeast(result.length, 1) + } finally { + cleanupTestDir(testDir) + } + }) + }) + + suite('autoDetectCompileFlags', function () { + test('generates -I flags for include directories', function () { + const testDir = createTestDir() + try { + const contractRoot = path.join(testDir, 'contract') + const includeDir = path.join(contractRoot, 'include') + const cppFile = path.join(contractRoot, 'test.cpp') + + fs.mkdirSync(includeDir, {recursive: true}) + fs.writeFileSync( + cppFile, + `#include +#include +` + ) + + const result = autoDetectCompileFlags(cppFile) + const includeFlags = result.filter((flag) => flag.startsWith('-I')) + assert.isAtLeast(includeFlags.length, 1) + assert.include(result, `-I${includeDir}`) + } finally { + cleanupTestDir(testDir) + } + }) + + test('generates -R flags for resource paths when quoted includes exist', function () { + const testDir = createTestDir() + try { + const contractRoot = path.join(testDir, 'contract') + const srcDir = path.join(contractRoot, 'src') + const cppFile = path.join(srcDir, 'test.cpp') + + fs.mkdirSync(srcDir, {recursive: true}) + fs.writeFileSync( + cppFile, + `#include "actions/commit.cpp" +#include "actions/reveal.cpp" +` + ) + + const result = autoDetectCompileFlags(cppFile) + const resourceFlags = result.filter((flag) => flag.startsWith('-R')) + assert.isAtLeast(resourceFlags.length, 1) + } finally { + cleanupTestDir(testDir) + } + }) + + test('does not generate -R flags when no quoted includes', function () { + const testDir = createTestDir() + try { + const contractRoot = path.join(testDir, 'contract') + const srcDir = path.join(contractRoot, 'src') + const cppFile = path.join(srcDir, 'test.cpp') + + fs.mkdirSync(srcDir, {recursive: true}) + fs.writeFileSync( + cppFile, + `#include +` + ) + + const result = autoDetectCompileFlags(cppFile) + const resourceFlags = result.filter((flag) => flag.startsWith('-R')) + assert.equal(resourceFlags.length, 0) + } finally { + cleanupTestDir(testDir) + } + }) + + test('handles complex directory structure', function () { + const testDir = createTestDir() + try { + const contractRoot = path.join(testDir, 'contract') + const includeDir = path.join(contractRoot, 'include') + const srcDir = path.join(contractRoot, 'src') + const cppFile = path.join(srcDir, 'test.cpp') + + fs.mkdirSync(includeDir, {recursive: true}) + fs.mkdirSync(srcDir, {recursive: true}) + fs.writeFileSync( + cppFile, + `#include +#include "actions/commit.cpp" +` + ) + + const result = autoDetectCompileFlags(cppFile) + assert.isAtLeast(result.length, 1) + assert.include(result, `-I${includeDir}`) + } finally { + cleanupTestDir(testDir) + } + }) + + test('returns empty array when no directories or includes found', function () { + const testDir = createTestDir() + try { + const cppFile = path.join(testDir, 'test.cpp') + fs.writeFileSync(cppFile, '') + + const result = autoDetectCompileFlags(cppFile) + assert.deepEqual(result, []) + } finally { + cleanupTestDir(testDir) + } + }) + }) + + suite('getFilesToCompile', function () { + test('finds files in current directory', async function () { + const testDir = createTestDir() + try { + const cppFile1 = path.join(testDir, 'file1.cpp') + const cppFile2 = path.join(testDir, 'file2.cpp') + + fs.writeFileSync(cppFile1, '') + fs.writeFileSync(cppFile2, '') + + const files = await getFilesToCompile(undefined, testDir) + assert.equal(files.length, 2) + assert.include(files, cppFile1) + assert.include(files, cppFile2) + } finally { + cleanupTestDir(testDir) + } + }) + + test('finds files recursively in subdirectories', async function () { + const testDir = createTestDir() + try { + const srcDir = path.join(testDir, 'src') + const nestedDir = path.join(testDir, 'src', 'actions') + const cppFile1 = path.join(srcDir, 'file1.cpp') + const cppFile2 = path.join(nestedDir, 'file2.cpp') + + fs.mkdirSync(nestedDir, {recursive: true}) + fs.writeFileSync(cppFile1, '') + fs.writeFileSync(cppFile2, '') + + const files = await getFilesToCompile(undefined, testDir) + assert.equal(files.length, 2) + assert.include(files, cppFile1) + assert.include(files, cppFile2) + } finally { + cleanupTestDir(testDir) + } + }) + + test('finds files in multiple nested directories', async function () { + const testDir = createTestDir() + try { + const rootCppFile = path.join(testDir, 'root.cpp') + const srcDir = path.join(testDir, 'src') + const srcCppFile = path.join(srcDir, 'src.cpp') + const deepDir = path.join(testDir, 'src', 'deep', 'nested') + const deepCppFile = path.join(deepDir, 'deep.cpp') + + fs.mkdirSync(deepDir, {recursive: true}) + fs.writeFileSync(rootCppFile, '') + fs.writeFileSync(srcCppFile, '') + fs.writeFileSync(deepCppFile, '') + + const files = await getFilesToCompile(undefined, testDir) + assert.equal(files.length, 3) + assert.include(files, rootCppFile) + assert.include(files, srcCppFile) + assert.include(files, deepCppFile) + } finally { + cleanupTestDir(testDir) + } + }) + + test('skips hidden directories and build directories', async function () { + const testDir = createTestDir() + try { + const srcDir = path.join(testDir, 'src') + const buildDir = path.join(testDir, 'build') + const nodeModulesDir = path.join(testDir, 'node_modules') + const hiddenDir = path.join(testDir, '.hidden') + const cppFile1 = path.join(srcDir, 'file1.cpp') + const buildCppFile = path.join(buildDir, 'build.cpp') + const nodeModulesCppFile = path.join(nodeModulesDir, 'module.cpp') + const hiddenCppFile = path.join(hiddenDir, 'hidden.cpp') + + fs.mkdirSync(srcDir, {recursive: true}) + fs.mkdirSync(buildDir, {recursive: true}) + fs.mkdirSync(nodeModulesDir, {recursive: true}) + fs.mkdirSync(hiddenDir, {recursive: true}) + fs.writeFileSync(cppFile1, '') + fs.writeFileSync(buildCppFile, '') + fs.writeFileSync(nodeModulesCppFile, '') + fs.writeFileSync(hiddenCppFile, '') + + const files = await getFilesToCompile(undefined, testDir) + assert.equal(files.length, 1) + assert.include(files, cppFile1) + assert.notInclude(files, buildCppFile) + assert.notInclude(files, nodeModulesCppFile) + assert.notInclude(files, hiddenCppFile) + } finally { + cleanupTestDir(testDir) + } + }) + + test('returns empty array when no files found', async function () { + const testDir = createTestDir() + try { + const files = await getFilesToCompile(undefined, testDir) + assert.deepEqual(files, []) + } finally { + cleanupTestDir(testDir) + } + }) + + test('handles specific file path', async function () { + const testDir = createTestDir() + try { + const cppFile = path.join(testDir, 'specific.cpp') + fs.writeFileSync(cppFile, '') + + const files = await getFilesToCompile('specific.cpp', testDir) + assert.equal(files.length, 1) + assert.include(files, cppFile) + } finally { + cleanupTestDir(testDir) + } + }) + }) +}) diff --git a/test/tests/contract-info.ts b/test/tests/contract-info.ts new file mode 100644 index 0000000..c13781f --- /dev/null +++ b/test/tests/contract-info.ts @@ -0,0 +1,396 @@ +import {assert} from 'chai' +import sinon from 'sinon' +import {ABI, Asset, Name, UInt64} from '@wharfkit/antelope' + +import {getApiUrl, lookupContractInfo} from 'src/commands/contract/info' + +import eosioTokenAbi from '../data/abis/eosio.token.json' +import rewardsGmAbi from '../data/abis/rewards.gm.json' + +suite('Contract Info', function () { + let sandbox: sinon.SinonSandbox + let consoleLogStub: sinon.SinonStub + let consoleErrorStub: sinon.SinonStub + let processExitStub: sinon.SinonStub + + setup(function () { + sandbox = sinon.createSandbox() + consoleLogStub = sandbox.stub(console, 'log') + consoleErrorStub = sandbox.stub(console, 'error') + processExitStub = sandbox.stub(process, 'exit') + }) + + teardown(function () { + sandbox.restore() + }) + + suite('getApiUrl', function () { + test('returns URL for known chain "EOS"', function () { + const url = getApiUrl('EOS') + assert.isString(url) + assert.include(url, 'http') + }) + + test('returns URL for known chain case-insensitive', function () { + const url = getApiUrl('eos') + assert.isString(url) + assert.include(url, 'http') + }) + + test('returns URL for Jungle4', function () { + const url = getApiUrl('Jungle4') + assert.isString(url) + assert.include(url, 'http') + }) + + test('returns localhost URL for "local" chain', function () { + const url = getApiUrl('local') + assert.equal(url, 'http://127.0.0.1:8888') + }) + + test('returns custom URL when provided', function () { + const customUrl = 'http://my-custom-node.com:8888' + const url = getApiUrl(customUrl) + assert.equal(url, customUrl) + }) + + test('throws error for unknown chain without URL format', function () { + try { + getApiUrl('unknownchain') + assert.fail('Should throw error for unknown chain') + } catch (error) { + assert.include((error as Error).message, 'Unknown chain: unknownchain') + } + }) + }) + + suite('lookupContractInfo', function () { + function createMockApiClient( + mockAccount: object, + mockAbiResponse: object, + accountError?: Error + ) { + return { + v1: { + chain: { + get_account: accountError + ? sandbox.stub().rejects(accountError) + : sandbox.stub().resolves(mockAccount), + get_abi: sandbox.stub().resolves(mockAbiResponse), + }, + }, + } + } + + test('outputs JSON format when --json option is used', async function () { + const mockAccount = { + last_code_update: '2024-01-01T00:00:00.000', + ram_usage: UInt64.from(50000), + ram_quota: UInt64.from(100000), + core_liquid_balance: Asset.from('100.0000 EOS'), + } + + const mockAbiResponse = { + account_name: Name.from('eosio.token'), + abi: ABI.from(eosioTokenAbi), + } + + const mockClient = createMockApiClient(mockAccount, mockAbiResponse) + + await lookupContractInfo('local', 'eosio.token', { + json: true, + _apiClient: mockClient, + }) + + assert(consoleLogStub.called, 'console.log should be called') + const output = consoleLogStub.firstCall.args[0] + const parsed = JSON.parse(output) + + assert.equal(parsed.account, 'eosio.token') + assert.equal(parsed.chain, 'local') + assert.isTrue(parsed.hasCode) + assert.isArray(parsed.actions) + assert.isArray(parsed.tables) + assert.isArray(parsed.structs) + }) + + test('outputs pretty format by default', async function () { + const mockAccount = { + last_code_update: '2024-01-01T00:00:00.000', + ram_usage: UInt64.from(50000), + ram_quota: UInt64.from(100000), + core_liquid_balance: Asset.from('100.0000 EOS'), + } + + const mockAbiResponse = { + account_name: Name.from('eosio.token'), + abi: ABI.from(eosioTokenAbi), + } + + const mockClient = createMockApiClient(mockAccount, mockAbiResponse) + + await lookupContractInfo('local', 'eosio.token', {_apiClient: mockClient}) + + assert(consoleLogStub.called, 'console.log should be called') + + // Check that contract name is logged + const allOutput = consoleLogStub + .getCalls() + .map((c) => c.args[0]) + .join('\n') + assert.include(allOutput, 'Contract: eosio.token') + assert.include(allOutput, 'Chain: local') + }) + + test('shows "no contract deployed" message when account has no ABI', async function () { + const mockAccount = { + last_code_update: '2024-01-01T00:00:00.000', + ram_usage: UInt64.from(5000), + ram_quota: UInt64.from(10000), + } + + const mockAbiResponse = { + account_name: Name.from('testaccount'), + abi: undefined, + } + + const mockClient = createMockApiClient(mockAccount, mockAbiResponse) + + await lookupContractInfo('local', 'testaccount', {_apiClient: mockClient}) + + const allOutput = consoleLogStub + .getCalls() + .map((c) => c.args[0]) + .join('\n') + assert.include(allOutput, 'No contract deployed') + }) + + test('displays actions with parameters', async function () { + const mockAccount = { + last_code_update: '2024-01-01T00:00:00.000', + ram_usage: UInt64.from(50000), + ram_quota: UInt64.from(100000), + core_liquid_balance: Asset.from('100.0000 EOS'), + } + + const mockAbiResponse = { + account_name: Name.from('eosio.token'), + abi: ABI.from(eosioTokenAbi), + } + + const mockClient = createMockApiClient(mockAccount, mockAbiResponse) + + await lookupContractInfo('local', 'eosio.token', {_apiClient: mockClient}) + + const allOutput = consoleLogStub + .getCalls() + .map((c) => c.args[0]) + .join('\n') + assert.include(allOutput, 'Actions:') + assert.include(allOutput, 'transfer') + }) + + test('displays tables', async function () { + const mockAccount = { + last_code_update: '2024-01-01T00:00:00.000', + ram_usage: UInt64.from(50000), + ram_quota: UInt64.from(100000), + core_liquid_balance: Asset.from('100.0000 EOS'), + } + + const mockAbiResponse = { + account_name: Name.from('eosio.token'), + abi: ABI.from(eosioTokenAbi), + } + + const mockClient = createMockApiClient(mockAccount, mockAbiResponse) + + await lookupContractInfo('local', 'eosio.token', {_apiClient: mockClient}) + + const allOutput = consoleLogStub + .getCalls() + .map((c) => c.args[0]) + .join('\n') + assert.include(allOutput, 'Tables:') + assert.include(allOutput, 'accounts') + }) + + test('handles account not found error', async function () { + const mockClient = createMockApiClient({}, {}, new Error('Account not found')) + + await lookupContractInfo('local', 'nonexistent', {_apiClient: mockClient}) + + assert(consoleErrorStub.called, 'console.error should be called') + const errorOutput = consoleErrorStub.firstCall.args[0] + assert.include(errorOutput, 'nonexistent') + assert.include(errorOutput, 'not found') + assert(processExitStub.calledWith(1), 'process.exit should be called with 1') + }) + + test('handles generic API errors', async function () { + const mockClient = createMockApiClient({}, {}, new Error('Network error')) + + await lookupContractInfo('local', 'testaccount', {_apiClient: mockClient}) + + assert(consoleErrorStub.called, 'console.error should be called') + const errorOutput = consoleErrorStub.firstCall.args[0] + assert.include(errorOutput, 'Network error') + assert(processExitStub.calledWith(1), 'process.exit should be called with 1') + }) + + test('JSON output includes correct action list', async function () { + const mockAccount = { + last_code_update: '2024-01-01T00:00:00.000', + ram_usage: UInt64.from(50000), + ram_quota: UInt64.from(100000), + core_liquid_balance: Asset.from('100.0000 EOS'), + } + + const mockAbiResponse = { + account_name: Name.from('eosio.token'), + abi: ABI.from(eosioTokenAbi), + } + + const mockClient = createMockApiClient(mockAccount, mockAbiResponse) + + await lookupContractInfo('local', 'eosio.token', { + json: true, + _apiClient: mockClient, + }) + + const output = consoleLogStub.firstCall.args[0] + const parsed = JSON.parse(output) + + // eosio.token has these actions: close, create, issue, open, retire, transfer + assert.include(parsed.actions, 'close') + assert.include(parsed.actions, 'create') + assert.include(parsed.actions, 'issue') + assert.include(parsed.actions, 'transfer') + }) + + test('JSON output includes correct table list', async function () { + const mockAccount = { + last_code_update: '2024-01-01T00:00:00.000', + ram_usage: UInt64.from(50000), + ram_quota: UInt64.from(100000), + core_liquid_balance: Asset.from('100.0000 EOS'), + } + + const mockAbiResponse = { + account_name: Name.from('eosio.token'), + abi: ABI.from(eosioTokenAbi), + } + + const mockClient = createMockApiClient(mockAccount, mockAbiResponse) + + await lookupContractInfo('local', 'eosio.token', { + json: true, + _apiClient: mockClient, + }) + + const output = consoleLogStub.firstCall.args[0] + const parsed = JSON.parse(output) + + // eosio.token has these tables: accounts, stat + assert.include(parsed.tables, 'accounts') + assert.include(parsed.tables, 'stat') + }) + + test('shows Ricardian contract indicator when present', async function () { + const mockAccount = { + last_code_update: '2024-01-01T00:00:00.000', + ram_usage: UInt64.from(50000), + ram_quota: UInt64.from(100000), + core_liquid_balance: Asset.from('100.0000 EOS'), + } + + // eosio.token.json has Ricardian contracts + const mockAbiResponse = { + account_name: Name.from('eosio.token'), + abi: ABI.from(eosioTokenAbi), + } + + const mockClient = createMockApiClient(mockAccount, mockAbiResponse) + + await lookupContractInfo('local', 'eosio.token', {_apiClient: mockClient}) + + const allOutput = consoleLogStub + .getCalls() + .map((c) => c.args[0]) + .join('\n') + assert.include(allOutput, 'Has Ricardian contracts') + }) + + test('works with contracts that have action_results', async function () { + const mockAccount = { + last_code_update: '2024-01-01T00:00:00.000', + ram_usage: UInt64.from(50000), + ram_quota: UInt64.from(100000), + core_liquid_balance: Asset.from('100.0000 GM'), + } + + const mockAbiResponse = { + account_name: Name.from('rewards.gm'), + abi: ABI.from(rewardsGmAbi), + } + + const mockClient = createMockApiClient(mockAccount, mockAbiResponse) + + // Should not throw an error + await lookupContractInfo('local', 'rewards.gm', {_apiClient: mockClient}) + + assert(consoleLogStub.called, 'console.log should be called') + }) + + test('handles account without core_liquid_balance', async function () { + const mockAccount = { + last_code_update: '2024-01-01T00:00:00.000', + ram_usage: UInt64.from(5000), + ram_quota: UInt64.from(10000), + // No core_liquid_balance + } + + const mockAbiResponse = { + account_name: Name.from('eosio.token'), + abi: ABI.from(eosioTokenAbi), + } + + const mockClient = createMockApiClient(mockAccount, mockAbiResponse) + + // Should not throw an error + await lookupContractInfo('local', 'eosio.token', {_apiClient: mockClient}) + + const allOutput = consoleLogStub + .getCalls() + .map((c) => c.args[0]) + .join('\n') + // Should not include Balance: line since there's no balance + assert.notInclude(allOutput, 'Balance:') + }) + + test('JSON output handles missing balance correctly', async function () { + const mockAccount = { + last_code_update: '2024-01-01T00:00:00.000', + ram_usage: UInt64.from(5000), + ram_quota: UInt64.from(10000), + } + + const mockAbiResponse = { + account_name: Name.from('eosio.token'), + abi: ABI.from(eosioTokenAbi), + } + + const mockClient = createMockApiClient(mockAccount, mockAbiResponse) + + await lookupContractInfo('local', 'eosio.token', { + json: true, + _apiClient: mockClient, + }) + + const output = consoleLogStub.firstCall.args[0] + const parsed = JSON.parse(output) + + assert.equal(parsed.balance, '0') + }) + }) +}) diff --git a/test/tests/deploy-utils.ts b/test/tests/deploy-utils.ts new file mode 100644 index 0000000..60fe37c --- /dev/null +++ b/test/tests/deploy-utils.ts @@ -0,0 +1,117 @@ +import {assert} from 'chai' +import {Asset} from '@wharfkit/antelope' +import { + calculateRamCost, + calculateRamNeeded, + formatBytes, +} from '../../src/commands/contract/deploy-utils' + +suite('deploy-utils', function () { + suite('calculateRamNeeded', function () { + test('calculates RAM for small contract', function () { + // 1KB WASM + 500 bytes ABI + const wasmSize = 1024 + const abiSize = 500 + + const ramNeeded = calculateRamNeeded(wasmSize, abiSize) + + // setcode requires 10x WASM, setabi requires ABI size + // Then 1% buffer is added + // Formula in code: setcodeRam + setabiRam + ceil((setcodeRam + setabiRam) * 0.01) + const setcodeRam = wasmSize * 10 + const setabiRam = abiSize + const buffer = Math.ceil((setcodeRam + setabiRam) * 0.01) + const expected = setcodeRam + setabiRam + buffer + assert.equal(ramNeeded, expected) + }) + + test('calculates RAM for medium contract', function () { + // 50KB WASM + 10KB ABI + const wasmSize = 50 * 1024 + const abiSize = 10 * 1024 + + const ramNeeded = calculateRamNeeded(wasmSize, abiSize) + + // setcode needs 10x WASM, setabi needs ABI size, plus 1% buffer + const setcodeRam = wasmSize * 10 + const setabiRam = abiSize + const buffer = Math.ceil((setcodeRam + setabiRam) * 0.01) + const expected = setcodeRam + setabiRam + buffer + assert.equal(ramNeeded, expected) + }) + + test('calculates RAM for large contract', function () { + // 200KB WASM + 50KB ABI + const wasmSize = 200 * 1024 + const abiSize = 50 * 1024 + + const ramNeeded = calculateRamNeeded(wasmSize, abiSize) + + const setcodeRam = wasmSize * 10 + const setabiRam = abiSize + const buffer = Math.ceil((setcodeRam + setabiRam) * 0.01) + const expected = setcodeRam + setabiRam + buffer + assert.equal(ramNeeded, expected) + // Should be around 2.1MB + assert.isAbove(ramNeeded, 2 * 1024 * 1024) + }) + }) + + suite('calculateRamCost', function () { + test('calculates cost for small amount of RAM', function () { + // 10KB of RAM at 0.001 EOS per byte + const bytesNeeded = 10 * 1024 + const pricePerByte = 0.0001 + const symbol = 'EOS' + + const cost = calculateRamCost(bytesNeeded, pricePerByte, symbol) + + // Expected: 10240 * 0.0001 * 1.005 (0.5% fee) = 1.02912 + assert.instanceOf(cost, Asset) + assert.include(String(cost), 'EOS') + // Value should be close to 1.03 (with fee) + assert.isAbove(cost.value, 1) + assert.isBelow(cost.value, 1.1) + }) + + test('uses correct symbol', function () { + const bytesNeeded = 1000 + const pricePerByte = 0.0001 + const symbol = 'WAX' + + const cost = calculateRamCost(bytesNeeded, pricePerByte, symbol) + + assert.include(String(cost), 'WAX') + }) + + test('handles zero bytes', function () { + const cost = calculateRamCost(0, 0.0001, 'EOS') + assert.equal(cost.value, 0) + }) + }) + + suite('formatBytes', function () { + test('formats bytes', function () { + assert.equal(formatBytes(500), '500 bytes') + assert.equal(formatBytes(1023), '1023 bytes') + }) + + test('formats kilobytes', function () { + assert.equal(formatBytes(1024), '1.00 KB') + assert.equal(formatBytes(2048), '2.00 KB') + assert.equal(formatBytes(10240), '10.00 KB') + assert.equal(formatBytes(1536), '1.50 KB') + }) + + test('formats megabytes', function () { + assert.equal(formatBytes(1024 * 1024), '1.00 MB') + assert.equal(formatBytes(2 * 1024 * 1024), '2.00 MB') + assert.equal(formatBytes(1.5 * 1024 * 1024), '1.50 MB') + }) + + test('handles edge cases', function () { + assert.equal(formatBytes(0), '0 bytes') + assert.equal(formatBytes(1), '1 bytes') + }) + }) +}) diff --git a/test/tests/e2e-cli-structure.ts b/test/tests/e2e-cli-structure.ts new file mode 100644 index 0000000..7ad31c7 --- /dev/null +++ b/test/tests/e2e-cli-structure.ts @@ -0,0 +1,77 @@ +import {assert} from 'chai' +import {execSync} from 'child_process' +import * as path from 'path' +import {isNodeosAvailable} from '../utils/test-helpers' + +/** + * E2E tests for CLI command structure: + * - Verifies commands exist and have correct help text + * - Tests command hierarchy + * + * Note: These tests don't require a running chain, but we still + * check for nodeos availability to match other E2E test behavior. + */ +suite('E2E: CLI Structure', () => { + const cliPath = path.join(__dirname, '../../lib/cli.js') + + suiteSetup(function () { + if (!isNodeosAvailable()) { + // eslint-disable-next-line no-console + console.log('Skipping E2E tests: nodeos is not available in PATH') + this.skip() + } + }) + + 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') + }) + + test('wallet keys command has add subcommand', function () { + const output = execSync(`node ${cliPath} wallet keys --help`, {encoding: 'utf8'}) + + assert.include(output, 'add') + assert.include(output, 'create') + assert.include(output, 'Add an existing private key') + }) + }) +}) + + diff --git a/test/tests/e2e-compile.ts b/test/tests/e2e-compile.ts new file mode 100644 index 0000000..8e9237b --- /dev/null +++ b/test/tests/e2e-compile.ts @@ -0,0 +1,69 @@ +import {assert} from 'chai' +import {execSync} from 'child_process' +import * as fs from 'fs' +import * as path from 'path' +import type {E2ETestContext} from '../utils/test-helpers' +import {setupE2ETestEnvironment, teardownE2ETestEnvironment} from '../utils/test-helpers' + +/** + * E2E tests for contract compilation: + * - Compile command behavior + * - Error handling for missing files + * - CDT integration + */ +suite('E2E: Compile', () => { + let ctx: E2ETestContext | null = null + + suiteSetup(async function () { + ctx = await setupE2ETestEnvironment(this) + }) + + suiteTeardown(function () { + this.timeout(30000) + teardownE2ETestEnvironment(ctx) + }) + + suite('Contract Compilation', () => { + test('shows helpful error when no cpp files found', function () { + if (!ctx) this.skip() + try { + execSync(`node ${ctx.cliPath} compile`, { + encoding: 'utf8', + cwd: ctx.testDir, + }) + assert.fail('Should throw error when no cpp files found') + } catch (error: any) { + assert.isTrue(error.status !== 0 || error.code !== 0) + } + }) + + test('can compile a cpp file when cdt is installed', function () { + if (!ctx) this.skip() + const rootCppPath = path.join(__dirname, '../../test.cpp') + const cppPath = path.join(ctx.testDir, 'test.cpp') + + fs.copyFileSync(rootCppPath, cppPath) + + try { + const output = execSync(`node ${ctx.cliPath} compile`, { + encoding: 'utf8', + cwd: ctx.testDir, + }) + + assert.isTrue( + output.includes('Compilation complete!') || + output.includes('cdt-cpp is not installed') || + output.includes('LEAP is not installed') + ) + } catch (error: any) { + const output = error.stderr || error.stdout + assert.isTrue( + output.includes('cdt-cpp is not installed') || + output.includes('LEAP is not installed') + ) + } + }) + }) +}) + + diff --git a/test/tests/e2e-deploy.ts b/test/tests/e2e-deploy.ts new file mode 100644 index 0000000..11c2cd0 --- /dev/null +++ b/test/tests/e2e-deploy.ts @@ -0,0 +1,760 @@ +import {assert} from 'chai' +import type {ChildProcess} from 'child_process' +import {execSync, spawn} from 'child_process' +import * as fs from 'fs' +import * as path from 'path' +import {ABI, APIClient, FetchProvider, Serializer} from '@wharfkit/antelope' +import fetch from 'node-fetch' +import {log} from '../../src/utils' +import type {E2ETestContext} from '../utils/test-helpers' +import { + getRandomLocalAccountName, + getTransactionExpiration, + setupE2ETestEnvironment, + teardownE2ETestEnvironment, +} from '../utils/test-helpers' + +/** + * E2E tests for deployment functionality: + * - Key selection logic + * - Account creation + * - Contract deployment + * - RAM analysis + * - Deploy key options (--key, env vars) + */ +suite('E2E: Deploy', () => { + let ctx: E2ETestContext | null = null + + suiteSetup(async function () { + ctx = await setupE2ETestEnvironment(this) + }) + + suiteTeardown(function () { + this.timeout(30000) + teardownE2ETestEnvironment(ctx) + }) + + suite('Key Selection Logic', () => { + test('deploy command has --account option for key selection', function () { + if (!ctx) this.skip() + const deployHelp = execSync(`node ${ctx.cliPath} contract deploy --help`, { + encoding: 'utf8', + }) + + assert.include(deployHelp, '--account') + }) + + test('deploy auto-selects key matching account name', function () { + if (!ctx) this.skip() + this.timeout(60000) + + const accountName = getRandomLocalAccountName('autokey') + + // 1. Create a key with the SAME name as the account + const keyOutput = execSync(`node ${ctx.cliPath} wallet create --name ${accountName}`, { + encoding: 'utf8', + }) + + const publicKeyMatch = keyOutput.match(/Public Key: (PUB_K1_[A-Za-z0-9]+)/) + assert.isNotNull(publicKeyMatch, 'Should have public key in output') + const accountKeyPublic = publicKeyMatch![1] + + // 2. Create account with the matching key's public key + execSync( + `node ${ctx.cliPath} wallet account create --name ${accountName} --url http://127.0.0.1:8888 --key ${accountKeyPublic}`, + {encoding: 'utf8'} + ) + + // 3. Copy and compile test contract + const rootCppPath = path.join(__dirname, '../../test.cpp') + const cppPath = path.join(ctx.testDir, 'autokey_test.cpp') + const wasmPath = path.join(ctx.testDir, 'autokey_test.wasm') + + const contractCode = fs.readFileSync(rootCppPath, 'utf8') + const modifiedCode = contractCode.replace( + /class \[\[eosio::contract\]\] test/, + 'class [[eosio::contract]] autokey_test' + ) + fs.writeFileSync(cppPath, modifiedCode) + + execSync(`node ${ctx.cliPath} compile ${cppPath} --output ${ctx.testDir}`, { + encoding: 'utf8', + cwd: ctx.testDir, + }) + + assert.isTrue(fs.existsSync(wasmPath), 'WASM file should be generated') + + // 4. Deploy WITHOUT specifying --key + const output = execSync( + `node ${ctx.cliPath} contract deploy ${wasmPath} --account ${accountName} --yes`, + { + encoding: 'utf8', + cwd: ctx.testDir, + } + ) + + assert.include(output, `Using wallet key: ${accountName}`) + assert.include(output, '✅ Contract deployed successfully!') + assert.include(output, 'Transaction ID:') + }) + }) + + suite('Account and Deployment', () => { + test('can create an account on the local chain', function () { + if (!ctx) this.skip() + const accountName = getRandomLocalAccountName('acc') + const output = execSync( + `node ${ctx.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 () { + if (!ctx) this.skip() + this.timeout(60000) + + const accountName = getRandomLocalAccountName('deploy') + execSync( + `node ${ctx.cliPath} wallet account create --name ${accountName} --url http://127.0.0.1:8888`, + {encoding: 'utf8'} + ) + + const rootCppPath = path.join(__dirname, '../../test.cpp') + const cppPath = path.join(ctx.testDir, 'test.cpp') + const wasmPath = path.join(ctx.testDir, 'test.wasm') + + fs.copyFileSync(rootCppPath, cppPath) + + execSync(`node ${ctx.cliPath} compile`, { + encoding: 'utf8', + cwd: ctx.testDir, + }) + + assert.isTrue(fs.existsSync(wasmPath), 'WASM file should be generated') + + const output = execSync( + `node ${ctx.cliPath} contract deploy ${wasmPath} --account ${accountName} --yes`, + { + encoding: 'utf8', + cwd: ctx.testDir, + } + ) + + assert.include(output, '✅ Contract deployed successfully!') + assert.include(output, 'Transaction ID:') + }) + + test('shows RAM analysis during deployment', function () { + if (!ctx) this.skip() + this.timeout(60000) + + const accountName = getRandomLocalAccountName('ramtest') + execSync( + `node ${ctx.cliPath} wallet account create --name ${accountName} --url http://127.0.0.1:8888`, + {encoding: 'utf8'} + ) + + const rootCppPath = path.join(__dirname, '../../test.cpp') + const cppPath = path.join(ctx.testDir, 'ramanalysis_test.cpp') + const wasmPath = path.join(ctx.testDir, 'ramanalysis_test.wasm') + + const contractCode = fs.readFileSync(rootCppPath, 'utf8') + const modifiedCode = contractCode.replace( + /class \[\[eosio::contract\]\] test/, + 'class [[eosio::contract]] ramanalysis_test' + ) + fs.writeFileSync(cppPath, modifiedCode) + + execSync(`node ${ctx.cliPath} compile ${cppPath} --output ${ctx.testDir}`, { + encoding: 'utf8', + cwd: ctx.testDir, + }) + + assert.isTrue(fs.existsSync(wasmPath), 'WASM file should be generated') + + const output = execSync( + `node ${ctx.cliPath} contract deploy ${wasmPath} --account ${accountName} --yes`, + { + encoding: 'utf8', + cwd: ctx.testDir, + } + ) + + assert.include(output, '📊 RAM Analysis') + assert.include(output, 'RAM needed:') + assert.include(output, 'Current RAM available:') + assert.isTrue( + output.includes('RAM to purchase:') || + output.includes('RAM management not required'), + 'Should show RAM info or local chain message' + ) + assert.include(output, '✅ Contract deployed successfully!') + }) + + test('shows QR code when insufficient funds and completes after transfer', async function () { + if (!ctx) this.skip() + this.timeout(120000) + + const accountName = getRandomLocalAccountName('qrtest') + execSync( + `node ${ctx.cliPath} wallet account create --name ${accountName} --url http://127.0.0.1:8888`, + {encoding: 'utf8'} + ) + + const client = new APIClient({ + provider: new FetchProvider('http://127.0.0.1:8888', {fetch}), + }) + try { + const balances = await client.v1.chain.get_currency_balance( + 'eosio.token', + accountName + ) + assert.equal(balances.length, 0, 'Account should have no token balance initially') + } catch { + // eosio.token might not be deployed on local chain + } + + const rootCppPath = path.join(__dirname, '../../test.cpp') + const cppPath = path.join(ctx.testDir, 'qrfunds_test.cpp') + const wasmPath = path.join(ctx.testDir, 'qrfunds_test.wasm') + + const contractCode = fs.readFileSync(rootCppPath, 'utf8') + const modifiedCode = contractCode.replace( + /class \[\[eosio::contract\]\] test/, + 'class [[eosio::contract]] qrfunds_test' + ) + fs.writeFileSync(cppPath, modifiedCode) + + execSync(`node ${ctx.cliPath} compile ${cppPath} --output ${ctx.testDir}`, { + encoding: 'utf8', + cwd: ctx.testDir, + }) + + assert.isTrue(fs.existsSync(wasmPath), 'WASM file should be generated') + + let deployOutput = '' + let deployExitCode: number | null = null + + const deployProcess: ChildProcess = spawn( + 'node', + [ctx.cliPath, 'contract', 'deploy', wasmPath, '--account', accountName, '--yes'], + { + cwd: ctx.testDir, + env: {...process.env, HOME: ctx.testDir}, + } + ) + + const deployPromise = new Promise((resolve, reject) => { + deployProcess.stdout?.on('data', (data: Buffer) => { + deployOutput += data.toString() + }) + + deployProcess.stderr?.on('data', (data: Buffer) => { + deployOutput += data.toString() + }) + + deployProcess.on('close', (code) => { + deployExitCode = code + if (code === 0) { + resolve() + } else { + reject(new Error(`Deploy process exited with code ${code}`)) + } + }) + + deployProcess.on('error', (err) => { + reject(err) + }) + }) + + const waitForQrCode = async (): Promise => { + const startTime = Date.now() + const timeout = 30000 + + while (Date.now() - startTime < timeout) { + if ( + deployOutput.includes('esr://') || + deployOutput.includes('Scan this QR code') + ) { + return true + } + if (deployExitCode !== null) { + return false + } + await new Promise((resolve) => setTimeout(resolve, 200)) + } + return false + } + + const qrCodeShown = await waitForQrCode() + + if (qrCodeShown) { + assert.include(deployOutput, 'esr://', 'Should show ESR link') + assert.include( + deployOutput, + 'Scan this QR code', + 'Should show QR code instructions' + ) + assert.include(deployOutput, 'Waiting for funds', 'Should show waiting message') + + 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) + + const transferTx = { + expiration: getTransactionExpiration(), + ref_block_num: blockNum & 0xffff, + 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.token', + name: 'transfer', + authorization: [{actor: 'eosio', permission: 'active'}], + data: Serializer.encode({ + object: { + from: 'eosio', + to: accountName, + quantity: '100.0000 SYS', + memo: 'funding for contract deployment', + }, + abi: (await client.v1.chain.get_abi('eosio.token')).abi!, + type: 'transfer', + }).hexString, + }, + ], + transaction_extensions: [], + } + + const txPath = path.join(ctx.testDir, 'transfer_for_deploy.json') + fs.writeFileSync(txPath, JSON.stringify(transferTx)) + + log('Transferring 100 SYS to account...', 'info') + execSync( + `node ${ctx.cliPath} wallet transact ${txPath} --broadcast --key chain-key`, + { + encoding: 'utf8', + env: {...process.env, HOME: ctx.testDir}, + } + ) + + try { + await Promise.race([ + deployPromise, + new Promise((_, reject) => + setTimeout( + () => reject(new Error('Deploy timed out after transfer')), + 20000 + ) + ), + ]) + } catch (e) { + if (deployExitCode === null) { + deployProcess.kill() + } + throw e + } + } else { + await deployPromise + } + + assert.include( + deployOutput, + '✅ Contract deployed successfully!', + 'Deployment should succeed' + ) + assert.include(deployOutput, 'Transaction ID:', 'Should show transaction ID') + }) + + test('validates table removal safety', async function () { + if (!ctx) this.skip() + this.timeout(120000) + + const accountName = getRandomLocalAccountName('val') + execSync( + `node ${ctx.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(ctx.testDir, 'v1.cpp') + fs.writeFileSync(cppPath, v1Code) + + execSync(`node ${ctx.cliPath} compile ${cppPath} --output ${ctx.testDir}`, { + encoding: 'utf8', + }) + execSync( + `node ${ctx.cliPath} contract deploy ${path.join( + ctx.testDir, + 'v1.wasm' + )} --account ${accountName} --yes`, + {encoding: 'utf8', cwd: ctx.testDir} + ) + + // 2. Add data to the table + const abiPath = path.join(ctx.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 + + 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) + + 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(ctx.testDir, 'insert_data.json') + fs.writeFileSync(txPath, JSON.stringify(tx)) + + try { + execSync(`node ${ctx.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(ctx.testDir, 'v2.cpp') + fs.writeFileSync(v2CppPath, v2Code) + + execSync(`node ${ctx.cliPath} compile ${v2CppPath} --output ${ctx.testDir}`, { + encoding: 'utf8', + }) + const v2Wasm = path.join(ctx.testDir, 'v2.wasm') + + // 4. Try to deploy V2 - SHOULD FAIL + try { + execSync( + `node ${ctx.cliPath} contract deploy ${v2Wasm} --account ${accountName} --yes`, + { + encoding: 'utf8', + cwd: ctx.testDir, + stdio: 'pipe', + } + ) + assert.fail('Should have failed validation') + } catch (error: unknown) { + const err = error as {stderr?: string; stdout?: string} + const output = (err.stderr || '').toString() + (err.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 ${ctx.cliPath} contract deploy ${v2Wasm} --account ${accountName} --force --yes`, + { + encoding: 'utf8', + cwd: ctx.testDir, + } + ) + assert.include(output, 'Contract deployed successfully') + assert.include(output, 'Proceeding despite data loss warning') + }) + }) + + suite('Deploy Key Options', () => { + let deployKeyPrivate: string + let deployKeyPublic: string + + suiteSetup(function () { + if (!ctx) return + const keyOutput = execSync(`node ${ctx.cliPath} wallet create --name deploy-test-key`, { + encoding: 'utf8', + }) + const privateKeyMatch = keyOutput.match(/Private Key: (PVT_K1_[A-Za-z0-9]+)/) + const publicKeyMatch = keyOutput.match(/Public Key: (PUB_K1_[A-Za-z0-9]+)/) + if (!privateKeyMatch || !publicKeyMatch) { + throw new Error('Could not extract keys from wallet create output') + } + deployKeyPrivate = privateKeyMatch[1] + deployKeyPublic = publicKeyMatch[1] + }) + + test('can deploy using --key option with wallet key name', function () { + if (!ctx) this.skip() + this.timeout(60000) + + const accountName = getRandomLocalAccountName('keyopt') + + execSync( + `node ${ctx.cliPath} wallet account create --name ${accountName} --url http://127.0.0.1:8888 --key ${deployKeyPublic}`, + {encoding: 'utf8'} + ) + + const rootCppPath = path.join(__dirname, '../../test.cpp') + const cppPath = path.join(ctx.testDir, 'keyopt_test.cpp') + const wasmPath = path.join(ctx.testDir, 'keyopt_test.wasm') + + const contractCode = fs.readFileSync(rootCppPath, 'utf8') + const modifiedCode = contractCode.replace( + /class \[\[eosio::contract\]\] test/, + 'class [[eosio::contract]] keyopt_test' + ) + fs.writeFileSync(cppPath, modifiedCode) + + execSync(`node ${ctx.cliPath} compile ${cppPath} --output ${ctx.testDir}`, { + encoding: 'utf8', + cwd: ctx.testDir, + }) + + assert.isTrue(fs.existsSync(wasmPath), 'WASM file should be generated') + + const output = execSync( + `node ${ctx.cliPath} contract deploy ${wasmPath} --account ${accountName} --key deploy-test-key --yes`, + { + encoding: 'utf8', + cwd: ctx.testDir, + } + ) + + assert.include(output, 'Using wallet key: deploy-test-key') + assert.include(output, '✅ Contract deployed successfully!') + assert.include(output, 'Transaction ID:') + }) + + test('can deploy using --key option with private key directly', function () { + if (!ctx) this.skip() + this.timeout(60000) + + const accountName = getRandomLocalAccountName('keypvt') + + execSync( + `node ${ctx.cliPath} wallet account create --name ${accountName} --url http://127.0.0.1:8888 --key ${deployKeyPublic}`, + {encoding: 'utf8'} + ) + + const rootCppPath = path.join(__dirname, '../../test.cpp') + const cppPath = path.join(ctx.testDir, 'keypvt_test.cpp') + const wasmPath = path.join(ctx.testDir, 'keypvt_test.wasm') + + const contractCode = fs.readFileSync(rootCppPath, 'utf8') + const modifiedCode = contractCode.replace( + /class \[\[eosio::contract\]\] test/, + 'class [[eosio::contract]] keypvt_test' + ) + fs.writeFileSync(cppPath, modifiedCode) + + execSync(`node ${ctx.cliPath} compile ${cppPath} --output ${ctx.testDir}`, { + encoding: 'utf8', + cwd: ctx.testDir, + }) + + assert.isTrue(fs.existsSync(wasmPath), 'WASM file should be generated') + + const output = execSync( + `node ${ctx.cliPath} contract deploy ${wasmPath} --account ${accountName} --key ${deployKeyPrivate} --yes`, + { + encoding: 'utf8', + cwd: ctx.testDir, + } + ) + + assert.include(output, 'Using private key from --key option') + assert.include(output, '✅ Contract deployed successfully!') + assert.include(output, 'Transaction ID:') + }) + + test('can deploy using WHARFKIT_DEPLOY_KEY environment variable with private key', function () { + if (!ctx) this.skip() + this.timeout(60000) + + const accountName = getRandomLocalAccountName('envkey') + + execSync( + `node ${ctx.cliPath} wallet account create --name ${accountName} --url http://127.0.0.1:8888 --key ${deployKeyPublic}`, + {encoding: 'utf8'} + ) + + const rootCppPath = path.join(__dirname, '../../test.cpp') + const cppPath = path.join(ctx.testDir, 'envkey_test.cpp') + const wasmPath = path.join(ctx.testDir, 'envkey_test.wasm') + + const contractCode = fs.readFileSync(rootCppPath, 'utf8') + const modifiedCode = contractCode.replace( + /class \[\[eosio::contract\]\] test/, + 'class [[eosio::contract]] envkey_test' + ) + fs.writeFileSync(cppPath, modifiedCode) + + execSync(`node ${ctx.cliPath} compile ${cppPath} --output ${ctx.testDir}`, { + encoding: 'utf8', + cwd: ctx.testDir, + }) + + assert.isTrue(fs.existsSync(wasmPath), 'WASM file should be generated') + + const output = execSync( + `node ${ctx.cliPath} contract deploy ${wasmPath} --account ${accountName} --yes`, + { + encoding: 'utf8', + cwd: ctx.testDir, + env: { + ...process.env, + HOME: ctx.testDir, + WHARFKIT_DEPLOY_KEY: deployKeyPrivate, + }, + } + ) + + assert.include( + output, + 'Using private key from WHARFKIT_DEPLOY_KEY environment variable' + ) + assert.include(output, '✅ Contract deployed successfully!') + assert.include(output, 'Transaction ID:') + }) + + test('can deploy using WHARFKIT_DEPLOY_KEY environment variable with wallet key name', function () { + if (!ctx) this.skip() + this.timeout(60000) + + const accountName = getRandomLocalAccountName('envnam') + + execSync( + `node ${ctx.cliPath} wallet account create --name ${accountName} --url http://127.0.0.1:8888 --key ${deployKeyPublic}`, + {encoding: 'utf8'} + ) + + const rootCppPath = path.join(__dirname, '../../test.cpp') + const cppPath = path.join(ctx.testDir, 'envnam_test.cpp') + const wasmPath = path.join(ctx.testDir, 'envnam_test.wasm') + + const contractCode = fs.readFileSync(rootCppPath, 'utf8') + const modifiedCode = contractCode.replace( + /class \[\[eosio::contract\]\] test/, + 'class [[eosio::contract]] envnam_test' + ) + fs.writeFileSync(cppPath, modifiedCode) + + execSync(`node ${ctx.cliPath} compile ${cppPath} --output ${ctx.testDir}`, { + encoding: 'utf8', + cwd: ctx.testDir, + }) + + assert.isTrue(fs.existsSync(wasmPath), 'WASM file should be generated') + + const output = execSync( + `node ${ctx.cliPath} contract deploy ${wasmPath} --account ${accountName} --yes`, + { + encoding: 'utf8', + cwd: ctx.testDir, + env: { + ...process.env, + HOME: ctx.testDir, + WHARFKIT_DEPLOY_KEY: 'deploy-test-key', + }, + } + ) + + assert.include(output, 'Using wallet key from environment: deploy-test-key') + assert.include(output, '✅ Contract deployed successfully!') + assert.include(output, 'Transaction ID:') + }) + + test('--key option takes precedence over WHARFKIT_DEPLOY_KEY', function () { + if (!ctx) this.skip() + this.timeout(60000) + + const accountName = getRandomLocalAccountName('keyprec') + + execSync( + `node ${ctx.cliPath} wallet account create --name ${accountName} --url http://127.0.0.1:8888 --key ${deployKeyPublic}`, + {encoding: 'utf8'} + ) + + const rootCppPath = path.join(__dirname, '../../test.cpp') + const cppPath = path.join(ctx.testDir, 'keyprec_test.cpp') + const wasmPath = path.join(ctx.testDir, 'keyprec_test.wasm') + + const contractCode = fs.readFileSync(rootCppPath, 'utf8') + const modifiedCode = contractCode.replace( + /class \[\[eosio::contract\]\] test/, + 'class [[eosio::contract]] keyprec_test' + ) + fs.writeFileSync(cppPath, modifiedCode) + + execSync(`node ${ctx.cliPath} compile ${cppPath} --output ${ctx.testDir}`, { + encoding: 'utf8', + cwd: ctx.testDir, + }) + + assert.isTrue(fs.existsSync(wasmPath), 'WASM file should be generated') + + const output = execSync( + `node ${ctx.cliPath} contract deploy ${wasmPath} --account ${accountName} --key deploy-test-key --yes`, + { + encoding: 'utf8', + cwd: ctx.testDir, + env: { + ...process.env, + HOME: ctx.testDir, + WHARFKIT_DEPLOY_KEY: 'some-other-key', + }, + } + ) + + assert.include(output, 'Using wallet key: deploy-test-key') + assert.include(output, '✅ Contract deployed successfully!') + }) + }) +}) + + diff --git a/test/tests/e2e-sign-request.ts b/test/tests/e2e-sign-request.ts new file mode 100644 index 0000000..d45d99e --- /dev/null +++ b/test/tests/e2e-sign-request.ts @@ -0,0 +1,208 @@ +import {assert} from 'chai' +import {execSync} from 'child_process' +import * as fs from 'fs' +import * as path from 'path' +import * as os from 'os' + +/** + * E2E tests for the sign request command: + * - Creating signing requests for actions + * - Using different data formats (JSON, key=value) + * - Using different chain options + * + * Note: These tests use Jungle4 testnet for contract ABIs since local chain + * may not have standard contracts deployed. + */ +suite('E2E: Sign Request', function () { + this.timeout(30000) + + const cliPath = path.join(__dirname, '../../lib/cli.js') + let testDir: string + + suiteSetup(function () { + // Create a temporary test directory + testDir = path.join(os.tmpdir(), `wharfkit-sign-test-${Date.now()}`) + fs.mkdirSync(testDir, {recursive: true}) + }) + + suiteTeardown(function () { + if (fs.existsSync(testDir)) { + fs.rmSync(testDir, {recursive: true, force: true}) + } + }) + + suite('Basic Request Creation', () => { + test('can create a signing request with JSON data', function () { + const output = execSync( + `node ${cliPath} sign request eosio.token::transfer '{"from":"alice","to":"bob","quantity":"1.0000 EOS","memo":"test"}' --chain Jungle4`, + {encoding: 'utf8'} + ) + + assert.include(output, 'Creating signing request...') + assert.include(output, 'Contract: eosio.token') + assert.include(output, 'Action: transfer') + assert.include(output, '✅ Signing request created!') + assert.include(output, 'esr://') + }) + + test('can create a signing request with key=value data', function () { + const output = execSync( + `node ${cliPath} sign request eosio.token::transfer 'from=alice,to=bob,quantity=1.0000 EOS,memo=test' --chain Jungle4`, + {encoding: 'utf8'} + ) + + assert.include(output, 'Creating signing request...') + assert.include(output, 'Contract: eosio.token') + assert.include(output, 'Action: transfer') + assert.include(output, '✅ Signing request created!') + }) + + test('can create a signing request with JSON file', function () { + // Create a test JSON file + const dataPath = path.join(testDir, 'action-data.json') + const actionData = { + from: 'alice', + to: 'bob', + quantity: '1.0000 EOS', + memo: 'test from file', + } + fs.writeFileSync(dataPath, JSON.stringify(actionData)) + + const output = execSync( + `node ${cliPath} sign request eosio.token::transfer ${dataPath} --chain Jungle4`, + {encoding: 'utf8'} + ) + + assert.include(output, 'Creating signing request...') + assert.include(output, '✅ Signing request created!') + }) + + test('can create a signing request with $signer placeholder', function () { + const output = execSync( + `node ${cliPath} sign request eosio.token::transfer '{"from":"$signer","to":"bob","quantity":"1.0000 EOS","memo":"test"}' --chain Jungle4`, + {encoding: 'utf8'} + ) + + assert.include(output, 'Creating signing request...') + assert.include(output, '') + assert.include(output, '✅ Signing request created!') + }) + }) + + suite('Authorization Options', () => { + test('can specify authorization with --auth option', function () { + const output = execSync( + `node ${cliPath} sign request eosio.token::transfer '{"from":"alice","to":"bob","quantity":"1.0000 EOS","memo":"test"}' --chain Jungle4 --auth alice@active`, + {encoding: 'utf8'} + ) + + assert.include(output, 'Authorization: alice@active') + assert.include(output, '✅ Signing request created!') + }) + + test('uses placeholder when no auth specified', function () { + const output = execSync( + `node ${cliPath} sign request eosio.token::transfer '{"from":"alice","to":"bob","quantity":"1.0000 EOS","memo":"test"}' --chain Jungle4`, + {encoding: 'utf8'} + ) + + assert.include(output, 'Authorization: @') + }) + + test('can specify account-only authorization (defaults to active)', function () { + const output = execSync( + `node ${cliPath} sign request eosio.token::transfer '{"from":"alice","to":"bob","quantity":"1.0000 EOS","memo":"test"}' --chain Jungle4 --auth alice`, + {encoding: 'utf8'} + ) + + assert.include(output, 'Authorization: alice@active') + }) + }) + + suite('Chain Options', () => { + test('can specify Jungle4 chain', function () { + const output = execSync( + `node ${cliPath} sign request eosio.token::transfer '{"from":"alice","to":"bob","quantity":"1.0000 EOS","memo":"test"}' --chain Jungle4`, + {encoding: 'utf8'} + ) + + assert.include(output, 'Chain: Jungle4') + assert.include(output, '✅ Signing request created!') + }) + + test('can specify EOS chain', function () { + const output = execSync( + `node ${cliPath} sign request eosio.token::transfer '{"from":"alice","to":"bob","quantity":"1.0000 EOS","memo":"test"}' --chain EOS`, + {encoding: 'utf8'} + ) + + assert.include(output, 'Chain: EOS') + assert.include(output, '✅ Signing request created!') + }) + + test('can specify a custom URL', function () { + const output = execSync( + `node ${cliPath} sign request eosio.token::transfer '{"from":"alice","to":"bob","quantity":"1.0000 EOS","memo":"test"}' --chain https://jungle4.greymass.com`, + {encoding: 'utf8'} + ) + + assert.include(output, 'https://jungle4.greymass.com') + assert.include(output, '✅ Signing request created!') + }) + }) + + suite('Error Handling', () => { + test('fails with invalid contract::action format', function () { + try { + execSync( + `node ${cliPath} sign request "invalid-format" '{"key":"value"}' --chain Jungle4`, + {encoding: 'utf8', stdio: 'pipe'} + ) + assert.fail('Should have thrown an error') + } catch (error: any) { + const output = error.stdout || error.stderr || '' + assert.include(output, 'contract::action') + } + }) + + test('fails with invalid action data format', function () { + try { + execSync( + `node ${cliPath} sign request eosio.token::transfer "not valid data" --chain Jungle4`, + {encoding: 'utf8', stdio: 'pipe'} + ) + assert.fail('Should have thrown an error') + } catch (error: any) { + const output = error.stdout || error.stderr || '' + assert.include(output, 'Invalid action data format') + } + }) + + test('fails with non-existent action', function () { + try { + execSync( + `node ${cliPath} sign request eosio.token::nonexistent '{"key":"value"}' --chain Jungle4`, + {encoding: 'utf8', stdio: 'pipe'} + ) + assert.fail('Should have thrown an error') + } catch (error: any) { + // The output may be in stdout, stderr, or the error message itself + const output = (error.stdout || '') + (error.stderr || '') + (error.message || '') + assert.include(output, 'not found') + } + }) + + test('fails with unknown chain name', function () { + try { + execSync( + `node ${cliPath} sign request eosio.token::transfer '{"from":"alice","to":"bob","quantity":"1.0000 EOS","memo":"test"}' --chain unknownchain`, + {encoding: 'utf8', stdio: 'pipe'} + ) + assert.fail('Should have thrown an error') + } catch (error: any) { + const output = error.stdout || error.stderr || '' + assert.include(output, 'Unknown chain') + } + }) + }) +}) diff --git a/test/tests/e2e-wallet.ts b/test/tests/e2e-wallet.ts new file mode 100644 index 0000000..c4a3adb --- /dev/null +++ b/test/tests/e2e-wallet.ts @@ -0,0 +1,273 @@ +import {assert} from 'chai' +import {execSync} from 'child_process' +import * as fs from 'fs' +import * as path from 'path' +import {APIClient, FetchProvider} from '@wharfkit/antelope' +import fetch from 'node-fetch' +import type {E2ETestContext} from '../utils/test-helpers' +import { + getTransactionExpiration, + setupE2ETestEnvironment, + teardownE2ETestEnvironment, +} from '../utils/test-helpers' + +/** + * E2E tests for wallet functionality: + * - Key creation and management + * - Key import (wallet keys add) + * - Transaction signing + * - Key persistence + */ +suite('E2E: Wallet', () => { + let ctx: E2ETestContext | null = null + + suiteSetup(async function () { + ctx = await setupE2ETestEnvironment(this) + }) + + suiteTeardown(function () { + this.timeout(30000) + teardownE2ETestEnvironment(ctx) + }) + + suite('Key Management', () => { + test('can create a wallet key', function () { + if (!ctx) this.skip() + const output = execSync(`node ${ctx.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 () { + if (!ctx) this.skip() + // Create a key first + execSync(`node ${ctx.cliPath} wallet create --name listtest`, {encoding: 'utf8'}) + + // List keys + const output = execSync(`node ${ctx.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 () { + if (!ctx) this.skip() + const output = execSync(`node ${ctx.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+)/) + }) + + test('can add an existing private key to wallet', function () { + if (!ctx) this.skip() + // Use a fresh private key that isn't already in the wallet + const privateKey = 'PVT_K1_2PZuogUksib5NkEVzSp5BseRhiTYtogVjy7YYxLv5GKxezXdYA' + const expectedPublicKey = 'PUB_K1_6C7Svr4XqPgcGS5iXpTyvtAKYXVipcP42FBNkM78zPw6UpLobb' + + // Add the private key to the wallet + const addOutput = execSync( + `node ${ctx.cliPath} wallet keys add ${privateKey} --name imported-key`, + {encoding: 'utf8'} + ) + + assert.include(addOutput, '✅ Key added successfully!') + assert.include(addOutput, 'Name: imported-key') + assert.include(addOutput, `Public Key: ${expectedPublicKey}`) + }) + + test('wallet keys add fails with invalid private key', function () { + if (!ctx) this.skip() + try { + execSync(`node ${ctx.cliPath} wallet keys add invalid-key --name badkey`, { + encoding: 'utf8', + stdio: 'pipe', + }) + assert.fail('Should have thrown an error') + } catch (error: any) { + // Check stdout for the error message (CLI writes errors there) + const output = error.stdout || error.stderr || '' + assert.include(output, 'Invalid private key format') + } + }) + + test('wallet keys add generates name when not specified', function () { + if (!ctx) this.skip() + // Use a fresh private key that isn't already in the wallet + const privateKey = 'PVT_K1_KpqNgdCDPhd7zmcFFea9u91HbWs4QrofxULj5SS5PmS1sfXBT' + + // Add without specifying name + const addOutput = execSync(`node ${ctx.cliPath} wallet keys add ${privateKey}`, { + encoding: 'utf8', + }) + + assert.include(addOutput, '✅ Key added successfully!') + // Should have auto-generated name + assert.match(addOutput, /Name: (default|key\d+)/) + }) + }) + + suite('Transaction Signing', () => { + test('can transact (sign) a transaction with wallet key', function () { + if (!ctx) this.skip() + // Create a key first + execSync(`node ${ctx.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(ctx.testDir, 'transaction.json') + fs.writeFileSync(txPath, JSON.stringify(transaction)) + + // Transact the transaction + const output = execSync(`node ${ctx.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 () { + if (!ctx) this.skip() + execSync(`node ${ctx.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(ctx.testDir, 'transaction-output.json') + const signedPath = path.join(ctx.testDir, 'signed-transaction.json') + fs.writeFileSync(txPath, JSON.stringify(transaction)) + + const output = execSync( + `node ${ctx.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 () { + if (!ctx) this.skip() + // 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(ctx.testDir, 'transaction-broadcast.json') + const transaction = { + expiration: getTransactionExpiration(), + ref_block_num: blockNum & 0xffff, + 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)) + + let output: string + try { + output = execSync( + `node ${ctx.cliPath} wallet transact ${txPath} --broadcast --key chain-key`, + {encoding: 'utf8'} + ) + } catch { + try { + output = execSync( + `node ${ctx.cliPath} wallet transact ${txPath} --broadcast --key default`, + {encoding: 'utf8'} + ) + } catch { + output = execSync( + `node ${ctx.cliPath} wallet transact ${txPath} --broadcast --key dev`, + {encoding: 'utf8'} + ) + } + } + + assert.include(output, '🚀 Transaction broadcast successfully!') + assert.include(output, 'Transaction ID:') + }) + }) + + suite('Key Persistence', () => { + test('created keys are persisted in wallet', function () { + if (!ctx) this.skip() + const keyName = `persistent-${Date.now()}` + + // Create a key + const createOutput = execSync(`node ${ctx.cliPath} wallet create --name ${keyName}`, { + encoding: 'utf8', + }) + assert.include(createOutput, keyName) + + // Verify it shows up in list + const listOutput = execSync(`node ${ctx.cliPath} wallet keys`, {encoding: 'utf8'}) + assert.include(listOutput, keyName) + }) + }) +}) diff --git a/test/tests/e2e/cli-structure.ts b/test/tests/e2e/cli-structure.ts new file mode 100644 index 0000000..b6a4469 --- /dev/null +++ b/test/tests/e2e/cli-structure.ts @@ -0,0 +1,75 @@ +import {assert} from 'chai' +import {execSync} from 'child_process' +import * as path from 'path' +import {isNodeosAvailable} from '../../utils/test-helpers' + +/** + * E2E tests for CLI command structure: + * - Verifies commands exist and have correct help text + * - Tests command hierarchy + * + * Note: These tests don't require a running chain, but we still + * check for nodeos availability to match other E2E test behavior. + */ +suite('E2E: CLI Structure', () => { + const cliPath = path.join(__dirname, '../../../lib/cli.js') + + suiteSetup(function () { + if (!isNodeosAvailable()) { + // eslint-disable-next-line no-console + console.log('Skipping E2E tests: nodeos is not available in PATH') + this.skip() + } + }) + + 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') + }) + + test('wallet keys command has add subcommand', function () { + const output = execSync(`node ${cliPath} wallet keys --help`, {encoding: 'utf8'}) + + assert.include(output, 'add') + assert.include(output, 'create') + assert.include(output, 'Add an existing private key') + }) + }) +}) diff --git a/test/tests/e2e/compile.ts b/test/tests/e2e/compile.ts new file mode 100644 index 0000000..53c0ff7 --- /dev/null +++ b/test/tests/e2e/compile.ts @@ -0,0 +1,67 @@ +import {assert} from 'chai' +import {execSync} from 'child_process' +import * as fs from 'fs' +import * as path from 'path' +import type {E2ETestContext} from '../../utils/test-helpers' +import {setupE2ETestEnvironment, teardownE2ETestEnvironment} from '../../utils/test-helpers' + +/** + * E2E tests for contract compilation: + * - Compile command behavior + * - Error handling for missing files + * - CDT integration + */ +suite('E2E: Compile', () => { + let ctx: E2ETestContext | null = null + + suiteSetup(async function () { + ctx = await setupE2ETestEnvironment(this) + }) + + suiteTeardown(function () { + this.timeout(30000) + teardownE2ETestEnvironment(ctx) + }) + + suite('Contract Compilation', () => { + test('shows helpful error when no cpp files found', function () { + if (!ctx) this.skip() + try { + execSync(`node ${ctx.cliPath} compile`, { + encoding: 'utf8', + cwd: ctx.testDir, + }) + assert.fail('Should throw error when no cpp files found') + } catch (error: any) { + assert.isTrue(error.status !== 0 || error.code !== 0) + } + }) + + test('can compile a cpp file when cdt is installed', function () { + if (!ctx) this.skip() + const rootCppPath = path.join(__dirname, '../../../test.cpp') + const cppPath = path.join(ctx.testDir, 'test.cpp') + + fs.copyFileSync(rootCppPath, cppPath) + + try { + const output = execSync(`node ${ctx.cliPath} compile`, { + encoding: 'utf8', + cwd: ctx.testDir, + }) + + assert.isTrue( + output.includes('Compilation complete!') || + output.includes('cdt-cpp is not installed') || + output.includes('LEAP is not installed') + ) + } catch (error: any) { + const output = error.stderr || error.stdout + assert.isTrue( + output.includes('cdt-cpp is not installed') || + output.includes('LEAP is not installed') + ) + } + }) + }) +}) diff --git a/test/tests/e2e/deploy.ts b/test/tests/e2e/deploy.ts new file mode 100644 index 0000000..b053e7e --- /dev/null +++ b/test/tests/e2e/deploy.ts @@ -0,0 +1,758 @@ +import {assert} from 'chai' +import type {ChildProcess} from 'child_process' +import {execSync, spawn} from 'child_process' +import * as fs from 'fs' +import * as path from 'path' +import {ABI, APIClient, FetchProvider, Serializer} from '@wharfkit/antelope' +import fetch from 'node-fetch' +import {log} from '../../../src/utils' +import type {E2ETestContext} from '../../utils/test-helpers' +import { + getRandomLocalAccountName, + getTransactionExpiration, + setupE2ETestEnvironment, + teardownE2ETestEnvironment, +} from '../../utils/test-helpers' + +/** + * E2E tests for deployment functionality: + * - Key selection logic + * - Account creation + * - Contract deployment + * - RAM analysis + * - Deploy key options (--key, env vars) + */ +suite('E2E: Deploy', () => { + let ctx: E2ETestContext | null = null + + suiteSetup(async function () { + ctx = await setupE2ETestEnvironment(this) + }) + + suiteTeardown(function () { + this.timeout(30000) + teardownE2ETestEnvironment(ctx) + }) + + suite('Key Selection Logic', () => { + test('deploy command has --account option for key selection', function () { + if (!ctx) this.skip() + const deployHelp = execSync(`node ${ctx.cliPath} contract deploy --help`, { + encoding: 'utf8', + }) + + assert.include(deployHelp, '--account') + }) + + test('deploy auto-selects key matching account name', function () { + if (!ctx) this.skip() + this.timeout(60000) + + const accountName = getRandomLocalAccountName('autokey') + + // 1. Create a key with the SAME name as the account + const keyOutput = execSync(`node ${ctx.cliPath} wallet create --name ${accountName}`, { + encoding: 'utf8', + }) + + const publicKeyMatch = keyOutput.match(/Public Key: (PUB_K1_[A-Za-z0-9]+)/) + assert.isNotNull(publicKeyMatch, 'Should have public key in output') + const accountKeyPublic = publicKeyMatch![1] + + // 2. Create account with the matching key's public key + execSync( + `node ${ctx.cliPath} wallet account create --name ${accountName} --url http://127.0.0.1:8888 --key ${accountKeyPublic}`, + {encoding: 'utf8'} + ) + + // 3. Copy and compile test contract + const rootCppPath = path.join(__dirname, '../../../test.cpp') + const cppPath = path.join(ctx.testDir, 'autokey_test.cpp') + const wasmPath = path.join(ctx.testDir, 'autokey_test.wasm') + + const contractCode = fs.readFileSync(rootCppPath, 'utf8') + const modifiedCode = contractCode.replace( + /class \[\[eosio::contract\]\] test/, + 'class [[eosio::contract]] autokey_test' + ) + fs.writeFileSync(cppPath, modifiedCode) + + execSync(`node ${ctx.cliPath} compile ${cppPath} --output ${ctx.testDir}`, { + encoding: 'utf8', + cwd: ctx.testDir, + }) + + assert.isTrue(fs.existsSync(wasmPath), 'WASM file should be generated') + + // 4. Deploy WITHOUT specifying --key + const output = execSync( + `node ${ctx.cliPath} contract deploy ${wasmPath} --account ${accountName} --yes`, + { + encoding: 'utf8', + cwd: ctx.testDir, + } + ) + + assert.include(output, `Using wallet key: ${accountName}`) + assert.include(output, '✅ Contract deployed successfully!') + assert.include(output, 'Transaction ID:') + }) + }) + + suite('Account and Deployment', () => { + test('can create an account on the local chain', function () { + if (!ctx) this.skip() + const accountName = getRandomLocalAccountName('acc') + const output = execSync( + `node ${ctx.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 () { + if (!ctx) this.skip() + this.timeout(60000) + + const accountName = getRandomLocalAccountName('deploy') + execSync( + `node ${ctx.cliPath} wallet account create --name ${accountName} --url http://127.0.0.1:8888`, + {encoding: 'utf8'} + ) + + const rootCppPath = path.join(__dirname, '../../../test.cpp') + const cppPath = path.join(ctx.testDir, 'test.cpp') + const wasmPath = path.join(ctx.testDir, 'test.wasm') + + fs.copyFileSync(rootCppPath, cppPath) + + execSync(`node ${ctx.cliPath} compile`, { + encoding: 'utf8', + cwd: ctx.testDir, + }) + + assert.isTrue(fs.existsSync(wasmPath), 'WASM file should be generated') + + const output = execSync( + `node ${ctx.cliPath} contract deploy ${wasmPath} --account ${accountName} --yes`, + { + encoding: 'utf8', + cwd: ctx.testDir, + } + ) + + assert.include(output, '✅ Contract deployed successfully!') + assert.include(output, 'Transaction ID:') + }) + + test('shows RAM analysis during deployment', function () { + if (!ctx) this.skip() + this.timeout(60000) + + const accountName = getRandomLocalAccountName('ramtest') + execSync( + `node ${ctx.cliPath} wallet account create --name ${accountName} --url http://127.0.0.1:8888`, + {encoding: 'utf8'} + ) + + const rootCppPath = path.join(__dirname, '../../../test.cpp') + const cppPath = path.join(ctx.testDir, 'ramanalysis_test.cpp') + const wasmPath = path.join(ctx.testDir, 'ramanalysis_test.wasm') + + const contractCode = fs.readFileSync(rootCppPath, 'utf8') + const modifiedCode = contractCode.replace( + /class \[\[eosio::contract\]\] test/, + 'class [[eosio::contract]] ramanalysis_test' + ) + fs.writeFileSync(cppPath, modifiedCode) + + execSync(`node ${ctx.cliPath} compile ${cppPath} --output ${ctx.testDir}`, { + encoding: 'utf8', + cwd: ctx.testDir, + }) + + assert.isTrue(fs.existsSync(wasmPath), 'WASM file should be generated') + + const output = execSync( + `node ${ctx.cliPath} contract deploy ${wasmPath} --account ${accountName} --yes`, + { + encoding: 'utf8', + cwd: ctx.testDir, + } + ) + + assert.include(output, '📊 RAM Analysis') + assert.include(output, 'RAM needed:') + assert.include(output, 'Current RAM available:') + assert.isTrue( + output.includes('RAM to purchase:') || + output.includes('RAM management not required'), + 'Should show RAM info or local chain message' + ) + assert.include(output, '✅ Contract deployed successfully!') + }) + + test('shows QR code when insufficient funds and completes after transfer', async function () { + if (!ctx) this.skip() + this.timeout(120000) + + const accountName = getRandomLocalAccountName('qrtest') + execSync( + `node ${ctx.cliPath} wallet account create --name ${accountName} --url http://127.0.0.1:8888`, + {encoding: 'utf8'} + ) + + const client = new APIClient({ + provider: new FetchProvider('http://127.0.0.1:8888', {fetch}), + }) + try { + const balances = await client.v1.chain.get_currency_balance( + 'eosio.token', + accountName + ) + assert.equal(balances.length, 0, 'Account should have no token balance initially') + } catch { + // eosio.token might not be deployed on local chain + } + + const rootCppPath = path.join(__dirname, '../../../test.cpp') + const cppPath = path.join(ctx.testDir, 'qrfunds_test.cpp') + const wasmPath = path.join(ctx.testDir, 'qrfunds_test.wasm') + + const contractCode = fs.readFileSync(rootCppPath, 'utf8') + const modifiedCode = contractCode.replace( + /class \[\[eosio::contract\]\] test/, + 'class [[eosio::contract]] qrfunds_test' + ) + fs.writeFileSync(cppPath, modifiedCode) + + execSync(`node ${ctx.cliPath} compile ${cppPath} --output ${ctx.testDir}`, { + encoding: 'utf8', + cwd: ctx.testDir, + }) + + assert.isTrue(fs.existsSync(wasmPath), 'WASM file should be generated') + + let deployOutput = '' + let deployExitCode: number | null = null + + const deployProcess: ChildProcess = spawn( + 'node', + [ctx.cliPath, 'contract', 'deploy', wasmPath, '--account', accountName, '--yes'], + { + cwd: ctx.testDir, + env: {...process.env, HOME: ctx.testDir}, + } + ) + + const deployPromise = new Promise((resolve, reject) => { + deployProcess.stdout?.on('data', (data: Buffer) => { + deployOutput += data.toString() + }) + + deployProcess.stderr?.on('data', (data: Buffer) => { + deployOutput += data.toString() + }) + + deployProcess.on('close', (code) => { + deployExitCode = code + if (code === 0) { + resolve() + } else { + reject(new Error(`Deploy process exited with code ${code}`)) + } + }) + + deployProcess.on('error', (err) => { + reject(err) + }) + }) + + const waitForQrCode = async (): Promise => { + const startTime = Date.now() + const timeout = 30000 + + while (Date.now() - startTime < timeout) { + if ( + deployOutput.includes('esr://') || + deployOutput.includes('Scan this QR code') + ) { + return true + } + if (deployExitCode !== null) { + return false + } + await new Promise((resolve) => setTimeout(resolve, 200)) + } + return false + } + + const qrCodeShown = await waitForQrCode() + + if (qrCodeShown) { + assert.include(deployOutput, 'esr://', 'Should show ESR link') + assert.include( + deployOutput, + 'Scan this QR code', + 'Should show QR code instructions' + ) + assert.include(deployOutput, 'Waiting for funds', 'Should show waiting message') + + 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) + + const transferTx = { + expiration: getTransactionExpiration(), + ref_block_num: blockNum & 0xffff, + 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.token', + name: 'transfer', + authorization: [{actor: 'eosio', permission: 'active'}], + data: Serializer.encode({ + object: { + from: 'eosio', + to: accountName, + quantity: '100.0000 SYS', + memo: 'funding for contract deployment', + }, + abi: (await client.v1.chain.get_abi('eosio.token')).abi!, + type: 'transfer', + }).hexString, + }, + ], + transaction_extensions: [], + } + + const txPath = path.join(ctx.testDir, 'transfer_for_deploy.json') + fs.writeFileSync(txPath, JSON.stringify(transferTx)) + + log('Transferring 100 SYS to account...', 'info') + execSync( + `node ${ctx.cliPath} wallet transact ${txPath} --broadcast --key chain-key`, + { + encoding: 'utf8', + env: {...process.env, HOME: ctx.testDir}, + } + ) + + try { + await Promise.race([ + deployPromise, + new Promise((_, reject) => + setTimeout( + () => reject(new Error('Deploy timed out after transfer')), + 20000 + ) + ), + ]) + } catch (e) { + if (deployExitCode === null) { + deployProcess.kill() + } + throw e + } + } else { + await deployPromise + } + + assert.include( + deployOutput, + '✅ Contract deployed successfully!', + 'Deployment should succeed' + ) + assert.include(deployOutput, 'Transaction ID:', 'Should show transaction ID') + }) + + test('validates table removal safety', async function () { + if (!ctx) this.skip() + this.timeout(120000) + + const accountName = getRandomLocalAccountName('val') + execSync( + `node ${ctx.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(ctx.testDir, 'v1.cpp') + fs.writeFileSync(cppPath, v1Code) + + execSync(`node ${ctx.cliPath} compile ${cppPath} --output ${ctx.testDir}`, { + encoding: 'utf8', + }) + execSync( + `node ${ctx.cliPath} contract deploy ${path.join( + ctx.testDir, + 'v1.wasm' + )} --account ${accountName} --yes`, + {encoding: 'utf8', cwd: ctx.testDir} + ) + + // 2. Add data to the table + const abiPath = path.join(ctx.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 + + 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) + + 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(ctx.testDir, 'insert_data.json') + fs.writeFileSync(txPath, JSON.stringify(tx)) + + try { + execSync(`node ${ctx.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(ctx.testDir, 'v2.cpp') + fs.writeFileSync(v2CppPath, v2Code) + + execSync(`node ${ctx.cliPath} compile ${v2CppPath} --output ${ctx.testDir}`, { + encoding: 'utf8', + }) + const v2Wasm = path.join(ctx.testDir, 'v2.wasm') + + // 4. Try to deploy V2 - SHOULD FAIL + try { + execSync( + `node ${ctx.cliPath} contract deploy ${v2Wasm} --account ${accountName} --yes`, + { + encoding: 'utf8', + cwd: ctx.testDir, + stdio: 'pipe', + } + ) + assert.fail('Should have failed validation') + } catch (error: unknown) { + const err = error as {stderr?: string; stdout?: string} + const output = (err.stderr || '').toString() + (err.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 ${ctx.cliPath} contract deploy ${v2Wasm} --account ${accountName} --force --yes`, + { + encoding: 'utf8', + cwd: ctx.testDir, + } + ) + assert.include(output, 'Contract deployed successfully') + assert.include(output, 'Proceeding despite data loss warning') + }) + }) + + suite('Deploy Key Options', () => { + let deployKeyPrivate: string + let deployKeyPublic: string + + suiteSetup(function () { + if (!ctx) return + const keyOutput = execSync(`node ${ctx.cliPath} wallet create --name deploy-test-key`, { + encoding: 'utf8', + }) + const privateKeyMatch = keyOutput.match(/Private Key: (PVT_K1_[A-Za-z0-9]+)/) + const publicKeyMatch = keyOutput.match(/Public Key: (PUB_K1_[A-Za-z0-9]+)/) + if (!privateKeyMatch || !publicKeyMatch) { + throw new Error('Could not extract keys from wallet create output') + } + deployKeyPrivate = privateKeyMatch[1] + deployKeyPublic = publicKeyMatch[1] + }) + + test('can deploy using --key option with wallet key name', function () { + if (!ctx) this.skip() + this.timeout(60000) + + const accountName = getRandomLocalAccountName('keyopt') + + execSync( + `node ${ctx.cliPath} wallet account create --name ${accountName} --url http://127.0.0.1:8888 --key ${deployKeyPublic}`, + {encoding: 'utf8'} + ) + + const rootCppPath = path.join(__dirname, '../../../test.cpp') + const cppPath = path.join(ctx.testDir, 'keyopt_test.cpp') + const wasmPath = path.join(ctx.testDir, 'keyopt_test.wasm') + + const contractCode = fs.readFileSync(rootCppPath, 'utf8') + const modifiedCode = contractCode.replace( + /class \[\[eosio::contract\]\] test/, + 'class [[eosio::contract]] keyopt_test' + ) + fs.writeFileSync(cppPath, modifiedCode) + + execSync(`node ${ctx.cliPath} compile ${cppPath} --output ${ctx.testDir}`, { + encoding: 'utf8', + cwd: ctx.testDir, + }) + + assert.isTrue(fs.existsSync(wasmPath), 'WASM file should be generated') + + const output = execSync( + `node ${ctx.cliPath} contract deploy ${wasmPath} --account ${accountName} --key deploy-test-key --yes`, + { + encoding: 'utf8', + cwd: ctx.testDir, + } + ) + + assert.include(output, 'Using wallet key: deploy-test-key') + assert.include(output, '✅ Contract deployed successfully!') + assert.include(output, 'Transaction ID:') + }) + + test('can deploy using --key option with private key directly', function () { + if (!ctx) this.skip() + this.timeout(60000) + + const accountName = getRandomLocalAccountName('keypvt') + + execSync( + `node ${ctx.cliPath} wallet account create --name ${accountName} --url http://127.0.0.1:8888 --key ${deployKeyPublic}`, + {encoding: 'utf8'} + ) + + const rootCppPath = path.join(__dirname, '../../../test.cpp') + const cppPath = path.join(ctx.testDir, 'keypvt_test.cpp') + const wasmPath = path.join(ctx.testDir, 'keypvt_test.wasm') + + const contractCode = fs.readFileSync(rootCppPath, 'utf8') + const modifiedCode = contractCode.replace( + /class \[\[eosio::contract\]\] test/, + 'class [[eosio::contract]] keypvt_test' + ) + fs.writeFileSync(cppPath, modifiedCode) + + execSync(`node ${ctx.cliPath} compile ${cppPath} --output ${ctx.testDir}`, { + encoding: 'utf8', + cwd: ctx.testDir, + }) + + assert.isTrue(fs.existsSync(wasmPath), 'WASM file should be generated') + + const output = execSync( + `node ${ctx.cliPath} contract deploy ${wasmPath} --account ${accountName} --key ${deployKeyPrivate} --yes`, + { + encoding: 'utf8', + cwd: ctx.testDir, + } + ) + + assert.include(output, 'Using private key from --key option') + assert.include(output, '✅ Contract deployed successfully!') + assert.include(output, 'Transaction ID:') + }) + + test('can deploy using WHARFKIT_DEPLOY_KEY environment variable with private key', function () { + if (!ctx) this.skip() + this.timeout(60000) + + const accountName = getRandomLocalAccountName('envkey') + + execSync( + `node ${ctx.cliPath} wallet account create --name ${accountName} --url http://127.0.0.1:8888 --key ${deployKeyPublic}`, + {encoding: 'utf8'} + ) + + const rootCppPath = path.join(__dirname, '../../../test.cpp') + const cppPath = path.join(ctx.testDir, 'envkey_test.cpp') + const wasmPath = path.join(ctx.testDir, 'envkey_test.wasm') + + const contractCode = fs.readFileSync(rootCppPath, 'utf8') + const modifiedCode = contractCode.replace( + /class \[\[eosio::contract\]\] test/, + 'class [[eosio::contract]] envkey_test' + ) + fs.writeFileSync(cppPath, modifiedCode) + + execSync(`node ${ctx.cliPath} compile ${cppPath} --output ${ctx.testDir}`, { + encoding: 'utf8', + cwd: ctx.testDir, + }) + + assert.isTrue(fs.existsSync(wasmPath), 'WASM file should be generated') + + const output = execSync( + `node ${ctx.cliPath} contract deploy ${wasmPath} --account ${accountName} --yes`, + { + encoding: 'utf8', + cwd: ctx.testDir, + env: { + ...process.env, + HOME: ctx.testDir, + WHARFKIT_DEPLOY_KEY: deployKeyPrivate, + }, + } + ) + + assert.include( + output, + 'Using private key from WHARFKIT_DEPLOY_KEY environment variable' + ) + assert.include(output, '✅ Contract deployed successfully!') + assert.include(output, 'Transaction ID:') + }) + + test('can deploy using WHARFKIT_DEPLOY_KEY environment variable with wallet key name', function () { + if (!ctx) this.skip() + this.timeout(60000) + + const accountName = getRandomLocalAccountName('envnam') + + execSync( + `node ${ctx.cliPath} wallet account create --name ${accountName} --url http://127.0.0.1:8888 --key ${deployKeyPublic}`, + {encoding: 'utf8'} + ) + + const rootCppPath = path.join(__dirname, '../../../test.cpp') + const cppPath = path.join(ctx.testDir, 'envnam_test.cpp') + const wasmPath = path.join(ctx.testDir, 'envnam_test.wasm') + + const contractCode = fs.readFileSync(rootCppPath, 'utf8') + const modifiedCode = contractCode.replace( + /class \[\[eosio::contract\]\] test/, + 'class [[eosio::contract]] envnam_test' + ) + fs.writeFileSync(cppPath, modifiedCode) + + execSync(`node ${ctx.cliPath} compile ${cppPath} --output ${ctx.testDir}`, { + encoding: 'utf8', + cwd: ctx.testDir, + }) + + assert.isTrue(fs.existsSync(wasmPath), 'WASM file should be generated') + + const output = execSync( + `node ${ctx.cliPath} contract deploy ${wasmPath} --account ${accountName} --yes`, + { + encoding: 'utf8', + cwd: ctx.testDir, + env: { + ...process.env, + HOME: ctx.testDir, + WHARFKIT_DEPLOY_KEY: 'deploy-test-key', + }, + } + ) + + assert.include(output, 'Using wallet key from environment: deploy-test-key') + assert.include(output, '✅ Contract deployed successfully!') + assert.include(output, 'Transaction ID:') + }) + + test('--key option takes precedence over WHARFKIT_DEPLOY_KEY', function () { + if (!ctx) this.skip() + this.timeout(60000) + + const accountName = getRandomLocalAccountName('keyprec') + + execSync( + `node ${ctx.cliPath} wallet account create --name ${accountName} --url http://127.0.0.1:8888 --key ${deployKeyPublic}`, + {encoding: 'utf8'} + ) + + const rootCppPath = path.join(__dirname, '../../../test.cpp') + const cppPath = path.join(ctx.testDir, 'keyprec_test.cpp') + const wasmPath = path.join(ctx.testDir, 'keyprec_test.wasm') + + const contractCode = fs.readFileSync(rootCppPath, 'utf8') + const modifiedCode = contractCode.replace( + /class \[\[eosio::contract\]\] test/, + 'class [[eosio::contract]] keyprec_test' + ) + fs.writeFileSync(cppPath, modifiedCode) + + execSync(`node ${ctx.cliPath} compile ${cppPath} --output ${ctx.testDir}`, { + encoding: 'utf8', + cwd: ctx.testDir, + }) + + assert.isTrue(fs.existsSync(wasmPath), 'WASM file should be generated') + + const output = execSync( + `node ${ctx.cliPath} contract deploy ${wasmPath} --account ${accountName} --key deploy-test-key --yes`, + { + encoding: 'utf8', + cwd: ctx.testDir, + env: { + ...process.env, + HOME: ctx.testDir, + WHARFKIT_DEPLOY_KEY: 'some-other-key', + }, + } + ) + + assert.include(output, 'Using wallet key: deploy-test-key') + assert.include(output, '✅ Contract deployed successfully!') + }) + }) +}) diff --git a/test/tests/e2e/wallet.ts b/test/tests/e2e/wallet.ts new file mode 100644 index 0000000..a1dd615 --- /dev/null +++ b/test/tests/e2e/wallet.ts @@ -0,0 +1,274 @@ +import {assert} from 'chai' +import {execSync} from 'child_process' +import * as fs from 'fs' +import * as path from 'path' +import {APIClient, FetchProvider, KeyType, PrivateKey} from '@wharfkit/antelope' +import fetch from 'node-fetch' +import type {E2ETestContext} from '../../utils/test-helpers' +import { + getTransactionExpiration, + setupE2ETestEnvironment, + teardownE2ETestEnvironment, +} from '../../utils/test-helpers' + +/** + * E2E tests for wallet functionality: + * - Key creation and management + * - Key import (wallet keys add) + * - Transaction signing + * - Key persistence + */ +suite('E2E: Wallet', () => { + let ctx: E2ETestContext | null = null + + suiteSetup(async function () { + ctx = await setupE2ETestEnvironment(this) + }) + + suiteTeardown(function () { + this.timeout(30000) + teardownE2ETestEnvironment(ctx) + }) + + suite('Key Management', () => { + test('can create a wallet key', function () { + if (!ctx) this.skip() + const output = execSync(`node ${ctx.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 () { + if (!ctx) this.skip() + // Create a key first + execSync(`node ${ctx.cliPath} wallet create --name listtest`, {encoding: 'utf8'}) + + // List keys + const output = execSync(`node ${ctx.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 () { + if (!ctx) this.skip() + const output = execSync(`node ${ctx.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+)/) + }) + + test('can add an existing private key to wallet', function () { + if (!ctx) this.skip() + // Generate a fresh private key (not in wallet yet) + const privateKey = PrivateKey.generate(KeyType.K1) + const expectedPublicKey = privateKey.toPublic().toString() + + // Add the private key to the wallet + const addOutput = execSync( + `node ${ctx.cliPath} wallet keys add ${privateKey.toString()} --name imported-key`, + {encoding: 'utf8'} + ) + + assert.include(addOutput, '✅ Key added successfully!') + assert.include(addOutput, 'Name: imported-key') + assert.include(addOutput, `Public Key: ${expectedPublicKey}`) + }) + + test('wallet keys add fails with invalid private key', function () { + if (!ctx) this.skip() + try { + execSync(`node ${ctx.cliPath} wallet keys add invalid-key --name badkey`, { + encoding: 'utf8', + stdio: 'pipe', + }) + assert.fail('Should have thrown an error') + } catch (error: any) { + // The error message is in stdout (CLI outputs to stdout before exiting) + const output = error.stdout || error.message + assert.include(output, 'Invalid private key') + } + }) + + test('wallet keys add generates name when not specified', function () { + if (!ctx) this.skip() + // Generate a fresh private key (not in wallet yet) + const privateKey = PrivateKey.generate(KeyType.K1) + + // Add without specifying name + const addOutput = execSync( + `node ${ctx.cliPath} wallet keys add ${privateKey.toString()}`, + {encoding: 'utf8'} + ) + + assert.include(addOutput, '✅ Key added successfully!') + // Should have auto-generated name + assert.match(addOutput, /Name: (default|key\d+)/) + }) + }) + + suite('Transaction Signing', () => { + test('can transact (sign) a transaction with wallet key', function () { + if (!ctx) this.skip() + // Create a key first + execSync(`node ${ctx.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(ctx.testDir, 'transaction.json') + fs.writeFileSync(txPath, JSON.stringify(transaction)) + + // Transact the transaction + const output = execSync(`node ${ctx.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 () { + if (!ctx) this.skip() + execSync(`node ${ctx.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(ctx.testDir, 'transaction-output.json') + const signedPath = path.join(ctx.testDir, 'signed-transaction.json') + fs.writeFileSync(txPath, JSON.stringify(transaction)) + + const output = execSync( + `node ${ctx.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 () { + if (!ctx) this.skip() + // 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(ctx.testDir, 'transaction-broadcast.json') + const transaction = { + expiration: getTransactionExpiration(), + ref_block_num: blockNum & 0xffff, + 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)) + + let output: string + try { + output = execSync( + `node ${ctx.cliPath} wallet transact ${txPath} --broadcast --key chain-key`, + {encoding: 'utf8'} + ) + } catch { + try { + output = execSync( + `node ${ctx.cliPath} wallet transact ${txPath} --broadcast --key default`, + {encoding: 'utf8'} + ) + } catch { + output = execSync( + `node ${ctx.cliPath} wallet transact ${txPath} --broadcast --key dev`, + {encoding: 'utf8'} + ) + } + } + + assert.include(output, '🚀 Transaction broadcast successfully!') + assert.include(output, 'Transaction ID:') + }) + }) + + suite('Key Persistence', () => { + test('created keys are persisted in wallet', function () { + if (!ctx) this.skip() + const keyName = `persistent-${Date.now()}` + + // Create a key + const createOutput = execSync(`node ${ctx.cliPath} wallet create --name ${keyName}`, { + encoding: 'utf8', + }) + assert.include(createOutput, keyName) + + // Verify it shows up in list + const listOutput = execSync(`node ${ctx.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..95ed9b5 --- /dev/null +++ b/test/tests/wallet-account.ts @@ -0,0 +1,283 @@ +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' +import * as walletUtils from 'src/commands/wallet/utils' + +suite('Wallet Account Create', () => { + let sandbox: sinon.SinonSandbox + let fetchStub: sinon.SinonStub + let makeClientStub: sinon.SinonStub + let logStub: sinon.SinonStub + let addKeyToWalletStub: sinon.SinonStub + + setup(function () { + sandbox = sinon.createSandbox() + fetchStub = sandbox.stub() + makeClientStub = sandbox.stub() + logStub = sandbox.stub() + addKeyToWalletStub = 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) + + // Mock addKeyToWallet from wallet utils + sandbox.stub(walletUtils, 'addKeyToWallet').callsFake(addKeyToWalletStub 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'))) + // Should not import key when only public key is provided + assert.isFalse(addKeyToWalletStub.called) + }) + + 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 import private key when it was generated (not log it) + const logCalls = logStub.getCalls().map((call) => call.args[0]) + assert.isFalse(logCalls.some((msg) => msg.includes('Private Key:'))) + assert.isTrue(logCalls.some((msg) => msg.includes('Private key imported into wallet'))) + assert.isTrue(addKeyToWalletStub.calledOnce) + }) + + 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, local/ + ), + '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..c913980 --- /dev/null +++ b/test/utils/test-helpers.ts @@ -0,0 +1,192 @@ +import {execSync} from 'child_process' +import {APIClient, FetchProvider} from '@wharfkit/antelope' +import fetch from 'node-fetch' +import * as fs from 'fs' +import * as path from 'path' +import * as os from 'os' + +/** + * Check if nodeos is available in PATH + */ +export function isNodeosAvailable(): boolean { + try { + execSync('which nodeos', {encoding: 'utf8', stdio: 'ignore'}) + return true + } catch { + return false + } +} + +/** + * Get CLI path relative to test directory + */ +export function getCliPath(): string { + return path.join(__dirname, '../../lib/cli.js') +} + +/** + * Get a transaction expiration date 1 hour from now + */ +export function getTransactionExpiration(): string { + const now = new Date() + now.setHours(now.getHours() + 1) + return now.toISOString().slice(0, 19) // Remove milliseconds and timezone +} + +/** + * Generate a random local account name (12 chars, no .gm suffix) + */ +export function getRandomLocalAccountName(prefix: string): string { + const chars = 'abcdefghijklmnopqrstuvwxyz12345' + let result = prefix + const remaining = 12 - prefix.length + for (let i = 0; i < remaining; i++) { + result += chars.charAt(Math.floor(Math.random() * chars.length)) + } + return result +} + +/** + * E2E test context that is shared across test files + */ +export interface E2ETestContext { + cliPath: string + testDir: string + testWalletDir: string + originalHome: string +} + +/** + * Setup E2E test environment with temporary directories and running chain + * Call this in suiteSetup() of your E2E test file + */ +export async function setupE2ETestEnvironment( + mochaContext: Mocha.Context +): Promise { + mochaContext.timeout(180000) + + // Skip E2E tests if nodeos is not available + if (!isNodeosAvailable()) { + // eslint-disable-next-line no-console + console.log('Skipping E2E tests: nodeos is not available in PATH') + mochaContext.skip() + return null + } + + const cliPath = getCliPath() + + // Create a temporary test directory + const testDir = path.join(os.tmpdir(), `wharfkit-e2e-test-${Date.now()}`) + fs.mkdirSync(testDir, {recursive: true}) + + // Create a temporary wallet directory for tests + const testWalletDir = path.join(testDir, '.wharfkit', 'wallet') + fs.mkdirSync(testWalletDir, {recursive: true}) + + // Mock HOME to use test wallet directory + const originalHome = process.env.HOME || '' + process.env.HOME = testDir + + // Stop any existing chain before starting + 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 + } + + // Also check port 8888 directly + killProcessAtPort(8888) + + // Start the chain with --clean to ensure fresh state + execSync(`node ${cliPath} chain local start --clean`, {encoding: 'utf8'}) + + // Wait for chain to be ready + await waitForChainReady('http://127.0.0.1:8888', 30000) + + return {cliPath, testDir, testWalletDir, originalHome} +} + +/** + * Teardown E2E test environment + * Call this in suiteTeardown() of your E2E test file + */ +export function teardownE2ETestEnvironment(context: E2ETestContext | null): void { + if (!context) return + + const {cliPath, testDir, originalHome} = context + + // Restore original HOME + process.env.HOME = originalHome + + // Clean up test directory + if (fs.existsSync(testDir)) { + fs.rmSync(testDir, {recursive: true, force: true}) + } + + try { + execSync(`node ${cliPath} chain local stop`, {encoding: 'utf8'}) + } catch { + // Ignore errors if chain wasn't started + } +} + +/** + * 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..0ff08d5 100644 --- a/yarn.lock +++ b/yarn.lock @@ -530,6 +530,11 @@ resolved "https://registry.npmjs.org/@types/node/-/node-18.17.12.tgz" integrity sha512-d6xjC9fJ/nSnfDeU0AMDsaJyb1iHsqCSOdi84w4u+SlN/UgQdY5tRhpMzaFYsI4mnpvgTivEaQd0yOUhAtOnEQ== +"@types/qrcode-terminal@^0.12.2": + version "0.12.2" + resolved "https://registry.yarnpkg.com/@types/qrcode-terminal/-/qrcode-terminal-0.12.2.tgz#8d7de3aa41f2d3c724bbc74a157ef3209abf8c75" + integrity sha512-v+RcIEJ+Uhd6ygSQ0u5YYY7ZM+la7GgPbs0V/7l/kFs2uO4S8BcIUEMoP7za4DNIqNnUD5npf0A/7kBhrCKG5Q== + "@types/resolve@1.17.1": version "1.17.1" resolved "https://registry.npmjs.org/@types/resolve/-/resolve-1.17.1.tgz" @@ -647,7 +652,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 +681,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 +728,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 +763,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" @@ -2276,6 +2317,11 @@ punycode@^2.1.0: resolved "https://registry.npmjs.org/punycode/-/punycode-2.3.0.tgz" integrity sha512-rRV+zQD8tVFys26lAGR9WUuS4iUAngJScM+ZRSKtvl5tKeZ2t5bvdNFdNHBW9FWR4guGHlgmsZ1G7BSm2wTbuA== +qrcode-terminal@^0.12.0: + version "0.12.0" + resolved "https://registry.yarnpkg.com/qrcode-terminal/-/qrcode-terminal-0.12.0.tgz#bb5b699ef7f9f0505092a3748be4464fe71b5819" + integrity sha512-EXtzRZmC+YGmGlDFbXKxQiMZNwCLEO6BANKXG4iCtSIM0yqc/pappSx3RIKr4r0uh5JsBckOXeKrB3Iz7mdQpQ== + queue-microtask@^1.2.2: version "1.2.3" resolved "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz"