diff --git a/src/commands/auth.ts b/src/commands/auth.ts index 2261ccf..54fa159 100644 --- a/src/commands/auth.ts +++ b/src/commands/auth.ts @@ -1,481 +1,488 @@ -import * as readline from 'node:readline'; - -import { - CHAIN_ENV_TO_CHAIN, - createNadoClient as createSdkClient, -} from '@nadohq/client'; -import { subaccountToHex } from '@nadohq/shared'; -import chalk from 'chalk'; -import { Command } from 'commander'; -import { - createPublicClient, - createWalletClient, - http, - isAddress, - type Address, -} from 'viem'; -import { generatePrivateKey, privateKeyToAccount } from 'viem/accounts'; - -import { - getConfigPath, - loadConfig, - loadTomlConfig, - saveTomlConfig, -} from '../config.js'; -import { ALL_DATA_ENVS, getDataEnvConfig, type DataEnv } from '../dataEnv.js'; -import { linkSigner } from '../handlers/auth.js'; -import { runConfirmed } from './helpers.js'; - -const VALID_KEYS = ['owner', 'private-key', 'network', 'subaccount-name']; - -export function createAuthCommand(): Command { - const auth = new Command('auth').description('Credential management'); - - auth - .command('set') - .description('Configure credentials and defaults') - .argument('[key]', `Setting to configure: ${VALID_KEYS.join(', ')}`) - .argument('[value]', 'Value to set') +import * as readline from 'node:readline'; + +import { + CHAIN_ENV_TO_CHAIN, + createNadoClient as createSdkClient, +} from '@nadohq/client'; +import { subaccountToHex } from '@nadohq/shared'; +import chalk from 'chalk'; +import { Command } from 'commander'; +import { + createPublicClient, + createWalletClient, + http, + isAddress, + type Address, +} from 'viem'; +import { generatePrivateKey, privateKeyToAccount } from 'viem/accounts'; + +import { + getConfigPath, + loadConfig, + loadTomlConfig, + saveTomlConfig, +} from '../config.js'; +import { ALL_DATA_ENVS, getDataEnvConfig, type DataEnv } from '../dataEnv.js'; +import { linkSigner } from '../handlers/auth.js'; +import { runConfirmed } from './helpers.js'; + +const VALID_KEYS = ['owner', 'private-key', 'network', 'subaccount-name']; + +export function createAuthCommand(): Command { + const auth = new Command('auth').description('Credential management'); + + auth + .command('set') + .description('Configure credentials and defaults') + .argument('[key]', `Setting to configure: ${VALID_KEYS.join(', ')}`) + .argument('[value]', 'Value to set') .action(async (key: string | undefined, value: string | undefined) => { - if (key && value) { - setKeyValue(key, value); - } else if (key) { - console.error(chalk.red(`Usage: nado auth set `)); - console.error(chalk.dim(`Valid keys: ${VALID_KEYS.join(', ')}`)); - process.exitCode = 1; - } else { - await setInteractive(); - } - }); - - auth - .command('whoami') - .description('Show current identity and config') - .action((_opts: unknown, cmd: Command) => { - const flags = cmd.optsWithGlobals(); - const config = loadConfig(flags); - - console.log(chalk.cyan('Network: ') + config.dataEnv); - console.log( - chalk.cyan('Owner: ') + - (config.subaccountOwner ?? chalk.dim('not set')), - ); - console.log(chalk.cyan('Subaccount: ') + config.subaccountName); - console.log( - chalk.cyan('Signer: ') + - (config.privateKey - ? chalk.green('configured') - : chalk.dim('not set')), - ); - console.log(chalk.cyan('Config file: ') + getConfigPath()); - }); - - const ZERO_ADDR = '0x0000000000000000000000000000000000000000'; - - auth - .command('link-signer') - .description('Link a signer address to your subaccount for 1-click trading') - .argument( - '
', - `Signer address to link (use ${ZERO_ADDR} to revoke)`, - ) - .action(async (address: string, _opts: unknown, cmd: Command) => { - await runConfirmed(cmd, (ctx) => { - const isRevoke = address === ZERO_ADDR; - - const summary = [ - '', - chalk.bold( - isRevoke ? ' Revoke linked signer' : ' Link signer to subaccount', - ), - ` Signer: ${address}`, - ` Subaccount: ${ctx.subaccountName}`, - isRevoke - ? chalk.yellow(' This will remove the existing linked signer') - : null, - '', - ] - .filter((line): line is string => line != null) - .join('\n'); - - return { - summary, - execute: () => linkSigner(ctx, { signer: address }), - }; - }); - }); - - return auth; -} - -export function createSetupCommand(): Command { - return new Command('setup') - .description('Set up 1-click trading with a hot signer key') - .action(async () => { - await setup1ct(); - }); -} - -function setKeyValue(key: string, value: string): void { - const toml = loadTomlConfig(); - - switch (key) { - case 'owner': { - if (!isAddress(value)) { - console.error(chalk.red('Error: Invalid Ethereum address')); - process.exitCode = 1; - return; - } - toml.credentials = toml.credentials ?? {}; - toml.credentials.subaccount_owner = value; - break; - } - case 'private-key': { - toml.credentials = toml.credentials ?? {}; - toml.credentials.private_key = value; - break; - } - case 'network': { - if (!ALL_DATA_ENVS.includes(value as DataEnv)) { + if (key === 'private-key' && value) { console.error( chalk.red( - `Error: Invalid network. Must be one of: ${ALL_DATA_ENVS.join(', ')}`, + 'Private keys must not be supplied as command-line arguments. Run `nado auth set` interactively or use the PRIVATE_KEY environment variable.', ), ); process.exitCode = 1; - return; - } - toml.default = { ...toml.default, data_env: value }; - break; - } - case 'subaccount-name': { - toml.default = { ...toml.default, subaccount_name: value }; - break; - } - default: { - console.error(chalk.red(`Unknown key "${key}"`)); - console.error(chalk.dim(`Valid keys: ${VALID_KEYS.join(', ')}`)); - process.exitCode = 1; - return; - } - } - - saveTomlConfig(toml); - console.log(chalk.green('✓') + ` Saved ${key} to ${getConfigPath()}`); -} - -async function setInteractive(): Promise { - const toml = loadTomlConfig(); - - console.log(chalk.dim(`Nado CLI Configuration (${getConfigPath()})\n`)); - - const rl = readline.createInterface({ - input: process.stdin, - output: process.stdout, - }); - - const ask = (prompt: string): Promise => - new Promise((resolve) => rl.question(prompt, resolve)); - - const currentEnv = toml.default?.data_env ?? 'nadoMainnet'; - const envInput = await ask( - `Network (${ALL_DATA_ENVS.join('/')}) ${chalk.dim(`[${currentEnv}]`)}: `, - ); - const dataEnv = envInput.trim() || currentEnv; - if (!ALL_DATA_ENVS.includes(dataEnv as DataEnv)) { - console.error(chalk.red(`Invalid network: ${dataEnv}`)); - rl.close(); - process.exitCode = 1; - return; - } - - const currentOwner = toml.credentials?.subaccount_owner ?? ''; - const ownerInput = await ask( - `Subaccount owner ${chalk.dim(currentOwner ? `[${currentOwner}]` : '[not set]')}: `, - ); - const owner = ownerInput.trim() || currentOwner; - if (owner && !isAddress(owner)) { - console.error(chalk.red('Invalid Ethereum address')); - rl.close(); - process.exitCode = 1; - return; - } - - const currentName = toml.default?.subaccount_name ?? 'default'; - const nameInput = await ask( - `Subaccount name ${chalk.dim(`[${currentName}]`)}: `, - ); - const subaccountName = nameInput.trim() || currentName; - - const currentKey = toml.credentials?.private_key; - const keyDisplay = currentKey ? 'set — press Enter to keep' : 'not set'; - rl.close(); - const keyInput = await askSecret( - `Private key ${chalk.dim(`[${keyDisplay}]`)}: `, - ); - const privateKey = keyInput.trim() || currentKey || undefined; - - toml.default = { data_env: dataEnv, subaccount_name: subaccountName }; - toml.credentials = toml.credentials ?? {}; - if (owner) toml.credentials.subaccount_owner = owner; - if (privateKey) toml.credentials.private_key = privateKey; - - saveTomlConfig(toml); - console.log( - '\n' + chalk.green('✓') + ` Configuration saved to ${getConfigPath()}`, - ); -} - -function askSecret(prompt: string): Promise { - if (!process.stdin.isTTY) { - const rl = readline.createInterface({ - input: process.stdin, - output: process.stdout, - }); - return new Promise((resolve) => - rl.question(prompt, (answer) => { - rl.close(); - resolve(answer); - }), - ); - } - - return new Promise((resolve) => { - process.stdout.write(prompt); - const buf: Buffer[] = []; - const wasRaw = process.stdin.isRaw; - process.stdin.setRawMode(true); - process.stdin.resume(); - - const onData = (key: Buffer): void => { - const ch = key[0]; - if (ch === 3) { - process.stdout.write('\n'); - process.exit(130); - } - if (ch === 13 || ch === 10) { - process.stdout.write('\n'); - process.stdin.setRawMode(wasRaw ?? false); - process.stdin.pause(); - process.stdin.removeListener('data', onData); - resolve(Buffer.concat(buf).toString('utf8')); - return; - } - if (ch === 127 || ch === 8) { - buf.pop(); - } else { - buf.push(key); - } - }; - - process.stdin.on('data', onData); - }); -} - -async function setup1ct(): Promise { - const toml = loadTomlConfig(); - - console.log(chalk.bold('\n Nado 1-Click Trading Setup\n')); - console.log( - chalk.dim( - ' This wizard creates a hot signer key that is saved to config\n' + - ' and linked to your subaccount. The CLI uses this hot key to\n' + - ' sign all transactions — your main wallet key is never stored.\n', - ), - ); - - const rl = readline.createInterface({ - input: process.stdin, - output: process.stdout, - }); - const ask = (prompt: string): Promise => - new Promise((resolve) => rl.question(prompt, resolve)); - - try { - // Step 1: Network - const currentEnv = toml.default?.data_env ?? 'nadoMainnet'; - const envInput = await ask( - ` Network (${ALL_DATA_ENVS.join('/')}) ${chalk.dim(`[${currentEnv}]`)}: `, - ); - const dataEnv = (envInput.trim() || currentEnv) as DataEnv; - if (!ALL_DATA_ENVS.includes(dataEnv)) { - console.error(chalk.red('\n Invalid network.')); - process.exitCode = 1; - return; - } - const isTestnet = dataEnv === 'nadoTestnet'; - const envConfig = getDataEnvConfig(dataEnv); - const chainEnv = envConfig.defaultChainEnv; - - // Step 2: Wallet address - const currentOwner = toml.credentials?.subaccount_owner ?? ''; - const ownerInput = await ask( - ` Your wallet address (EOA) ${chalk.dim(currentOwner ? `[${currentOwner}]` : '')}: `, - ); - const owner = ownerInput.trim() || currentOwner; - if (!owner || !isAddress(owner)) { - console.error(chalk.red('\n Invalid Ethereum address.')); - process.exitCode = 1; - return; - } - - // Step 3: Subaccount name - const currentName = toml.default?.subaccount_name ?? 'default'; - const nameInput = await ask( - ` Subaccount name ${chalk.dim(`[${currentName}]`)}: `, - ); - const subaccountName = nameInput.trim() || currentName; - - // Step 4: Choose signer method - console.log( - '\n ' + - chalk.bold('How would you like to set up the signer?\n') + - ' 1. ' + - chalk.cyan('Generate new') + - ' — creates a fresh hot key (you link it via the web app)\n' + - ' 2. ' + - chalk.cyan('Deterministic') + - ' — derives the same key the web app uses (requires your main private key once)\n', - ); - - const methodInput = await ask(` Choice ${chalk.dim('[1]')}: `); - const method = methodInput.trim() || '1'; - - let hotPrivateKey: string; - let signerAddress: string; - let autoLinked = false; - - if (method === '2') { - // Deterministic: needs main wallet private key temporarily - console.log( - chalk.dim( - '\n Your main private key is used once to derive the signer\n' + - ' and link it on-chain. It is NOT saved to disk.\n' + - ' The derived hot key IS saved and used for future signing.\n', - ), - ); - rl.close(); - const mainKeyInput = await askSecret(' Main wallet private key: '); - const mainKey = mainKeyInput.trim(); - if (!mainKey) { - console.error(chalk.red('\n Private key is required.')); - process.exitCode = 1; - return; - } - - try { - const mainAccount = privateKeyToAccount(mainKey as Address); - if (mainAccount.address.toLowerCase() !== owner.toLowerCase()) { - console.error( - chalk.red( - `\n Key does not match owner. Key address: ${mainAccount.address}`, - ), - ); - process.exitCode = 1; - return; - } - - const chain = CHAIN_ENV_TO_CHAIN[chainEnv]; - const rpcUrl = chain.rpcUrls.default.http[0]; - const publicClient = createPublicClient({ - transport: http(rpcUrl), - }); - const walletClient = createWalletClient({ - account: mainAccount, - chain, - transport: http(rpcUrl), - }); - - console.log(chalk.dim(' Deriving deterministic signer...')); - const client = createSdkClient(chainEnv, { - publicClient, - walletClient, - }); - - const result = - await client.subaccount.createStandardLinkedSigner(subaccountName); - hotPrivateKey = result.privateKey; - signerAddress = result.account.address; - - console.log(` Signer address: ${chalk.cyan(signerAddress)}`); - - // Check if already linked - const linked = await client.context.engineClient.getLinkedSigner({ - subaccountOwner: owner, - subaccountName, - }); - - if (linked.signer.toLowerCase() === signerAddress.toLowerCase()) { - console.log(chalk.green(' Already linked! ✓')); - autoLinked = true; - } else { - console.log(chalk.dim(' Linking signer on-chain...')); - // EIP712 expects signer as bytes32 - use subaccountToHex (address + empty name) like the web app - const signerBytes32 = subaccountToHex({ - subaccountOwner: signerAddress, - subaccountName: '', - }); - await client.subaccount.linkSigner({ - subaccountOwner: owner, - subaccountName, - signer: signerBytes32, - }); - console.log(chalk.green(' Signer linked! ✓')); - autoLinked = true; - } - } catch (err) { - const msg = err instanceof Error ? err.message : String(err); - console.error(chalk.red(`\n Error: ${msg}`)); - process.exitCode = 1; - return; - } - } else { - // Generate fresh hot key - hotPrivateKey = generatePrivateKey(); - const account = privateKeyToAccount(hotPrivateKey as Address); - signerAddress = account.address; - - console.error( - chalk.yellow( - '\n ⚠ The key below is sensitive — do not share or log it.\n', - ), - ); - console.error( - ` Generated hot private key: ${chalk.cyan(hotPrivateKey)}`, - ); - console.error( - chalk.yellow('\n ⚠ You must link this signer in the Nado web app:\n') + - ` 1. Go to ${chalk.underline( - isTestnet ? 'https://testnet.nado.xyz' : 'https://app.nado.xyz', - )}\n` + - ` 2. Settings → 1-Click Trading → Link Signer\n` + - ` 3. Paste the private key shown above\n`, - ); - - await ask(' Press Enter once you have linked the signer...'); - } - - // Step 5: Save config - toml.default = { data_env: dataEnv, subaccount_name: subaccountName }; - toml.credentials = toml.credentials ?? {}; - toml.credentials.private_key = hotPrivateKey; - toml.credentials.subaccount_owner = owner; - - saveTomlConfig(toml); - - console.log( - '\n' + - chalk.green(' ✓ 1-Click Trading configured!\n') + - chalk.dim(` Config: ${getConfigPath()}\n`) + - ` Network: ${dataEnv}\n` + - ` Owner: ${owner}\n` + - ` Signer: ${signerAddress}\n` + - ` Subaccount: ${subaccountName}\n` + - (autoLinked - ? chalk.green(' Linked: yes ✓\n') - : chalk.yellow(' Linked: verify in web app\n')), - ); - } finally { - rl.close(); - } -} + } else if (key && value) { + setKeyValue(key, value); + } else if (key) { + console.error(chalk.red(`Usage: nado auth set `)); + console.error(chalk.dim(`Valid keys: ${VALID_KEYS.join(', ')}`)); + process.exitCode = 1; + } else { + await setInteractive(); + } + }); + + auth + .command('whoami') + .description('Show current identity and config') + .action((_opts: unknown, cmd: Command) => { + const flags = cmd.optsWithGlobals(); + const config = loadConfig(flags); + + console.log(chalk.cyan('Network: ') + config.dataEnv); + console.log( + chalk.cyan('Owner: ') + + (config.subaccountOwner ?? chalk.dim('not set')), + ); + console.log(chalk.cyan('Subaccount: ') + config.subaccountName); + console.log( + chalk.cyan('Signer: ') + + (config.privateKey + ? chalk.green('configured') + : chalk.dim('not set')), + ); + console.log(chalk.cyan('Config file: ') + getConfigPath()); + }); + + const ZERO_ADDR = '0x0000000000000000000000000000000000000000'; + + auth + .command('link-signer') + .description('Link a signer address to your subaccount for 1-click trading') + .argument( + '
', + `Signer address to link (use ${ZERO_ADDR} to revoke)`, + ) + .action(async (address: string, _opts: unknown, cmd: Command) => { + await runConfirmed(cmd, (ctx) => { + const isRevoke = address === ZERO_ADDR; + + const summary = [ + '', + chalk.bold( + isRevoke ? ' Revoke linked signer' : ' Link signer to subaccount', + ), + ` Signer: ${address}`, + ` Subaccount: ${ctx.subaccountName}`, + isRevoke + ? chalk.yellow(' This will remove the existing linked signer') + : null, + '', + ] + .filter((line): line is string => line != null) + .join('\n'); + + return { + summary, + execute: () => linkSigner(ctx, { signer: address }), + }; + }); + }); + + return auth; +} + +export function createSetupCommand(): Command { + return new Command('setup') + .description('Set up 1-click trading with a hot signer key') + .action(async () => { + await setup1ct(); + }); +} + +function setKeyValue(key: string, value: string): void { + const toml = loadTomlConfig(); + + switch (key) { + case 'owner': { + if (!isAddress(value)) { + console.error(chalk.red('Error: Invalid Ethereum address')); + process.exitCode = 1; + return; + } + toml.credentials = toml.credentials ?? {}; + toml.credentials.subaccount_owner = value; + break; + } + case 'private-key': { + toml.credentials = toml.credentials ?? {}; + toml.credentials.private_key = value; + break; + } + case 'network': { + if (!ALL_DATA_ENVS.includes(value as DataEnv)) { + console.error( + chalk.red( + `Error: Invalid network. Must be one of: ${ALL_DATA_ENVS.join(', ')}`, + ), + ); + process.exitCode = 1; + return; + } + toml.default = { ...toml.default, data_env: value }; + break; + } + case 'subaccount-name': { + toml.default = { ...toml.default, subaccount_name: value }; + break; + } + default: { + console.error(chalk.red(`Unknown key "${key}"`)); + console.error(chalk.dim(`Valid keys: ${VALID_KEYS.join(', ')}`)); + process.exitCode = 1; + return; + } + } + + saveTomlConfig(toml); + console.log(chalk.green('✓') + ` Saved ${key} to ${getConfigPath()}`); +} + +async function setInteractive(): Promise { + const toml = loadTomlConfig(); + + console.log(chalk.dim(`Nado CLI Configuration (${getConfigPath()})\n`)); + + const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout, + }); + + const ask = (prompt: string): Promise => + new Promise((resolve) => rl.question(prompt, resolve)); + + const currentEnv = toml.default?.data_env ?? 'nadoMainnet'; + const envInput = await ask( + `Network (${ALL_DATA_ENVS.join('/')}) ${chalk.dim(`[${currentEnv}]`)}: `, + ); + const dataEnv = envInput.trim() || currentEnv; + if (!ALL_DATA_ENVS.includes(dataEnv as DataEnv)) { + console.error(chalk.red(`Invalid network: ${dataEnv}`)); + rl.close(); + process.exitCode = 1; + return; + } + + const currentOwner = toml.credentials?.subaccount_owner ?? ''; + const ownerInput = await ask( + `Subaccount owner ${chalk.dim(currentOwner ? `[${currentOwner}]` : '[not set]')}: `, + ); + const owner = ownerInput.trim() || currentOwner; + if (owner && !isAddress(owner)) { + console.error(chalk.red('Invalid Ethereum address')); + rl.close(); + process.exitCode = 1; + return; + } + + const currentName = toml.default?.subaccount_name ?? 'default'; + const nameInput = await ask( + `Subaccount name ${chalk.dim(`[${currentName}]`)}: `, + ); + const subaccountName = nameInput.trim() || currentName; + + const currentKey = toml.credentials?.private_key; + const keyDisplay = currentKey ? 'set — press Enter to keep' : 'not set'; + rl.close(); + const keyInput = await askSecret( + `Private key ${chalk.dim(`[${keyDisplay}]`)}: `, + ); + const privateKey = keyInput.trim() || currentKey || undefined; + + toml.default = { data_env: dataEnv, subaccount_name: subaccountName }; + toml.credentials = toml.credentials ?? {}; + if (owner) toml.credentials.subaccount_owner = owner; + if (privateKey) toml.credentials.private_key = privateKey; + + saveTomlConfig(toml); + console.log( + '\n' + chalk.green('✓') + ` Configuration saved to ${getConfigPath()}`, + ); +} + +function askSecret(prompt: string): Promise { + if (!process.stdin.isTTY) { + const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout, + }); + return new Promise((resolve) => + rl.question(prompt, (answer) => { + rl.close(); + resolve(answer); + }), + ); + } + + return new Promise((resolve) => { + process.stdout.write(prompt); + const buf: Buffer[] = []; + const wasRaw = process.stdin.isRaw; + process.stdin.setRawMode(true); + process.stdin.resume(); + + const onData = (key: Buffer): void => { + const ch = key[0]; + if (ch === 3) { + process.stdout.write('\n'); + process.exit(130); + } + if (ch === 13 || ch === 10) { + process.stdout.write('\n'); + process.stdin.setRawMode(wasRaw ?? false); + process.stdin.pause(); + process.stdin.removeListener('data', onData); + resolve(Buffer.concat(buf).toString('utf8')); + return; + } + if (ch === 127 || ch === 8) { + buf.pop(); + } else { + buf.push(key); + } + }; + + process.stdin.on('data', onData); + }); +} + +async function setup1ct(): Promise { + const toml = loadTomlConfig(); + + console.log(chalk.bold('\n Nado 1-Click Trading Setup\n')); + console.log( + chalk.dim( + ' This wizard creates a hot signer key that is saved to config\n' + + ' and linked to your subaccount. The CLI uses this hot key to\n' + + ' sign all transactions — your main wallet key is never stored.\n', + ), + ); + + const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout, + }); + const ask = (prompt: string): Promise => + new Promise((resolve) => rl.question(prompt, resolve)); + + try { + // Step 1: Network + const currentEnv = toml.default?.data_env ?? 'nadoMainnet'; + const envInput = await ask( + ` Network (${ALL_DATA_ENVS.join('/')}) ${chalk.dim(`[${currentEnv}]`)}: `, + ); + const dataEnv = (envInput.trim() || currentEnv) as DataEnv; + if (!ALL_DATA_ENVS.includes(dataEnv)) { + console.error(chalk.red('\n Invalid network.')); + process.exitCode = 1; + return; + } + const isTestnet = dataEnv === 'nadoTestnet'; + const envConfig = getDataEnvConfig(dataEnv); + const chainEnv = envConfig.defaultChainEnv; + + // Step 2: Wallet address + const currentOwner = toml.credentials?.subaccount_owner ?? ''; + const ownerInput = await ask( + ` Your wallet address (EOA) ${chalk.dim(currentOwner ? `[${currentOwner}]` : '')}: `, + ); + const owner = ownerInput.trim() || currentOwner; + if (!owner || !isAddress(owner)) { + console.error(chalk.red('\n Invalid Ethereum address.')); + process.exitCode = 1; + return; + } + + // Step 3: Subaccount name + const currentName = toml.default?.subaccount_name ?? 'default'; + const nameInput = await ask( + ` Subaccount name ${chalk.dim(`[${currentName}]`)}: `, + ); + const subaccountName = nameInput.trim() || currentName; + + // Step 4: Choose signer method + console.log( + '\n ' + + chalk.bold('How would you like to set up the signer?\n') + + ' 1. ' + + chalk.cyan('Generate new') + + ' — creates a fresh hot key (you link it via the web app)\n' + + ' 2. ' + + chalk.cyan('Deterministic') + + ' — derives the same key the web app uses (requires your main private key once)\n', + ); + + const methodInput = await ask(` Choice ${chalk.dim('[1]')}: `); + const method = methodInput.trim() || '1'; + + let hotPrivateKey: string; + let signerAddress: string; + let autoLinked = false; + + if (method === '2') { + // Deterministic: needs main wallet private key temporarily + console.log( + chalk.dim( + '\n Your main private key is used once to derive the signer\n' + + ' and link it on-chain. It is NOT saved to disk.\n' + + ' The derived hot key IS saved and used for future signing.\n', + ), + ); + rl.close(); + const mainKeyInput = await askSecret(' Main wallet private key: '); + const mainKey = mainKeyInput.trim(); + if (!mainKey) { + console.error(chalk.red('\n Private key is required.')); + process.exitCode = 1; + return; + } + + try { + const mainAccount = privateKeyToAccount(mainKey as Address); + if (mainAccount.address.toLowerCase() !== owner.toLowerCase()) { + console.error( + chalk.red( + `\n Key does not match owner. Key address: ${mainAccount.address}`, + ), + ); + process.exitCode = 1; + return; + } + + const chain = CHAIN_ENV_TO_CHAIN[chainEnv]; + const rpcUrl = chain.rpcUrls.default.http[0]; + const publicClient = createPublicClient({ + transport: http(rpcUrl), + }); + const walletClient = createWalletClient({ + account: mainAccount, + chain, + transport: http(rpcUrl), + }); + + console.log(chalk.dim(' Deriving deterministic signer...')); + const client = createSdkClient(chainEnv, { + publicClient, + walletClient, + }); + + const result = + await client.subaccount.createStandardLinkedSigner(subaccountName); + hotPrivateKey = result.privateKey; + signerAddress = result.account.address; + + console.log(` Signer address: ${chalk.cyan(signerAddress)}`); + + // Check if already linked + const linked = await client.context.engineClient.getLinkedSigner({ + subaccountOwner: owner, + subaccountName, + }); + + if (linked.signer.toLowerCase() === signerAddress.toLowerCase()) { + console.log(chalk.green(' Already linked! ✓')); + autoLinked = true; + } else { + console.log(chalk.dim(' Linking signer on-chain...')); + // EIP712 expects signer as bytes32 - use subaccountToHex (address + empty name) like the web app + const signerBytes32 = subaccountToHex({ + subaccountOwner: signerAddress, + subaccountName: '', + }); + await client.subaccount.linkSigner({ + subaccountOwner: owner, + subaccountName, + signer: signerBytes32, + }); + console.log(chalk.green(' Signer linked! ✓')); + autoLinked = true; + } + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + console.error(chalk.red(`\n Error: ${msg}`)); + process.exitCode = 1; + return; + } + } else { + // Generate fresh hot key + hotPrivateKey = generatePrivateKey(); + const account = privateKeyToAccount(hotPrivateKey as Address); + signerAddress = account.address; + + console.error( + chalk.yellow( + '\n ⚠ The key below is sensitive — do not share or log it.\n', + ), + ); + console.error( + ` Generated hot private key: ${chalk.cyan(hotPrivateKey)}`, + ); + console.error( + chalk.yellow('\n ⚠ You must link this signer in the Nado web app:\n') + + ` 1. Go to ${chalk.underline( + isTestnet ? 'https://testnet.nado.xyz' : 'https://app.nado.xyz', + )}\n` + + ` 2. Settings → 1-Click Trading → Link Signer\n` + + ` 3. Paste the private key shown above\n`, + ); + + await ask(' Press Enter once you have linked the signer...'); + } + + // Step 5: Save config + toml.default = { data_env: dataEnv, subaccount_name: subaccountName }; + toml.credentials = toml.credentials ?? {}; + toml.credentials.private_key = hotPrivateKey; + toml.credentials.subaccount_owner = owner; + + saveTomlConfig(toml); + + console.log( + '\n' + + chalk.green(' ✓ 1-Click Trading configured!\n') + + chalk.dim(` Config: ${getConfigPath()}\n`) + + ` Network: ${dataEnv}\n` + + ` Owner: ${owner}\n` + + ` Signer: ${signerAddress}\n` + + ` Subaccount: ${subaccountName}\n` + + (autoLinked + ? chalk.green(' Linked: yes ✓\n') + : chalk.yellow(' Linked: verify in web app\n')), + ); + } finally { + rl.close(); + } +} diff --git a/src/commands/shell.ts b/src/commands/shell.ts index b6f7a50..ecbc261 100644 --- a/src/commands/shell.ts +++ b/src/commands/shell.ts @@ -1,163 +1,181 @@ -import * as fs from 'node:fs'; -import * as os from 'node:os'; -import * as path from 'node:path'; -import * as readline from 'node:readline'; - -import chalk from 'chalk'; -import { Command, CommanderError } from 'commander'; - -import { ensureConfigDir } from '../config.js'; -import { setSharedReadline } from '../utils/confirm.js'; - +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import * as readline from 'node:readline'; + +import chalk from 'chalk'; +import { Command, CommanderError } from 'commander'; + +import { ensureConfigDir } from '../config.js'; +import { setSharedReadline } from '../utils/confirm.js'; + const HISTORY_FILE = path.join(os.homedir(), '.config', 'nado', 'history'); const MAX_HISTORY = 1000; - -export function createShellCommand(program: Command): Command { - return new Command('shell') - .description('Interactive REPL') - .action(async () => { - await startShell(program); - }); -} - -async function startShell(program: Command): Promise { - const history = loadHistory(); - - applyExitOverride(program); - program.configureOutput({ - writeOut: (str) => process.stdout.write(str), - writeErr: (str) => process.stderr.write(str), - }); - - const completions = [ - 'exit', - 'quit', - 'clear', - 'help', - ...buildCompletions(program), - ]; - - const rl = readline.createInterface({ - input: process.stdin, - output: process.stdout, - prompt: chalk.green('nado') + chalk.dim('> '), - completer: (line: string): [string[], string] => { - const hits = completions.filter((c) => c.startsWith(line)); - return [hits.length ? hits : completions, line]; - }, - history, - historySize: MAX_HISTORY, - }); - - setSharedReadline(rl); - - console.log( - chalk.dim( - 'Nado interactive shell. Type "help" for commands, "exit" to quit.', - ), +const SENSITIVE_HISTORY_PATTERNS = [ + /\bauth\s+set\s+private-key\b/i, + /\bprivate[-_ ]?key\b[^\n]*0x[0-9a-f]{64}\b/i, + /\bprivate[-_ ]?key\b[^\n]*\b[0-9a-f]{64}\b/i, + /\bPRIVATE_KEY\s*=/i, +]; + +export function createShellCommand(program: Command): Command { + return new Command('shell') + .description('Interactive REPL') + .action(async () => { + await startShell(program); + }); +} + +async function startShell(program: Command): Promise { + const history = loadHistory(); + + applyExitOverride(program); + program.configureOutput({ + writeOut: (str) => process.stdout.write(str), + writeErr: (str) => process.stderr.write(str), + }); + + const completions = [ + 'exit', + 'quit', + 'clear', + 'help', + ...buildCompletions(program), + ]; + + const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout, + prompt: chalk.green('nado') + chalk.dim('> '), + completer: (line: string): [string[], string] => { + const hits = completions.filter((c) => c.startsWith(line)); + return [hits.length ? hits : completions, line]; + }, + history, + historySize: MAX_HISTORY, + }); + + setSharedReadline(rl); + + console.log( + chalk.dim( + 'Nado interactive shell. Type "help" for commands, "exit" to quit.', + ), + ); + rl.prompt(); + + for await (const line of rl) { + const trimmed = line.trim(); + + if (!trimmed) { + rl.prompt(); + continue; + } + + if (trimmed === 'exit' || trimmed === 'quit') { + break; + } + + if (trimmed === 'clear') { + console.clear(); + rl.prompt(); + continue; + } + + if (trimmed === 'shell') { + console.log(chalk.yellow('Already in shell mode.')); + rl.prompt(); + continue; + } + + if (trimmed === 'help') { + program.outputHelp(); + rl.prompt(); + continue; + } + + const args = splitArgs(trimmed); + + try { + await program.parseAsync(args, { from: 'user' }); + } catch (err) { + if (err instanceof CommanderError) { + // Commander already printed help, validation, or version info + } else if (err instanceof Error) { + console.error(chalk.red(`Error: ${err.message}`)); + } + } + + process.exitCode = 0; + rl.prompt(); + } + + setSharedReadline(null); + saveHistory(rl); + rl.close(); + console.log(chalk.dim('\nBye!')); +} + +function applyExitOverride(cmd: Command): void { + cmd.exitOverride(); + for (const sub of cmd.commands) { + applyExitOverride(sub); + } +} + +function buildCompletions(cmd: Command, prefix = ''): string[] { + const results: string[] = []; + for (const sub of cmd.commands) { + if (sub.name() === 'shell') continue; + const full = prefix ? `${prefix} ${sub.name()}` : sub.name(); + results.push(full); + results.push(...buildCompletions(sub, full)); + } + return results; +} + +function splitArgs(line: string): string[] { + const matches = line.match(/(?:[^\s"']+|"[^"]*"|'[^']*')+/g); + if (!matches) return []; + return matches.map((m) => { + if ( + (m.startsWith('"') && m.endsWith('"')) || + (m.startsWith("'") && m.endsWith("'")) + ) { + return m.slice(1, -1); + } + return m; + }); +} + +function loadHistory(): string[] { + try { + const content = fs.readFileSync(HISTORY_FILE, 'utf-8'); + return filterPersistableHistory(content.trim().split('\n').filter(Boolean)).reverse(); + } catch { + return []; + } +} + +export function filterPersistableHistory(history: string[]): string[] { + return history.filter( + (entry) => + !SENSITIVE_HISTORY_PATTERNS.some((pattern) => pattern.test(entry)), ); - rl.prompt(); - - for await (const line of rl) { - const trimmed = line.trim(); - - if (!trimmed) { - rl.prompt(); - continue; - } - - if (trimmed === 'exit' || trimmed === 'quit') { - break; - } - - if (trimmed === 'clear') { - console.clear(); - rl.prompt(); - continue; - } - - if (trimmed === 'shell') { - console.log(chalk.yellow('Already in shell mode.')); - rl.prompt(); - continue; - } - - if (trimmed === 'help') { - program.outputHelp(); - rl.prompt(); - continue; - } - - const args = splitArgs(trimmed); - - try { - await program.parseAsync(args, { from: 'user' }); - } catch (err) { - if (err instanceof CommanderError) { - // Commander already printed help, validation, or version info - } else if (err instanceof Error) { - console.error(chalk.red(`Error: ${err.message}`)); - } - } - - process.exitCode = 0; - rl.prompt(); - } - - setSharedReadline(null); - saveHistory(rl); - rl.close(); - console.log(chalk.dim('\nBye!')); -} - -function applyExitOverride(cmd: Command): void { - cmd.exitOverride(); - for (const sub of cmd.commands) { - applyExitOverride(sub); - } } -function buildCompletions(cmd: Command, prefix = ''): string[] { - const results: string[] = []; - for (const sub of cmd.commands) { - if (sub.name() === 'shell') continue; - const full = prefix ? `${prefix} ${sub.name()}` : sub.name(); - results.push(full); - results.push(...buildCompletions(sub, full)); - } - return results; -} - -function splitArgs(line: string): string[] { - const matches = line.match(/(?:[^\s"']+|"[^"]*"|'[^']*')+/g); - if (!matches) return []; - return matches.map((m) => { - if ( - (m.startsWith('"') && m.endsWith('"')) || - (m.startsWith("'") && m.endsWith("'")) - ) { - return m.slice(1, -1); - } - return m; - }); -} - -function loadHistory(): string[] { - try { - const content = fs.readFileSync(HISTORY_FILE, 'utf-8'); - return content.trim().split('\n').filter(Boolean).reverse(); - } catch { - return []; - } -} - -function saveHistory(rl: readline.Interface): void { - try { - ensureConfigDir(); - const history = (rl as unknown as { history: string[] }).history ?? []; - const lines = history.slice(0, MAX_HISTORY).reverse(); - fs.writeFileSync(HISTORY_FILE, lines.join('\n') + '\n'); - } catch { - // Silently ignore history save errors - } -} +function saveHistory(rl: readline.Interface): void { + try { + ensureConfigDir(); + const history = (rl as unknown as { history: string[] }).history ?? []; + const lines = filterPersistableHistory(history.slice(0, MAX_HISTORY)).reverse(); + fs.writeFileSync(HISTORY_FILE, lines.join('\n') + '\n', { + encoding: 'utf-8', + mode: 0o600, + }); + // chmod also protects an existing history file that was created with weaker permissions. + fs.chmodSync(HISTORY_FILE, 0o600); + } catch { + // Silently ignore history save errors + } +} diff --git a/src/index.ts b/src/index.ts index faa626e..ece8fe5 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,15 +1,17 @@ import { createProgram } from './cli.js'; - -const program = createProgram(); - -program - .parseAsync(process.argv) - .then(() => { - process.exit(process.exitCode ?? 0); - }) - .catch((err: unknown) => { - if (err instanceof Error && err.message) { - console.error(err.message); +import { formatError } from './utils/errors.js'; + +const program = createProgram(); + +program + .parseAsync(process.argv) + .then(() => { + process.exit(process.exitCode ?? 0); + }) + .catch((err: unknown) => { + const message = formatError(err); + if (message) { + console.error(message); } - process.exit(1); - }); + process.exit(1); + }); diff --git a/src/utils/errors.ts b/src/utils/errors.ts index 72d5d54..dfa8d75 100644 --- a/src/utils/errors.ts +++ b/src/utils/errors.ts @@ -1,32 +1,32 @@ -/** - * Redacts 32-byte hex strings (0x + 64 hex chars) to prevent accidental - * private-key exposure in error messages. This also redacts order digests and - * tx hashes that share the same format — an acceptable trade-off for safety. - */ -function redactSecrets(text: string): string { - return text.replace(/0x[0-9a-fA-F]{64}/g, '0x[REDACTED]'); -} - -/** Error thrown when a tool/command execution fails. */ -export class ToolExecutionError extends Error { - constructor( - readonly toolName: string, - message: string, - readonly cause?: unknown, - ) { - const causeMsg = - cause instanceof Error ? `: ${redactSecrets(cause.message)}` : ''; - super(`[${toolName}] ${message}${causeMsg}`); - this.name = 'ToolExecutionError'; - } -} - -/** - * Format an error for terminal display. Strips stack traces for known - * error types and redacts secrets from unknown errors. - */ +/** + * Redacts 32-byte hex strings (0x + 64 hex chars) to prevent accidental + * private-key exposure in error messages. This also redacts order digests and + * tx hashes that share the same format — an acceptable trade-off for safety. + */ +function redactSecrets(text: string): string { + return text.replace(/0x[0-9a-fA-F]{64}/g, '0x[REDACTED]'); +} + +/** Error thrown when a tool/command execution fails. */ +export class ToolExecutionError extends Error { + constructor( + readonly toolName: string, + message: string, + readonly cause?: unknown, + ) { + const causeMsg = + cause instanceof Error ? `: ${redactSecrets(cause.message)}` : ''; + super(`[${toolName}] ${message}${causeMsg}`); + this.name = 'ToolExecutionError'; + } +} + +/** + * Format an error for terminal display. Strips stack traces for known + * error types and redacts secrets from unknown errors. + */ export function formatError(err: unknown): string { - if (err instanceof ToolExecutionError) return err.message; + if (err instanceof ToolExecutionError) return redactSecrets(err.message); if (err instanceof Error) return redactSecrets(err.message); - return String(err); + return redactSecrets(String(err)); }