diff --git a/src/trader/trader-commands.test.ts b/src/trader/trader-commands.test.ts index 465b842..5a1f832 100644 --- a/src/trader/trader-commands.test.ts +++ b/src/trader/trader-commands.test.ts @@ -11,6 +11,7 @@ import { resolveTenantAddress, createTraderCommand, buildCreateIntentParams, + buildWithdrawParams, } from './trader-commands.js'; describe('parseTimeout (trader)', () => { @@ -79,7 +80,7 @@ describe('resolveTenantAddress', () => { }); describe('createTraderCommand', () => { - it('exposes the 6 controller-scoped trader subcommands', () => { + it('exposes the 7 controller-scoped trader subcommands', () => { const trader = createTraderCommand(); const names = trader.commands.map((c) => c.name()).sort(); // No `status` here: STATUS is system-scoped per Unicity @@ -92,6 +93,7 @@ describe('createTraderCommand', () => { 'list-intents', 'portfolio', 'set-strategy', + 'withdraw', ]); }); @@ -287,3 +289,73 @@ describe('buildCreateIntentParams (wire shape)', () => { expect(result.params['volume_max']).toBe(huge); }); }); + +describe('buildWithdrawParams (wire shape)', () => { + const baseOpts = { + asset: 'UCT', + amount: '1000', + toAddress: '@alice', + }; + + it('emits asset / amount / to_address (snake_case wire fields)', () => { + const result = buildWithdrawParams(baseOpts); + if ('error' in result) throw new Error(result.error); + expect(result.params['asset']).toBe('UCT'); + expect(result.params['amount']).toBe('1000'); + expect(result.params['to_address']).toBe('@alice'); + expect(result.params).not.toHaveProperty('toAddress'); + }); + + it('preserves bigint-string amount verbatim (no coercion to Number)', () => { + const huge = '99999999999999999999999999'; + const result = buildWithdrawParams({ ...baseOpts, amount: huge }); + if ('error' in result) throw new Error(result.error); + expect(result.params['amount']).toBe(huge); + }); + + it('rejects empty asset', () => { + const result = buildWithdrawParams({ ...baseOpts, asset: '' }); + expect('error' in result).toBe(true); + if (!('error' in result)) return; + expect(result.error).toMatch(/asset is required/); + }); + + it('rejects whitespace-only asset', () => { + const result = buildWithdrawParams({ ...baseOpts, asset: ' ' }); + expect('error' in result).toBe(true); + }); + + it('rejects empty amount', () => { + const result = buildWithdrawParams({ ...baseOpts, amount: '' }); + expect('error' in result).toBe(true); + if (!('error' in result)) return; + expect(result.error).toMatch(/amount is required/); + }); + + it('rejects zero, negative, decimal, scientific notation, leading-zero amounts', () => { + for (const bad of ['0', '-100', '1.5', '1e6', '01000', 'abc']) { + const r = buildWithdrawParams({ ...baseOpts, amount: bad }); + expect('error' in r, `expected error for amount="${bad}"`).toBe(true); + } + }); + + it('rejects empty to-address', () => { + const result = buildWithdrawParams({ ...baseOpts, toAddress: '' }); + expect('error' in result).toBe(true); + if (!('error' in result)) return; + expect(result.error).toMatch(/to-address is required/); + }); + + it('accepts all three address forms (@nametag, DIRECT://hex, raw hex)', () => { + for (const addr of [ + '@alice', + 'DIRECT://0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef', + '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef', + ]) { + const r = buildWithdrawParams({ ...baseOpts, toAddress: addr }); + expect('error' in r, `expected accept for to-address="${addr}"`).toBe(false); + if ('error' in r) continue; + expect(r.params['to_address']).toBe(addr); + } + }); +}); diff --git a/src/trader/trader-commands.ts b/src/trader/trader-commands.ts index 237c917..a25139c 100644 --- a/src/trader/trader-commands.ts +++ b/src/trader/trader-commands.ts @@ -65,6 +65,12 @@ interface SetStrategyOpts { trustedEscrows?: string; } +interface WithdrawOpts { + asset: string; + amount: string; + toAddress: string; +} + // ============================================================================= // Helpers // ============================================================================= @@ -334,6 +340,62 @@ async function handlePortfolio(cmd: Command): Promise { // rely on `sphere trader portfolio`/`list-intents` succeeding as an // implicit liveness signal. +/** + * Build the wire payload for WITHDRAW_TOKEN. Pure function for unit-testing. + * + * Validation here mirrors the trader-side handler in + * trader-service/src/trader/trader-command-handler.ts:633: asset must be + * non-empty, amount must be a positive bigint string, to_address must + * be a valid address. The trader re-validates everything; we catch + * the trivial cases at the CLI layer for a faster local error path. + */ +export function buildWithdrawParams( + opts: WithdrawOpts, +): { readonly params: Record } | { readonly error: string } { + if (!opts.asset || opts.asset.trim() === '') { + return { error: '--asset is required (e.g. UCT, USDU, or hex coin id)' }; + } + if (!opts.amount) { + return { error: '--amount is required (positive integer in smallest units)' }; + } + // Strict positive-integer parse — reject `1e6`-style scientific + // notation, leading zeros, signs, decimals. The trader uses + // safeParseBigint which is similarly strict. + if (!/^[1-9]\d*$/.test(opts.amount)) { + return { + error: `--amount must be a positive integer in smallest units (got "${opts.amount}")`, + }; + } + if (!opts.toAddress || opts.toAddress.trim() === '') { + return { error: '--to-address is required' }; + } + // Trim before forwarding so a leading/trailing space in the operator's + // input doesn't reach the wire (where the trader's address regex + // would reject it as INVALID_PARAM with a remote-side error rather + // than a local CLI message). The empty-after-trim cases were already + // caught above; this pass just normalizes content of valid inputs. + return { + params: { + asset: opts.asset.trim(), + amount: opts.amount, + to_address: opts.toAddress.trim(), + }, + }; +} + +async function handleWithdraw(cmd: Command, opts: WithdrawOpts): Promise { + await runWithTransport(cmd, async ({ transport, json }) => { + const built = buildWithdrawParams(opts); + if ('error' in built) { + writeStderr(built.error); + process.exitCode = 1; + return; + } + const response = await transport.sendCommand('WITHDRAW_TOKEN', built.params); + emitResult(json, response); + }); +} + async function handleSetStrategy(cmd: Command, opts: SetStrategyOpts): Promise { await runWithTransport(cmd, async ({ transport, json }) => { const params: Record = {}; @@ -439,6 +501,16 @@ export function createTraderCommand(): Command { await handleSetStrategy(this, opts); }); + trader + .command('withdraw') + .description('Withdraw a token from the trader to an external address (ACP WITHDRAW_TOKEN)') + .requiredOption('--asset ', 'Asset symbol (e.g. UCT, USDU) or hex coin id') + .requiredOption('--amount ', 'Amount to withdraw in smallest units (string-encoded bigint, must be > 0)') + .requiredOption('--to-address
', 'Destination address: @nametag (lowercase alphanumeric + _/-, 1-30 chars), DIRECT://<64-80 hex>, or PROXY://<64-80 hex>') + .action(async function (this: Command, opts: WithdrawOpts) { + await handleWithdraw(this, opts); + }); + // Attach the shared-options help text to every subcommand. for (const sub of trader.commands) { sub.addHelpText('after', `\n${inheritedHelp}`);