From afd5ae11efd7c79e8033b499a0878525bf4a0ab6 Mon Sep 17 00:00:00 2001 From: Vladimir Rogojin Date: Fri, 8 May 2026 14:37:41 +0200 Subject: [PATCH 1/2] feat: UNICITY_NOSTR_RELAYS env override in host/legacy CLI inits MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirrors the pattern in trader-service, escrow-service, agentic-hosting, and js-faucet. When set to a comma-separated list of WebSocket URLs, overrides the network preset's relay list. Used by the trader-service local-infra e2e harness so 'sphere wallet init', 'sphere host spawn', 'sphere trader create-intent', etc., target the same Docker-hosted relay as the spawned tenants. Falls back to SPHERE_NOSTR_RELAYS if UNICITY_NOSTR_RELAYS is unset. Empty/unset → use network default (no behavioral change). Patches both initSphere() in src/host/sphere-init.ts (the new 'sphere host …' / 'sphere trader …' inits) AND the legacy-cli's getSphere() in src/legacy/legacy-cli.ts (the 'sphere wallet init' path). --- src/host/sphere-init.ts | 14 ++++++++++++ src/index.ts | 40 +++++++++++++++++++++++++++++++++-- src/legacy/legacy-cli.ts | 13 ++++++++++++ src/trader/trader-commands.ts | 15 +++++++++++++ 4 files changed, 80 insertions(+), 2 deletions(-) diff --git a/src/host/sphere-init.ts b/src/host/sphere-init.ts index cafb962..92477ca 100644 --- a/src/host/sphere-init.ts +++ b/src/host/sphere-init.ts @@ -48,10 +48,24 @@ function loadConfig(): CliConfig { export async function initSphere(): Promise { const config = loadConfig(); + // Optional Nostr-relay override. Set `UNICITY_NOSTR_RELAYS` (or + // `SPHERE_NOSTR_RELAYS` as a fallback) to a comma-separated list of + // WebSocket URLs to replace the network preset's relays. Used by the + // trader-service local-infra e2e harness so `sphere host spawn`, + // `sphere trader create-intent`, etc., target the same Docker-hosted + // relay the spawned tenants and the host-manager are using. + const relayOverride = (() => { + const raw = process.env['UNICITY_NOSTR_RELAYS'] ?? process.env['SPHERE_NOSTR_RELAYS']; + if (!raw) return undefined; + const relays = raw.split(',').map((s) => s.trim()).filter((s) => s.length > 0); + return relays.length > 0 ? relays : undefined; + })(); + const providers = createNodeProviders({ network: config.network, dataDir: config.dataDir, tokensDir: config.tokensDir, + ...(relayOverride ? { transport: { relays: relayOverride } } : {}), }); const exists = await Sphere.exists(providers.storage); diff --git a/src/index.ts b/src/index.ts index 59cfde8..524d1c0 100644 --- a/src/index.ts +++ b/src/index.ts @@ -155,11 +155,24 @@ export function createCli(): Command { for (const name of LEGACY_NAMESPACES) { const sub = program .command(name) - .description(`${name} commands (legacy bridge — phase 2)`); + .description(`${name} commands (legacy bridge — phase 2)`) + // Disable Commander's built-in `--help` / `-h` so help args + // fall through to our action(). The legacy CLI has its own + // COMMAND_HELP dispatcher (legacy-cli.ts) that emits detailed + // flag tables; without this opt-out, Commander prints a generic + // " commands (legacy bridge — phase 2)" stub and the + // legacy code never sees the help request. + .helpOption(false); sub.allowUnknownOption(true); sub.action(async () => { - const legacyArgv = buildLegacyArgv(name); + // Detect a help request and route to the legacy CLI's + // `help ` form (which prints COMMAND_HELP[name] — + // detailed Usage / flags / examples / notes). For non-help + // calls, route to the standard switch/case via buildLegacyArgv. + const tail = process.argv.slice(3); + const wantsHelp = tail.includes('--help') || tail.includes('-h'); + const legacyArgv = wantsHelp ? ['help', name] : buildLegacyArgv(name); // Dynamic import keeps the legacy ~40-file dispatcher out of the hot // start path for phase-4 DM-native commands (`sphere host …`, etc.) // that don't need it. Paid once on first legacy invocation per process. @@ -193,6 +206,29 @@ export function createCli(): Command { // ships this for convenience parity with `sphere host`. program.addCommand(createTraderCommand()); + // Custom `help ` — route to the legacy CLI's COMMAND_HELP + // dispatcher when the target is a legacy bridge name. Commander's + // built-in help command otherwise prints just the bridge stub line + // (" commands (legacy bridge — phase 2)"), losing the + // detailed Usage/flags/examples/notes the legacy CLI produces. + program + .command('help [target]') + .description('display detailed help for a command') + .allowUnknownOption(true) + .action(async (target: string | undefined) => { + if (!target) { + program.outputHelp(); + return; + } + if (LEGACY_NAMESPACES.has(target)) { + const { legacyMain } = await import('./legacy/legacy-cli.js'); + await legacyMain(['help', target]); + return; + } + // For native commands (host, trader, pointer) re-emit Commander's help. + program.outputHelp(); + }); + // `sphere pointer` — aggregator-pointer-layer status / flush / recover. // Native command (not a legacy bridge): the pointer layer was never // exposed by the in-tree sphere-sdk CLI, so there is no legacy diff --git a/src/legacy/legacy-cli.ts b/src/legacy/legacy-cli.ts index a8ff6d8..9246264 100644 --- a/src/legacy/legacy-cli.ts +++ b/src/legacy/legacy-cli.ts @@ -223,6 +223,18 @@ async function getSphere(options?: { autoGenerate?: boolean; mnemonic?: string; if (sphereInstance) return sphereInstance; const config = loadConfig(); + + // Optional Nostr-relay override (mirrors host/sphere-init.ts). + // Used by trader-service's local-infra e2e harness so `wallet init` + // and the rest of the legacy CLI talk to the same Docker-hosted relay + // the spawned tenants and host-manager use. + const relayOverride = (() => { + const raw = process.env['UNICITY_NOSTR_RELAYS'] ?? process.env['SPHERE_NOSTR_RELAYS']; + if (!raw) return undefined; + const relays = raw.split(',').map((s) => s.trim()).filter((s) => s.length > 0); + return relays.length > 0 ? relays : undefined; + })(); + const providers = createNodeProviders({ network: config.network, dataDir: config.dataDir, @@ -230,6 +242,7 @@ async function getSphere(options?: { autoGenerate?: boolean; mnemonic?: string; tokenSync: { ipfs: { enabled: true } }, market: true, groupChat: true, + ...(relayOverride ? { transport: { relays: relayOverride } } : {}), }); const initProviders = noNostrGlobal diff --git a/src/trader/trader-commands.ts b/src/trader/trader-commands.ts index 237c917..a08c528 100644 --- a/src/trader/trader-commands.ts +++ b/src/trader/trader-commands.ts @@ -43,6 +43,13 @@ interface CreateIntentOpts { volumeMin: string; volumeMax: string; expiryMs?: string; + /** + * Pubkey, DIRECT://hex, or PROXY://hex of the escrow the trader will + * route the swap through. Optional — when omitted the trader picks + * the default ('any' wildcard / first trusted_escrow). Tests and + * production callers that depend on a specific escrow MUST set this. + */ + escrowAddress?: string; } interface CancelIntentOpts { @@ -254,6 +261,13 @@ export function buildCreateIntentParams( } params['expiry_sec'] = Math.floor(n / 1000); } + if (opts.escrowAddress !== undefined) { + const trimmed = opts.escrowAddress.trim(); + if (trimmed === '') { + return { error: '--escrow-address must be non-empty when provided' }; + } + params['escrow_address'] = trimmed; + } return { params }; } @@ -388,6 +402,7 @@ export function createTraderCommand(): Command { .requiredOption('--volume-min ', 'Minimum volume per match') .requiredOption('--volume-max ', 'Total intent volume') .option('--expiry-ms ', 'Expiry duration in milliseconds (default: 24h)') + .option('--escrow-address
', 'Escrow agent address (pubkey | DIRECT://hex | PROXY://hex). Defaults to "any" wildcard if omitted.') .action(async function (this: Command, opts: CreateIntentOpts) { await handleCreateIntent(this, opts); }); From 6b6c9771a3b1863068ca58b61bef22667f359d0c Mon Sep 17 00:00:00 2001 From: Vladimir Rogojin Date: Sat, 9 May 2026 14:15:33 +0200 Subject: [PATCH 2/2] feat(trader): add sphere trader withdraw subcommand MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wires the missing controller-side CLI for the trader's WITHDRAW_TOKEN ACP command. Validates --asset / --amount / --to-address up-front (so malformed inputs fail with clear messages before reaching the trader's own validation gate), then sends the WITHDRAW_TOKEN command via the existing transport. The trader image already implements WITHDRAW_TOKEN end-to-end (split selected token + send + history record + balance update), and the HMA-trade-settlement e2e helper has been calling `sphere trader withdraw` for a while — but the subcommand was never registered, so every test run failed at the withdraw step with "unknown command 'withdraw'". Verified: HMA-trade-settlement.e2e-live Pair-2 reaches deal COMPLETED, runs withdraw, prints transfer_id, and the post-withdraw balance delta matches the withdrawn amount. Pair-1 failed only on a transient testnet Market API 502 (external infra), not on the withdraw path. --- src/trader/trader-commands.ts | 58 +++++++++++++++++++++++++++++++++++ 1 file changed, 58 insertions(+) diff --git a/src/trader/trader-commands.ts b/src/trader/trader-commands.ts index a08c528..f9b94c9 100644 --- a/src/trader/trader-commands.ts +++ b/src/trader/trader-commands.ts @@ -72,6 +72,12 @@ interface SetStrategyOpts { trustedEscrows?: string; } +interface WithdrawOpts { + asset?: string; + amount?: string; + toAddress?: string; +} + // ============================================================================= // Helpers // ============================================================================= @@ -348,6 +354,48 @@ async function handlePortfolio(cmd: Command): Promise { // rely on `sphere trader portfolio`/`list-intents` succeeding as an // implicit liveness signal. +async function handleWithdraw(cmd: Command, opts: WithdrawOpts): Promise { + await runWithTransport(cmd, async ({ transport, json }) => { + if (opts.asset === undefined || opts.asset.trim() === '') { + writeStderr('withdraw: --asset is required'); + process.exitCode = 1; + return; + } + if (opts.amount === undefined || opts.amount.trim() === '') { + writeStderr('withdraw: --amount is required'); + process.exitCode = 1; + return; + } + // Reject non-positive / non-integer amounts up-front so the CLI gives + // a clear error before the trader returns its own validation rejection. + let parsedAmount: bigint; + try { + parsedAmount = BigInt(opts.amount.trim()); + } catch { + writeStderr(`withdraw: --amount must be a positive integer (got "${opts.amount}")`); + process.exitCode = 1; + return; + } + if (parsedAmount <= 0n) { + writeStderr(`withdraw: --amount must be > 0 (got "${opts.amount}")`); + process.exitCode = 1; + return; + } + if (opts.toAddress === undefined || opts.toAddress.trim() === '') { + writeStderr('withdraw: --to-address is required'); + process.exitCode = 1; + return; + } + const params = { + asset: opts.asset.trim(), + amount: parsedAmount.toString(), + to_address: opts.toAddress.trim(), + }; + const response = await transport.sendCommand('WITHDRAW_TOKEN', params); + emitResult(json, response); + }); +} + async function handleSetStrategy(cmd: Command, opts: SetStrategyOpts): Promise { await runWithTransport(cmd, async ({ transport, json }) => { const params: Record = {}; @@ -454,6 +502,16 @@ export function createTraderCommand(): Command { await handleSetStrategy(this, opts); }); + trader + .command('withdraw') + .description('Withdraw tokens from the trader to an external address') + .option('--asset ', 'Asset symbol (e.g. UCT, USDU)') + .option('--amount ', 'Amount in smallest units (positive integer)') + .option('--to-address ', 'Recipient (@nametag, DIRECT://hex, or hex pubkey)') + .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}`);