From 43bacf0469f1cfe202d75d8d2dcc8fcf11e9def4 Mon Sep 17 00:00:00 2001 From: Vladimir Rogojin Date: Sat, 25 Apr 2026 23:15:57 +0200 Subject: [PATCH] feat(trader): add sphere trader command tree (mirrors trader-ctl) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The canonical trader-cli is bin/trader-ctl in vrogojin/trader-service. sphere-cli ships this as a mirror so operators don't need to install another tool; canonical trader-ctl users keep using their tool. Same pattern as sphere host (HMCP). ## What - src/trader/trader-commands.ts — createTraderCommand() commander tree with 7 subcommands: create-intent, cancel-intent, list-intents, list-deals, portfolio, set-strategy, status - src/trader/acp-protocols.ts — ACP-0 types + typed payload guards (mirror of trader-service/src/protocols/acp.ts) - src/trader/acp-envelope.ts — parseAcpJson + isTimestampFresh + hasDangerousKeys + 64KiB size cap - src/trader/acp-transport.ts — ACP DM transport (correlates by command_id, distinct from HMCP's in_reply_to-correlated transport) - src/shared/timeout-constants.ts — MIN_TIMEOUT_MS=100 shared with agentic-hosting tenant dispatcher - src/index.ts — wire createTraderCommand() next to createHostCommand() - README — add sphere trader to status table + quickstart examples ## Why a separate transport sphere-cli's existing dm-transport.ts is HMCP-shaped (correlates by in_reply_to, parses HMCP envelope). The trader uses ACP-0 (correlates by command_id, parses ACP envelope). The two diverge enough to warrant distinct files; the existing transport stays untouched. ## Tests 50 → 94 (+44 new across trader-commands + the protocol/envelope module tests that come with the new files). All lint + typecheck + build clean. `bin/sphere.mjs trader --help` lists all 7 subcommands. ## Cross-repo coordination trader-service (vrogojin/trader-service) owns the canonical command surface; this PR mirrors that surface. If trader-service's commands evolve, this mirror needs an update; flag in PR description if you change command shapes there. --- README.md | 8 + src/index.test.ts | 2 +- src/index.ts | 7 + src/shared/timeout-constants.ts | 14 + src/trader/acp-envelope.ts | 56 ++++ src/trader/acp-protocols.ts | 113 ++++++++ src/trader/acp-transport.ts | 210 +++++++++++++++ src/trader/trader-commands.test.ts | 173 ++++++++++++ src/trader/trader-commands.ts | 414 +++++++++++++++++++++++++++++ 9 files changed, 996 insertions(+), 1 deletion(-) create mode 100644 src/shared/timeout-constants.ts create mode 100644 src/trader/acp-envelope.ts create mode 100644 src/trader/acp-protocols.ts create mode 100644 src/trader/acp-transport.ts create mode 100644 src/trader/trader-commands.test.ts create mode 100644 src/trader/trader-commands.ts diff --git a/README.md b/README.md index 2fb8121..30cfe13 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,7 @@ parallel refactor branch). | `sphere nametag` / `crypto` / `util` / `faucet` | legacy bridge | | | `sphere daemon` / `config` / `completions` | legacy bridge | | | `sphere host` | **DM-native (live)** | HMCP-0: spawn, list, stop, start, inspect, remove, pause, resume, help, cmd | +| `sphere trader` | **DM-native (live)** | ACP-0: create-intent, cancel-intent, list-intents, list-deals, portfolio, set-strategy, status. Mirrors canonical [`trader-ctl`](https://github.com/vrogojin/trader-service) | | `sphere tenant` | Phase 4 (stub) | Exits with scheduled message | ## Install @@ -45,6 +46,13 @@ sphere wallet init --network testnet # DM-native host example sphere host list --manager @myhostmanager sphere host spawn --manager @myhostmanager --template tpl-1 mybot + +# DM-native trader example (talks directly to a running trader tenant) +sphere trader status --tenant @trader-alice +sphere trader create-intent --tenant @trader-alice \ + --direction sell --base UCT --quote USDC \ + --rate-min 95 --rate-max 100 --volume-min 10 --volume-total 100 +sphere trader list-deals --tenant @trader-alice --state active ``` ## Development diff --git a/src/index.test.ts b/src/index.test.ts index a6e8b0f..1300659 100644 --- a/src/index.test.ts +++ b/src/index.test.ts @@ -23,7 +23,7 @@ describe('sphere-cli scaffold', () => { const expectedNamespaces = [ 'wallet', 'balance', 'payments', 'dm', 'group', 'market', 'swap', 'invoice', 'nametag', 'crypto', 'util', 'faucet', 'daemon', - 'host', 'tenant', 'config', 'completions', + 'host', 'tenant', 'trader', 'config', 'completions', ]; for (const ns of expectedNamespaces) { expect(help).toContain(ns); diff --git a/src/index.ts b/src/index.ts index 86f6996..2f643ab 100644 --- a/src/index.ts +++ b/src/index.ts @@ -22,6 +22,7 @@ import { Command } from 'commander'; import { VERSION } from './version.js'; import { createHostCommand } from './host/host-commands.js'; +import { createTraderCommand } from './trader/trader-commands.js'; // Legacy namespaces that delegate to the sphere-sdk CLI dispatcher. // These are wired in phase 2 and replaced command-by-command in phase 4+. @@ -171,6 +172,12 @@ export function createCli(): Command { // Phase 4 (live): `sphere host` — HMCP over Sphere DMs. program.addCommand(createHostCommand()); + // Phase 4 (live): `sphere trader` — ACP over Sphere DMs. + // Mirrors the canonical `trader-ctl` tool from vrogojin/trader-service. + // Operators with the canonical tool installed can use either; sphere-cli + // ships this for convenience parity with `sphere host`. + program.addCommand(createTraderCommand()); + return program; } diff --git a/src/shared/timeout-constants.ts b/src/shared/timeout-constants.ts new file mode 100644 index 0000000..6eb9d02 --- /dev/null +++ b/src/shared/timeout-constants.ts @@ -0,0 +1,14 @@ +/** + * Shared timeout constants used across CLI namespaces. + * + * MIN_TIMEOUT_MS is the smallest --timeout value sphere-cli accepts before + * forwarding to the tenant. Anything finer-grained guarantees a timeout + * before the tenant has even finished parsing the request, which a malicious + * controller could weaponise to drain the registry's concurrency slots. + * + * Aligned with agentic-hosting's `command-registry.ts` MIN_TIMEOUT_MS = 100. + * If those layers diverge, this value MUST track the tenant-side floor — + * sending a value below the tenant's floor produces a confusing two-hop + * `invalid_params` error far from the source. + */ +export const MIN_TIMEOUT_MS = 100; diff --git a/src/trader/acp-envelope.ts b/src/trader/acp-envelope.ts new file mode 100644 index 0000000..432108b --- /dev/null +++ b/src/trader/acp-envelope.ts @@ -0,0 +1,56 @@ +/** + * ACP-0 envelope helpers — JSON parse + size cap + dangerous-keys + freshness. + * + * Mirror of trader-service/src/protocols/envelope.ts (itself mirroring + * agentic-hosting/src/protocols/envelope.ts post-decoupling). Trimmed to the + * surface this CLI's DM transport actually needs. + */ + +import { isValidAcpMessage } from './acp-protocols.js'; +import type { AcpMessage } from './acp-protocols.js'; + +/** 64 KiB ceiling on serialized ACP payloads (UTF-16 code-unit count). */ +export const MAX_MESSAGE_SIZE = 64 * 1024; +export const MAX_NESTING_DEPTH = 20; + +/** + * ±5min clock-skew tolerance applied at every inbound parse site. Beyond + * the structural validity check, this catches stale replays whose msg_id / + * content hash slipped past dedup (e.g. after TTL expiry, restart of the + * receiver, or cross-instance log loss). Symmetric — receivers rejecting + * only "future" leak clock-skew info to the sender. + */ +export const MAX_CLOCK_SKEW_MS = 300_000; + +export function hasDangerousKeys(obj: unknown, depth = 0): boolean { + if (depth > MAX_NESTING_DEPTH) return true; + if (typeof obj !== 'object' || obj === null) return false; + for (const key of Object.keys(obj as Record)) { + if (key === '__proto__' || key === 'constructor' || key === 'prototype') { + return true; + } + const val = (obj as Record)[key]; + if (typeof val === 'object' && val !== null && hasDangerousKeys(val, depth + 1)) { + return true; + } + } + return false; +} + +export function isTimestampFresh(tsMs: number, now: number = Date.now()): boolean { + if (typeof tsMs !== 'number' || !Number.isFinite(tsMs)) return false; + return Math.abs(tsMs - now) <= MAX_CLOCK_SKEW_MS; +} + +export function parseAcpJson(data: string): AcpMessage | null { + if (data.length > MAX_MESSAGE_SIZE) return null; + let parsed: unknown; + try { + parsed = JSON.parse(data); + } catch { + return null; + } + if (hasDangerousKeys(parsed)) return null; + if (!isValidAcpMessage(parsed)) return null; + return parsed; +} diff --git a/src/trader/acp-protocols.ts b/src/trader/acp-protocols.ts new file mode 100644 index 0000000..a2543a1 --- /dev/null +++ b/src/trader/acp-protocols.ts @@ -0,0 +1,113 @@ +/** + * ACP-0 (Agent Control Protocol) types, constructors, and validators. + * + * Mirror of trader-service/src/protocols/acp.ts (which itself mirrors + * agentic-hosting/src/protocols/acp.ts after the Phase 4(h) decoupling). + * ACP-0 is owned by the agentic-hosting protocol spec; if the spec evolves, + * all three repos must update in lockstep. + * + * Why duplicated rather than imported: this CLI ships standalone (no + * runtime dep on trader-service); the transport boundary is the same + * 6-message ACP envelope set so the duplication is small + bounded. + */ + +import { randomUUID } from 'node:crypto'; +import { hasDangerousKeys } from './acp-envelope.js'; + +export const ACP_VERSION = '0.1'; + +export const ACP_MESSAGE_TYPES = [ + 'acp.hello', + 'acp.hello_ack', + 'acp.heartbeat', + 'acp.ping', + 'acp.pong', + 'acp.command', + 'acp.result', + 'acp.error', +] as const; +export type AcpMessageType = (typeof ACP_MESSAGE_TYPES)[number]; + +export interface AcpCommandPayload { + readonly command_id: string; + readonly name: string; + readonly params: Readonly>; +} + +export interface AcpResultPayload { + readonly command_id: string; + readonly ok: true; + readonly result: Readonly>; +} + +export interface AcpErrorPayload { + readonly command_id: string; + readonly ok: false; + readonly error_code: string; + readonly message: string; +} + +export interface AcpMessage { + readonly acp_version: string; + readonly msg_id: string; + readonly ts_ms: number; + readonly instance_id: string; + readonly instance_name: string; + readonly type: AcpMessageType; + readonly payload: Record; +} + +export function createAcpMessage( + type: AcpMessageType, + instanceId: string, + instanceName: string, + payload: Record, +): AcpMessage { + return { + acp_version: ACP_VERSION, + msg_id: randomUUID(), + ts_ms: Date.now(), + instance_id: instanceId, + instance_name: instanceName, + type, + payload, + }; +} + +export function isValidAcpMessage(msg: unknown): msg is AcpMessage { + if (typeof msg !== 'object' || msg === null) return false; + const obj = msg as Record; + return ( + obj['acp_version'] === ACP_VERSION && + typeof obj['msg_id'] === 'string' && obj['msg_id'] !== '' && + Number.isFinite(obj['ts_ms']) && + typeof obj['instance_id'] === 'string' && obj['instance_id'] !== '' && + typeof obj['instance_name'] === 'string' && obj['instance_name'] !== '' && + typeof obj['type'] === 'string' && + (ACP_MESSAGE_TYPES as readonly string[]).includes(obj['type'] as string) && + typeof obj['payload'] === 'object' && + obj['payload'] !== null && + !hasDangerousKeys(obj) + ); +} + +export function isAcpResultPayload(payload: unknown): payload is AcpResultPayload { + if (typeof payload !== 'object' || payload === null) return false; + const p = payload as Record; + return ( + typeof p['command_id'] === 'string' && p['command_id'] !== '' && + p['ok'] === true && + typeof p['result'] === 'object' && p['result'] !== null && !Array.isArray(p['result']) + ); +} + +export function isAcpErrorPayload(payload: unknown): payload is AcpErrorPayload { + if (typeof payload !== 'object' || payload === null) return false; + const p = payload as Record; + return ( + typeof p['command_id'] === 'string' && + p['ok'] === false && + typeof p['error_code'] === 'string' && p['error_code'] !== '' && + typeof p['message'] === 'string' + ); +} diff --git a/src/trader/acp-transport.ts b/src/trader/acp-transport.ts new file mode 100644 index 0000000..40a0135 --- /dev/null +++ b/src/trader/acp-transport.ts @@ -0,0 +1,210 @@ +/** + * ACP DM transport for `sphere trader` — talks directly to a running trader + * tenant (manager NOT in the loop). + * + * Why a separate transport from `src/transport/dm-transport.ts`: the host + * variant correlates HMCP responses by `in_reply_to`; the trader variant + * correlates ACP results by `command_id` and parses ACP envelopes (different + * envelope shape). The two diverge just enough to warrant separate files. + * + * Mirror of trader-service/src/cli/dm-transport.ts. + */ + +import type { DirectMessage } from '@unicitylabs/sphere-sdk'; + +import { + createAcpMessage, + isAcpResultPayload, + isAcpErrorPayload, +} from './acp-protocols.js'; +import type { + AcpMessage, + AcpResultPayload, + AcpErrorPayload, +} from './acp-protocols.js'; +import { parseAcpJson, isTimestampFresh, MAX_MESSAGE_SIZE } from './acp-envelope.js'; +import { MIN_TIMEOUT_MS } from '../shared/timeout-constants.js'; +import { TimeoutError, TransportError } from '../transport/errors.js'; + +export type { TimeoutError, TransportError }; +export { MIN_TIMEOUT_MS }; + +export interface SphereComms { + sendDM(recipient: string, content: string): Promise<{ recipientPubkey: string }>; + onDirectMessage(handler: (message: DirectMessage) => void): () => void; +} + +export interface AcpDmTransportConfig { + /** Tenant address: @nametag, DIRECT://, or 64-char hex pubkey. */ + tenantAddress: string; + /** Default per-request timeout in ms. */ + timeoutMs?: number; + /** Required by the ACP envelope; cosmetic — appears in tenant logs. */ + instanceId: string; + instanceName: string; +} + +export interface AcpDmTransport { + /** + * Send an ACP command and resolve with the typed payload of the matching + * acp.result / acp.error message. Rejects with TimeoutError if no response + * arrives in time, or TransportError on send failure. + */ + sendCommand( + name: string, + params: Record, + options?: { timeoutMs?: number; commandId?: string }, + ): Promise; + dispose(): Promise; +} + +const DEFAULT_TIMEOUT_MS = 30_000; + +/** Strip 02/03 prefix to get x-only 64-char hex — matches sphere-sdk normalisation. */ +function normalizeKey(key: string): string { + if (key.length === 66 && (key.startsWith('02') || key.startsWith('03'))) { + return key.slice(2); + } + return key.toLowerCase(); +} + +interface Correlator { + resolve: (response: AcpResultPayload | AcpErrorPayload) => void; + reject: (err: Error) => void; + timer: ReturnType; +} + +class AcpDmTransportImpl implements AcpDmTransport { + private readonly correlators = new Map(); + private readonly unsubscribe: () => void; + /** Resolved x-only pubkey of the tenant — set on first send. */ + private resolvedPubkey: string | null = null; + /** Buffer DMs that arrive between subscribe and pubkey resolution. */ + private readonly earlyMessages: DirectMessage[] = []; + private static readonly EARLY_MESSAGE_CAP = 32; + private disposed = false; + private readonly timeoutMs: number; + + constructor( + private readonly comms: SphereComms, + private readonly tenantAddress: string, + private readonly instanceId: string, + private readonly instanceName: string, + timeoutMs: number, + ) { + this.timeoutMs = timeoutMs; + this.unsubscribe = comms.onDirectMessage((msg) => this.handleIncoming(msg)); + } + + private handleIncoming(msg: DirectMessage): void { + if (this.disposed) return; + if (msg.content.length > MAX_MESSAGE_SIZE) return; + + if (!this.resolvedPubkey) { + if (this.earlyMessages.length < AcpDmTransportImpl.EARLY_MESSAGE_CAP) { + this.earlyMessages.push(msg); + } + return; + } + if (normalizeKey(msg.senderPubkey) !== this.resolvedPubkey) return; + + const acpMsg = parseAcpJson(msg.content); + if (acpMsg === null) return; + if (!isTimestampFresh(acpMsg.ts_ms)) return; + if (acpMsg.type !== 'acp.result' && acpMsg.type !== 'acp.error') return; + + let typed: AcpResultPayload | AcpErrorPayload | null = null; + if (acpMsg.type === 'acp.result' && isAcpResultPayload(acpMsg.payload)) { + typed = acpMsg.payload; + } else if (acpMsg.type === 'acp.error' && isAcpErrorPayload(acpMsg.payload)) { + typed = acpMsg.payload; + } + if (typed === null) return; + + const correlator = this.correlators.get(typed.command_id); + if (correlator !== undefined) { + clearTimeout(correlator.timer); + this.correlators.delete(typed.command_id); + correlator.resolve(typed); + } + } + + async sendCommand( + name: string, + params: Record, + options: { timeoutMs?: number; commandId?: string } = {}, + ): Promise { + if (this.disposed) { + throw new TransportError('Transport has been disposed'); + } + const commandId = options.commandId ?? `cmd-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`; + const timeout = Math.max(options.timeoutMs ?? this.timeoutMs, MIN_TIMEOUT_MS); + + const envelope: AcpMessage = createAcpMessage( + 'acp.command', + this.instanceId, + this.instanceName, + { command_id: commandId, name, params }, + ); + + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + this.correlators.delete(commandId); + reject(new TimeoutError(`No response for ${name} (command_id=${commandId}) within ${timeout} ms`)); + }, timeout); + + this.correlators.set(commandId, { resolve, reject, timer }); + + const payload = JSON.stringify(envelope); + if (payload.length > MAX_MESSAGE_SIZE) { + clearTimeout(timer); + this.correlators.delete(commandId); + reject(new TransportError( + `Request too large: ${payload.length} bytes exceeds MAX_MESSAGE_SIZE (${MAX_MESSAGE_SIZE})`, + )); + return; + } + + this.comms.sendDM(this.tenantAddress, payload).then((sent) => { + if (!this.resolvedPubkey) { + this.resolvedPubkey = normalizeKey(sent.recipientPubkey); + // Drain pre-resolution buffer. + const pending = this.earlyMessages.splice(0); + for (const m of pending) this.handleIncoming(m); + } + }).catch((err: unknown) => { + clearTimeout(timer); + this.correlators.delete(commandId); + reject(new TransportError( + `Failed to send ${name}: ${err instanceof Error ? err.message : String(err)}`, + )); + }); + }); + } + + async dispose(): Promise { + if (this.disposed) return; + this.disposed = true; + this.unsubscribe(); + const err = new TransportError('Transport disposed'); + const pending = Array.from(this.correlators.values()); + this.correlators.clear(); + for (const { timer, reject } of pending) { + clearTimeout(timer); + reject(err); + } + } +} + +export function createAcpDmTransport( + comms: SphereComms, + config: AcpDmTransportConfig, +): AcpDmTransport { + return new AcpDmTransportImpl( + comms, + config.tenantAddress, + config.instanceId, + config.instanceName, + config.timeoutMs ?? DEFAULT_TIMEOUT_MS, + ); +} diff --git a/src/trader/trader-commands.test.ts b/src/trader/trader-commands.test.ts new file mode 100644 index 0000000..3002dca --- /dev/null +++ b/src/trader/trader-commands.test.ts @@ -0,0 +1,173 @@ +/** + * Pure-function tests for `sphere trader` helpers — no DM transport, no + * real Sphere. Exercises the exported parsers (parseTimeout) and the + * commander wiring around createTraderCommand. + */ + +import { describe, it, expect } from 'vitest'; +import type { Command } from 'commander'; +import { + parseTimeout, + resolveTenantAddress, + createTraderCommand, +} from './trader-commands.js'; + +describe('parseTimeout (trader)', () => { + it('falls back when input is undefined', () => { + expect(parseTimeout(undefined, 5000)).toBe(5000); + }); + + it('parses a valid positive integer', () => { + expect(parseTimeout('1000', 5000)).toBe(1000); + }); + + it('floors a decimal', () => { + expect(parseTimeout('1234.7', 5000)).toBe(1234); + }); + + it('throws on zero, negative, NaN, Infinity', () => { + expect(() => parseTimeout('0', 5000)).toThrow(/Invalid timeout/); + expect(() => parseTimeout('-100', 5000)).toThrow(/Invalid timeout/); + expect(() => parseTimeout('abc', 5000)).toThrow(/Invalid timeout/); + expect(() => parseTimeout('Infinity', 5000)).toThrow(/Invalid timeout/); + }); + + it('rejects values below MIN_TIMEOUT_MS=100ms', () => { + expect(() => parseTimeout('99', 5000)).toThrow(/minimum 100ms/); + expect(() => parseTimeout('50', 5000)).toThrow(/minimum 100ms/); + expect(() => parseTimeout('1', 5000)).toThrow(/minimum 100ms/); + }); + + it('accepts values at and above MIN_TIMEOUT_MS=100ms', () => { + expect(parseTimeout('100', 5000)).toBe(100); + expect(parseTimeout('30000', 5000)).toBe(30000); + }); +}); + +describe('resolveTenantAddress', () => { + it('uses --tenant flag when supplied', () => { + expect(resolveTenantAddress({ tenant: '@trader-alice' })).toBe('@trader-alice'); + }); + + it('trims whitespace', () => { + expect(resolveTenantAddress({ tenant: ' @trader-bob ' })).toBe('@trader-bob'); + }); + + it('falls back to SPHERE_TRADER_TENANT env var', () => { + const prev = process.env['SPHERE_TRADER_TENANT']; + process.env['SPHERE_TRADER_TENANT'] = '@env-fallback'; + try { + expect(resolveTenantAddress({})).toBe('@env-fallback'); + } finally { + if (prev === undefined) delete process.env['SPHERE_TRADER_TENANT']; + else process.env['SPHERE_TRADER_TENANT'] = prev; + } + }); + + it('throws when neither flag nor env var is set', () => { + const prev = process.env['SPHERE_TRADER_TENANT']; + delete process.env['SPHERE_TRADER_TENANT']; + try { + expect(() => resolveTenantAddress({})).toThrow(/No trader tenant address/); + expect(() => resolveTenantAddress({ tenant: '' })).toThrow(/No trader tenant address/); + expect(() => resolveTenantAddress({ tenant: ' ' })).toThrow(/No trader tenant address/); + } finally { + if (prev !== undefined) process.env['SPHERE_TRADER_TENANT'] = prev; + } + }); +}); + +describe('createTraderCommand', () => { + it('exposes all 7 expected subcommands', () => { + const trader = createTraderCommand(); + const names = trader.commands.map((c) => c.name()).sort(); + expect(names).toEqual([ + 'cancel-intent', + 'create-intent', + 'list-deals', + 'list-intents', + 'portfolio', + 'set-strategy', + 'status', + ]); + }); + + it('attaches the inherited-options help to every subcommand', () => { + const trader = createTraderCommand(); + for (const sub of trader.commands) { + let captured = ''; + const origWrite = process.stdout.write.bind(process.stdout); + process.stdout.write = ((chunk: string | Uint8Array): boolean => { + captured += typeof chunk === 'string' ? chunk : Buffer.from(chunk).toString('utf8'); + return true; + }) as typeof process.stdout.write; + try { + sub.outputHelp(); + } finally { + process.stdout.write = origWrite; + } + expect(captured, `subcommand "${sub.name()}" should render inherited --tenant`) + .toContain('--tenant'); + expect(captured).toContain('--json'); + expect(captured).toContain('--timeout'); + } + }); + + it('create-intent declares all required flags', () => { + const trader = createTraderCommand(); + const cmd = trader.commands.find((c) => c.name() === 'create-intent'); + expect(cmd).toBeDefined(); + const optionFlags = cmd!.options.map((o) => o.long); + expect(optionFlags).toEqual(expect.arrayContaining([ + '--direction', '--base', '--quote', '--rate-min', '--rate-max', + '--volume-min', '--volume-total', '--expiry-ms', + ])); + }); + + it('cancel-intent requires --intent-id', () => { + const trader = createTraderCommand(); + const cmd = trader.commands.find((c) => c.name() === 'cancel-intent'); + expect(cmd).toBeDefined(); + const intentIdOption = cmd!.options.find((o) => o.long === '--intent-id'); + expect(intentIdOption).toBeDefined(); + expect(intentIdOption!.required).toBe(true); + }); + + it('list-intents and list-deals have optional --state and --limit', () => { + const trader = createTraderCommand(); + for (const name of ['list-intents', 'list-deals']) { + const cmd = trader.commands.find((c) => c.name() === name); + expect(cmd, name).toBeDefined(); + const flags = cmd!.options.map((o) => o.long); + expect(flags, name).toEqual(expect.arrayContaining(['--state', '--limit'])); + } + }); + + it('set-strategy has all three optional knobs', () => { + const trader = createTraderCommand(); + const cmd = trader.commands.find((c) => c.name() === 'set-strategy'); + expect(cmd).toBeDefined(); + const flags = cmd!.options.map((o) => o.long); + expect(flags).toEqual(expect.arrayContaining([ + '--rate-strategy', '--max-concurrent', '--trusted-escrows', + ])); + }); + + it('parent has --tenant, --json, --timeout global options', () => { + const trader = createTraderCommand(); + const flags = trader.options.map((o) => o.long); + expect(flags).toEqual(expect.arrayContaining(['--tenant', '--json', '--timeout'])); + }); + + it('subcommand action functions are wired', () => { + const trader = createTraderCommand(); + // Each subcommand should have an action handler attached. Access via + // the internal _actionHandler property — fragile but good as a smoke test + // that registration didn't silently drop the .action() call. + for (const sub of trader.commands) { + const actionHandler = (sub as Command & { _actionHandler?: unknown })._actionHandler; + expect(actionHandler, `subcommand "${sub.name()}" should have an action handler`) + .toBeDefined(); + } + }); +}); diff --git a/src/trader/trader-commands.ts b/src/trader/trader-commands.ts new file mode 100644 index 0000000..25edafb --- /dev/null +++ b/src/trader/trader-commands.ts @@ -0,0 +1,414 @@ +/** + * `sphere trader` Commander subcommand tree — ACP-0 client over Sphere DMs. + * + * Talks DIRECTLY to a running trader tenant (the host manager is NOT in the + * loop). The tenant's AcpListener authenticates the sender against either + * UNICITY_MANAGER_PUBKEY or UNICITY_CONTROLLER_PUBKEY; the operator running + * `sphere trader` does so under the wallet identity that matches one of those. + * + * Mirrors the canonical `trader-ctl` from vrogojin/trader-service (which owns + * the command surface). Operators with the canonical tool installed can use + * either; `sphere trader` ships in sphere-cli for convenience parity with + * `sphere host`. + */ + +import { Command } from 'commander'; +import type { Sphere } from '@unicitylabs/sphere-sdk'; + +import { initSphere } from '../host/sphere-init.js'; +import { createAcpDmTransport } from './acp-transport.js'; +import type { AcpDmTransport } from './acp-transport.js'; +import type { AcpResultPayload, AcpErrorPayload } from './acp-protocols.js'; +import { TimeoutError, TransportError } from '../transport/errors.js'; +import { MIN_TIMEOUT_MS } from '../shared/timeout-constants.js'; + +const DEFAULT_TIMEOUT_MS = 30_000; + +// ============================================================================= +// Option types +// ============================================================================= + +interface GlobalOpts { + tenant?: string; + json?: boolean; + timeout?: string; +} + +interface CreateIntentOpts { + direction: string; + base: string; + quote: string; + rateMin: string; + rateMax: string; + volumeMin: string; + volumeTotal: string; + expiryMs?: string; +} + +interface CancelIntentOpts { + intentId: string; +} + +interface ListIntentsOpts { + state?: string; + limit?: string; +} + +interface ListDealsOpts { + state?: string; + limit?: string; +} + +interface SetStrategyOpts { + rateStrategy?: string; + maxConcurrent?: string; + trustedEscrows?: string; +} + +// ============================================================================= +// Helpers +// ============================================================================= + +function parseGlobalOpts(cmd: Command): GlobalOpts { + // optsWithGlobals walks the parent chain — same pattern used in host-commands. + return cmd.optsWithGlobals(); +} + +/** + * Reject sub-floor timeouts at the CLI surface so the operator gets a clear + * local error, not a confusing two-hop `invalid_params` from the tenant. + * Aligned with agentic-hosting's MIN_TIMEOUT_MS via shared/timeout-constants. + */ +function parseTimeout(raw: string | undefined, fallback: number): number { + if (!raw) return fallback; + const n = Number(raw); + if (!Number.isFinite(n) || n <= 0) { + throw new Error(`Invalid timeout: ${raw}`); + } + const floored = Math.floor(n); + if (floored < MIN_TIMEOUT_MS) { + throw new Error( + `Invalid timeout: ${raw} (minimum ${MIN_TIMEOUT_MS}ms — values below this are rejected by the tenant dispatcher)`, + ); + } + return floored; +} + +export function resolveTenantAddress(opts: { tenant?: string }): string { + const address = opts.tenant ?? process.env['SPHERE_TRADER_TENANT']; + if (!address || address.trim() === '') { + throw new Error( + 'No trader tenant address. Pass --tenant <@nametag|DIRECT://hex|hex> or set SPHERE_TRADER_TENANT.', + ); + } + return address.trim(); +} + +function writeStderr(msg: unknown): void { + const s = typeof msg === 'string' ? msg : String(msg ?? 'unknown error'); + const prefixed = s.startsWith('sphere trader:') || s.startsWith('sphere:') + ? s + : `sphere trader: ${s}`; + process.stderr.write(prefixed.endsWith('\n') ? prefixed : `${prefixed}\n`); +} + +function printJson(value: unknown): void { + process.stdout.write(`${JSON.stringify(value, null, 2)}\n`); +} + +// ============================================================================= +// Core runner +// ============================================================================= + +interface RunContext { + sphere: Sphere; + transport: AcpDmTransport; + timeoutMs: number; + json: boolean; +} + +type Handler = (ctx: RunContext) => Promise; + +async function runWithTransport(cmd: Command, handler: Handler): Promise { + const globals = parseGlobalOpts(cmd); + const json = globals.json ?? false; + + let timeoutMs: number; + let tenantAddress: string; + try { + timeoutMs = parseTimeout(globals.timeout, DEFAULT_TIMEOUT_MS); + tenantAddress = resolveTenantAddress({ tenant: globals.tenant }); + } catch (err) { + writeStderr((err as Error).message); + process.exitCode = 1; + return; + } + + let sphere: Sphere | null = null; + let transport: AcpDmTransport | null = null; + try { + sphere = await initSphere(); + transport = createAcpDmTransport(sphere.communications, { + tenantAddress, + timeoutMs, + // Cosmetic — appears in tenant logs to identify the controller's + // session. Could be made configurable; sphere-cli is fine for now. + instanceId: process.env['UNICITY_INSTANCE_ID'] ?? 'sphere-cli', + instanceName: process.env['UNICITY_INSTANCE_NAME'] ?? 'sphere-cli', + }); + await handler({ sphere, transport, timeoutMs, json }); + } catch (err) { + handleError(err, json); + } finally { + if (transport) { + try { await transport.dispose(); } catch (e) { + if (process.env['DEBUG']) writeStderr(`sphere-cli: transport.dispose error: ${e}`); + } + } + if (sphere) { + try { await sphere.destroy(); } catch (e) { + if (process.env['DEBUG']) writeStderr(`sphere-cli: sphere.destroy error: ${e}`); + } + } + } +} + +function handleError(err: unknown, json: boolean): void { + if (err instanceof TimeoutError) { + writeStderr('Request timed out'); + } else if (err instanceof TransportError) { + writeStderr(err.message); + } else if (err instanceof Error) { + writeStderr(err.message); + } else { + writeStderr(String(err)); + } + void json; + process.exitCode = 1; +} + +function emitResult(json: boolean, response: AcpResultPayload | AcpErrorPayload): void { + if (json) { + printJson(response); + } else if (response.ok === false) { + writeStderr(`[${response.error_code}] ${response.message}`); + } else { + printJson(response.result); + } + if (response.ok === false) { + process.exitCode = 1; + } +} + +// ============================================================================= +// Subcommand handlers +// ============================================================================= + +async function handleCreateIntent(cmd: Command, opts: CreateIntentOpts): Promise { + await runWithTransport(cmd, async ({ transport, json }) => { + if (opts.direction !== 'buy' && opts.direction !== 'sell') { + writeStderr('--direction must be "buy" or "sell"'); + process.exitCode = 1; + return; + } + const params: Record = { + direction: opts.direction, + base_asset: opts.base, + quote_asset: opts.quote, + rate_min: opts.rateMin, + rate_max: opts.rateMax, + volume_min: opts.volumeMin, + volume_total: opts.volumeTotal, + }; + if (opts.expiryMs !== undefined) { + const n = Number.parseInt(opts.expiryMs, 10); + if (!Number.isFinite(n) || n <= 0) { + writeStderr(`--expiry-ms must be a positive integer (got "${opts.expiryMs}")`); + process.exitCode = 1; + return; + } + params['expiry_ms'] = n; + } + const response = await transport.sendCommand('CREATE_INTENT', params); + emitResult(json, response); + }); +} + +async function handleCancelIntent(cmd: Command, opts: CancelIntentOpts): Promise { + await runWithTransport(cmd, async ({ transport, json }) => { + const response = await transport.sendCommand('CANCEL_INTENT', { intent_id: opts.intentId }); + emitResult(json, response); + }); +} + +async function handleListIntents(cmd: Command, opts: ListIntentsOpts): Promise { + await runWithTransport(cmd, async ({ transport, json }) => { + const params: Record = {}; + if (opts.state !== undefined) params['state'] = opts.state; + if (opts.limit !== undefined) { + const n = Number.parseInt(opts.limit, 10); + if (!Number.isFinite(n) || n <= 0) { + writeStderr(`--limit must be a positive integer (got "${opts.limit}")`); + process.exitCode = 1; + return; + } + params['limit'] = n; + } + const response = await transport.sendCommand('LIST_INTENTS', params); + emitResult(json, response); + }); +} + +async function handleListDeals(cmd: Command, opts: ListDealsOpts): Promise { + await runWithTransport(cmd, async ({ transport, json }) => { + const params: Record = {}; + if (opts.state !== undefined) params['state'] = opts.state; + if (opts.limit !== undefined) { + const n = Number.parseInt(opts.limit, 10); + if (!Number.isFinite(n) || n <= 0) { + writeStderr(`--limit must be a positive integer (got "${opts.limit}")`); + process.exitCode = 1; + return; + } + params['limit'] = n; + } + // Trader exposes the swap-set via LIST_SWAPS; alias it as `list-deals` + // because operators think in deal language. Spec also accepts LIST_SWAPS + // — keep the wire name canonical. + const response = await transport.sendCommand('LIST_SWAPS', params); + emitResult(json, response); + }); +} + +async function handlePortfolio(cmd: Command): Promise { + await runWithTransport(cmd, async ({ transport, json }) => { + const response = await transport.sendCommand('GET_PORTFOLIO', {}); + emitResult(json, response); + }); +} + +async function handleStatus(cmd: Command): Promise { + await runWithTransport(cmd, async ({ transport, json }) => { + const response = await transport.sendCommand('STATUS', {}); + emitResult(json, response); + }); +} + +async function handleSetStrategy(cmd: Command, opts: SetStrategyOpts): Promise { + await runWithTransport(cmd, async ({ transport, json }) => { + const params: Record = {}; + if (opts.rateStrategy !== undefined) params['rate_strategy'] = opts.rateStrategy; + if (opts.maxConcurrent !== undefined) { + const n = Number.parseInt(opts.maxConcurrent, 10); + if (!Number.isFinite(n) || n <= 0) { + writeStderr(`--max-concurrent must be a positive integer (got "${opts.maxConcurrent}")`); + process.exitCode = 1; + return; + } + params['max_concurrent_negotiations'] = n; + } + if (opts.trustedEscrows !== undefined) { + params['trusted_escrows'] = opts.trustedEscrows.split(',').map((s) => s.trim()).filter((s) => s !== ''); + } + if (Object.keys(params).length === 0) { + writeStderr('set-strategy: at least one of --rate-strategy / --max-concurrent / --trusted-escrows must be provided'); + process.exitCode = 1; + return; + } + const response = await transport.sendCommand('SET_STRATEGY', params); + emitResult(json, response); + }); +} + +// ============================================================================= +// Command tree +// ============================================================================= + +export function createTraderCommand(): Command { + const trader = new Command('trader') + .description('ACP: controller → trader tenant (over Sphere DM)') + .option('--tenant
', 'Trader tenant address (@nametag, DIRECT://hex, or hex pubkey)') + .option('--json', 'Output raw JSON response') + .option('--timeout ', 'Override default request timeout (ms)', String(DEFAULT_TIMEOUT_MS)); + + const inheritedHelp = + 'Inherited options:\n' + + ' --tenant
Trader tenant address (@nametag, DIRECT://hex, or hex pubkey)\n' + + ' --json Output raw JSON response\n' + + ' --timeout Override default request timeout (ms)'; + + trader + .command('create-intent') + .description('Submit a new trading intent to the trader') + .requiredOption('--direction ', 'Trade direction') + .requiredOption('--base ', 'Base asset (e.g. UCT)') + .requiredOption('--quote ', 'Quote asset (e.g. USDC)') + .requiredOption('--rate-min ', 'Minimum acceptable rate (string-encoded bigint)') + .requiredOption('--rate-max ', 'Maximum acceptable rate (string-encoded bigint)') + .requiredOption('--volume-min ', 'Minimum volume per match') + .requiredOption('--volume-total ', 'Total intent volume') + .option('--expiry-ms ', 'Expiry duration in milliseconds (default: 24h)') + .action(async function (this: Command, opts: CreateIntentOpts) { + await handleCreateIntent(this, opts); + }); + + trader + .command('cancel-intent') + .description('Cancel an active intent by ID') + .requiredOption('--intent-id ', 'Intent ID to cancel') + .action(async function (this: Command, opts: CancelIntentOpts) { + await handleCancelIntent(this, opts); + }); + + trader + .command('list-intents') + .description("List the trader's active and recent intents") + .option('--state ', 'Filter by state: active|filled|cancelled|expired') + .option('--limit ', 'Maximum number of intents to return') + .action(async function (this: Command, opts: ListIntentsOpts) { + await handleListIntents(this, opts); + }); + + trader + .command('list-deals') + .description('List active and completed deals (a.k.a. swaps)') + .option('--state ', 'Filter by state: active|completed|failed') + .option('--limit ', 'Maximum number of deals to return') + .action(async function (this: Command, opts: ListDealsOpts) { + await handleListDeals(this, opts); + }); + + trader + .command('portfolio') + .description("Show the trader's current asset balances") + .action(async function (this: Command) { + await handlePortfolio(this); + }); + + trader + .command('status') + .description('Show STATUS — uptime + adapter info') + .action(async function (this: Command) { + await handleStatus(this); + }); + + trader + .command('set-strategy') + .description("Update the trader's strategy parameters") + .option('--rate-strategy ', 'Rate strategy: aggressive|moderate|conservative') + .option('--max-concurrent ', 'Max concurrent negotiations') + .option('--trusted-escrows ', 'Comma-separated escrow addresses (overwrites)') + .action(async function (this: Command, opts: SetStrategyOpts) { + await handleSetStrategy(this, opts); + }); + + // Attach the shared-options help text to every subcommand. + for (const sub of trader.commands) { + sub.addHelpText('after', `\n${inheritedHelp}`); + } + + return trader; +} + +// Exported for unit tests. +export { parseTimeout };