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
14 changes: 14 additions & 0 deletions src/host/sphere-init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,10 +48,24 @@ function loadConfig(): CliConfig {
export async function initSphere(): Promise<Sphere> {
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);
Expand Down
40 changes: 38 additions & 2 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
// "<name> 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 <command>` 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.
Expand Down Expand Up @@ -193,6 +206,29 @@ export function createCli(): Command {
// ships this for convenience parity with `sphere host`.
program.addCommand(createTraderCommand());

// Custom `help <command>` — 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
// ("<name> 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
Expand Down
13 changes: 13 additions & 0 deletions src/legacy/legacy-cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -223,13 +223,26 @@ 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,
tokensDir: config.tokensDir,
tokenSync: { ipfs: { enabled: true } },
market: true,
groupChat: true,
...(relayOverride ? { transport: { relays: relayOverride } } : {}),
});

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

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

// =============================================================================
// Helpers
// =============================================================================
Expand Down Expand Up @@ -254,6 +267,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 };
}

Expand Down Expand Up @@ -334,6 +354,48 @@ async function handlePortfolio(cmd: Command): Promise<void> {
// rely on `sphere trader portfolio`/`list-intents` succeeding as an
// implicit liveness signal.

async function handleWithdraw(cmd: Command, opts: WithdrawOpts): Promise<void> {
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<void> {
await runWithTransport(cmd, async ({ transport, json }) => {
const params: Record<string, unknown> = {};
Expand Down Expand Up @@ -388,6 +450,7 @@ export function createTraderCommand(): Command {
.requiredOption('--volume-min <bigint>', 'Minimum volume per match')
.requiredOption('--volume-max <bigint>', 'Total intent volume')
.option('--expiry-ms <ms>', 'Expiry duration in milliseconds (default: 24h)')
.option('--escrow-address <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);
});
Expand Down Expand Up @@ -439,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 <symbol>', 'Asset symbol (e.g. UCT, USDU)')
.option('--amount <n>', 'Amount in smallest units (positive integer)')
.option('--to-address <addr>', '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}`);
Expand Down
Loading