Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
74 changes: 73 additions & 1 deletion src/trader/trader-commands.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
resolveTenantAddress,
createTraderCommand,
buildCreateIntentParams,
buildWithdrawParams,
} from './trader-commands.js';

describe('parseTimeout (trader)', () => {
Expand Down Expand Up @@ -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
Expand All @@ -92,6 +93,7 @@ describe('createTraderCommand', () => {
'list-intents',
'portfolio',
'set-strategy',
'withdraw',
]);
});

Expand Down Expand Up @@ -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);
}
});
});
72 changes: 72 additions & 0 deletions src/trader/trader-commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,12 @@ interface SetStrategyOpts {
trustedEscrows?: string;
}

interface WithdrawOpts {
asset: string;
amount: string;
toAddress: string;
}

// =============================================================================
// Helpers
// =============================================================================
Expand Down Expand Up @@ -334,6 +340,62 @@ async function handlePortfolio(cmd: Command): Promise<void> {
// 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<string, unknown> } | { 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<void> {
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<void> {
await runWithTransport(cmd, async ({ transport, json }) => {
const params: Record<string, unknown> = {};
Expand Down Expand Up @@ -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 <symbol>', 'Asset symbol (e.g. UCT, USDU) or hex coin id')
.requiredOption('--amount <bigint>', 'Amount to withdraw in smallest units (string-encoded bigint, must be > 0)')
.requiredOption('--to-address <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}`);
Expand Down
Loading