From 71308ec343d1afe5b8be29fbea93c038cfed0d06 Mon Sep 17 00:00:00 2001 From: Vladimir Rogojin Date: Wed, 22 Apr 2026 14:32:11 +0200 Subject: [PATCH 01/16] feat(phase2): import sphere-sdk legacy CLI + wire commander dispatcher MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Copy sphere-sdk/cli/{index,daemon,daemon-config}.ts → src/legacy/ - Rewrite all relative imports to @unicitylabs/sphere-sdk package paths - Fix 8 TypeScript errors: flags→args.includes(), readonly mutations via intermediate Record, inline import() → top-level type imports - Prefix unused _sphere vars to satisfy no-unused-vars - Wire all 15 legacy namespaces to dynamically import legacyMain() - Phase 4 stubs (host, tenant) still show "not implemented yet" - Update tests to reflect phase 2 behavior (legacy bridge, phase 4 stubs) - npm run check: lint + typecheck + 8 tests all green --- package-lock.json | 77 + package.json | 1 + src/index.test.ts | 22 +- src/index.ts | 63 +- src/legacy/daemon-config.ts | 414 +++ src/legacy/daemon.ts | 686 +++++ src/legacy/legacy-cli.ts | 5053 +++++++++++++++++++++++++++++++++++ tsconfig.json | 2 +- 8 files changed, 6274 insertions(+), 44 deletions(-) create mode 100644 src/legacy/daemon-config.ts create mode 100644 src/legacy/daemon.ts create mode 100644 src/legacy/legacy-cli.ts diff --git a/package-lock.json b/package-lock.json index 74ea354..5e6a7a7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "0.0.0", "license": "MIT", "dependencies": { + "@unicitylabs/sphere-sdk": "file:../../sphere-sdk", "commander": "^12.1.0" }, "bin": { @@ -32,6 +33,78 @@ "node": ">=20.0.0" } }, + "../../sphere-sdk": { + "name": "@unicitylabs/sphere-sdk", + "version": "0.7.0", + "license": "MIT", + "dependencies": { + "@noble/curves": "^2.0.1", + "@noble/hashes": "^2.0.1", + "@unicitylabs/nostr-js-sdk": "^0.4.1", + "@unicitylabs/state-transition-sdk": "1.6.1-rc.f37cb85", + "bip39": "^3.1.0", + "buffer": "^6.0.3", + "canonicalize": "^3.0.0", + "crypto-js": "^4.2.0", + "elliptic": "^6.6.1" + }, + "devDependencies": { + "@eslint/js": "^9.39.2", + "@libp2p/bootstrap": "^12.0.11", + "@libp2p/crypto": "^5.1.13", + "@libp2p/interface": "^3.1.0", + "@libp2p/peer-id": "^6.0.4", + "@types/crypto-js": "^4.2.2", + "@types/elliptic": "^6.4.18", + "@types/node": "^22.0.0", + "@types/ws": "^8.18.1", + "@typescript-eslint/eslint-plugin": "^8.54.0", + "@typescript-eslint/parser": "^8.54.0", + "eslint": "^9.39.2", + "fake-indexeddb": "^6.2.5", + "multiformats": "^13.4.2", + "testcontainers": "^11.11.0", + "tsup": "^8.5.1", + "tsx": "^4.21.0", + "typescript": "~5.6.0", + "typescript-eslint": "^8.54.0", + "vitest": "^2.0.0", + "ws": "^8.18.1" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "@libp2p/crypto": "^5.1.13", + "@libp2p/peer-id": "^6.0.4", + "ipns": "^10.0.0", + "multiformats": "^13.4.2" + }, + "peerDependencies": { + "@libp2p/crypto": ">=5.0.0", + "@libp2p/peer-id": ">=6.0.0", + "ipns": ">=10.0.0", + "multiformats": ">=13.0.0", + "ws": ">=8.0.0" + }, + "peerDependenciesMeta": { + "@libp2p/crypto": { + "optional": true + }, + "@libp2p/peer-id": { + "optional": true + }, + "ipns": { + "optional": true + }, + "multiformats": { + "optional": true + }, + "ws": { + "optional": true + } + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.27.7", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz", @@ -1402,6 +1475,10 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/@unicitylabs/sphere-sdk": { + "resolved": "../../sphere-sdk", + "link": true + }, "node_modules/@vitest/expect": { "version": "2.1.9", "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-2.1.9.tgz", diff --git a/package.json b/package.json index 9ffecdd..4872366 100644 --- a/package.json +++ b/package.json @@ -56,6 +56,7 @@ "prepublishOnly": "npm run check && npm run build" }, "dependencies": { + "@unicitylabs/sphere-sdk": "file:../../sphere-sdk", "commander": "^12.1.0" }, "devDependencies": { diff --git a/src/index.test.ts b/src/index.test.ts index 5857ca9..971777a 100644 --- a/src/index.test.ts +++ b/src/index.test.ts @@ -10,15 +10,10 @@ describe('sphere-cli scaffold', () => { it('--version prints the VERSION constant', () => { const program = createCli(); - // commander's version() wires a flag that writes to stdout and exits. - // Rather than intercept process.exit, verify the version metadata. const opts = program.opts(); expect(typeof opts).toBe('object'); - // version is on the program, not in opts; commander attaches it privately. - // We verify via the public API: the help text includes VERSION. const help = program.helpInformation(); expect(VERSION).toBeTruthy(); - // Help text should include "Usage: sphere" expect(help).toContain('Usage: sphere'); }); @@ -35,8 +30,7 @@ describe('sphere-cli scaffold', () => { } }); - it('invoking an unimplemented namespace prints "not implemented yet" and exits non-zero', async () => { - // Capture stderr writes + intercept process.exit so the test runner survives. + it('invoking a phase-4 namespace prints "not implemented yet" and exits 64', async () => { const stderrCalls: string[] = []; const writeSpy = vi .spyOn(process.stderr, 'write') @@ -49,12 +43,11 @@ describe('sphere-cli scaffold', () => { .spyOn(process, 'exit') .mockImplementation(((code?: number) => { exitCode = code; - // Throw to short-circuit the namespace action; main()'s catch will swallow it. throw new Error('__mock_exit__'); }) as never); try { - await main(['node', 'sphere', 'wallet']); + await main(['node', 'sphere', 'host']); } finally { writeSpy.mockRestore(); exitSpy.mockRestore(); @@ -63,13 +56,18 @@ describe('sphere-cli scaffold', () => { expect(exitCode).toBe(64); const joined = stderrCalls.join(''); expect(joined).toContain('not implemented yet'); - expect(joined).toContain('phase 2'); + expect(joined).toContain('phase 4'); }); - it('help namespaces show the phase annotations', () => { + it('help shows phase 4 annotation for DM-native namespaces', () => { const program = createCli(); const help = program.helpInformation(); - expect(help).toContain('[phase 2]'); expect(help).toContain('[phase 4]'); }); + + it('help shows legacy bridge annotation for phase 2 namespaces', () => { + const program = createCli(); + const help = program.helpInformation(); + expect(help).toContain('legacy bridge'); + }); }); diff --git a/src/index.ts b/src/index.ts index 0023410..2d702bf 100644 --- a/src/index.ts +++ b/src/index.ts @@ -15,14 +15,27 @@ * sphere daemon — long-running event listener * sphere config — CLI configuration (profiles, relays, manager nametag) * - * Phase 1 wires only the scaffold + `--version`. Real commands land in - * phase 2 (migrate from sphere-sdk/cli/) and phase 4 (migrate from - * agentic-hosting/src/cli/ + the new DM transport). + * Phase 2: legacy sphere-sdk CLI wired under all namespaces. + * Phase 4: real DM-native commands replace legacy. */ import { Command } from 'commander'; import { VERSION } from './version.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+. +const LEGACY_NAMESPACES = new Set([ + 'wallet', 'balance', 'payments', 'dm', 'group', 'market', 'swap', + 'invoice', 'nametag', 'crypto', 'util', 'faucet', 'daemon', 'config', + 'completions', +]); + +// Phase 4 namespaces — DM-native, not yet implemented. +const PHASE4_NAMESPACES: Array<[string, string]> = [ + ['host', 'HMCP: controller → host manager (over DM)'], + ['tenant', 'ACP: controller → tenant (over DM, host-agnostic)'], +]; + export function createCli(): Command { const program = new Command(); @@ -31,41 +44,29 @@ export function createCli(): Command { .description('The unified CLI for Sphere SDK and agentic-hosting control') .version(VERSION, '-v, --version', 'output the version number'); - // Namespace placeholders. Each is a real commander subcommand with a help - // stub that tells the user "not yet — see phase N". This lets `sphere --help` - // reflect the full topology immediately, so users know what's coming. - const namespaces: Array<[string, string, string]> = [ - ['wallet', 'Wallet lifecycle (init, status, profiles)', 'phase 2'], - ['balance', 'L3 token balances + asset info', 'phase 2'], - ['payments', 'Send, receive, history', 'phase 2'], - ['dm', 'Direct messages (NIP-17) — send, inbox, history', 'phase 2'], - ['group', 'Group chat (NIP-29)', 'phase 2'], - ['market', 'Post and search trading intents', 'phase 2'], - ['swap', 'P2P atomic swap lifecycle', 'phase 2'], - ['invoice', 'Invoicing + accounting', 'phase 2'], - ['nametag', 'Register and look up nametags', 'phase 2'], - ['crypto', 'Key / wallet utility commands', 'phase 2'], - ['util', 'Amount conversion, base58 codec', 'phase 2'], - ['faucet', 'Testnet token faucet', 'phase 2'], - ['daemon', 'Long-running event listener', 'phase 2'], - ['host', 'HMCP: controller → host manager (over DM)', 'phase 4'], - ['tenant', 'ACP: controller → tenant (over DM, host-agnostic)', 'phase 4'], - ['config', 'CLI configuration (profiles, relays, manager)', 'phase 1+'], - ['completions', 'Shell completion scripts', 'phase 2'], - ]; + // Phase 2: legacy commands — delegate to the sphere-sdk CLI dispatcher. + for (const name of LEGACY_NAMESPACES) { + const sub = program + .command(name) + .description(`${name} commands (legacy bridge — phase 2)`); + + sub.allowUnknownOption(true); + sub.action(async () => { + const { legacyMain } = await import('./legacy/legacy-cli.js'); + await legacyMain(); + }); + } - for (const [name, description, phase] of namespaces) { + // Phase 4 stubs — DM-native commands, not yet implemented. + for (const [name, description] of PHASE4_NAMESPACES) { const sub = program .command(name) - .description(`${description} [${phase}]`); + .description(`${description} [phase 4]`); - // Catch-all action so `sphere wallet init` (with args) prints a helpful - // message until the real command lands. Subcommands within each namespace - // come in later phases. sub.allowUnknownOption(true); sub.action(() => { process.stderr.write( - `sphere ${name}: not implemented yet (scheduled for ${phase}). ` + + `sphere ${name}: not implemented yet (scheduled for phase 4). ` + `See SPHERE-CLI-EXTRACTION-PLAN.md for the migration schedule.\n`, ); process.exit(64); // EX_USAGE diff --git a/src/legacy/daemon-config.ts b/src/legacy/daemon-config.ts new file mode 100644 index 0000000..35e325c --- /dev/null +++ b/src/legacy/daemon-config.ts @@ -0,0 +1,414 @@ +/** + * Daemon configuration: interfaces, validation, loading, and CLI flag parsing. + */ + +import * as fs from 'fs'; +import * as path from 'path'; + +// ============================================================================= +// Interfaces +// ============================================================================= + +export interface DaemonConfig { + logFile?: string; + pidFile?: string; + marketFeed?: boolean; + actionTimeout?: number; + rules: DaemonRule[]; +} + +export interface DaemonRule { + name?: string; + events: string[]; + filter?: Record; + actions: DaemonAction[]; + sequential?: boolean; + disabled?: boolean; +} + +export type DaemonAction = BashAction | WebhookAction | BuiltinAction; + +export interface BashAction { + type: 'bash'; + command: string; + timeout?: number; + cwd?: string; +} + +export interface WebhookAction { + type: 'webhook'; + url: string; + headers?: Record; + timeout?: number; + method?: 'POST' | 'PUT'; +} + +export interface BuiltinAction { + type: 'builtin'; + action: 'auto-receive' | 'log-to-file'; + path?: string; + finalize?: boolean; +} + +// ============================================================================= +// Defaults +// ============================================================================= + +const DEFAULT_CONFIG_PATH = './.sphere-cli/daemon.json'; +const DEFAULT_LOG_FILE = './.sphere-cli/daemon.log'; +const DEFAULT_PID_FILE = './.sphere-cli/daemon.pid'; +const DEFAULT_ACTION_TIMEOUT = 30000; + +// ============================================================================= +// Validation +// ============================================================================= + +export function validateDaemonConfig(config: unknown): DaemonConfig { + if (!config || typeof config !== 'object') { + throw new Error('Daemon config must be a JSON object'); + } + + const c = config as Record; + + if (c.logFile !== undefined && typeof c.logFile !== 'string') { + throw new Error('logFile must be a string'); + } + if (c.pidFile !== undefined && typeof c.pidFile !== 'string') { + throw new Error('pidFile must be a string'); + } + if (c.marketFeed !== undefined && typeof c.marketFeed !== 'boolean') { + throw new Error('marketFeed must be a boolean'); + } + if (c.actionTimeout !== undefined) { + if (typeof c.actionTimeout !== 'number' || c.actionTimeout <= 0) { + throw new Error('actionTimeout must be a positive number'); + } + } + + if (!Array.isArray(c.rules)) { + throw new Error('rules must be an array'); + } + + const rules = c.rules.map((rule: unknown, i: number) => validateRule(rule, i)); + + return { + logFile: c.logFile as string | undefined, + pidFile: c.pidFile as string | undefined, + marketFeed: c.marketFeed as boolean | undefined, + actionTimeout: c.actionTimeout as number | undefined, + rules, + }; +} + +function validateRule(rule: unknown, index: number): DaemonRule { + if (!rule || typeof rule !== 'object') { + throw new Error(`rules[${index}]: must be an object`); + } + + const r = rule as Record; + + if (r.name !== undefined && typeof r.name !== 'string') { + throw new Error(`rules[${index}]: name must be a string`); + } + + if (!Array.isArray(r.events) || r.events.length === 0) { + throw new Error(`rules[${index}]: events must be a non-empty array of strings`); + } + for (const e of r.events) { + if (typeof e !== 'string' || e.length === 0) { + throw new Error(`rules[${index}]: each event must be a non-empty string`); + } + } + + if (r.filter !== undefined) { + if (!r.filter || typeof r.filter !== 'object' || Array.isArray(r.filter)) { + throw new Error(`rules[${index}]: filter must be an object`); + } + } + + if (!Array.isArray(r.actions) || r.actions.length === 0) { + throw new Error(`rules[${index}]: actions must be a non-empty array`); + } + const actions = r.actions.map((a: unknown, j: number) => validateAction(a, index, j)); + + if (r.sequential !== undefined && typeof r.sequential !== 'boolean') { + throw new Error(`rules[${index}]: sequential must be a boolean`); + } + if (r.disabled !== undefined && typeof r.disabled !== 'boolean') { + throw new Error(`rules[${index}]: disabled must be a boolean`); + } + + return { + name: r.name as string | undefined, + events: r.events as string[], + filter: r.filter as Record | undefined, + actions, + sequential: r.sequential as boolean | undefined, + disabled: r.disabled as boolean | undefined, + }; +} + +function validateAction(action: unknown, ruleIndex: number, actionIndex: number): DaemonAction { + if (!action || typeof action !== 'object') { + throw new Error(`rules[${ruleIndex}].actions[${actionIndex}]: must be an object`); + } + + const a = action as Record; + const prefix = `rules[${ruleIndex}].actions[${actionIndex}]`; + + switch (a.type) { + case 'bash': { + if (typeof a.command !== 'string' || a.command.length === 0) { + throw new Error(`${prefix}: bash action requires a non-empty command`); + } + if (a.timeout !== undefined && (typeof a.timeout !== 'number' || a.timeout <= 0)) { + throw new Error(`${prefix}: timeout must be a positive number`); + } + if (a.cwd !== undefined && typeof a.cwd !== 'string') { + throw new Error(`${prefix}: cwd must be a string`); + } + return { + type: 'bash', + command: a.command as string, + timeout: a.timeout as number | undefined, + cwd: a.cwd as string | undefined, + }; + } + + case 'webhook': { + if (typeof a.url !== 'string' || a.url.length === 0) { + throw new Error(`${prefix}: webhook action requires a non-empty url`); + } + if (a.headers !== undefined) { + if (!a.headers || typeof a.headers !== 'object' || Array.isArray(a.headers)) { + throw new Error(`${prefix}: headers must be an object`); + } + } + if (a.timeout !== undefined && (typeof a.timeout !== 'number' || a.timeout <= 0)) { + throw new Error(`${prefix}: timeout must be a positive number`); + } + if (a.method !== undefined && a.method !== 'POST' && a.method !== 'PUT') { + throw new Error(`${prefix}: method must be "POST" or "PUT"`); + } + return { + type: 'webhook', + url: a.url as string, + headers: a.headers as Record | undefined, + timeout: a.timeout as number | undefined, + method: a.method as 'POST' | 'PUT' | undefined, + }; + } + + case 'builtin': { + if (a.action !== 'auto-receive' && a.action !== 'log-to-file') { + throw new Error(`${prefix}: builtin action must be "auto-receive" or "log-to-file"`); + } + if (a.action === 'log-to-file' && (typeof a.path !== 'string' || a.path.length === 0)) { + throw new Error(`${prefix}: log-to-file requires a non-empty path`); + } + if (a.finalize !== undefined && typeof a.finalize !== 'boolean') { + throw new Error(`${prefix}: finalize must be a boolean`); + } + return { + type: 'builtin', + action: a.action as 'auto-receive' | 'log-to-file', + path: a.path as string | undefined, + finalize: a.finalize as boolean | undefined, + }; + } + + default: + throw new Error(`${prefix}: type must be "bash", "webhook", or "builtin" (got "${a.type}")`); + } +} + +// ============================================================================= +// Loading +// ============================================================================= + +export function loadDaemonConfig(configPath?: string): DaemonConfig { + const filePath = configPath || DEFAULT_CONFIG_PATH; + if (!fs.existsSync(filePath)) { + throw new Error(`Config file not found: ${filePath}`); + } + + const raw = fs.readFileSync(filePath, 'utf8'); + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + throw new Error(`Invalid JSON in config file: ${filePath}`); + } + + return validateDaemonConfig(parsed); +} + +// ============================================================================= +// CLI Flag Parsing +// ============================================================================= + +export interface DaemonFlags { + configPath?: string; + detach: boolean; + logFile?: string; + pidFile?: string; + events: string[]; + actions: string[]; + marketFeed: boolean; + verbose: boolean; + _forked: boolean; +} + +export function parseDaemonFlags(args: string[]): DaemonFlags { + const flags: DaemonFlags = { + detach: false, + events: [], + actions: [], + marketFeed: false, + verbose: false, + _forked: false, + }; + + for (let i = 0; i < args.length; i++) { + switch (args[i]) { + case '--config': + flags.configPath = args[++i]; + break; + case '--detach': + flags.detach = true; + break; + case '--log': + flags.logFile = args[++i]; + break; + case '--pid': + flags.pidFile = args[++i]; + break; + case '--event': + flags.events.push(args[++i]); + break; + case '--action': + flags.actions.push(args[++i]); + break; + case '--market-feed': + flags.marketFeed = true; + break; + case '--verbose': + flags.verbose = true; + break; + case '--_forked': + flags._forked = true; + break; + } + } + + return flags; +} + +/** + * Build a DaemonConfig from quick-mode CLI flags (--event / --action). + * When no --event/--action flags are given and a config file exists, load from file. + * When --event/--action flags are given, build an inline config. + */ +export function buildConfigFromFlags(flags: DaemonFlags): DaemonConfig { + // If there are quick-mode flags, build inline config + if (flags.events.length > 0 || flags.actions.length > 0) { + if (flags.events.length === 0) { + throw new Error('--action requires at least one --event'); + } + if (flags.actions.length === 0) { + throw new Error('--event requires at least one --action'); + } + + const actions: DaemonAction[] = flags.actions.map(parseActionSpec); + + return { + logFile: flags.logFile, + pidFile: flags.pidFile, + marketFeed: flags.marketFeed, + rules: [ + { + name: 'cli-quick', + events: flags.events, + actions, + }, + ], + }; + } + + // Otherwise load from config file + const config = loadDaemonConfig(flags.configPath); + + // Apply CLI overrides + if (flags.logFile) config.logFile = flags.logFile; + if (flags.pidFile) config.pidFile = flags.pidFile; + if (flags.marketFeed) config.marketFeed = true; + + return config; +} + +/** + * Parse an action spec string from --action flag. + * Formats: "auto-receive", "bash:command", "webhook:url", "log:path" + */ +function parseActionSpec(spec: string): DaemonAction { + if (spec === 'auto-receive') { + return { type: 'builtin', action: 'auto-receive', finalize: true }; + } + + const colonIdx = spec.indexOf(':'); + if (colonIdx === -1) { + throw new Error(`Invalid action spec "${spec}". Expected: auto-receive, bash:command, webhook:url, or log:path`); + } + + const prefix = spec.substring(0, colonIdx); + const value = spec.substring(colonIdx + 1); + + if (!value) { + throw new Error(`Invalid action spec "${spec}": missing value after "${prefix}:"`); + } + + switch (prefix) { + case 'bash': + return { type: 'bash', command: value }; + case 'webhook': + return { type: 'webhook', url: value }; + case 'log': + return { type: 'builtin', action: 'log-to-file', path: value }; + default: + throw new Error(`Unknown action type "${prefix}". Expected: bash, webhook, log, or auto-receive`); + } +} + +// ============================================================================= +// Resolved Config (with defaults applied) +// ============================================================================= + +export interface ResolvedDaemonConfig extends DaemonConfig { + logFile: string; + pidFile: string; + actionTimeout: number; +} + +export function resolveConfig(config: DaemonConfig): ResolvedDaemonConfig { + return { + ...config, + logFile: config.logFile || DEFAULT_LOG_FILE, + pidFile: config.pidFile || DEFAULT_PID_FILE, + actionTimeout: config.actionTimeout || DEFAULT_ACTION_TIMEOUT, + }; +} + +export function getDefaultConfigPath(): string { + return DEFAULT_CONFIG_PATH; +} + +export function getDefaultPidFile(): string { + return DEFAULT_PID_FILE; +} + +export function ensureDir(filePath: string): void { + const dir = path.dirname(filePath); + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }); + } +} diff --git a/src/legacy/daemon.ts b/src/legacy/daemon.ts new file mode 100644 index 0000000..6c12a9a --- /dev/null +++ b/src/legacy/daemon.ts @@ -0,0 +1,686 @@ +/** + * CLI Daemon: persistent event listener with configurable actions. + * + * Subscribes to all Sphere events and dispatches configurable actions + * (bash scripts, HTTP webhooks, built-in actions) based on rules. + */ + +import * as fs from 'fs'; +import * as path from 'path'; +import { exec } from 'child_process'; +import { fork } from 'child_process'; +import type { Sphere } from '@unicitylabs/sphere-sdk'; +import type { SphereEventType } from '@unicitylabs/sphere-sdk'; +import type { + DaemonRule, + DaemonAction, + BashAction, + WebhookAction, + BuiltinAction, + ResolvedDaemonConfig, + DaemonFlags, +} from './daemon-config'; +import { + parseDaemonFlags, + buildConfigFromFlags, + resolveConfig, + getDefaultPidFile, + ensureDir, +} from './daemon-config'; + +// ============================================================================= +// All known SphereEventType values (for wildcard expansion) +// ============================================================================= + +const ALL_SPHERE_EVENTS: SphereEventType[] = [ + 'transfer:incoming', + 'transfer:confirmed', + 'transfer:failed', + 'payment_request:incoming', + 'payment_request:accepted', + 'payment_request:rejected', + 'payment_request:paid', + 'payment_request:response', + 'message:dm', + 'message:read', + 'message:typing', + 'composing:started', + 'message:broadcast', + 'sync:started', + 'sync:completed', + 'sync:provider', + 'sync:error', + 'connection:changed', + 'nametag:registered', + 'nametag:recovered', + 'identity:changed', + 'address:activated', + 'address:hidden', + 'address:unhidden', + 'sync:remote-update', + 'groupchat:message', + 'groupchat:joined', + 'groupchat:left', + 'groupchat:kicked', + 'groupchat:group_deleted', + 'groupchat:updated', + 'groupchat:connection', +]; + +// ============================================================================= +// Event Matching +// ============================================================================= + +/** + * Expand a pattern like "transfer:*" or "*" against known event types. + */ +function expandPattern(pattern: string): SphereEventType[] { + if (pattern === '*') return [...ALL_SPHERE_EVENTS]; + + if (pattern.endsWith(':*')) { + const prefix = pattern.slice(0, -1); // "transfer:" from "transfer:*" + return ALL_SPHERE_EVENTS.filter(e => e.startsWith(prefix)); + } + + // Exact match — verify it's a known event type + if (ALL_SPHERE_EVENTS.includes(pattern as SphereEventType)) { + return [pattern as SphereEventType]; + } + + // Unknown event pattern — return as-is (may be a custom/future event) + return [pattern as SphereEventType]; +} + +/** + * Build a dispatch map: event type → list of rules that match it. + */ +function buildDispatchMap(rules: DaemonRule[]): Map { + const map = new Map(); + + for (const rule of rules) { + if (rule.disabled) continue; + + for (const pattern of rule.events) { + const expanded = expandPattern(pattern); + for (const eventType of expanded) { + if (!map.has(eventType)) map.set(eventType, []); + const list = map.get(eventType)!; + if (!list.includes(rule)) list.push(rule); + } + } + } + + return map; +} + +// ============================================================================= +// Filter Matching +// ============================================================================= + +function matchesFilter(filter: Record | undefined, data: unknown): boolean { + if (!filter) return true; + if (!data || typeof data !== 'object') return false; + const obj = data as Record; + for (const [key, value] of Object.entries(filter)) { + if (obj[key] !== value) return false; + } + return true; +} + +// ============================================================================= +// Environment Variables from Event Data +// ============================================================================= + +function buildEnvVars(eventType: string, data: unknown): Record { + const env: Record = { + SPHERE_EVENT: eventType, + SPHERE_EVENT_JSON: JSON.stringify(data), + SPHERE_TIMESTAMP: new Date().toISOString(), + }; + + if (data && typeof data === 'object') { + const d = data as Record; + + if (d.senderNametag) env.SPHERE_SENDER = String(d.senderNametag); + else if (d.senderPubkey) env.SPHERE_SENDER = String(d.senderPubkey); + else if (d.authorNametag) env.SPHERE_SENDER = String(d.authorNametag); + else if (d.authorPubkey) env.SPHERE_SENDER = String(d.authorPubkey); + + if (d.amount) env.SPHERE_AMOUNT = String(d.amount); + if (d.coinId) env.SPHERE_COIN_ID = String(d.coinId); + if (d.groupId) env.SPHERE_GROUP_ID = String(d.groupId); + + if (d.content) env.SPHERE_MESSAGE = String(d.content); + else if (d.memo) env.SPHERE_MESSAGE = String(d.memo); + else if (d.message) env.SPHERE_MESSAGE = String(d.message); + + // For incoming transfers, sum token amounts + if (Array.isArray(d.tokens) && d.tokens.length > 0 && !d.amount) { + try { + const total = d.tokens.reduce((sum: bigint, t: Record) => { + return sum + BigInt(String(t.amount || '0')); + }, BigInt(0)); + env.SPHERE_AMOUNT = total.toString(); + } catch { + // Ignore amount calculation errors + } + } + } + + return env; +} + +// ============================================================================= +// Action Executors +// ============================================================================= + +function executeBash(action: BashAction, envVars: Record, globalTimeout: number): Promise { + const timeout = action.timeout || globalTimeout; + return new Promise((resolve) => { + const child = exec(action.command, { + env: { ...process.env, ...envVars }, + timeout, + cwd: action.cwd || process.cwd(), + }, (error, stdout, stderr) => { + if (error) { + log(` [BASH ERROR] ${action.command}: ${error.message}`); + } + if (stdout) { + for (const line of stdout.trimEnd().split('\n')) { + log(` [BASH] ${line}`); + } + } + if (stderr) { + for (const line of stderr.trimEnd().split('\n')) { + log(` [BASH STDERR] ${line}`); + } + } + resolve(); + }); + + // Pipe event JSON to stdin + if (child.stdin) { + child.stdin.write(envVars.SPHERE_EVENT_JSON); + child.stdin.end(); + } + }); +} + +async function executeWebhook(action: WebhookAction, eventType: string, data: unknown, globalTimeout: number): Promise { + const timeout = action.timeout || Math.min(globalTimeout, 10000); + const method = action.method || 'POST'; + const body = JSON.stringify({ + event: eventType, + timestamp: new Date().toISOString(), + data, + }); + + try { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeout); + + const response = await fetch(action.url, { + method, + headers: { + 'Content-Type': 'application/json', + ...action.headers, + }, + body, + signal: controller.signal, + }); + + clearTimeout(timer); + + if (!response.ok) { + log(` [WEBHOOK ERROR] ${action.url}: HTTP ${response.status}`); + } else { + log(` [WEBHOOK] ${action.url}: ${response.status}`); + } + } catch (err) { + log(` [WEBHOOK ERROR] ${action.url}: ${err instanceof Error ? err.message : err}`); + } +} + +async function executeAutoReceive(action: BuiltinAction, sphere: Sphere): Promise { + try { + const finalize = action.finalize !== false; + const result = await sphere.payments.receive({ finalize }); + log(` [AUTO-RECEIVE] Received ${result.transfers.length} transfer(s), finalize=${finalize}`); + try { + await sphere.payments.sync(); + log(' [AUTO-RECEIVE] Synced with IPFS'); + } catch (err) { + log(` [AUTO-RECEIVE] Sync warning: ${err instanceof Error ? err.message : err}`); + } + } catch (err) { + log(` [AUTO-RECEIVE ERROR] ${err instanceof Error ? err.message : err}`); + } +} + +function executeLogToFile(action: BuiltinAction, eventType: string, data: unknown): void { + const filePath = action.path!; + try { + ensureDir(filePath); + const entry = JSON.stringify({ + timestamp: new Date().toISOString(), + event: eventType, + data, + }) + '\n'; + fs.appendFileSync(filePath, entry); + log(` [LOG] → ${filePath}`); + } catch (err) { + log(` [LOG ERROR] ${filePath}: ${err instanceof Error ? err.message : err}`); + } +} + +// ============================================================================= +// Action Dispatch +// ============================================================================= + +async function executeAction( + action: DaemonAction, + eventType: string, + data: unknown, + envVars: Record, + sphere: Sphere, + globalTimeout: number, +): Promise { + switch (action.type) { + case 'bash': + await executeBash(action, envVars, globalTimeout); + break; + case 'webhook': + await executeWebhook(action, eventType, data, globalTimeout); + break; + case 'builtin': + if (action.action === 'auto-receive') { + await executeAutoReceive(action, sphere); + } else if (action.action === 'log-to-file') { + executeLogToFile(action, eventType, data); + } + break; + } +} + +async function dispatchRule( + rule: DaemonRule, + eventType: string, + data: unknown, + envVars: Record, + sphere: Sphere, + globalTimeout: number, +): Promise { + const ruleName = rule.name || '(unnamed)'; + + if (!matchesFilter(rule.filter, data)) return; + + const actionNames = rule.actions.map(a => { + if (a.type === 'builtin') return a.action; + if (a.type === 'bash') return `bash`; + return a.type; + }).join(', '); + + log(`[${new Date().toISOString()}] EVENT ${eventType} | rule:${ruleName} | actions: ${actionNames}`); + + if (rule.sequential) { + for (const action of rule.actions) { + await executeAction(action, eventType, data, envVars, sphere, globalTimeout); + } + } else { + await Promise.all( + rule.actions.map(action => + executeAction(action, eventType, data, envVars, sphere, globalTimeout) + ) + ); + } +} + +// ============================================================================= +// Logging +// ============================================================================= + +let logStream: fs.WriteStream | null = null; +let verboseMode = false; + +function log(message: string): void { + const line = message.startsWith('[') ? message : `[${new Date().toISOString()}] ${message}`; + if (logStream) { + logStream.write(line + '\n'); + } + console.log(line); +} + +// ============================================================================= +// Market Feed Integration +// ============================================================================= + +function setupMarketFeed( + sphere: Sphere, + dispatchMap: Map, + globalTimeout: number, +): (() => void) | null { + if (!sphere.market) { + log('Warning: Market module not available, skipping market feed'); + return null; + } + + // Market feed events are dispatched as synthetic "market:feed" rules + const marketRules = dispatchMap.get('market:feed' as SphereEventType); + if (!marketRules || marketRules.length === 0) return null; + + const unsubscribe = sphere.market.subscribeFeed((message) => { + const envVars = buildEnvVars('market:feed', message); + for (const rule of marketRules) { + dispatchRule(rule, 'market:feed', message, envVars, sphere, globalTimeout).catch(err => { + log(`[DISPATCH ERROR] market:feed: ${err instanceof Error ? err.message : err}`); + }); + } + }); + + log('Subscribed to market feed'); + return unsubscribe; +} + +// ============================================================================= +// runDaemon — Foreground Mode +// ============================================================================= + +export async function runDaemon( + args: string[], + getSphere: () => Promise, + closeSphere: () => Promise, +): Promise { + const flags = parseDaemonFlags(args); + + // Detach mode: fork and exit parent + if (flags.detach && !flags._forked) { + return detachDaemon(args, flags); + } + + // Build or load config + let config: ResolvedDaemonConfig; + try { + const rawConfig = buildConfigFromFlags(flags); + config = resolveConfig(rawConfig); + } catch (err) { + console.error(`Config error: ${err instanceof Error ? err.message : err}`); + process.exit(1); + } + + verboseMode = flags.verbose; + + // In forked mode, redirect stdout/stderr to log file + if (flags._forked) { + ensureDir(config.logFile); + const stream = fs.createWriteStream(config.logFile, { flags: 'a' }); + logStream = stream; + // Redirect console output to log file + const origLog = console.log; + const origErr = console.error; + const origWarn = console.warn; + console.log = (...a: unknown[]) => { stream.write(a.map(String).join(' ') + '\n'); }; + console.error = (...a: unknown[]) => { stream.write('[ERROR] ' + a.map(String).join(' ') + '\n'); }; + console.warn = (...a: unknown[]) => { stream.write('[WARN] ' + a.map(String).join(' ') + '\n'); }; + + // Write PID file + ensureDir(config.pidFile); + fs.writeFileSync(config.pidFile, String(process.pid)); + + // Disconnect from parent + if (process.disconnect) process.disconnect(); + + // Restore on exit for cleanup logging + process.on('exit', () => { + console.log = origLog; + console.error = origErr; + console.warn = origWarn; + }); + } + + // Filter out disabled rules + const activeRules = config.rules.filter(r => !r.disabled); + if (activeRules.length === 0) { + log('No active rules in config. Nothing to do.'); + process.exit(0); + } + + // Build dispatch map + const dispatchMap = buildDispatchMap(activeRules); + const subscribedEvents = [...dispatchMap.keys()].filter(e => e !== 'market:feed' as string); + + log('Starting Sphere daemon...'); + log(`Active rules: ${activeRules.length}`); + log(`Subscribed events: ${subscribedEvents.join(', ') || '(none)'}`); + if (config.marketFeed || dispatchMap.has('market:feed' as SphereEventType)) { + log('Market feed: enabled'); + } + + // Initialize Sphere + let sphere: Sphere; + try { + sphere = await getSphere(); + } catch (err) { + log(`Failed to initialize Sphere: ${err instanceof Error ? err.message : err}`); + process.exit(1); + } + + const identity = sphere.identity; + if (identity) { + log(`Wallet: ${identity.nametag ? '@' + identity.nametag : identity.l1Address}`); + } + + // Subscribe to events + const unsubscribers: (() => void)[] = []; + + for (const eventType of subscribedEvents) { + const rules = dispatchMap.get(eventType)!; + const unsub = sphere.on(eventType, (data: unknown) => { + if (verboseMode) { + log(`[${new Date().toISOString()}] EVENT ${eventType} data=${JSON.stringify(data)}`); + } + const envVars = buildEnvVars(eventType, data); + for (const rule of rules) { + dispatchRule(rule, eventType, data, envVars, sphere, config.actionTimeout).catch(err => { + log(`[DISPATCH ERROR] ${eventType}: ${err instanceof Error ? err.message : err}`); + }); + } + }); + unsubscribers.push(unsub); + } + + // Market feed + if (config.marketFeed || dispatchMap.has('market:feed' as SphereEventType)) { + const unsub = setupMarketFeed(sphere, dispatchMap, config.actionTimeout); + if (unsub) unsubscribers.push(unsub); + } + + log('Daemon running. Waiting for events...'); + + // Graceful shutdown + let shuttingDown = false; + const shutdown = async () => { + if (shuttingDown) return; + shuttingDown = true; + log('Shutting down daemon...'); + + for (const unsub of unsubscribers) { + try { unsub(); } catch { /* ignore */ } + } + + try { await closeSphere(); } catch { /* ignore */ } + + // Clean up PID file + if (flags._forked || flags.detach) { + try { + if (fs.existsSync(config.pidFile)) fs.unlinkSync(config.pidFile); + } catch { /* ignore */ } + } + + if (logStream) { + logStream.end(); + logStream = null; + } + + log('Daemon stopped.'); + process.exit(0); + }; + + process.on('SIGINT', shutdown); + process.on('SIGTERM', shutdown); + + // Keep alive + await new Promise(() => {}); +} + +// ============================================================================= +// Detach Mode +// ============================================================================= + +function detachDaemon(args: string[], flags: DaemonFlags): void { + // Build the resolved config just to get the PID file path + let pidFile: string; + try { + const rawConfig = buildConfigFromFlags(flags); + const config = resolveConfig(rawConfig); + pidFile = config.pidFile; + } catch { + pidFile = getDefaultPidFile(); + } + + // Check if already running + if (fs.existsSync(pidFile)) { + const existingPid = parseInt(fs.readFileSync(pidFile, 'utf8').trim(), 10); + if (isProcessAlive(existingPid)) { + console.error(`Daemon already running (PID ${existingPid}). Use "daemon stop" first.`); + process.exit(1); + } + // Stale PID file + fs.unlinkSync(pidFile); + } + + // Build child args: replace --detach with --_forked, keep everything else + const childArgs = ['daemon', 'start', '--_forked', ...args.filter(a => a !== '--detach')]; + + // Fork the child process + const child = fork(process.argv[1], childArgs, { + detached: true, + stdio: 'ignore', + }); + + child.unref(); + + console.log(`Daemon started in background (PID ${child.pid})`); + console.log(`PID file: ${pidFile}`); + + if (flags.logFile) { + console.log(`Log file: ${flags.logFile}`); + } else { + console.log('Log file: .sphere-cli/daemon.log'); + } + + process.exit(0); +} + +// ============================================================================= +// stopDaemon +// ============================================================================= + +export async function stopDaemon(args: string[]): Promise { + const flags = parseDaemonFlags(args); + const pidFile = flags.pidFile || getDefaultPidFile(); + + if (!fs.existsSync(pidFile)) { + console.log('No daemon running (PID file not found).'); + return; + } + + const pid = parseInt(fs.readFileSync(pidFile, 'utf8').trim(), 10); + + if (!isProcessAlive(pid)) { + console.log(`Stale PID file (process ${pid} not running). Cleaning up.`); + fs.unlinkSync(pidFile); + return; + } + + console.log(`Stopping daemon (PID ${pid})...`); + + // Send SIGTERM + process.kill(pid, 'SIGTERM'); + + // Wait up to 5 seconds for graceful shutdown + const deadline = Date.now() + 5000; + while (Date.now() < deadline) { + await sleep(200); + if (!isProcessAlive(pid)) { + console.log('Daemon stopped.'); + // Clean up PID file if it still exists + if (fs.existsSync(pidFile)) fs.unlinkSync(pidFile); + return; + } + } + + // Force kill + console.log('Graceful shutdown timed out, sending SIGKILL...'); + try { + process.kill(pid, 'SIGKILL'); + } catch { + // Process may have exited between check and kill + } + + await sleep(500); + + if (fs.existsSync(pidFile)) fs.unlinkSync(pidFile); + console.log('Daemon killed.'); +} + +// ============================================================================= +// statusDaemon +// ============================================================================= + +export async function statusDaemon(args: string[]): Promise { + const flags = parseDaemonFlags(args); + const pidFile = flags.pidFile || getDefaultPidFile(); + + if (!fs.existsSync(pidFile)) { + console.log('Daemon is not running.'); + return; + } + + const pid = parseInt(fs.readFileSync(pidFile, 'utf8').trim(), 10); + + if (!isProcessAlive(pid)) { + console.log(`Daemon is not running (stale PID file, process ${pid}).`); + return; + } + + console.log(`Daemon is running (PID ${pid}).`); + console.log(`PID file: ${path.resolve(pidFile)}`); + + // Try to show config summary + try { + const rawConfig = buildConfigFromFlags(flags); + const config = resolveConfig(rawConfig); + console.log(`Log file: ${path.resolve(config.logFile)}`); + console.log(`Rules: ${config.rules.filter(r => !r.disabled).length} active`); + if (config.marketFeed) console.log('Market feed: enabled'); + } catch { + // Config may not be loadable without full flags + } +} + +// ============================================================================= +// Helpers +// ============================================================================= + +function isProcessAlive(pid: number): boolean { + try { + process.kill(pid, 0); + return true; + } catch { + return false; + } +} + +function sleep(ms: number): Promise { + return new Promise(resolve => setTimeout(resolve, ms)); +} diff --git a/src/legacy/legacy-cli.ts b/src/legacy/legacy-cli.ts new file mode 100644 index 0000000..819b0dd --- /dev/null +++ b/src/legacy/legacy-cli.ts @@ -0,0 +1,5053 @@ +#!/usr/bin/env npx tsx +/** + * Sphere SDK CLI + * Usage: npx tsx cli/index.ts [args...] + */ + +import * as fs from 'fs'; +import * as path from 'path'; +import * as readline from 'readline'; +import { encrypt, decrypt } from '@unicitylabs/sphere-sdk'; +import { parseWalletText, isTextWalletEncrypted, parseAndDecryptWalletText } from '@unicitylabs/sphere-sdk'; +import { parseWalletDat, isSQLiteDatabase, isWalletDatEncrypted } from '@unicitylabs/sphere-sdk'; +import { isValidPrivateKey, base58Encode, base58Decode } from '@unicitylabs/sphere-sdk'; +import { hexToWIF, generatePrivateKey } from '@unicitylabs/sphere-sdk'; +import { toSmallestUnit, toHumanReadable, formatAmount } from '@unicitylabs/sphere-sdk'; +import { getPublicKey } from '@unicitylabs/sphere-sdk'; +import { generateAddressFromMasterKey } from '@unicitylabs/sphere-sdk'; +import { Sphere } from '@unicitylabs/sphere-sdk'; +import { createNodeProviders } from '@unicitylabs/sphere-sdk/impl/nodejs'; +import { TokenRegistry } from '@unicitylabs/sphere-sdk'; +import { TokenValidator } from '@unicitylabs/sphere-sdk'; +import { tokenToTxf } from '@unicitylabs/sphere-sdk'; +import type { NetworkType } from '@unicitylabs/sphere-sdk'; +import type { TransportProvider } from '@unicitylabs/sphere-sdk'; +import type { ProviderStatus } from '@unicitylabs/sphere-sdk'; +import type { + CreateInvoiceRequest, + InvoiceRequestedAsset, + GetInvoicesOptions, + PayInvoiceParams, + ReturnPaymentParams, + TxfToken, +} from '@unicitylabs/sphere-sdk'; + +const args = process.argv.slice(2); +const command = args[0]; + +// ============================================================================= +// CLI Configuration +// ============================================================================= + +const DEFAULT_DATA_DIR = './.sphere-cli'; +const DEFAULT_TOKENS_DIR = './.sphere-cli/tokens'; +const CONFIG_FILE = './.sphere-cli/config.json'; +const PROFILES_FILE = './.sphere-cli/profiles.json'; + +interface CliConfig { + network: NetworkType; + dataDir: string; + tokensDir: string; + currentProfile?: string; +} + +interface WalletProfile { + name: string; + dataDir: string; + tokensDir: string; + network: NetworkType; + createdAt: string; +} + +interface ProfilesStore { + profiles: WalletProfile[]; +} + +function loadConfig(): CliConfig { + try { + if (fs.existsSync(CONFIG_FILE)) { + return JSON.parse(fs.readFileSync(CONFIG_FILE, 'utf8')); + } + } catch { + // Use defaults + } + return { + network: 'testnet', + dataDir: DEFAULT_DATA_DIR, + tokensDir: DEFAULT_TOKENS_DIR, + }; +} + +function saveConfig(config: CliConfig): void { + fs.mkdirSync(path.dirname(CONFIG_FILE), { recursive: true }); + fs.writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2)); +} + +// ============================================================================= +// Wallet Profile Management +// ============================================================================= + +function loadProfiles(): ProfilesStore { + try { + if (fs.existsSync(PROFILES_FILE)) { + return JSON.parse(fs.readFileSync(PROFILES_FILE, 'utf8')); + } + } catch { + // Use defaults + } + return { profiles: [] }; +} + +function saveProfiles(store: ProfilesStore): void { + fs.mkdirSync(path.dirname(PROFILES_FILE), { recursive: true }); + fs.writeFileSync(PROFILES_FILE, JSON.stringify(store, null, 2)); +} + +function getProfile(name: string): WalletProfile | undefined { + const store = loadProfiles(); + return store.profiles.find(p => p.name === name); +} + +function addProfile(profile: WalletProfile): void { + const store = loadProfiles(); + const existing = store.profiles.findIndex(p => p.name === profile.name); + if (existing >= 0) { + store.profiles[existing] = profile; + } else { + store.profiles.push(profile); + } + saveProfiles(store); +} + +function deleteProfile(name: string): boolean { + const store = loadProfiles(); + const index = store.profiles.findIndex(p => p.name === name); + if (index >= 0) { + store.profiles.splice(index, 1); + saveProfiles(store); + return true; + } + return false; +} + +function switchToProfile(name: string): boolean { + const profile = getProfile(name); + if (!profile) return false; + + const config = loadConfig(); + config.dataDir = profile.dataDir; + config.tokensDir = profile.tokensDir; + config.network = profile.network; + config.currentProfile = name; + saveConfig(config); + return true; +} + +// ============================================================================= +// Sphere Instance Management +// ============================================================================= + +let sphereInstance: Sphere | null = null; +let noNostrGlobal = false; + +/** + * Create a no-op transport that does nothing. + * Used with --no-nostr to prove IPFS-only recovery. + */ +function createNoopTransport(): TransportProvider { + return { + id: 'noop-transport', + name: 'No-Op Transport', + type: 'p2p' as const, + description: 'No-op transport (Nostr disabled)', + setIdentity: () => {}, + connect: async () => {}, + disconnect: async () => {}, + isConnected: () => false, + getStatus: () => 'disconnected' as ProviderStatus, + sendMessage: async () => '', + onMessage: () => () => {}, + sendTokenTransfer: async () => '', + onTokenTransfer: () => () => {}, + fetchPendingEvents: async () => {}, + }; +} + +async function getSphere(options?: { autoGenerate?: boolean; mnemonic?: string; nametag?: string }): Promise { + if (sphereInstance) return sphereInstance; + + const config = loadConfig(); + const providers = createNodeProviders({ + network: config.network, + dataDir: config.dataDir, + tokensDir: config.tokensDir, + tokenSync: { ipfs: { enabled: true } }, + market: true, + groupChat: true, + }); + + const initProviders = noNostrGlobal + ? { ...providers, transport: createNoopTransport() } + : providers; + + const result = await Sphere.init({ + ...initProviders, + autoGenerate: options?.autoGenerate, + mnemonic: options?.mnemonic, + nametag: options?.nametag, + market: true, + groupChat: true, + accounting: true, + swap: true, + }); + + sphereInstance = result.sphere; + + // Attach IPFS storage provider for sync if available + if (providers.ipfsTokenStorage) { + await sphereInstance.addTokenStorageProvider(providers.ipfsTokenStorage); + } + + return sphereInstance; +} + +async function closeSphere(): Promise { + if (sphereInstance) { + await sphereInstance.destroy(); + sphereInstance = null; + } +} + +/** Push local state changes to IPFS after write operations. Tolerates failures. */ +async function syncAfterWrite(sphere: Sphere): Promise { + try { + await sphere.payments.sync(); + } catch { + // Tolerated — IPFS may be unavailable + } +} + +/** + * Resolve a coin identifier (symbol, name, or hex ID) to its registry definition. + * Tries symbol first, then name, then raw hex ID. + * Exits with error if not found. + */ +function resolveCoin(identifier: string): { coinId: string; symbol: string; decimals: number; name: string } { + const registry = TokenRegistry.getInstance(); + let def = registry.getDefinitionBySymbol(identifier); + if (!def) def = registry.getDefinitionByName(identifier); + if (!def) def = registry.getDefinition(identifier); + if (!def) { + console.error(`Unknown asset: "${identifier}"`); + console.error('Use "npm run cli -- assets" to list all registered assets.'); + process.exit(1); + } + return { + coinId: def.id, + symbol: def.symbol || identifier, + decimals: def.decimals ?? 8, + name: def.name || identifier, + }; +} + +/** + * Parse an asset argument in " " format. + * Examples: "1000000 UCT", "10.5 BTC", "500000 USDU" + */ +/** + * Resolve a swap ID prefix to a full 64-char swap ID. + * Accepts full IDs or unique prefixes (like invoice commands do). + */ + +function parseAssetArg(value: string): { amount: string; coin: string } { + const parts = value.trim().split(/\s+/); + if (parts.length !== 2) { + console.error(`Invalid asset format: "${value}". Expected " " (e.g., "1000000 UCT")`); + process.exit(1); + } + return { amount: parts[0], coin: parts[1] }; +} + +/** Map common symbols to faucet coin names. */ +const FAUCET_COIN_MAP: Record = { + 'UCT': 'unicity', 'BTC': 'bitcoin', 'ETH': 'ethereum', + 'SOL': 'solana', 'USDT': 'tether', 'USDC': 'usd-coin', + 'USDU': 'unicity-usd', 'EURU': 'unicity-eur', 'ALPHT': 'alpha-token', +}; + +/** + * Ensure wallet state is synced before reading: + * 1. Wait for Nostr transport to deliver pending wallet events (DMs, transfers) + * 2. Process incoming transfers (receive) + * 3. Sync with IPFS + * + * Replaces the old syncIfEnabled(). All CLI commands that read wallet state + * should call this before accessing in-memory data. + */ +/** + * Sync wallet state before executing a command. + * All sync steps tolerate failures (Nostr timeout, IPFS unreachable). + * Nostr WRITES (sendDM in swap-propose, swap-accept) propagate errors + * naturally through the SwapModule — they are NOT part of this sync. + * + * Two sync modes: + * + * - **'nostr'**: Fetch pending Nostr events only (DMs: swap proposals, + * invoice receipts, escrow messages, transfer notifications). + * Used by: swap-list, swap-accept, swap-status, receive, dm-inbox, dm-history. + * + * - **'full'**: Nostr fetch + receive incoming transfers + IPFS sync. + * Gives the most up-to-date token inventory and invoice state. + * Used by: send, balance, tokens, history, verify-balance, sync, + * invoice-pay, invoice-close, invoice-cancel, invoice-transfers, swap-deposit. + */ +async function ensureSync(sphere: Sphere, mode: 'nostr' | 'full'): Promise { + console.log('Syncing...'); + + // Step 1: Always fetch pending Nostr events through the multi-address + // transport mux. This ensures DMs (swap proposals, invoice receipts, + // escrow messages) are delivered to all module handlers. + // Tolerates relay being down — continues after timeout. + try { + await sphere.fetchPendingEvents(); + // Allow async DM handlers (swap proposal processing, invoice import, etc.) + // to complete before reading in-memory state. These fire-and-forget handlers + // resolve addresses, validate manifests, and persist state asynchronously. + await new Promise(resolve => setTimeout(resolve, 500)); + } catch { + // Tolerated — relay may be unreachable for reads + } + + if (mode === 'full') { + // Step 2: Process incoming token transfers via Nostr. + // Tolerates failures — tokens may arrive later. + try { + const result = await sphere.payments.receive(); + if (result.transfers?.length > 0) { + console.log(` Received ${result.transfers.length} transfer(s)`); + } + } catch { + // Tolerated + } + + // Step 3: Sync with IPFS remote storage. + // Tolerates IPFS being down for both read and write. + try { + const result = await sphere.payments.sync(); + if (result.added > 0 || result.removed > 0) { + console.log(` IPFS: +${result.added} added, -${result.removed} removed`); + } + } catch { + // Tolerated — IPFS may be unavailable + } + } + + console.log(' Ready.'); +} + +// ============================================================================= +// Interactive Input +// ============================================================================= + +function _prompt(question: string): Promise { + const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout, + }); + return new Promise((resolve) => { + rl.question(question, (answer) => { + rl.close(); + resolve(answer); + }); + }); +} + +// ============================================================================= +// Per-command help definitions +// ============================================================================= + +interface CommandHelp { + usage: string; + description: string; + flags?: Array<{ flag: string; description: string; default?: string }>; + examples: string[]; + notes?: string[]; +} + +const COMMAND_HELP: Record = { + // --- WALLET --- + 'init': { + usage: 'init [--network ] [--mnemonic ""] [--nametag ]', + description: 'Create a new wallet or import an existing one from a mnemonic phrase. If no mnemonic is provided, a new 24-word mnemonic is generated automatically.', + flags: [ + { flag: '--network ', description: 'Network to use (mainnet, testnet, dev)', default: 'testnet' }, + { flag: '--mnemonic ""', description: 'Import wallet from BIP-39 mnemonic phrase (24 words in quotes)' }, + { flag: '--nametag ', description: 'Register a nametag during initialization (mints on-chain)' }, + { flag: '--no-nostr', description: 'Disable Nostr transport (use no-op transport)' }, + ], + examples: [ + 'npm run cli -- init --network testnet', + 'npm run cli -- init --mnemonic "word1 word2 ... word24"', + 'npm run cli -- init --nametag alice --network mainnet', + ], + notes: [ + 'If a wallet already exists in the current profile, it will be loaded instead of creating a new one.', + 'Back up the generated mnemonic immediately -- it cannot be retrieved later.', + ], + }, + 'status': { + usage: 'status', + description: 'Show wallet identity information including L1 address, Direct address, chain public key, nametag, and current network/profile.', + examples: [ + 'npm run cli -- status', + ], + }, + 'config': { + usage: 'config [set ]', + description: 'Show current CLI configuration or update a specific setting. Without arguments, displays all config values as JSON.', + flags: [ + { flag: 'set network ', description: 'Set network (mainnet, testnet, dev)' }, + { flag: 'set dataDir ', description: 'Set wallet data directory path' }, + { flag: 'set tokensDir ', description: 'Set token storage directory path' }, + ], + examples: [ + 'npm run cli -- config', + 'npm run cli -- config set network mainnet', + 'npm run cli -- config set dataDir ./.my-wallet', + ], + }, + 'clear': { + usage: 'clear', + description: 'Delete all wallet data including keys, tokens, and storage. This is irreversible -- make sure you have your mnemonic backed up.', + examples: [ + 'npm run cli -- clear', + ], + notes: [ + 'This deletes everything in the current profile data and token directories.', + ], + }, + 'wallet': { + usage: 'wallet ', + description: 'Manage wallet profiles. Each profile has its own data directory, token storage, and network configuration.', + examples: [ + 'npm run cli -- wallet list', + 'npm run cli -- wallet create myprofile', + 'npm run cli -- wallet use myprofile', + 'npm run cli -- wallet current', + 'npm run cli -- wallet delete myprofile', + ], + notes: [ + 'Subcommands: list, create, use, current, delete', + 'Use "help wallet " for detailed help on each.', + ], + }, + 'wallet list': { + usage: 'wallet list', + description: 'List all wallet profiles with their network and data directory. The current active profile is marked with an arrow.', + examples: [ + 'npm run cli -- wallet list', + ], + }, + 'wallet create': { + usage: 'wallet create [--network ]', + description: 'Create a new wallet profile and switch to it. The profile stores its data in .sphere-cli-/. After creating, run "init" to initialize the wallet.', + flags: [ + { flag: '--network ', description: 'Network for the profile (mainnet, testnet, dev)', default: 'testnet' }, + ], + examples: [ + 'npm run cli -- wallet create alice', + 'npm run cli -- wallet create bob --network mainnet', + ], + notes: [ + 'Profile names must be unique. After creation, initialize with: npm run cli -- init --nametag ', + ], + }, + 'wallet use': { + usage: 'wallet use ', + description: 'Switch the active wallet profile. All subsequent commands will operate on this profile.', + examples: [ + 'npm run cli -- wallet use alice', + ], + }, + 'wallet current': { + usage: 'wallet current', + description: 'Show the currently active wallet profile, its network, data directory, and identity (if initialized).', + examples: [ + 'npm run cli -- wallet current', + ], + }, + 'wallet delete': { + usage: 'wallet delete ', + description: 'Remove a wallet profile from the profiles list. The on-disk data directory is NOT deleted -- remove it manually if needed.', + examples: [ + 'npm run cli -- wallet delete bob', + ], + notes: [ + 'Cannot delete the currently active profile. Switch to a different profile first.', + ], + }, + + // --- BALANCE & TOKENS --- + 'balance': { + usage: 'balance [--finalize] [--no-sync]', + description: 'Show L3 token balance grouped by coin. Receives any pending incoming transfers before displaying. Shows confirmed and unconfirmed amounts, token counts, and USD value.', + flags: [ + { flag: '--finalize', description: 'Wait for unconfirmed tokens to be finalized (may take several seconds)' }, + { flag: '--no-sync', description: 'Skip IPFS sync before showing balance' }, + ], + examples: [ + 'npm run cli -- balance', + 'npm run cli -- balance --finalize', + 'npm run cli -- balance --no-sync', + ], + }, + 'tokens': { + usage: 'tokens [--no-sync]', + description: 'List all individual tokens with their ID, coin type, amount, and status.', + flags: [ + { flag: '--no-sync', description: 'Skip IPFS sync before listing tokens' }, + ], + examples: [ + 'npm run cli -- tokens', + 'npm run cli -- tokens --no-sync', + ], + }, + 'assets': { + usage: 'assets [--type ]', + description: 'List all registered assets (coins and NFTs) from the token registry. Shows symbol, name, kind, decimals, and coin ID.', + flags: [ + { flag: '--type ', description: 'Filter by asset kind: fungible (or coin/coins), nft (or nfts/non-fungible)' }, + ], + examples: [ + 'npm run cli -- assets', + 'npm run cli -- assets --type fungible', + 'npm run cli -- assets --type nft', + ], + notes: [ + 'Requires wallet initialization (to load the token registry from the network).', + 'The registry is fetched from a remote URL and cached locally.', + ], + }, + 'asset-info': { + usage: 'asset-info ', + description: 'Show detailed information for a specific asset. Looks up by symbol (UCT), name (bitcoin), or coin ID hex.', + examples: [ + 'npm run cli -- asset-info UCT', + 'npm run cli -- asset-info bitcoin', + 'npm run cli -- asset-info 0a1b2c3d4e5f...', + ], + notes: [ + 'Use "assets" command first to see all available assets.', + 'Lookup is case-insensitive for symbols and names.', + ], + }, + 'l1-balance': { + usage: 'l1-balance', + description: 'Show L1 (ALPHA blockchain) balance including confirmed and unconfirmed amounts. Connects to Fulcrum electrum server on first use.', + examples: [ + 'npm run cli -- l1-balance', + ], + }, + 'topup': { + usage: 'topup [ ]', + description: 'Request test tokens from the Unicity faucet. Without arguments, requests default amounts of all supported coins. With amount and coin, requests a specific coin.', + flags: [ + { flag: '', description: 'Amount to request (numeric)' }, + { flag: '', description: 'Coin symbol (UCT, BTC, ETH, SOL, USDT, USDC, USDU, EURU, ALPHT) or faucet name (unicity, bitcoin, ethereum, solana, tether, usd-coin, unicity-usd)' }, + ], + examples: [ + 'npm run cli -- topup', + 'npm run cli -- topup 100 UCT', + 'npm run cli -- topup 2 BTC', + 'npm run cli -- topup 42 ETH', + ], + notes: [ + 'Requires a registered nametag. The faucet is only available on testnet.', + 'Also accessible as "top-up" or "faucet".', + ], + }, + 'top-up': { + usage: 'top-up [ ]', + description: 'Alias for "topup". Request test tokens from the Unicity faucet.', + examples: [ + 'npm run cli -- top-up', + 'npm run cli -- top-up 2 BTC', + ], + notes: [ + 'This is an alias for the "topup" command. See "help topup" for full details.', + ], + }, + 'faucet': { + usage: 'faucet [ ]', + description: 'Alias for "topup". Request test tokens from the Unicity faucet.', + examples: [ + 'npm run cli -- faucet', + 'npm run cli -- faucet 100 ETH', + ], + notes: [ + 'This is an alias for the "topup" command. See "help topup" for full details.', + ], + }, + 'verify-balance': { + usage: 'verify-balance [--remove] [-v|--verbose]', + description: 'Verify all tokens against the aggregator to detect spent tokens that were not properly removed from local storage. Useful for cleaning up stale token state.', + flags: [ + { flag: '--remove', description: 'Move detected spent tokens to the Sent (archive) folder' }, + { flag: '-v, --verbose', description: 'Show all tokens, not just spent ones; show progress and errors' }, + ], + examples: [ + 'npm run cli -- verify-balance', + 'npm run cli -- verify-balance --verbose', + 'npm run cli -- verify-balance --remove', + ], + }, + 'sync': { + usage: 'sync', + description: 'Sync tokens with IPFS remote storage. Uploads local tokens and downloads any tokens stored remotely.', + examples: [ + 'npm run cli -- sync', + ], + }, + + // --- TRANSFERS --- + 'send': { + usage: 'send [--direct|--proxy] [--instant|--conservative] [--no-sync]', + description: 'Send L3 tokens to a recipient. The recipient can be a @nametag, DIRECT:// address, chain public key (02/03 prefix), or alpha1... L1 address. Amount is in decimal (e.g., 0.5) and is converted to smallest units automatically.', + flags: [ + { flag: '', description: 'Asset symbol (UCT, BTC) as positional argument after amount', default: 'UCT' }, + { flag: '--direct', description: 'Force DirectAddress transfer (requires nametag with directAddress)' }, + { flag: '--proxy', description: 'Force PROXY address transfer (works with any nametag)' }, + { flag: '--instant', description: 'Send immediately via Nostr; receiver gets unconfirmed token', default: 'yes' }, + { flag: '--conservative', description: 'Collect all proofs first; receiver gets confirmed token' }, + { flag: '--no-sync', description: 'Skip IPFS sync after sending' }, + ], + examples: [ + 'npm run cli -- send @alice 10 UCT', + 'npm run cli -- send @alice 0.5 BTC', + 'npm run cli -- send DIRECT://0000be36... 500000 UCT --conservative', + 'npm run cli -- send @bob 100 USDU --no-sync', + ], + notes: [ + 'Cannot use both --direct and --proxy simultaneously.', + 'Cannot use both --instant and --conservative simultaneously.', + ], + }, + 'receive': { + usage: 'receive [--finalize] [--no-sync]', + description: 'Check for incoming token transfers via Nostr. Displays receive addresses and fetches any pending transfers.', + flags: [ + { flag: '--finalize', description: 'Wait for unconfirmed tokens to be finalized before returning' }, + { flag: '--no-sync', description: 'Skip IPFS sync after receiving' }, + ], + examples: [ + 'npm run cli -- receive', + 'npm run cli -- receive --finalize', + ], + }, + 'history': { + usage: 'history [limit] [--no-sync]', + description: 'Show transaction history ordered by most recent first. Each entry shows date, direction, amount, coin, and counterparty.', + flags: [ + { flag: '', description: 'Maximum number of transactions to show', default: '10' }, + { flag: '--no-sync', description: 'Skip sync before showing history' }, + ], + examples: [ + 'npm run cli -- history', + 'npm run cli -- history 20', + ], + }, + + // --- ADDRESSES --- + 'addresses': { + usage: 'addresses', + description: 'List all tracked HD addresses with their index, L1 address, DIRECT address, nametag, and hidden status. The currently active address is marked with an arrow.', + examples: [ + 'npm run cli -- addresses', + ], + }, + 'switch': { + usage: 'switch ', + description: 'Switch to a different HD-derived address by index. Index 0 is the default. New addresses are created on first switch.', + examples: [ + 'npm run cli -- switch 1', + 'npm run cli -- switch 0', + ], + }, + 'hide': { + usage: 'hide ', + description: 'Hide an address from the active address list. Hidden addresses are excluded from getActiveAddresses() but still tracked.', + examples: [ + 'npm run cli -- hide 2', + ], + }, + 'unhide': { + usage: 'unhide ', + description: 'Unhide a previously hidden address, making it visible in the active address list again.', + examples: [ + 'npm run cli -- unhide 2', + ], + }, + + // --- NAMETAGS --- + 'nametag': { + usage: 'nametag ', + description: 'Register a nametag (@name) for the current address. Mints a nametag token on-chain and publishes to Nostr relay. The name should not include the @ prefix.', + examples: [ + 'npm run cli -- nametag alice', + 'npm run cli -- nametag myname', + ], + }, + 'nametag-info': { + usage: 'nametag-info ', + description: 'Look up information about a nametag, including the associated public key and addresses. Resolves via the Nostr relay.', + examples: [ + 'npm run cli -- nametag-info alice', + 'npm run cli -- nametag-info bob', + ], + }, + 'my-nametag': { + usage: 'my-nametag', + description: 'Show the nametag registered for the current address.', + examples: [ + 'npm run cli -- my-nametag', + ], + }, + 'nametag-sync': { + usage: 'nametag-sync', + description: 'Re-publish the current nametag identity binding with chainPubkey. Useful for fixing legacy nametags that were registered without the chainPubkey field.', + examples: [ + 'npm run cli -- nametag-sync', + ], + }, + + // --- MESSAGING --- + 'dm': { + usage: 'dm <@nametag|pubkey> ', + description: 'Send an encrypted direct message to a peer via Nostr (NIP-17 gift-wrapped).', + examples: [ + 'npm run cli -- dm @alice "Hello, how are you?"', + 'npm run cli -- dm @bob "Payment sent for order #42"', + ], + }, + 'dm-inbox': { + usage: 'dm-inbox', + description: 'List all DM conversations with unread counts and last message preview.', + examples: [ + 'npm run cli -- dm-inbox', + ], + }, + 'dm-history': { + usage: 'dm-history <@nametag|pubkey> [--limit ]', + description: 'Show message history for a specific conversation. Resolves @nametag to pubkey automatically.', + flags: [ + { flag: '--limit ', description: 'Maximum number of messages to display', default: '50' }, + ], + examples: [ + 'npm run cli -- dm-history @alice', + 'npm run cli -- dm-history @alice --limit 20', + ], + }, + + // --- GROUP CHAT --- + 'group-create': { + usage: 'group-create [--description ] [--private]', + description: 'Create a new NIP-29 group chat on the relay.', + flags: [ + { flag: '--description ', description: 'Group description text' }, + { flag: '--private', description: 'Create a private (invite-only) group' }, + ], + examples: [ + 'npm run cli -- group-create "Trading Chat"', + 'npm run cli -- group-create "Team Alpha" --description "Internal team chat" --private', + ], + }, + 'group-list': { + usage: 'group-list', + description: 'List all available public groups on the relay with their names, IDs, descriptions, and member counts.', + examples: [ + 'npm run cli -- group-list', + ], + }, + 'group-my': { + usage: 'group-my', + description: 'List groups you have joined, with unread message counts and last message preview.', + examples: [ + 'npm run cli -- group-my', + ], + }, + 'group-join': { + usage: 'group-join [--invite ]', + description: 'Join a group by its ID. Private groups require an invite code.', + flags: [ + { flag: '--invite ', description: 'Invite code for private groups' }, + ], + examples: [ + 'npm run cli -- group-join tradingchat', + 'npm run cli -- group-join privatechat --invite abc123', + ], + }, + 'group-leave': { + usage: 'group-leave ', + description: 'Leave a group you have previously joined.', + examples: [ + 'npm run cli -- group-leave tradingchat', + ], + }, + 'group-send': { + usage: 'group-send [--reply ]', + description: 'Send a message to a group. Optionally reply to a specific message by event ID.', + flags: [ + { flag: '--reply ', description: 'Event ID of the message to reply to' }, + ], + examples: [ + 'npm run cli -- group-send tradingchat "Hello everyone!"', + 'npm run cli -- group-send tradingchat "I agree" --reply abc123def', + ], + }, + 'group-messages': { + usage: 'group-messages [--limit ]', + description: 'Show recent messages in a group. Fetches from relay and marks the group as read.', + flags: [ + { flag: '--limit ', description: 'Maximum number of messages to display', default: '50' }, + ], + examples: [ + 'npm run cli -- group-messages tradingchat', + 'npm run cli -- group-messages tradingchat --limit 20', + ], + }, + 'group-members': { + usage: 'group-members ', + description: 'List members of a group with their nametags, roles (ADMIN/MOD), and join dates.', + examples: [ + 'npm run cli -- group-members tradingchat', + ], + }, + 'group-info': { + usage: 'group-info ', + description: 'Show detailed information about a group including name, visibility, description, member count, creation date, and relay URL.', + examples: [ + 'npm run cli -- group-info tradingchat', + ], + }, + + // --- MARKET --- + 'market-post': { + usage: 'market-post --type [options]', + description: 'Post an intent to the market bulletin board. Type is required.', + flags: [ + { flag: '--type ', description: 'Intent type: buy, sell, service, announcement, other (required)' }, + { flag: '--category ', description: 'Intent category for filtering' }, + { flag: '--price ', description: 'Price amount (numeric)' }, + { flag: '--currency ', description: 'Currency code (USD, UCT, etc.)' }, + { flag: '--location ', description: 'Location for geographic filtering' }, + { flag: '--contact ', description: 'Contact handle (e.g., @nametag, email)' }, + { flag: '--expires ', description: 'Expiration in days', default: '30' }, + ], + examples: [ + 'npm run cli -- market-post "Buying 100 UCT" --type buy', + 'npm run cli -- market-post "Selling ETH" --type sell --price 50 --currency USD', + 'npm run cli -- market-post "Web dev services" --type service --contact @alice', + ], + }, + 'market-search': { + usage: 'market-search [options]', + description: 'Search the market bulletin board using semantic search. Returns intents ranked by relevance score.', + flags: [ + { flag: '--type ', description: 'Filter by intent type' }, + { flag: '--category ', description: 'Filter by category' }, + { flag: '--min-price ', description: 'Minimum price filter' }, + { flag: '--max-price ', description: 'Maximum price filter' }, + { flag: '--min-score <0-1>', description: 'Minimum similarity score threshold' }, + { flag: '--location ', description: 'Location filter' }, + { flag: '--limit ', description: 'Maximum results to return', default: '10' }, + ], + examples: [ + 'npm run cli -- market-search "UCT tokens" --type sell --limit 5', + 'npm run cli -- market-search "tokens" --min-score 0.7', + 'npm run cli -- market-search "services" --min-price 10 --max-price 100', + ], + }, + 'market-my': { + usage: 'market-my', + description: 'List all intents you have posted to the market.', + examples: [ + 'npm run cli -- market-my', + ], + }, + 'market-close': { + usage: 'market-close ', + description: 'Close (delete) one of your market intents by its ID.', + examples: [ + 'npm run cli -- market-close abc123', + ], + }, + 'market-feed': { + usage: 'market-feed [--rest]', + description: 'Watch the live market listing feed via WebSocket. Shows new intents in real time. Use --rest for a one-shot fetch instead.', + flags: [ + { flag: '--rest', description: 'Use REST fallback: fetch recent listings once and exit' }, + ], + examples: [ + 'npm run cli -- market-feed', + 'npm run cli -- market-feed --rest', + ], + notes: [ + 'WebSocket mode runs indefinitely. Press Ctrl+C to stop.', + ], + }, + + // --- INVOICES --- + 'invoice-create': { + usage: 'invoice-create --target
--asset " " [options]', + description: 'Create a new invoice by specifying a target address and requested payment. Alternatively, load full terms from a JSON file with --terms. The invoice is minted as an on-chain token.', + flags: [ + { flag: '--target
', description: 'Target address (@nametag or DIRECT:// address) (required unless --terms)' }, + { flag: '--asset " "', description: 'Requested asset in " " format (e.g., "1000000 UCT")' }, + { flag: '--nft ', description: 'Request a specific NFT by token ID (instead of coin+amount)' }, + { flag: '--due ', description: 'Due date in ISO-8601 format (e.g., 2026-12-31)' }, + { flag: '--memo ', description: 'Invoice memo text' }, + { flag: '--delivery ', description: 'Delivery method description' }, + { flag: '--terms ', description: 'Load full CreateInvoiceRequest from a JSON file (overrides other flags)' }, + ], + examples: [ + 'npm run cli -- invoice-create --target @alice --asset "1000000 UCT"', + 'npm run cli -- invoice-create --target @alice --asset "500000 BTC" --memo "Order #42" --due 2026-12-31', + 'npm run cli -- invoice-create --terms invoice-terms.json', + ], + notes: [ + 'Amounts must be positive integers in smallest units (no decimals, no leading zeros).', + ], + }, + 'invoice-import': { + usage: 'invoice-import ', + description: 'Import an invoice from a TXF token JSON file. Parses the invoice terms from the token data and adds it to local tracking.', + examples: [ + 'npm run cli -- invoice-import ./received-invoice.json', + ], + }, + 'invoice-list': { + usage: 'invoice-list [--state ] [--role ] [--limit ]', + description: 'List invoices with optional filtering by state and role. States can be comma-separated.', + flags: [ + { flag: '--state ', description: 'Filter by state: OPEN, PARTIAL, COVERED, CLOSED, CANCELLED, EXPIRED (comma-separated)' }, + { flag: '--role ', description: 'Filter by role: "creator" (invoices you created) or "payer" (invoices targeting you)' }, + { flag: '--limit ', description: 'Maximum number of invoices to return' }, + ], + examples: [ + 'npm run cli -- invoice-list', + 'npm run cli -- invoice-list --state OPEN,PARTIAL --limit 5', + 'npm run cli -- invoice-list --role creator', + ], + }, + 'invoice-status': { + usage: 'invoice-status ', + description: 'Show detailed invoice status including state, per-target balance breakdown, total forward/back payments, and confirmation status. Accepts full ID or unique prefix.', + examples: [ + 'npm run cli -- invoice-status a1b2c3d4', + 'npm run cli -- invoice-status a1b2c3d4e5f6...', + ], + }, + 'invoice-close': { + usage: 'invoice-close [--auto-return]', + description: 'Close an invoice (terminal state). Freezes balances and stops dynamic recomputation. Optionally triggers auto-return for overpayments.', + flags: [ + { flag: '--auto-return', description: 'Trigger auto-return of excess payments on close' }, + ], + examples: [ + 'npm run cli -- invoice-close a1b2c3d4', + 'npm run cli -- invoice-close a1b2c3d4 --auto-return', + ], + }, + 'invoice-cancel': { + usage: 'invoice-cancel ', + description: 'Cancel an invoice (terminal state). If auto-return is enabled, payments will be automatically refunded.', + examples: [ + 'npm run cli -- invoice-cancel a1b2c3d4', + ], + }, + 'invoice-pay': { + usage: 'invoice-pay [--amount ] [--target-index ]', + description: 'Pay an invoice. By default pays the remaining amount for the first target. For multi-target invoices, specify --target-index.', + flags: [ + { flag: '--amount ', description: 'Amount to pay in smallest units (positive integer). Defaults to remaining amount.' }, + { flag: '--target-index ', description: 'Target index for multi-target invoices (0-based)', default: '0' }, + ], + examples: [ + 'npm run cli -- invoice-pay a1b2c3d4', + 'npm run cli -- invoice-pay a1b2c3d4 --amount 500000', + 'npm run cli -- invoice-pay a1b2c3d4 --target-index 1 --amount 250000', + ], + }, + 'invoice-return': { + usage: 'invoice-return --recipient
--asset " "', + description: 'Manually return a payment to a sender for a specific invoice.', + flags: [ + { flag: '--recipient
', description: 'Recipient address or @nametag (required)' }, + { flag: '--asset " "', description: 'Asset to return in " " format (e.g., "100000 UCT")' }, + ], + examples: [ + 'npm run cli -- invoice-return a1b2c3d4 --recipient @bob --asset "100000 UCT"', + ], + }, + 'invoice-receipts': { + usage: 'invoice-receipts ', + description: 'Send payment receipts via DM to each sender who contributed to this invoice. Typically used after closing an invoice.', + examples: [ + 'npm run cli -- invoice-receipts a1b2c3d4', + ], + }, + 'invoice-notices': { + usage: 'invoice-notices ', + description: 'Send cancellation notice DMs to each sender who contributed to a cancelled invoice.', + examples: [ + 'npm run cli -- invoice-notices a1b2c3d4', + ], + }, + 'invoice-auto-return': { + usage: 'invoice-auto-return [--enable|--disable] [--invoice ]', + description: 'Show or configure auto-return settings. Without flags, displays current settings. Auto-return automatically refunds payments received against closed or cancelled invoices.', + flags: [ + { flag: '--enable', description: 'Enable auto-return' }, + { flag: '--disable', description: 'Disable auto-return' }, + { flag: '--invoice ', description: 'Target a specific invoice (default: global "*" scope)' }, + ], + examples: [ + 'npm run cli -- invoice-auto-return', + 'npm run cli -- invoice-auto-return --enable', + 'npm run cli -- invoice-auto-return --disable --invoice a1b2c3d4', + ], + }, + 'invoice-transfers': { + usage: 'invoice-transfers ', + description: 'List all transfers related to an invoice in chronological order, including forward payments and returns.', + examples: [ + 'npm run cli -- invoice-transfers a1b2c3d4', + ], + }, + 'invoice-export': { + usage: 'invoice-export ', + description: 'Export an invoice to a JSON file. The file is saved as invoice-.json in the current directory.', + examples: [ + 'npm run cli -- invoice-export a1b2c3d4', + ], + }, + 'invoice-parse-memo': { + usage: 'invoice-parse-memo ', + description: 'Parse an invoice memo string (INV:...) and display its decoded fields.', + examples: [ + 'npm run cli -- invoice-parse-memo "INV:a1b2c3d4...:F"', + ], + }, + + // --- SWAPS --- + 'swap-propose': { + usage: 'swap-propose --to --offer " " --want " " [options]', + description: 'Propose a token swap deal to a counterparty. Both parties deposit tokens into an escrow, which executes the swap atomically.', + flags: [ + { flag: '--to ', description: 'Counterparty @nametag or address (required)' }, + { flag: '--offer " "', description: 'Asset you are offering (e.g., "10 BTC", "0.5 ETH")' }, + { flag: '--want " "', description: 'Asset you want in return (e.g., "100 USDU", "5 UCT")' }, + { flag: '--escrow
', description: 'Custom escrow address (optional, uses config default)' }, + { flag: '--timeout ', description: 'Swap timeout in seconds (60-86400)', default: '3600' }, + { flag: '--message ', description: 'Optional message to the counterparty' }, + ], + examples: [ + 'npm run cli -- swap-propose --to @bob --offer "10 UCT" --want "5 USDU"', + 'npm run cli -- swap-propose --to @bob --offer "1 BTC" --want "10 ETH" --timeout 7200 --message "Quick trade?"', + ], + notes: [ + 'Amounts are in human-readable units (e.g., "10 BTC" = 10 whole BTC). Decimals are supported (e.g., "0.5 ETH").', + ], + }, + 'swap-list': { + usage: 'swap-list [--all] [--role ] [--progress ]', + description: 'List swap deals. By default shows only open and in-progress swaps. Use --all to include completed/cancelled/failed swaps.', + flags: [ + { flag: '--all', description: 'Include terminal states (completed, cancelled, failed)' }, + { flag: '--role ', description: 'Filter by your role: "proposer" or "acceptor"' }, + { flag: '--progress ', description: 'Filter by progress state' }, + ], + examples: [ + 'npm run cli -- swap-list', + 'npm run cli -- swap-list --all', + 'npm run cli -- swap-list --role proposer', + 'npm run cli -- swap-list --all --role proposer', + ], + }, + 'swap-accept': { + usage: 'swap-accept [--deposit] [--no-wait]', + description: 'Accept a proposed swap deal. Announces acceptance to the escrow.', + flags: [ + { flag: '--deposit', description: 'Immediately deposit after accepting' }, + { flag: '--no-wait', description: 'Do not wait for swap completion after depositing (only with --deposit)' }, + ], + examples: [ + 'npm run cli -- swap-accept 3611a464', + 'npm run cli -- swap-accept 3611a464 --deposit', + 'npm run cli -- swap-accept 3611a464 --deposit --no-wait', + ], + notes: [ + 'Accepts full 64-char swap ID or a unique prefix (min 4 chars).', + 'Without --deposit, run "swap-deposit " separately when ready.', + ], + }, + 'swap-status': { + usage: 'swap-status [--query-escrow]', + description: 'Show detailed status of a swap deal including progress, deal terms, and deposit information.', + flags: [ + { flag: '--query-escrow', description: 'Query the escrow service for the latest status' }, + ], + examples: [ + 'npm run cli -- swap-status 3611a464', + 'npm run cli -- swap-status 3611a464 --query-escrow', + ], + notes: [ + 'Accepts full 64-char swap ID or a unique prefix (min 4 chars).', + 'If the swap has an associated deposit invoice, its status is also displayed.', + ], + }, + 'swap-deposit': { + usage: 'swap-deposit ', + description: 'Deposit tokens into an accepted swap. The swap must be in the "announced" state (accepted and awaiting deposits).', + examples: [ + 'npm run cli -- swap-deposit 3611a464', + ], + notes: [ + 'Accepts full 64-char swap ID or a unique prefix (min 4 chars).', + ], + }, + + 'swap-reject': { + usage: 'swap-reject [reason]', + description: 'Reject an incoming swap proposal. Sends a rejection DM to the proposer and marks the swap as cancelled.', + examples: [ + 'npm run cli -- swap-reject 3611a464', + 'npm run cli -- swap-reject 3611a464 "Price too high"', + ], + notes: [ + 'Accepts full 64-char swap ID or a unique prefix (min 4 chars).', + 'Only works on proposals you received (role: acceptor, progress: proposed).', + 'The optional reason is included in the rejection DM sent to the proposer.', + ], + }, + 'swap-cancel': { + usage: 'swap-cancel ', + description: 'Cancel a swap you proposed or accepted. Works before deposits are confirmed by the escrow.', + examples: [ + 'npm run cli -- swap-cancel 3611a464', + ], + notes: [ + 'Accepts full 64-char swap ID or a unique prefix (min 4 chars).', + 'Pre-deposit cancellation is local only (no escrow notification).', + 'Post-deposit cancellation: escrow handles timeout and returns deposits automatically.', + ], + }, + + // --- DAEMON --- + 'daemon': { + usage: 'daemon [options]', + description: 'Manage the persistent event listener daemon. The daemon listens for wallet events and triggers configured actions.', + examples: [ + 'npm run cli -- daemon start --event transfer:incoming --action auto-receive', + 'npm run cli -- daemon start --detach --event "*" --action "log:./events.jsonl"', + 'npm run cli -- daemon stop', + 'npm run cli -- daemon status', + ], + }, + 'daemon start': { + usage: 'daemon start [options]', + description: 'Start the persistent event listener. Can subscribe to specific events and trigger actions (auto-receive, webhook, bash command, or log file).', + flags: [ + { flag: '--config ', description: 'Config file path', default: '.sphere-cli/daemon.json' }, + { flag: '--detach', description: 'Run in background (fork process, PID file, redirect logs)' }, + { flag: '--log ', description: 'Override log file path' }, + { flag: '--pid ', description: 'Override PID file path' }, + { flag: '--event ', description: 'Event type to subscribe to (repeatable). Use "*" for all events.' }, + { flag: '--action ', description: 'Action to trigger: auto-receive, bash:, webhook:, log:' }, + { flag: '--market-feed', description: 'Also subscribe to the market WebSocket feed' }, + { flag: '--verbose', description: 'Print full event JSON in logs' }, + ], + examples: [ + 'npm run cli -- daemon start --event transfer:incoming --action auto-receive', + 'npm run cli -- daemon start --event "transfer:*" --action "webhook:https://example.com/hook" --detach', + 'npm run cli -- daemon start --event "*" --action "log:./events.jsonl" --verbose', + 'npm run cli -- daemon start --event message:dm --action "bash:echo DM from \\$SPHERE_SENDER"', + 'npm run cli -- daemon start --config ./my-daemon.json --detach', + ], + }, + 'daemon stop': { + usage: 'daemon stop', + description: 'Stop the running daemon process.', + examples: [ + 'npm run cli -- daemon stop', + ], + }, + 'daemon status': { + usage: 'daemon status', + description: 'Check if the daemon is currently running and show its PID.', + examples: [ + 'npm run cli -- daemon status', + ], + }, + + // --- ENCRYPTION --- + 'encrypt': { + usage: 'encrypt ', + description: 'Encrypt a string with AES using a password. Outputs the encrypted result as JSON.', + examples: [ + 'npm run cli -- encrypt "my secret data" mypassword', + ], + }, + 'decrypt': { + usage: 'decrypt ', + description: 'Decrypt an AES-encrypted JSON blob using a password. Outputs the original plaintext.', + examples: [ + 'npm run cli -- decrypt \'{"iv":"...","data":"..."}\' mypassword', + ], + }, + + // --- WALLET PARSING --- + 'parse-wallet': { + usage: 'parse-wallet [password]', + description: 'Parse a wallet backup file (.txt or .dat format). If the file is encrypted, a password is required.', + examples: [ + 'npm run cli -- parse-wallet wallet.txt', + 'npm run cli -- parse-wallet wallet.txt mypassword', + 'npm run cli -- parse-wallet wallet.dat', + ], + }, + 'wallet-info': { + usage: 'wallet-info ', + description: 'Show metadata about a wallet file: format (.txt, .dat, .json), whether it is encrypted, and other format-specific info.', + examples: [ + 'npm run cli -- wallet-info wallet.txt', + 'npm run cli -- wallet-info backup.dat', + ], + }, + + // --- KEY OPERATIONS --- + 'generate-key': { + usage: 'generate-key', + description: 'Generate a random secp256k1 private key and derive the public key, WIF, and L1 address from it.', + examples: [ + 'npm run cli -- generate-key', + ], + }, + 'validate-key': { + usage: 'validate-key ', + description: 'Validate whether a hex string is a valid secp256k1 private key. Exits with code 0 if valid, 1 if invalid.', + examples: [ + 'npm run cli -- validate-key 0a1b2c3d...', + ], + }, + 'hex-to-wif': { + usage: 'hex-to-wif ', + description: 'Convert a hex private key to Wallet Import Format (WIF).', + examples: [ + 'npm run cli -- hex-to-wif 0a1b2c3d...', + ], + }, + 'derive-pubkey': { + usage: 'derive-pubkey ', + description: 'Derive the compressed secp256k1 public key from a private key.', + examples: [ + 'npm run cli -- derive-pubkey 0a1b2c3d...', + ], + }, + 'derive-address': { + usage: 'derive-address [index]', + description: 'Derive an L1 (alpha1...) address from a private key at the given HD derivation index.', + flags: [ + { flag: '', description: 'HD derivation index', default: '0' }, + ], + examples: [ + 'npm run cli -- derive-address 0a1b2c3d...', + 'npm run cli -- derive-address 0a1b2c3d... 3', + ], + }, + + // --- CURRENCY --- + 'to-smallest': { + usage: 'to-smallest ', + description: 'Convert a human-readable amount to the smallest unit. Use the coin symbol to apply the correct decimals for a specific asset. Default uses 8 decimals if no coin specified.', + flags: [ + { flag: '', description: 'Asset symbol (UCT, BTC), name (bitcoin), or hex coin ID. Determines decimal precision.' }, + ], + examples: [ + 'npm run cli -- to-smallest 1.5 UCT', + 'npm run cli -- to-smallest 0.001 BTC', + 'npm run cli -- to-smallest 100.5 USDT', + ], + notes: [ + 'When a coin is provided, the wallet is loaded briefly to access the token registry.', + ], + }, + 'to-human': { + usage: 'to-human ', + description: 'Convert an amount in smallest units back to human-readable format. Use the coin symbol to apply the correct decimals for a specific asset. Default uses 8 decimals if no coin specified.', + flags: [ + { flag: '', description: 'Asset symbol (UCT, BTC), name (bitcoin), or hex coin ID. Determines decimal precision.' }, + ], + examples: [ + 'npm run cli -- to-human 150000000 UCT', + 'npm run cli -- to-human 1000000 BTC', + 'npm run cli -- to-human 1000000 USDT', + ], + notes: [ + 'When a coin is provided, the wallet is loaded briefly to access the token registry.', + ], + }, + 'format': { + usage: 'format [decimals]', + description: 'Format an amount with the specified number of decimal places.', + flags: [ + { flag: '', description: 'Number of decimal places', default: '8' }, + ], + examples: [ + 'npm run cli -- format 150000000', + 'npm run cli -- format 150000000 6', + ], + }, + + // --- ENCODING --- + 'base58-encode': { + usage: 'base58-encode ', + description: 'Encode a hex string to Base58.', + examples: [ + 'npm run cli -- base58-encode 0a1b2c3d', + ], + }, + 'base58-decode': { + usage: 'base58-decode ', + description: 'Decode a Base58 string to hex.', + examples: [ + 'npm run cli -- base58-decode 2NEpo7TZRhna', + ], + }, +}; + +function printCommandHelp(cmdName: string): void { + const help = COMMAND_HELP[cmdName]; + if (!help) { + console.error(`No help available for command: ${cmdName}`); + console.error('Run "npm run cli -- help" for a list of all commands.'); + process.exit(1); + } + + console.log(`\n ${cmdName}\n`); + console.log(` Usage: npm run cli -- ${help.usage}\n`); + console.log(` ${help.description}\n`); + + if (help.flags && help.flags.length > 0) { + console.log(' Flags:'); + const maxFlagLen = Math.max(...help.flags.map(f => f.flag.length)); + for (const f of help.flags) { + const defaultStr = f.default ? ` (default: ${f.default})` : ''; + console.log(` ${f.flag.padEnd(maxFlagLen + 2)}${f.description}${defaultStr}`); + } + console.log(''); + } + + if (help.examples.length > 0) { + console.log(' Examples:'); + for (const ex of help.examples) { + console.log(` ${ex}`); + } + console.log(''); + } + + if (help.notes && help.notes.length > 0) { + console.log(' Notes:'); + for (const note of help.notes) { + console.log(` - ${note}`); + } + console.log(''); + } +} + +function printUsage() { + console.log(` +Sphere SDK CLI v0.3.0 + +Usage: npm run cli -- [args...] + or: npx tsx cli/index.ts [args...] + or: npm run cli -- help Show detailed help for a command + +WALLET: + init Create or import wallet + status Show wallet identity + config Show or set CLI configuration + clear Delete all wallet data + +WALLET PROFILES: + wallet list List all wallet profiles + wallet create Create a new wallet profile + wallet use Switch to a wallet profile + wallet current Show active profile + wallet delete Delete a wallet profile + +BALANCE & TOKENS: + balance Show L3 token balance + tokens List individual tokens + assets List all registered assets (coins & NFTs) + asset-info Show detailed info for an asset + l1-balance L1 (ALPHA) balance + topup [ ] Request test tokens from faucet + verify-balance Detect spent tokens via aggregator + sync Sync tokens with IPFS + +TRANSFERS: + send Send L3 tokens + receive Check for incoming transfers + history [limit] Transaction history + +ADDRESSES: + addresses List tracked addresses + switch Switch to HD address + hide Hide address + unhide Unhide address + +NAMETAGS: + nametag Register a nametag + nametag-info Look up nametag info + my-nametag Show current nametag + nametag-sync Re-publish nametag binding to Nostr + +MESSAGING: + dm <@nametag> Send a direct message + dm-inbox List conversations and unread counts + dm-history <@nametag|pubkey> Show conversation history + +GROUP CHAT: + group-create Create a new group + group-list List available groups on relay + group-my List your joined groups + group-join Join a group + group-leave Leave a group + group-send Send a message to a group + group-messages Show group messages + group-members List group members + group-info Show group details + +MARKET: + market-post --type Post an intent + market-search Search intents (semantic) + market-my List your own intents + market-close Close (delete) an intent + market-feed Watch the live listing feed + +INVOICES: + invoice-create Create invoice + invoice-import Import invoice from token file + invoice-list List invoices + invoice-status Show invoice status + invoice-pay Pay an invoice + invoice-close Close an invoice + invoice-cancel Cancel an invoice + invoice-return Return payment to sender + invoice-receipts Send receipts + invoice-notices Send cancellation notices + invoice-auto-return Show/set auto-return settings + invoice-transfers List related transfers + invoice-export Export invoice to JSON file + invoice-parse-memo Parse invoice memo string + +SWAPS: + swap-propose Propose a token swap deal + swap-list List swap deals + swap-accept Accept a swap deal + swap-status Show swap status + swap-deposit Deposit into a swap + swap-reject [reason] Reject a swap proposal + swap-cancel Cancel a swap + +EVENT DAEMON: + daemon start Start persistent event listener + daemon stop Stop running daemon + daemon status Check if daemon is running + +UTILITIES: + encrypt Encrypt data with password + decrypt Decrypt encrypted JSON data + parse-wallet [password] Parse wallet file (.txt, .dat) + wallet-info Show wallet file info + generate-key Generate new private key + validate-key Validate a private key + hex-to-wif Convert hex to WIF + derive-pubkey Derive public key + derive-address [index] Derive L1 address + to-smallest Convert to smallest unit + to-human Convert to human-readable + format [decimals] Format amount + base58-encode Base58 encode + base58-decode Base58 decode + +Run "npm run cli -- help " for detailed help on any command. + +Examples: + npm run cli -- init --network testnet + npm run cli -- init --mnemonic "word1 word2 ... word24" + npm run cli -- status + npm run cli -- balance + npm run cli -- send @alice 1000000 ETH + npm run cli -- nametag myname + npm run cli -- history 10 + npm run cli -- help send +`); +} + +export async function legacyMain(): Promise { + await main(); +} + +async function main(): Promise { + // Global flag: --no-nostr disables Nostr transport (uses no-op) + noNostrGlobal = args.includes('--no-nostr'); + + if (!command || command === '--help' || command === '-h') { + printUsage(); + process.exit(0); + } + + if (command === 'help') { + const helpTarget = args[1]; + if (!helpTarget || helpTarget === '--help' || helpTarget === '-h' || helpTarget === 'help') { + printUsage(); + process.exit(0); + } + // Try compound key first (e.g., "wallet create", "daemon start") + const compoundKey = args[2] ? `${helpTarget} ${args[2]}` : undefined; + if (compoundKey && COMMAND_HELP[compoundKey]) { + printCommandHelp(compoundKey); + } else if (COMMAND_HELP[helpTarget]) { + printCommandHelp(helpTarget); + } else { + console.error(`No help available for command: ${helpTarget}`); + console.error('Run "npm run cli -- help" for a list of all commands.'); + process.exit(1); + } + process.exit(0); + } + + try { + switch (command) { + // === WALLET MANAGEMENT === + case 'init': { + const networkIndex = args.indexOf('--network'); + const mnemonicIndex = args.indexOf('--mnemonic'); + + let network: NetworkType = 'testnet'; + let mnemonic: string | undefined; + let nametag: string | undefined; + + if (networkIndex !== -1 && args[networkIndex + 1]) { + network = args[networkIndex + 1] as NetworkType; + } + if (mnemonicIndex !== -1) { + if (args[mnemonicIndex + 1] && !args[mnemonicIndex + 1].startsWith('--')) { + mnemonic = args[mnemonicIndex + 1]; + // Clear from argv to reduce exposure in /proc//cmdline + args[mnemonicIndex + 1] = '***'; + } else { + // Interactive prompt — mnemonic never appears in process args + const rl = readline.createInterface({ input: process.stdin, output: process.stderr }); + mnemonic = await new Promise((resolve) => { + rl.question('Enter mnemonic phrase: ', (answer) => { rl.close(); resolve(answer.trim()); }); + }); + } + } + + const nametagIndex = args.indexOf('--nametag'); + if (nametagIndex !== -1 && args[nametagIndex + 1]) { + nametag = args[nametagIndex + 1]; + } + + // Save config + const config = loadConfig(); + config.network = network; + saveConfig(config); + + console.log(`Initializing wallet on ${network}...`); + if (noNostrGlobal) console.log(' (Nostr transport disabled)'); + + const sphere = await getSphere({ + autoGenerate: !mnemonic, + mnemonic, + nametag, + }); + + const identity = sphere.identity; + if (!identity) { + console.error('Failed to initialize wallet identity'); + process.exit(1); + } + + console.log('\nWallet initialized successfully!\n'); + console.log('Identity:'); + console.log(JSON.stringify({ + l1Address: identity.l1Address, + directAddress: identity.directAddress, + chainPubkey: identity.chainPubkey, + nametag: identity.nametag, + }, null, 2)); + + if (!mnemonic) { + // Show generated mnemonic for backup + const storedMnemonic = sphere.getMnemonic(); + if (storedMnemonic) { + console.log('\n⚠️ BACKUP YOUR MNEMONIC (24 words):'); + console.log('─'.repeat(50)); + console.log(storedMnemonic); + console.log('─'.repeat(50)); + console.log('Store this safely! You will need it to recover your wallet.\n'); + } + } + + await closeSphere(); + break; + } + + case 'status': { + try { + const sphere = await getSphere(); + const identity = sphere.identity; + const config = loadConfig(); + + if (!identity) { + console.log('No wallet found. Run: npm run cli -- init'); + break; + } + + console.log('\nWallet Status:'); + console.log('─'.repeat(50)); + if (config.currentProfile) { + console.log(`Profile: ${config.currentProfile}`); + } + console.log(`Network: ${config.network}`); + console.log(`L1 Address: ${identity.l1Address}`); + console.log(`Direct Addr: ${identity.directAddress || '(not set)'}`); + console.log(`Chain Pubkey: ${identity.chainPubkey}`); + console.log(`Nametag: ${identity.nametag || '(not set)'}`); + console.log('─'.repeat(50)); + + await closeSphere(); + } catch { + console.log('No wallet found. Run: npm run cli -- init'); + } + break; + } + + case 'config': { + const [, subCmd, key, value] = args; + const config = loadConfig(); + + if (subCmd === 'set' && key && value) { + if (key === 'network') { + config.network = value as NetworkType; + } else if (key === 'dataDir') { + config.dataDir = value; + } else if (key === 'tokensDir') { + config.tokensDir = value; + } else { + console.error('Unknown config key:', key); + console.error('Valid keys: network, dataDir, tokensDir'); + process.exit(1); + } + saveConfig(config); + console.log(`Set ${key} = ${value}`); + } else { + console.log('\nCurrent Configuration:'); + console.log(JSON.stringify(config, null, 2)); + } + break; + } + + case 'clear': { + if (!args.includes('--yes') && !args.includes('-y')) { + const rl = readline.createInterface({ input: process.stdin, output: process.stderr }); + const answer = await new Promise((resolve) => { + rl.question('This will permanently delete ALL wallet data (keys, tokens, history). Type "yes" to confirm: ', (a) => { rl.close(); resolve(a.trim()); }); + }); + if (answer !== 'yes') { + console.log('Aborted.'); + break; + } + } + + const config = loadConfig(); + const providers = createNodeProviders({ + network: config.network, + dataDir: config.dataDir, + tokensDir: config.tokensDir, + }); + + await providers.storage.connect(); + await providers.tokenStorage.initialize(); + + console.log('Clearing all wallet data...'); + await Sphere.clear({ storage: providers.storage, tokenStorage: providers.tokenStorage }); + console.log('All wallet data cleared.'); + + await providers.storage.disconnect(); + await providers.tokenStorage.shutdown(); + break; + } + + // === WALLET PROFILES === + case 'wallet': { + const [, subCmd, profileName] = args; + + switch (subCmd) { + case 'list': { + const store = loadProfiles(); + const config = loadConfig(); + + console.log('\nWallet Profiles:'); + console.log('─'.repeat(60)); + + if (store.profiles.length === 0) { + console.log('No profiles found. Create one with: npm run cli -- wallet create '); + } else { + for (const profile of store.profiles) { + const isCurrent = config.currentProfile === profile.name; + const marker = isCurrent ? '→ ' : ' '; + console.log(`${marker}${profile.name}`); + console.log(` Network: ${profile.network}`); + console.log(` DataDir: ${profile.dataDir}`); + } + } + console.log('─'.repeat(60)); + break; + } + + case 'use': { + if (!profileName) { + console.error('Usage: wallet use '); + console.error('Example: npm run cli -- wallet use babaika9'); + process.exit(1); + } + + if (switchToProfile(profileName)) { + console.log(`✓ Switched to wallet profile: ${profileName}`); + + // Show wallet status + try { + const sphere = await getSphere(); + const identity = sphere.identity; + if (identity) { + console.log(` Nametag: ${identity.nametag || '(not set)'}`); + console.log(` L1 Addr: ${identity.l1Address}`); + } + await closeSphere(); + } catch { + console.log(' (wallet not initialized in this profile)'); + } + } else { + console.error(`Profile "${profileName}" not found.`); + console.error('Run: npm run cli -- wallet list'); + process.exit(1); + } + break; + } + + case 'create': { + if (!profileName) { + console.error('Usage: wallet create [--network testnet|mainnet|dev]'); + console.error('Example: npm run cli -- wallet create mywalletname'); + process.exit(1); + } + if (!/^[a-zA-Z0-9_-]+$/.test(profileName)) { + console.error('Profile name must contain only letters, digits, hyphens, and underscores.'); + process.exit(1); + } + + // Check if profile already exists + if (getProfile(profileName)) { + console.error(`Profile "${profileName}" already exists.`); + console.error('Run: npm run cli -- wallet use ' + profileName); + process.exit(1); + } + + // Parse optional network + const networkIdx = args.indexOf('--network'); + let network: NetworkType = 'testnet'; + if (networkIdx !== -1 && args[networkIdx + 1]) { + network = args[networkIdx + 1] as NetworkType; + } + + const dataDir = `./.sphere-cli-${profileName}`; + const tokensDir = `${dataDir}/tokens`; + + // Create the profile + const profile: WalletProfile = { + name: profileName, + dataDir, + tokensDir, + network, + createdAt: new Date().toISOString(), + }; + addProfile(profile); + + // Switch to the new profile + switchToProfile(profileName); + + console.log(`✓ Created wallet profile: ${profileName}`); + console.log(` Network: ${network}`); + console.log(` DataDir: ${dataDir}`); + console.log(''); + console.log('Now initialize the wallet:'); + console.log(` npm run cli -- init --nametag ${profileName}`); + break; + } + + case 'current': { + const config = loadConfig(); + const currentName = config.currentProfile; + + console.log('\nCurrent Wallet:'); + console.log('─'.repeat(50)); + + if (currentName) { + const profile = getProfile(currentName); + if (profile) { + console.log(`Profile: ${profile.name}`); + console.log(`Network: ${profile.network}`); + console.log(`DataDir: ${profile.dataDir}`); + } else { + console.log(`Profile: ${currentName} (not found in profiles)`); + } + } else { + console.log('Profile: (default)'); + } + + console.log(`DataDir: ${config.dataDir}`); + console.log(`Network: ${config.network}`); + + // Try to get identity + try { + const sphere = await getSphere(); + const identity = sphere.identity; + if (identity) { + console.log(`Nametag: ${identity.nametag || '(not set)'}`); + console.log(`L1 Addr: ${identity.l1Address}`); + } + await closeSphere(); + } catch { + console.log('Wallet: (not initialized)'); + } + + console.log('─'.repeat(50)); + break; + } + + case 'delete': { + if (!profileName) { + console.error('Usage: wallet delete '); + process.exit(1); + } + + const config = loadConfig(); + if (config.currentProfile === profileName) { + console.error(`Cannot delete the current profile. Switch to another profile first.`); + process.exit(1); + } + + if (deleteProfile(profileName)) { + console.log(`✓ Deleted profile: ${profileName}`); + console.log('Note: Wallet data directory was NOT deleted. Remove manually if needed.'); + } else { + console.error(`Profile "${profileName}" not found.`); + process.exit(1); + } + break; + } + + default: + console.error('Unknown wallet subcommand:', subCmd); + console.log('\nUsage:'); + console.log(' wallet list List all profiles'); + console.log(' wallet use Switch to profile'); + console.log(' wallet create Create new profile'); + console.log(' wallet current Show current profile'); + console.log(' wallet delete Delete profile'); + process.exit(1); + } + break; + } + + // === BALANCE & TOKENS === + case 'balance': { + const finalize = args.includes('--finalize'); + const noSync = args.includes('--no-sync'); + const sphere = await getSphere(); + + if (!noSync) await ensureSync(sphere, 'full'); + + console.log(finalize ? '\nFetching and finalizing tokens...' : '\nFetching tokens...'); + const result = await sphere.payments.receive({ + finalize, + onProgress: (resolution) => { + if (resolution.stillPending > 0) { + const currentBalances = sphere.payments.getBalance(); + for (const bal of currentBalances) { + if (BigInt(bal.unconfirmedAmount) > 0n) { + console.log(` ${bal.symbol}: ${bal.unconfirmedTokenCount} token(s) still unconfirmed...`); + } + } + } + }, + }); + + if (finalize) { + if (result.timedOut) { + console.log(' Warning: finalization timed out, some tokens still unconfirmed.'); + } else if (result.finalization && result.finalization.resolved > 0) { + console.log(`All tokens finalized in ${((result.finalizationDurationMs ?? 0) / 1000).toFixed(1)}s.`); + } else { + console.log('All tokens are already confirmed.'); + } + } + + const assets = sphere.payments.getBalance(); + const totalUsd = await sphere.payments.getFiatBalance(); + + console.log('\nL3 Balance:'); + console.log('─'.repeat(50)); + + if (assets.length === 0) { + console.log('No tokens found.'); + } else { + for (const asset of assets) { + const decimals = asset.decimals ?? 8; + const confirmedFormatted = toHumanReadable(asset.confirmedAmount, decimals); + const unconfirmedBigInt = BigInt(asset.unconfirmedAmount); + + let line = `${asset.symbol}: ${confirmedFormatted}`; + if (unconfirmedBigInt > 0n) { + const unconfirmedFormatted = toHumanReadable(asset.unconfirmedAmount, decimals); + line += ` (+ ${unconfirmedFormatted} unconfirmed) [${asset.confirmedTokenCount}+${asset.unconfirmedTokenCount} tokens]`; + } else { + line += ` (${asset.tokenCount} token${asset.tokenCount !== 1 ? 's' : ''})`; + } + if (asset.fiatValueUsd != null) { + line += ` ≈ $${asset.fiatValueUsd.toFixed(2)}`; + } + console.log(line); + } + } + console.log('─'.repeat(50)); + if (totalUsd != null) { + console.log(`Total: $${totalUsd.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`); + } + + await closeSphere(); + break; + } + + case 'tokens': { + const noSync = args.includes('--no-sync'); + const sphere = await getSphere(); + + if (!noSync) await ensureSync(sphere, 'full'); + + const tokens = sphere.payments.getTokens(); + const registry = TokenRegistry.getInstance(); + + console.log('\nTokens:'); + console.log('─'.repeat(50)); + + if (tokens.length === 0) { + console.log('No tokens found.'); + } else { + for (const token of tokens) { + const def = registry.getDefinition(token.coinId); + const symbol = def?.symbol || token.symbol || 'UNK'; + const decimals = def?.decimals ?? token.decimals ?? 8; + const formatted = toHumanReadable(token.amount || '0', decimals); + console.log(`ID: ${token.id.slice(0, 16)}...`); + console.log(` Coin: ${symbol} (${token.coinId.slice(0, 8)}...)`); + console.log(` Amount: ${formatted} ${symbol}`); + console.log(` Status: ${token.status || 'active'}`); + console.log(''); + } + } + console.log('─'.repeat(50)); + + await closeSphere(); + break; + } + + case 'assets': { + // Initialize Sphere so TokenRegistry is loaded with remote data + const _sphere = await getSphere(); + const registry = TokenRegistry.getInstance(); + const allDefs = registry.getAllDefinitions(); + + const typeFilter = args.indexOf('--type'); + const filterValue = typeFilter !== -1 ? args[typeFilter + 1] : undefined; + + let defs = allDefs; + if (filterValue === 'fungible' || filterValue === 'coin' || filterValue === 'coins') { + defs = registry.getFungibleTokens(); + } else if (filterValue === 'nft' || filterValue === 'nfts' || filterValue === 'non-fungible') { + defs = registry.getNonFungibleTokens(); + } + + if (defs.length === 0) { + console.log('No registered assets found.'); + console.log('The token registry may not have loaded yet. Try again in a moment.'); + } else { + // Table header + console.log(''); + console.log(`${'SYMBOL'.padEnd(10)} ${'NAME'.padEnd(20)} ${'KIND'.padEnd(14)} ${'DECIMALS'.padEnd(10)} ${'COIN ID'}`); + console.log('─'.repeat(90)); + + // Sort: fungible first (by symbol), then NFTs (by name) + const sorted = [...defs].sort((a, b) => { + if (a.assetKind !== b.assetKind) return a.assetKind === 'fungible' ? -1 : 1; + const aLabel = a.symbol || a.name || a.id; + const bLabel = b.symbol || b.name || b.id; + return aLabel.localeCompare(bLabel); + }); + + for (const def of sorted) { + const symbol = (def.symbol || '—').padEnd(10); + const name = (def.name ? def.name.charAt(0).toUpperCase() + def.name.slice(1) : '—').padEnd(20); + const kind = def.assetKind.padEnd(14); + const decimals = (def.decimals !== undefined ? String(def.decimals) : '—').padEnd(10); + const coinId = def.id.slice(0, 16) + '...'; + console.log(`${symbol} ${name} ${kind} ${decimals} ${coinId}`); + } + + console.log('─'.repeat(90)); + console.log(`Total: ${defs.length} asset(s)`); + } + + await closeSphere(); + break; + } + + case 'asset-info': { + const identifier = args[1]; + if (!identifier) { + console.error('Usage: asset-info '); + console.error('Examples:'); + console.error(' npm run cli -- asset-info UCT'); + console.error(' npm run cli -- asset-info bitcoin'); + console.error(' npm run cli -- asset-info 0a1b2c3d...'); + process.exit(1); + } + + // Initialize Sphere so TokenRegistry is loaded + const _sphere = await getSphere(); + const registry = TokenRegistry.getInstance(); + + // Try multiple lookup strategies + let def = registry.getDefinitionBySymbol(identifier); + if (!def) def = registry.getDefinitionByName(identifier); + if (!def) def = registry.getDefinition(identifier); // by coinId hex + + if (!def) { + console.error(`Asset not found: "${identifier}"`); + console.error('Use "assets" command to list all registered assets.'); + process.exit(1); + } + + console.log(''); + console.log('Asset Details'); + console.log('─'.repeat(50)); + console.log(` Symbol: ${def.symbol || '—'}`); + console.log(` Name: ${def.name ? def.name.charAt(0).toUpperCase() + def.name.slice(1) : '—'}`); + console.log(` Kind: ${def.assetKind}`); + console.log(` Decimals: ${def.decimals !== undefined ? def.decimals : '—'}`); + console.log(` Coin ID: ${def.id}`); + console.log(` Network: ${def.network}`); + console.log(` Description: ${def.description || '—'}`); + if (def.icons && def.icons.length > 0) { + console.log(` Icons:`); + for (const icon of def.icons) { + console.log(` - ${icon.url}`); + } + } + console.log('─'.repeat(50)); + + await closeSphere(); + break; + } + + case 'l1-balance': { + const sphere = await getSphere(); + + if (!sphere.payments.l1) { + console.error('L1 module not available. Initialize with L1 config.'); + process.exit(1); + } + + const balance = await sphere.payments.l1.getBalance(); + + console.log('\nL1 (ALPHA) Balance:'); + console.log('─'.repeat(50)); + console.log(`Confirmed: ${toHumanReadable(balance.confirmed.toString())} ALPHA`); + console.log(`Unconfirmed: ${toHumanReadable(balance.unconfirmed.toString())} ALPHA`); + console.log('─'.repeat(50)); + + await closeSphere(); + break; + } + + case 'verify-balance': { + // Verify tokens against the aggregator to detect spent tokens + // Uses SDK Token.fromJSON() to calculate current state hash (per TOKEN_INVENTORY_SPEC.md) + const removeSpent = args.includes('--remove'); + const verbose = args.includes('--verbose') || args.includes('-v'); + + const sphere = await getSphere(); + await ensureSync(sphere, 'full'); + const tokens = sphere.payments.getTokens(); + const identity = sphere.identity; + + if (!identity) { + console.error('No wallet identity found.'); + process.exit(1); + } + + console.log(`\nVerifying ${tokens.length} token(s) against aggregator...`); + console.log('─'.repeat(60)); + + // Get aggregator client from the initialized oracle provider in Sphere + const oracle = sphere.getAggregator(); + const aggregatorClient = (oracle as { getAggregatorClient?: () => unknown }).getAggregatorClient?.(); + + if (!aggregatorClient) { + console.error('Aggregator client not available. Cannot verify tokens.'); + await closeSphere(); + process.exit(1); + } + + // Create validator with aggregator client + const validator = new TokenValidator({ + aggregatorClient: aggregatorClient as Parameters[0], + }); + + // Use checkSpentTokens which properly calculates state hash using SDK + // (following TOKEN_INVENTORY_SPEC.md Step 7: Spent Token Detection) + const result = await validator.checkSpentTokens( + tokens, + identity.chainPubkey, + { + batchSize: 5, + onProgress: (completed, total) => { + if (verbose && (completed % 10 === 0 || completed === total)) { + console.log(` Checked ${completed}/${total} tokens...`); + } + } + } + ); + + // Build result maps for display + const registry = TokenRegistry.getInstance(); + const spentTokenIds = new Set(result.spentTokens.map(s => s.localId)); + + const spentDisplay: { id: string; tokenId: string; symbol: string; amount: string }[] = []; + const validDisplay: { id: string; tokenId: string; symbol: string; amount: string }[] = []; + + for (const token of tokens) { + const def = registry.getDefinition(token.coinId); + const symbol = def?.symbol || token.symbol || 'UNK'; + const decimals = def?.decimals ?? token.decimals ?? 8; + const formatted = toHumanReadable(token.amount || '0', decimals); + + const txf = tokenToTxf(token); + const tokenId = txf?.genesis?.data?.tokenId || token.id; + + if (spentTokenIds.has(token.id)) { + spentDisplay.push({ + id: token.id, + tokenId: tokenId.slice(0, 16), + symbol, + amount: formatted, + }); + console.log(`✗ SPENT: ${formatted} ${symbol} (${tokenId.slice(0, 12)}...)`); + } else { + validDisplay.push({ + id: token.id, + tokenId: tokenId.slice(0, 16), + symbol, + amount: formatted, + }); + if (verbose) { + console.log(`✓ Valid: ${formatted} ${symbol} (${tokenId.slice(0, 12)}...)`); + } + } + } + + console.log('─'.repeat(60)); + console.log(`\nSummary:`); + console.log(` Valid tokens: ${validDisplay.length}`); + console.log(` Spent tokens: ${spentDisplay.length}`); + if (result.errors.length > 0) { + console.log(` Errors: ${result.errors.length}`); + if (verbose) { + for (const err of result.errors) { + console.log(` - ${err}`); + } + } + } + + // Move spent tokens to Sent folder if requested (per TOKEN_INVENTORY_SPEC.md) + if (removeSpent && spentDisplay.length > 0) { + console.log(`\nMoving ${spentDisplay.length} spent token(s) to Sent folder...`); + + // Access PaymentsModule's removeToken which: + // 1. Archives token to Sent folder (archivedTokens) + // 2. Creates tombstone to prevent re-sync + // 3. Removes from active tokens + const paymentsModule = sphere.payments as unknown as { + removeToken?: (tokenId: string, recipientNametag?: string, skipHistory?: boolean) => Promise; + }; + + if (!paymentsModule.removeToken) { + console.error(' Error: removeToken method not available'); + } else { + for (const spent of spentDisplay) { + try { + // Use removeToken which archives to Sent folder and creates tombstone + // skipHistory=true since this is spent detection, not a new send + await paymentsModule.removeToken(spent.id, undefined, true); + console.log(` Archived: ${spent.amount} ${spent.symbol} (${spent.tokenId}...)`); + } catch (err) { + console.error(` Failed to archive ${spent.id}: ${err}`); + } + } + console.log(' Tokens moved to Sent folder.'); + } + } else if (spentDisplay.length > 0) { + console.log(`\nTo move spent tokens to Sent folder, run: npm run cli -- verify-balance --remove`); + } + + await closeSphere(); + break; + } + + case 'sync': { + const sphere = await getSphere(); + await ensureSync(sphere, 'full'); + await closeSphere(); + break; + } + + // === TRANSFERS === + case 'send': { + const [, recipient, amountStr] = args; + if (!recipient || !amountStr) { + console.error('Usage: send [--direct|--proxy] [--instant|--conservative]'); + console.error(' recipient: @nametag or DIRECT:// address'); + console.error(' amount: decimal amount (e.g., 0.5, 100)'); + console.error(' coin: token symbol (e.g., UCT, BTC, ETH, SOL) - default: UCT'); + console.error(' --direct: force DirectAddress transfer (requires new nametag with directAddress)'); + console.error(' --proxy: force PROXY address transfer (works with any nametag)'); + console.error(' --instant: send via Nostr immediately (default, receiver gets unconfirmed token)'); + console.error(' --conservative: collect all proofs first, receiver gets confirmed token'); + process.exit(1); + } + + // Parse coin: positional arg[3] + let coinSymbol: string; + if (args[3] && !args[3].startsWith('--')) { + coinSymbol = args[3]; + } else { + coinSymbol = 'UCT'; // default + } + + // Parse --direct and --proxy options + const forceDirect = args.includes('--direct'); + const forceProxy = args.includes('--proxy'); + if (forceDirect && forceProxy) { + console.error('Cannot use both --direct and --proxy'); + process.exit(1); + } + const addressMode = forceDirect ? 'direct' : forceProxy ? 'proxy' : 'auto'; + + // Parse --instant and --conservative options + const forceInstant = args.includes('--instant'); + const forceConservative = args.includes('--conservative'); + if (forceInstant && forceConservative) { + console.error('Cannot use both --instant and --conservative'); + process.exit(1); + } + const transferMode = forceConservative ? 'conservative' as const : 'instant' as const; + + // Initialize Sphere first so TokenRegistry is loaded + const noSyncSend = args.includes('--no-sync'); + const sphere = await getSphere(); + + // Sync token inventory (need latest state for spending) + if (!noSyncSend) await ensureSync(sphere, 'full'); + + // Resolve symbol/name/hex to coinId and get decimals + const { coinId: coinIdHex, symbol: resolvedSymbol, decimals } = resolveCoin(coinSymbol); + + // Convert amount to smallest units (supports decimal input like "0.2") + const amountSmallest = toSmallestUnit(amountStr, decimals).toString(); + + const modeLabel = addressMode === 'auto' ? '' : ` (${addressMode})`; + const txModeLabel = forceConservative ? ' [conservative]' : ''; + console.log(`\nSending ${amountStr} ${resolvedSymbol} to ${recipient}${modeLabel}${txModeLabel}...`); + + const result = await sphere.payments.send({ + recipient, + amount: amountSmallest, + coinId: coinIdHex, + addressMode, + transferMode, + }); + + if (result.status === 'completed' || result.status === 'submitted') { + console.log('\n✓ Transfer successful!'); + console.log(` Transfer ID: ${result.id}`); + console.log(` Status: ${result.status}`); + } else { + console.error('\n✗ Transfer failed:', result.error || result.status); + } + + // Wait for background tasks (e.g., change token creation from instant split) + await sphere.payments.waitForPendingOperations(); + await syncAfterWrite(sphere); + await closeSphere(); + break; + } + + case 'receive': { + const finalize = args.includes('--finalize'); + const noSyncRecv = args.includes('--no-sync'); + const sphere = await getSphere(); + + // Sync from Nostr to get incoming transfers + if (!noSyncRecv) await ensureSync(sphere, 'nostr'); + + const identity = sphere.identity; + + if (!identity) { + console.error('No wallet initialized.'); + process.exit(1); + } + + // Show addresses + console.log('\nReceive Address:'); + console.log('─'.repeat(50)); + console.log(`L3 (Direct): ${identity.directAddress || '(not available)'}`); + console.log(`L1 (ALPHA): ${identity.l1Address}`); + if (identity.nametag) { + console.log(`Nametag: @${identity.nametag}`); + } + console.log('─'.repeat(50)); + + // Fetch pending transfers + console.log('\nChecking for incoming transfers...'); + const registry = TokenRegistry.getInstance(); + const result = await sphere.payments.receive({ + finalize, + onProgress: (resolution) => { + if (resolution.stillPending > 0) { + console.log(` ${resolution.stillPending} token(s) still finalizing...`); + } + }, + }); + + if (result.transfers.length === 0) { + console.log('No new transfers found.'); + } else { + console.log(`\nReceived ${result.transfers.length} new transfer(s):`); + for (const transfer of result.transfers) { + for (const token of transfer.tokens) { + const def = registry.getDefinition(token.coinId); + const decimals = def?.decimals ?? token.decimals ?? 8; + const symbol = def?.symbol || token.symbol; + const formatted = toHumanReadable(token.amount, decimals); + const statusTag = token.status === 'confirmed' ? '' : ` [${token.status}]`; + console.log(` ${formatted} ${symbol}${statusTag}`); + } + } + } + + if (finalize && result.timedOut) { + console.log('\nWarning: finalization timed out, some tokens still unconfirmed.'); + } else if (finalize && result.finalizationDurationMs) { + console.log(`\nAll tokens finalized in ${(result.finalizationDurationMs / 1000).toFixed(1)}s.`); + } + + await syncAfterWrite(sphere); + await closeSphere(); + break; + } + + case 'history': { + const limitStr = (args[1] && !args[1].startsWith('--')) ? args[1] : '10'; + const limit = parseInt(limitStr); + const noSync = args.includes('--no-sync'); + + const sphere = await getSphere(); + if (!noSync) await ensureSync(sphere, 'full'); + const history = sphere.payments.getHistory(); + const limited = history.slice(0, limit); + + console.log(`\nTransaction History (last ${limit}):`) + console.log('─'.repeat(60)); + + if (limited.length === 0) { + console.log('No transactions found.'); + } else { + const registry = TokenRegistry.getInstance(); + for (const tx of limited) { + const date = new Date(tx.timestamp).toLocaleString(); + const direction = tx.type === 'SENT' ? '→' : '←'; + // Look up decimals from registry, default to 8 + const coinDef = registry.getDefinition(tx.coinId); + const decimals = coinDef?.decimals ?? 8; + const amount = toHumanReadable(tx.amount?.toString() || '0', decimals); + console.log(`${date} ${direction} ${amount} ${tx.symbol}`); + const counterparty = tx.type === 'SENT' ? tx.recipientNametag : tx.senderPubkey; + console.log(` ${tx.type === 'SENT' ? 'To' : 'From'}: ${counterparty || 'unknown'}`); + console.log(''); + } + } + console.log('─'.repeat(60)); + + await closeSphere(); + break; + } + + // === ADDRESSES === + case 'addresses': { + const sphere = await getSphere(); + const all = sphere.getAllTrackedAddresses(); + const currentIndex = sphere.getCurrentAddressIndex(); + + console.log('\nTracked Addresses:'); + console.log('─'.repeat(70)); + + if (all.length === 0) { + console.log('No tracked addresses.'); + } else { + for (const addr of all) { + const marker = addr.index === currentIndex ? '→ ' : ' '; + const hidden = addr.hidden ? ' [hidden]' : ''; + const tag = addr.nametag ? ` @${addr.nametag}` : ''; + console.log(`${marker}#${addr.index}: ${addr.l1Address}${tag}${hidden}`); + console.log(` DIRECT: ${addr.directAddress}`); + } + } + + console.log('─'.repeat(70)); + await closeSphere(); + break; + } + + case 'switch': { + const [, indexStr] = args; + if (!indexStr) { + console.error('Usage: switch '); + console.error(' index: HD address index (0, 1, 2, ...)'); + process.exit(1); + } + + const index = parseInt(indexStr); + if (isNaN(index) || index < 0) { + console.error('Invalid index. Must be a non-negative integer.'); + process.exit(1); + } + + const sphere = await getSphere(); + await sphere.switchToAddress(index); + + const identity = sphere.identity; + console.log(`\nSwitched to address #${index}`); + console.log(` L1: ${identity?.l1Address}`); + console.log(` DIRECT: ${identity?.directAddress}`); + console.log(` Nametag: ${identity?.nametag || '(not set)'}`); + + await closeSphere(); + break; + } + + case 'hide': { + const [, indexStr] = args; + if (!indexStr) { + console.error('Usage: hide '); + process.exit(1); + } + + const sphere = await getSphere(); + await sphere.setAddressHidden(parseInt(indexStr), true); + console.log(`Address #${indexStr} hidden.`); + await closeSphere(); + break; + } + + case 'unhide': { + const [, indexStr] = args; + if (!indexStr) { + console.error('Usage: unhide '); + process.exit(1); + } + + const sphere = await getSphere(); + await sphere.setAddressHidden(parseInt(indexStr), false); + console.log(`Address #${indexStr} unhidden.`); + await closeSphere(); + break; + } + + // === NAMETAGS === + case 'nametag': { + const [, name] = args; + if (!name) { + console.error('Usage: nametag '); + console.error(' name: desired nametag (without @)'); + process.exit(1); + } + + const cleanName = name.replace('@', ''); + const sphere = await getSphere(); + + console.log(`\nRegistering nametag @${cleanName}...`); + + try { + await sphere.registerNametag(cleanName); + console.log(`\n✓ Nametag @${cleanName} registered successfully!`); + } catch (err) { + console.error('\n✗ Registration failed:', err instanceof Error ? err.message : err); + } + + await closeSphere(); + break; + } + + case 'nametag-info': { + const [, name] = args; + if (!name) { + console.error('Usage: nametag-info '); + process.exit(1); + } + + const cleanName = name.replace('@', ''); + const sphere = await getSphere(); + + // Use transport provider to resolve nametag + const transport = (sphere as unknown as { _transport?: { resolveNametagInfo?: (n: string) => Promise } })._transport; + const info = await transport?.resolveNametagInfo?.(cleanName); + + if (info) { + console.log(`\nNametag Info: @${cleanName}`); + console.log('─'.repeat(50)); + console.log(JSON.stringify(info, null, 2)); + console.log('─'.repeat(50)); + } else { + console.log(`\nNametag @${cleanName} not found.`); + } + + await closeSphere(); + break; + } + + case 'my-nametag': { + const sphere = await getSphere(); + const identity = sphere.identity; + + if (identity?.nametag) { + console.log(`\nYour nametag: @${identity.nametag}`); + } else { + console.log('\nNo nametag registered.'); + console.log('Register one with: npm run cli -- nametag '); + } + + await closeSphere(); + break; + } + + case 'nametag-sync': { + // Force re-publish nametag binding with chainPubkey + // Useful for legacy nametags that were registered without chainPubkey + const sphere = await getSphere(); + const identity = sphere.identity; + + if (!identity?.nametag) { + console.error('\nNo nametag to sync.'); + console.error('Register one first with: npm run cli -- nametag '); + process.exit(1); + } + + console.log(`\nRe-publishing nametag @${identity.nametag} with chainPubkey...`); + + // Get transport provider and force re-publish + const transport = (sphere as unknown as { _transport?: { publishIdentityBinding?: (ck: string, l1: string, da: string, nt: string) => Promise } })._transport; + if (!transport?.publishIdentityBinding) { + console.error('Transport provider does not support identity binding'); + process.exit(1); + } + + try { + const success = await transport.publishIdentityBinding( + identity.chainPubkey, + identity.l1Address, + identity.directAddress || '', + identity.nametag, + ); + + if (success) { + console.log(`\n✓ Nametag @${identity.nametag} synced successfully!`); + console.log(` chainPubkey: ${identity.chainPubkey.slice(0, 16)}...`); + } else { + console.error('\n✗ Nametag sync failed. The nametag may be taken by another pubkey.'); + process.exit(1); + } + } catch (err) { + console.error('\n✗ Sync failed:', err instanceof Error ? err.message : err); + process.exit(1); + } + + await closeSphere(); + break; + } + + // === ENCRYPTION === + case 'encrypt': { + const [, data, password] = args; + if (!data || !password) { + console.error('Usage: encrypt '); + process.exit(1); + } + const result = encrypt(data, password); + console.log(JSON.stringify(result, null, 2)); + break; + } + + case 'decrypt': { + const [, encrypted, password] = args; + if (!encrypted || !password) { + console.error('Usage: decrypt '); + process.exit(1); + } + const encryptedData = JSON.parse(encrypted); + const result = decrypt(encryptedData, password); + console.log(result); + break; + } + + // === WALLET PARSING === + case 'parse-wallet': { + const [, filePath, password] = args; + if (!filePath) { + console.error('Usage: parse-wallet [password]'); + process.exit(1); + } + + if (!fs.existsSync(filePath)) { + console.error('File not found:', filePath); + process.exit(1); + } + + if (filePath.endsWith('.dat')) { + const data = fs.readFileSync(filePath); + if (!isSQLiteDatabase(data)) { + console.error('Not a valid wallet.dat (SQLite) file'); + process.exit(1); + } + const result = parseWalletDat(data); + console.log(JSON.stringify(result, null, 2)); + } else { + const content = fs.readFileSync(filePath, 'utf8'); + const isEncrypted = isTextWalletEncrypted(content); + + if (isEncrypted && !password) { + console.log('Wallet is encrypted. Provide password: parse-wallet '); + process.exit(0); + } + + const result = password + ? parseAndDecryptWalletText(content, password) + : parseWalletText(content); + console.log(JSON.stringify(result, null, 2)); + } + break; + } + + case 'wallet-info': { + const [, filePath] = args; + if (!filePath) { + console.error('Usage: wallet-info '); + process.exit(1); + } + + if (!fs.existsSync(filePath)) { + console.error('File not found:', filePath); + process.exit(1); + } + + const info: Record = { file: filePath }; + + if (filePath.endsWith('.dat')) { + const data = fs.readFileSync(filePath); + info.format = 'dat'; + info.isSQLite = isSQLiteDatabase(data); + info.isEncrypted = isWalletDatEncrypted(data); + } else if (filePath.endsWith('.txt')) { + const content = fs.readFileSync(filePath, 'utf8'); + info.format = 'txt'; + info.isEncrypted = isTextWalletEncrypted(content); + } else if (filePath.endsWith('.json')) { + const content = JSON.parse(fs.readFileSync(filePath, 'utf8')); + info.format = 'json'; + info.isEncrypted = !!content.encrypted; + info.hasChainCode = !!content.chainCode; + } + + console.log(JSON.stringify(info, null, 2)); + break; + } + + // === KEY OPERATIONS === + case 'generate-key': { + const privateKey = generatePrivateKey(); + const publicKey = getPublicKey(privateKey); + const wif = hexToWIF(privateKey); + const addressInfo = generateAddressFromMasterKey(privateKey, 0); + + if (args.includes('--unsafe-print')) { + console.log(JSON.stringify({ + privateKey, + publicKey, + wif, + address: addressInfo.address, + }, null, 2)); + } else { + console.log(`Public Key: ${publicKey}`); + console.log(`Address: ${addressInfo.address}`); + console.log('(private key and WIF hidden — use --unsafe-print to display)'); + } + break; + } + + case 'validate-key': { + const [, hex] = args; + if (!hex) { + console.error('Usage: validate-key '); + process.exit(1); + } + const valid = isValidPrivateKey(hex); + console.log(JSON.stringify({ valid, length: hex.length })); + process.exit(valid ? 0 : 1); + break; + } + + case 'hex-to-wif': { + const [, hex] = args; + if (!hex) { + console.error('Usage: hex-to-wif '); + process.exit(1); + } + console.log(hexToWIF(hex)); + break; + } + + case 'derive-pubkey': { + const [, privateKey] = args; + if (!privateKey) { + console.error('Usage: derive-pubkey '); + process.exit(1); + } + const publicKey = getPublicKey(privateKey); + console.log(publicKey); + break; + } + + case 'derive-address': { + const [, privateKey, index = '0'] = args; + if (!privateKey) { + console.error('Usage: derive-address [index]'); + console.error('Index: address derivation index (default: 0)'); + process.exit(1); + } + const addressInfo = generateAddressFromMasterKey(privateKey, parseInt(index)); + console.log(addressInfo.address); + break; + } + + // === CURRENCY === + case 'to-smallest': { + const [, amount] = args; + if (!amount) { + console.error('Usage: to-smallest '); + process.exit(1); + } + const coinArgSmallest: string | undefined = (args[2] && !args[2].startsWith('--')) ? args[2] : undefined; + let decimalsSmallest = 8; + if (coinArgSmallest) { + await getSphere(); + decimalsSmallest = resolveCoin(coinArgSmallest).decimals; + await closeSphere(); + } + console.log(toSmallestUnit(amount, decimalsSmallest)); + break; + } + + case 'to-human': { + const [, amount] = args; + if (!amount) { + console.error('Usage: to-human '); + process.exit(1); + } + const coinArgHuman: string | undefined = (args[2] && !args[2].startsWith('--')) ? args[2] : undefined; + let decimalsHuman = 8; + if (coinArgHuman) { + await getSphere(); + decimalsHuman = resolveCoin(coinArgHuman).decimals; + await closeSphere(); + } + console.log(toHumanReadable(amount, decimalsHuman)); + break; + } + + case 'format': { + const [, amount, decimals = '8'] = args; + if (!amount) { + console.error('Usage: format [decimals]'); + process.exit(1); + } + console.log(formatAmount(amount, { decimals: parseInt(decimals) })); + break; + } + + // === ENCODING === + case 'base58-encode': { + const [, hex] = args; + if (!hex) { + console.error('Usage: base58-encode '); + process.exit(1); + } + console.log(base58Encode(hex)); + break; + } + + case 'base58-decode': { + const [, str] = args; + if (!str) { + console.error('Usage: base58-decode '); + process.exit(1); + } + const bytes = base58Decode(str); + console.log(Buffer.from(bytes).toString('hex')); + break; + } + + // === FAUCET / TOPUP === + case 'topup': + case 'top-up': + case 'faucet': { + // Get nametag from wallet + const sphere = await getSphere(); + const nametag = sphere.getNametag(); + if (!nametag) { + console.error('Error: No nametag registered. Use "nametag " first.'); + await closeSphere(); + process.exit(1); + } + + // Parse options: topup [ ] + const amountArg: string | undefined = args[1]; + const coinArg: string | undefined = args[2]; + + const FAUCET_URL = 'https://faucet.unicity.network/api/v1/faucet/request'; + + // Default amounts in whole coins (the faucet API converts to smallest units internally). + const DEFAULT_COINS: Record = { + 'unicity': 100, + 'bitcoin': 1, + 'ethereum': 42, + 'solana': 1000, + 'tether': 1000, + 'usd-coin': 1000, + 'unicity-usd': 1000, + }; + + async function requestFaucet(coin: string, amount: number): Promise<{ success: boolean; message?: string }> { + try { + const response = await fetch(FAUCET_URL, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + unicityId: nametag, // Without @ prefix - faucet API expects raw nametag + coin, + amount, + }), + }); + const result = await response.json() as { success: boolean; message?: string; error?: string }; + // API returns 'error' field on failure, normalize to 'message' + return { + success: result.success, + message: result.message || result.error, + }; + } catch (error) { + return { success: false, message: error instanceof Error ? error.message : 'Request failed' }; + } + } + + if (coinArg) { + // Request specific coin — accept symbols (UCT, BTC) as well as faucet names (unicity, bitcoin) + const coin = FAUCET_COIN_MAP[coinArg.toUpperCase()] || coinArg.toLowerCase(); + const amount = amountArg ? parseFloat(amountArg) : (DEFAULT_COINS[coin] || 1); + + // The faucet API converts whole-coin amounts to smallest units internally. + console.log(`Requesting ${amount} ${coin} from faucet for @${nametag}...`); + const result = await requestFaucet(coin, amount); + + if (result.success) { + console.log(`\n✓ Received ${amount} ${coin}`); + } else { + console.error(`\n✗ Failed: ${result.message || 'Unknown error'}`); + } + } else { + // Request all coins + console.log(`Requesting all test tokens for @${nametag}...`); + console.log('─'.repeat(50)); + + const results = await Promise.all( + Object.entries(DEFAULT_COINS).map(async ([coin, amount]) => { + const result = await requestFaucet(coin, amount); + return { coin, amount, ...result }; + }) + ); + + for (const result of results) { + if (result.success) { + console.log(`✓ ${result.coin}: ${result.amount}`); + } else { + console.log(`✗ ${result.coin}: Failed - ${result.message || 'Unknown error'}`); + } + } + + console.log('─'.repeat(50)); + console.log('TopUp complete! Run "balance" to see updated balances.'); + } + + await closeSphere(); + break; + } + + // === MESSAGING (Direct Messages) === + case 'dm': { + const [, recipient, ...messageParts] = args; + // Collect message: everything after recipient that isn't a flag + const message = messageParts.filter(p => !p.startsWith('--')).join(' '); + if (!recipient || !message) { + console.error('Usage: dm <@nametag|pubkey> '); + console.error(' Example: npm run cli -- dm @alice "Hello!"'); + process.exit(1); + } + + const sphere = await getSphere(); + const dm = await sphere.communications.sendDM(recipient, message); + console.log(`\n✓ Message sent to ${recipient}`); + console.log(` ID: ${dm.id}`); + console.log(` Time: ${new Date(dm.timestamp).toLocaleString()}`); + await closeSphere(); + break; + } + + case 'dm-inbox': { + const sphere = await getSphere(); + await ensureSync(sphere, 'nostr'); + const conversations = sphere.communications.getConversations(); + const totalUnread = sphere.communications.getUnreadCount(); + + console.log(`\nInbox (${conversations.size} conversation${conversations.size !== 1 ? 's' : ''}, ${totalUnread} unread):`); + console.log('─'.repeat(60)); + + if (conversations.size === 0) { + console.log('No conversations found.'); + } else { + for (const [peerPubkey, messages] of conversations) { + const unread = sphere.communications.getUnreadCount(peerPubkey); + const lastMsg = messages[messages.length - 1]; + const time = new Date(lastMsg.timestamp).toLocaleString(); + const peerLabel = lastMsg.senderPubkey === peerPubkey + ? (lastMsg.senderNametag ? `@${lastMsg.senderNametag}` : peerPubkey.slice(0, 16) + '...') + : peerPubkey.slice(0, 16) + '...'; + const unreadBadge = unread > 0 ? ` [${unread} unread]` : ''; + const preview = lastMsg.content.length > 60 + ? lastMsg.content.slice(0, 60) + '...' + : lastMsg.content; + + console.log(`${peerLabel}${unreadBadge}`); + console.log(` Last: ${preview}`); + console.log(` Time: ${time}`); + console.log(''); + } + } + console.log('─'.repeat(60)); + await closeSphere(); + break; + } + + case 'dm-history': { + const [, peer] = args; + if (!peer) { + console.error('Usage: dm-history <@nametag|pubkey> [--limit ]'); + console.error(' Example: npm run cli -- dm-history @alice --limit 20'); + process.exit(1); + } + + const limitIndex = args.indexOf('--limit'); + const limit = limitIndex !== -1 && args[limitIndex + 1] ? parseInt(args[limitIndex + 1]) : 50; + + const sphere = await getSphere(); + await ensureSync(sphere, 'nostr'); + + // Resolve @nametag to pubkey if needed + let peerPubkey = peer; + if (peer.startsWith('@')) { + const resolved = await sphere.resolve(peer); + if (!resolved) { + console.error(`Could not resolve ${peer}`); + process.exit(1); + } + peerPubkey = resolved.chainPubkey; + } + + const messages = sphere.communications.getConversation(peerPubkey); + const limited = messages.slice(-limit); + const myPubkey = sphere.identity?.chainPubkey; + + console.log(`\nConversation with ${peer} (${limited.length} message${limited.length !== 1 ? 's' : ''}):`); + console.log('─'.repeat(60)); + + if (limited.length === 0) { + console.log('No messages found.'); + } else { + for (const msg of limited) { + const time = new Date(msg.timestamp).toLocaleString(); + const isMe = msg.senderPubkey === myPubkey; + const sender = isMe ? 'You' : (msg.senderNametag ? `@${msg.senderNametag}` : msg.senderPubkey.slice(0, 12) + '...'); + console.log(`[${time}] ${sender}: ${msg.content}`); + } + } + console.log('─'.repeat(60)); + await closeSphere(); + break; + } + + // === GROUP CHAT (NIP-29) === + case 'group-create': { + const groupName = args[1]; + if (!groupName) { + console.error('Usage: group-create [--description ] [--private]'); + console.error(' Example: npm run cli -- group-create "Trading Chat" --description "Discuss trades"'); + process.exit(1); + } + + const descIndex = args.indexOf('--description'); + const description = descIndex !== -1 && args[descIndex + 1] ? args[descIndex + 1] : undefined; + const isPrivate = args.includes('--private'); + + const sphere = await getSphere(); + + if (!sphere.groupChat) { + console.error('Group chat module not available.'); + process.exit(1); + } + + await sphere.groupChat.connect(); + const group = await sphere.groupChat.createGroup({ + name: groupName, + description, + visibility: isPrivate ? 'PRIVATE' : 'PUBLIC', + }); + + if (group) { + console.log('\n✓ Group created!'); + console.log(` ID: ${group.id}`); + console.log(` Name: ${group.name}`); + console.log(` Visibility: ${group.visibility}`); + if (group.description) console.log(` Description: ${group.description}`); + } else { + console.error('\n✗ Failed to create group.'); + } + + await closeSphere(); + break; + } + + case 'group-list': { + const sphere = await getSphere(); + + if (!sphere.groupChat) { + console.error('Group chat module not available.'); + process.exit(1); + } + + await sphere.groupChat.connect(); + const groups = await sphere.groupChat.fetchAvailableGroups(); + + console.log(`\nAvailable Groups (${groups.length}):`); + console.log('─'.repeat(60)); + + if (groups.length === 0) { + console.log('No groups found on relay.'); + } else { + for (const group of groups) { + console.log(`${group.name} [${group.visibility}]`); + console.log(` ID: ${group.id}`); + if (group.description) console.log(` Description: ${group.description}`); + if (group.memberCount != null) console.log(` Members: ${group.memberCount}`); + console.log(''); + } + } + console.log('─'.repeat(60)); + await closeSphere(); + break; + } + + case 'group-my': { + const sphere = await getSphere(); + + if (!sphere.groupChat) { + console.error('Group chat module not available.'); + process.exit(1); + } + + await sphere.groupChat.connect(); + const groups = sphere.groupChat.getGroups(); + + console.log(`\nYour Groups (${groups.length}):`); + console.log('─'.repeat(60)); + + if (groups.length === 0) { + console.log('You have not joined any groups.'); + } else { + for (const group of groups) { + const unreadBadge = (group.unreadCount || 0) > 0 ? ` [${group.unreadCount} unread]` : ''; + console.log(`${group.name}${unreadBadge}`); + console.log(` ID: ${group.id}`); + if (group.lastMessageText) { + const preview = group.lastMessageText.length > 60 + ? group.lastMessageText.slice(0, 60) + '...' + : group.lastMessageText; + console.log(` Last: ${preview}`); + } + console.log(''); + } + } + console.log('─'.repeat(60)); + await closeSphere(); + break; + } + + case 'group-join': { + const groupId = args[1]; + if (!groupId) { + console.error('Usage: group-join [--invite ]'); + console.error(' Example: npm run cli -- group-join tradingchat'); + process.exit(1); + } + + const inviteIndex = args.indexOf('--invite'); + const inviteCode = inviteIndex !== -1 && args[inviteIndex + 1] ? args[inviteIndex + 1] : undefined; + + const sphere = await getSphere(); + + if (!sphere.groupChat) { + console.error('Group chat module not available.'); + process.exit(1); + } + + await sphere.groupChat.connect(); + const success = await sphere.groupChat.joinGroup(groupId, inviteCode); + + if (success) { + console.log(`\n✓ Joined group: ${groupId}`); + } else { + console.error(`\n✗ Failed to join group: ${groupId}`); + } + + await closeSphere(); + break; + } + + case 'group-leave': { + const groupId = args[1]; + if (!groupId) { + console.error('Usage: group-leave '); + console.error(' Example: npm run cli -- group-leave tradingchat'); + process.exit(1); + } + + const sphere = await getSphere(); + + if (!sphere.groupChat) { + console.error('Group chat module not available.'); + process.exit(1); + } + + await sphere.groupChat.connect(); + const success = await sphere.groupChat.leaveGroup(groupId); + + if (success) { + console.log(`\n✓ Left group: ${groupId}`); + } else { + console.error(`\n✗ Failed to leave group: ${groupId}`); + } + + await closeSphere(); + break; + } + + case 'group-send': { + const groupId = args[1]; + // Collect message: everything after groupId that isn't a flag or flag value + const msgParts: string[] = []; + let skipNext = false; + for (let i = 2; i < args.length; i++) { + if (skipNext) { skipNext = false; continue; } + if (args[i] === '--reply') { skipNext = true; continue; } + if (args[i].startsWith('--')) continue; + msgParts.push(args[i]); + } + const msgContent = msgParts.join(' '); + + if (!groupId || !msgContent) { + console.error('Usage: group-send [--reply ]'); + console.error(' Example: npm run cli -- group-send tradingchat "Hello everyone!"'); + process.exit(1); + } + + const replyIndex = args.indexOf('--reply'); + const replyToId = replyIndex !== -1 && args[replyIndex + 1] ? args[replyIndex + 1] : undefined; + + const sphere = await getSphere(); + + if (!sphere.groupChat) { + console.error('Group chat module not available.'); + process.exit(1); + } + + await sphere.groupChat.connect(); + const sent = await sphere.groupChat.sendMessage(groupId, msgContent, replyToId); + + if (sent) { + console.log('\n✓ Message sent!'); + console.log(` ID: ${sent.id}`); + console.log(` Group: ${groupId}`); + } else { + console.error('\n✗ Failed to send message.'); + } + + await closeSphere(); + break; + } + + case 'group-messages': { + const groupId = args[1]; + if (!groupId) { + console.error('Usage: group-messages [--limit ]'); + console.error(' Example: npm run cli -- group-messages tradingchat --limit 20'); + process.exit(1); + } + + const limitIndex = args.indexOf('--limit'); + const limit = limitIndex !== -1 && args[limitIndex + 1] ? parseInt(args[limitIndex + 1]) : 50; + + const sphere = await getSphere(); + + if (!sphere.groupChat) { + console.error('Group chat module not available.'); + process.exit(1); + } + + await sphere.groupChat.connect(); + + // Fetch latest from relay + await sphere.groupChat.fetchMessages(groupId, undefined, limit); + + // Get from local state (sorted) + const messages = sphere.groupChat.getMessages(groupId).slice(-limit); + + const group = sphere.groupChat.getGroup(groupId); + const groupLabel = group?.name || groupId; + + console.log(`\nMessages in "${groupLabel}" (${messages.length}):`); + console.log('─'.repeat(60)); + + if (messages.length === 0) { + console.log('No messages found.'); + } else { + for (const msg of messages) { + const time = new Date(msg.timestamp).toLocaleString(); + const sender = msg.senderNametag ? `@${msg.senderNametag}` : msg.senderPubkey.slice(0, 12) + '...'; + const replyTag = msg.replyToId ? ` (reply)` : ''; + console.log(`[${time}] ${sender}${replyTag}: ${msg.content}`); + } + } + console.log('─'.repeat(60)); + + // Mark as read + sphere.groupChat.markGroupAsRead(groupId); + await closeSphere(); + break; + } + + case 'group-members': { + const groupId = args[1]; + if (!groupId) { + console.error('Usage: group-members '); + console.error(' Example: npm run cli -- group-members tradingchat'); + process.exit(1); + } + + const sphere = await getSphere(); + + if (!sphere.groupChat) { + console.error('Group chat module not available.'); + process.exit(1); + } + + await sphere.groupChat.connect(); + const members = sphere.groupChat.getMembers(groupId); + const group = sphere.groupChat.getGroup(groupId); + const groupLabel = group?.name || groupId; + + console.log(`\nMembers of "${groupLabel}" (${members.length}):`); + console.log('─'.repeat(60)); + + if (members.length === 0) { + console.log('No members found. Try joining the group first.'); + } else { + for (const member of members) { + const label = member.nametag ? `@${member.nametag}` : member.pubkey.slice(0, 16) + '...'; + const roleBadge = member.role === 'ADMIN' ? ' [ADMIN]' : member.role === 'MODERATOR' ? ' [MOD]' : ''; + const joined = new Date(member.joinedAt).toLocaleDateString(); + console.log(` ${label}${roleBadge} (joined ${joined})`); + } + } + console.log('─'.repeat(60)); + await closeSphere(); + break; + } + + case 'group-info': { + const groupId = args[1]; + if (!groupId) { + console.error('Usage: group-info '); + console.error(' Example: npm run cli -- group-info tradingchat'); + process.exit(1); + } + + const sphere = await getSphere(); + + if (!sphere.groupChat) { + console.error('Group chat module not available.'); + process.exit(1); + } + + await sphere.groupChat.connect(); + const group = sphere.groupChat.getGroup(groupId); + + if (!group) { + console.error(`Group "${groupId}" not found. You may need to join it first.`); + process.exit(1); + } + + const myRole = sphere.groupChat.getCurrentUserRole(groupId); + const members = sphere.groupChat.getMembers(groupId); + + console.log('\nGroup Info:'); + console.log('─'.repeat(50)); + console.log(`ID: ${group.id}`); + console.log(`Name: ${group.name}`); + console.log(`Visibility: ${group.visibility}`); + if (group.description) console.log(`Description: ${group.description}`); + console.log(`Members: ${members.length}`); + console.log(`Created: ${new Date(group.createdAt).toLocaleString()}`); + if (group.lastMessageTime) console.log(`Last Active: ${new Date(group.lastMessageTime).toLocaleString()}`); + if (myRole) console.log(`Your Role: ${myRole}`); + console.log(`Relay: ${group.relayUrl}`); + console.log('─'.repeat(50)); + await closeSphere(); + break; + } + + // === MARKET (Intent Bulletin Board) === + case 'market-post': { + const description = args[1]; + if (!description) { + console.error('Usage: market-post --type [--category ] [--price ] [--currency ] [--location ] [--contact ] [--expires ]'); + process.exit(1); + } + + const typeIndex = args.indexOf('--type'); + const intentType = typeIndex !== -1 ? args[typeIndex + 1] : undefined; + if (!intentType) { + console.error('Error: --type is required (buy, sell, service, announcement, other)'); + process.exit(1); + } + + const categoryIndex = args.indexOf('--category'); + const category = categoryIndex !== -1 ? args[categoryIndex + 1] : undefined; + + const priceIndex = args.indexOf('--price'); + const price = priceIndex !== -1 ? parseFloat(args[priceIndex + 1]) : undefined; + + const currencyIndex = args.indexOf('--currency'); + const currency = currencyIndex !== -1 ? args[currencyIndex + 1] : undefined; + + const locationIndex = args.indexOf('--location'); + const location = locationIndex !== -1 ? args[locationIndex + 1] : undefined; + + const contactIndex = args.indexOf('--contact'); + const contactHandle = contactIndex !== -1 ? args[contactIndex + 1] : undefined; + + const expiresIndex = args.indexOf('--expires'); + const expiresInDays = expiresIndex !== -1 ? parseInt(args[expiresIndex + 1]) : undefined; + + const sphere = await getSphere(); + + if (!sphere.market) { + console.error('Market module not available.'); + process.exit(1); + } + + const result = await sphere.market.postIntent({ + description, + intentType, + category, + price, + currency, + location, + contactHandle, + expiresInDays, + }); + + console.log('✓ Intent posted!'); + console.log(` ID: ${result.intentId}`); + console.log(` Expires: ${result.expiresAt}`); + + await closeSphere(); + break; + } + + case 'market-search': { + const query = args[1]; + if (!query) { + console.error('Usage: market-search [--type ] [--category ] [--min-price ] [--max-price ] [--min-score <0-1>] [--location ] [--limit ]'); + process.exit(1); + } + + const typeIndex = args.indexOf('--type'); + const intentType = typeIndex !== -1 ? args[typeIndex + 1] : undefined; + + const categoryIndex = args.indexOf('--category'); + const category = categoryIndex !== -1 ? args[categoryIndex + 1] : undefined; + + const minPriceIndex = args.indexOf('--min-price'); + const minPrice = minPriceIndex !== -1 ? parseFloat(args[minPriceIndex + 1]) : undefined; + + const maxPriceIndex = args.indexOf('--max-price'); + const maxPrice = maxPriceIndex !== -1 ? parseFloat(args[maxPriceIndex + 1]) : undefined; + + const minScoreIndex = args.indexOf('--min-score'); + const minScore = minScoreIndex !== -1 ? parseFloat(args[minScoreIndex + 1]) : undefined; + + const locationIndex = args.indexOf('--location'); + const location = locationIndex !== -1 ? args[locationIndex + 1] : undefined; + + const limitIndex = args.indexOf('--limit'); + const limit = limitIndex !== -1 ? parseInt(args[limitIndex + 1]) : 10; + + const sphere = await getSphere(); + + if (!sphere.market) { + console.error('Market module not available.'); + process.exit(1); + } + + const result = await sphere.market.search(query, { + filters: { + intentType, + category, + minPrice, + maxPrice, + minScore, + location, + }, + limit, + }); + + console.log(`Found ${result.count} intent(s):`); + console.log('─'.repeat(50)); + + for (const intent of result.intents) { + const scoreStr = intent.score != null ? `[${intent.score.toFixed(2)}] ` : ''; + console.log(`${scoreStr}${intent.description}`); + const byStr = intent.agentNametag ? `@${intent.agentNametag}` : intent.agentPublicKey.slice(0, 12) + '...'; + console.log(` By: ${byStr}`); + let details = ` Type: ${intent.intentType}`; + if (intent.category) details += ` | Category: ${intent.category}`; + if (intent.price != null) details += ` | Price: ${intent.price} ${intent.currency || ''}`; + console.log(details); + let extra = ''; + if (intent.contactHandle) extra += ` Contact: ${intent.contactHandle}`; + if (intent.expiresAt) extra += `${extra ? ' | ' : ' '}Expires: ${intent.expiresAt.split('T')[0]}`; + if (extra) console.log(extra); + console.log('─'.repeat(50)); + } + + await closeSphere(); + break; + } + + case 'market-my': { + const sphere = await getSphere(); + + if (!sphere.market) { + console.error('Market module not available.'); + process.exit(1); + } + + const intents = await sphere.market.getMyIntents(); + + console.log(`Your intents (${intents.length}):`); + for (const intent of intents) { + const desc = intent.id; + const cat = intent.category || ''; + const expires = intent.expiresAt ? intent.expiresAt.split('T')[0] : ''; + console.log(` ${desc} ${intent.intentType} ${intent.status} ${cat} expires ${expires}`); + } + + await closeSphere(); + break; + } + + case 'market-close': { + const intentId = args[1]; + if (!intentId) { + console.error('Usage: market-close '); + process.exit(1); + } + + const sphere = await getSphere(); + + if (!sphere.market) { + console.error('Market module not available.'); + process.exit(1); + } + + await sphere.market.closeIntent(intentId); + console.log(`✓ Intent ${intentId} closed.`); + + await closeSphere(); + break; + } + + case 'market-feed': { + const useRest = args.includes('--rest'); + const sphere = await getSphere(); + + if (!sphere.market) { + console.error('Market module not available.'); + process.exit(1); + } + + if (useRest) { + // REST fallback: fetch recent listings once + const listings = await sphere.market.getRecentListings(); + console.log(`Recent listings (${listings.length}):`); + console.log('─'.repeat(50)); + for (const listing of listings) { + console.log(`[${listing.type.toUpperCase()}] ${listing.agentName}: ${listing.title}`); + if (listing.descriptionPreview !== listing.title) { + console.log(` ${listing.descriptionPreview}`); + } + console.log(` ID: ${listing.id} Posted: ${listing.createdAt}`); + console.log(''); + } + await closeSphere(); + } else { + // WebSocket live feed + console.log('Connecting to live feed... (Ctrl+C to stop)'); + const unsubscribe = sphere.market.subscribeFeed((message) => { + if (message.type === 'initial') { + console.log(`Connected — ${message.listings.length} recent listing(s):`); + console.log('─'.repeat(50)); + for (const listing of message.listings) { + console.log(`[${listing.type.toUpperCase()}] ${listing.agentName}: ${listing.title}`); + } + console.log('─'.repeat(50)); + console.log('Watching for new listings...\n'); + } else { + const l = message.listing; + console.log(`[NEW] [${l.type.toUpperCase()}] ${l.agentName}: ${l.title}`); + if (l.descriptionPreview !== l.title) { + console.log(` ${l.descriptionPreview}`); + } + } + }); + + // Keep alive until Ctrl+C + process.on('SIGINT', () => { + console.log('\nDisconnecting...'); + unsubscribe(); + closeSphere().then(() => process.exit(0)); + }); + + // Prevent the process from exiting + await new Promise(() => {}); + } + break; + } + + // ================================================================= + // INVOICE MANAGEMENT (AccountingModule) + // ================================================================= + + case 'invoice-create': { + const sphere = await getSphere(); + if (!sphere.accounting) { + console.error('Accounting module not enabled. Initialize with accounting support.'); + process.exit(1); + } + + // Parse options + const targetIdx = args.indexOf('--target'); + const assetIdx = args.indexOf('--asset'); + const nftIdx = args.indexOf('--nft'); + const dueIdx = args.indexOf('--due'); + const memoIdx = args.indexOf('--memo'); + const deliveryIdx = args.indexOf('--delivery'); + const termsIdx = args.indexOf('--terms'); + + if (termsIdx !== -1 && args[termsIdx + 1]) { + // Load terms from JSON file + const termsFile = args[termsIdx + 1]; + let termsJson: unknown; + try { + const resolvedPath = path.resolve(termsFile); + const raw = fs.readFileSync(resolvedPath, 'utf8'); + termsJson = JSON.parse(raw); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + // Sanitize: only show whether it was a file read or JSON parse error + if (msg.includes('ENOENT')) { + console.error(`File not found: "${termsFile}"`); + } else if (msg.includes('EACCES') || msg.includes('EPERM')) { + console.error(`Access denied: "${termsFile}"`); + } else if (msg.includes('Unexpected token') || msg.includes('JSON')) { + console.error(`Invalid JSON in terms file "${termsFile}"`); + } else { + console.error(`Failed to read terms file "${termsFile}"`); + } + process.exit(1); + } + let result; + try { + result = await sphere.accounting.createInvoice(termsJson as CreateInvoiceRequest); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + console.error(`Failed to create invoice from terms file: ${msg}`); + process.exit(1); + } + console.log('Invoice created:'); + console.log(JSON.stringify(result, null, 2)); + } else { + // Build from individual options + if (targetIdx === -1 || !args[targetIdx + 1]) { + console.error('Usage: invoice-create --target
--asset " " [--nft ] [--due ] [--memo ] [--delivery ] [--terms ]'); + process.exit(1); + } + const targetAddress = args[targetIdx + 1]; + const nftId = nftIdx !== -1 ? args[nftIdx + 1] : undefined; + const dueDate = dueIdx !== -1 ? new Date(args[dueIdx + 1]).getTime() : undefined; + if (dueDate !== undefined && isNaN(dueDate)) { + console.error('Invalid due date format. Use ISO-8601, e.g. 2026-12-31'); + process.exit(1); + } + const memo = memoIdx !== -1 ? args[memoIdx + 1] : undefined; + const delivery = deliveryIdx !== -1 ? args[deliveryIdx + 1] : undefined; + + const assets: InvoiceRequestedAsset[] = []; + if (assetIdx !== -1 && args[assetIdx + 1]) { + const parsed = parseAssetArg(args[assetIdx + 1]); + if (!/^[1-9][0-9]*$/.test(parsed.amount)) { + console.error(`Invalid amount "${parsed.amount}" — must be a positive integer in smallest units (no decimals, no leading zeros)`); + process.exit(1); + } + const { coinId: resolvedCoinId } = resolveCoin(parsed.coin); + assets.push({ coin: [resolvedCoinId, parsed.amount] }); + } else if (nftId) { + assets.push({ nft: { tokenId: nftId } }); + } + + const request: CreateInvoiceRequest = { + targets: [{ address: targetAddress, assets }], + dueDate, + memo, + deliveryMethods: delivery ? [delivery] : undefined, + }; + const result = await sphere.accounting.createInvoice(request); + console.log('Invoice created:'); + console.log(JSON.stringify(result, null, 2)); + } + + await syncAfterWrite(sphere); + await closeSphere(); + break; + } + + case 'invoice-import': { + const tokenFile = args[1]; + if (!tokenFile) { + console.error('Usage: invoice-import '); + process.exit(1); + } + + const sphere = await getSphere(); + if (!sphere.accounting) { + console.error('Accounting module not enabled.'); + process.exit(1); + } + + let tokenJson: unknown; + try { + tokenJson = JSON.parse(fs.readFileSync(path.resolve(tokenFile), 'utf8')); + } catch (err: unknown) { + // W23-R2 fix: Sanitize error messages to avoid leaking file system paths + const code = (err as NodeJS.ErrnoException)?.code; + if (code === 'ENOENT') { + console.error(`Token file not found: "${tokenFile}"`); + } else if (code === 'EACCES') { + console.error(`Permission denied reading: "${tokenFile}"`); + } else if (err instanceof SyntaxError) { + console.error(`Invalid JSON in token file: "${tokenFile}"`); + } else { + console.error(`Failed to read token file: "${tokenFile}"`); + } + process.exit(1); + } + + let terms; + try { + terms = await sphere.accounting.importInvoice(tokenJson as TxfToken); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + console.error(`Failed to import invoice: ${msg}`); + process.exit(1); + } + console.log('Invoice imported:'); + console.log(JSON.stringify(terms, null, 2)); + + await closeSphere(); + break; + } + + case 'invoice-list': { + const sphere = await getSphere(); + if (!sphere.accounting) { + console.error('Accounting module not enabled.'); + process.exit(1); + } + await ensureSync(sphere, 'nostr'); + + const stateIdx = args.indexOf('--state'); + const limitIdx2 = args.indexOf('--limit'); + const createdByMe = args.includes('--role') && args[args.indexOf('--role') + 1] === 'creator'; + const targetingMe = args.includes('--role') && args[args.indexOf('--role') + 1] === 'payer'; + const stateFilter = stateIdx !== -1 ? args[stateIdx + 1] : undefined; + + const validStates = new Set(['OPEN', 'PARTIAL', 'COVERED', 'CLOSED', 'CANCELLED', 'EXPIRED']); + const optionsMut: Record = {}; + if (createdByMe) optionsMut['createdByMe'] = true; + if (targetingMe) optionsMut['targetingMe'] = true; + if (stateFilter) { + const stateValues = stateFilter.split(',').map(s => s.trim()); + const invalid = stateValues.filter(s => !validStates.has(s)); + if (invalid.length > 0) { + console.error(`Invalid state(s): ${invalid.join(', ')}. Valid: ${[...validStates].join(', ')}`); + process.exit(1); + } + optionsMut['state'] = stateValues.length === 1 ? stateValues[0] : stateValues; + } + if (limitIdx2 !== -1 && args[limitIdx2 + 1]) { + const limit = parseInt(args[limitIdx2 + 1]!, 10); + if (!Number.isNaN(limit) && limit > 0) { + optionsMut['limit'] = limit; + } + } + const options = optionsMut as GetInvoicesOptions; + + const invoices = await sphere.accounting.getInvoices(options); + + if (invoices.length === 0) { + console.log('No invoices found.'); + } else { + console.log(`Invoices (${invoices.length}):`); + console.log('─'.repeat(60)); + for (const inv of invoices) { + console.log(`ID: ${inv.invoiceId}`); + console.log(`Creator: ${inv.isCreator ? 'yes' : 'no'}`); + console.log(`Cancelled: ${inv.cancelled}`); + console.log(`Closed: ${inv.closed}`); + if (inv.terms.dueDate) console.log(`Due: ${new Date(inv.terms.dueDate).toISOString()}`); + if (inv.terms.memo) console.log(`Memo: ${inv.terms.memo}`); + console.log('─'.repeat(60)); + } + } + + await closeSphere(); + break; + } + + case 'invoice-status': { + const idOrPrefix = args[1]; + if (!idOrPrefix) { + console.error('Usage: invoice-status '); + process.exit(1); + } + + const sphere = await getSphere(); + if (!sphere.accounting) { + console.error('Accounting module not enabled.'); + process.exit(1); + } + await ensureSync(sphere, 'nostr'); + + // Resolve ID from prefix + const allInvoices = await sphere.accounting.getInvoices(); + const matched = allInvoices.filter(inv => inv.invoiceId.startsWith(idOrPrefix)); + if (matched.length === 0) { + console.error(`No invoice found matching prefix: ${idOrPrefix}`); + process.exit(1); + } + if (matched.length > 1) { + console.error(`Ambiguous prefix "${idOrPrefix}" matches ${matched.length} invoices. Use more characters.`); + process.exit(1); + } + const invoiceId = matched[0].invoiceId; + + const status = await sphere.accounting.getInvoiceStatus(invoiceId); + console.log('Invoice Status:'); + console.log(JSON.stringify(status, null, 2)); + + await closeSphere(); + break; + } + + case 'invoice-close': { + const idOrPrefix = args[1]; + if (!idOrPrefix) { + console.error('Usage: invoice-close '); + process.exit(1); + } + + const sphere = await getSphere(); + await ensureSync(sphere, 'full'); + if (!sphere.accounting) { + console.error('Accounting module not enabled.'); + process.exit(1); + } + + const allInvoices = await sphere.accounting.getInvoices(); + const matched = allInvoices.filter(inv => inv.invoiceId.startsWith(idOrPrefix)); + if (matched.length === 0) { + console.error(`No invoice found matching prefix: ${idOrPrefix}`); + process.exit(1); + } + if (matched.length > 1) { + console.error(`Ambiguous prefix "${idOrPrefix}" matches ${matched.length} invoices.`); + process.exit(1); + } + const invoiceId = matched[0].invoiceId; + + const autoReturn = args.includes('--auto-return'); + await sphere.accounting.closeInvoice(invoiceId, autoReturn ? { autoReturn: true } : undefined); + console.log(`Invoice ${invoiceId} closed.${autoReturn ? ' Auto-return triggered.' : ''}`); + + await syncAfterWrite(sphere); + await closeSphere(); + break; + } + + case 'invoice-cancel': { + const idOrPrefix = args[1]; + if (!idOrPrefix) { + console.error('Usage: invoice-cancel '); + process.exit(1); + } + + const sphere = await getSphere(); + await ensureSync(sphere, 'full'); + if (!sphere.accounting) { + console.error('Accounting module not enabled.'); + process.exit(1); + } + + const allInvoices = await sphere.accounting.getInvoices(); + const matched = allInvoices.filter(inv => inv.invoiceId.startsWith(idOrPrefix)); + if (matched.length === 0) { + console.error(`No invoice found matching prefix: ${idOrPrefix}`); + process.exit(1); + } + if (matched.length > 1) { + console.error(`Ambiguous prefix "${idOrPrefix}" matches ${matched.length} invoices.`); + process.exit(1); + } + const invoiceId = matched[0].invoiceId; + + await sphere.accounting.cancelInvoice(invoiceId); + console.log(`Invoice ${invoiceId} cancelled.`); + + await syncAfterWrite(sphere); + await closeSphere(); + break; + } + + case 'invoice-pay': { + const idOrPrefix = args[1]; + if (!idOrPrefix) { + console.error('Usage: invoice-pay [--amount ] [--target-index ]'); + process.exit(1); + } + + const sphere = await getSphere(); + if (!sphere.accounting) { + console.error('Accounting module not enabled.'); + process.exit(1); + } + await ensureSync(sphere, 'full'); + + const allInvoices = await sphere.accounting.getInvoices(); + const matched = allInvoices.filter(inv => inv.invoiceId.startsWith(idOrPrefix)); + if (matched.length === 0) { + console.error(`No invoice found matching prefix: ${idOrPrefix}`); + process.exit(1); + } + if (matched.length > 1) { + console.error(`Ambiguous prefix "${idOrPrefix}" matches ${matched.length} invoices.`); + process.exit(1); + } + const invoiceId = matched[0].invoiceId; + + const amountIdx2 = args.indexOf('--amount'); + const targetIndexIdx = args.indexOf('--target-index'); + + const rawTargetIdx = targetIndexIdx !== -1 ? args[targetIndexIdx + 1] : undefined; + const targetIndex = rawTargetIdx !== undefined ? parseInt(rawTargetIdx, 10) : 0; + if (isNaN(targetIndex) || targetIndex < 0) { + console.error('--target-index must be a non-negative integer'); + process.exit(1); + } + + const payParamsMut: Record = { targetIndex }; + if (amountIdx2 !== -1 && args[amountIdx2 + 1]) { + const rawAmount = args[amountIdx2 + 1]; + if (!/^[1-9][0-9]*$/.test(rawAmount!)) { + console.error(`Invalid amount "${rawAmount}" — must be a positive integer in smallest units (no decimals, no leading zeros)`); + process.exit(1); + } + payParamsMut['amount'] = rawAmount; + } + const payParams = payParamsMut as unknown as PayInvoiceParams; + + const result = await sphere.accounting.payInvoice(invoiceId, payParams); + console.log('Payment result:'); + console.log(JSON.stringify({ id: result.id, status: result.status }, null, 2)); + + await syncAfterWrite(sphere); + await closeSphere(); + break; + } + + case 'invoice-return': { + const idOrPrefix = args[1]; + if (!idOrPrefix) { + console.error('Usage: invoice-return --recipient
--asset " "'); + process.exit(1); + } + + const sphere = await getSphere(); + await ensureSync(sphere, 'full'); + if (!sphere.accounting) { + console.error('Accounting module not enabled.'); + process.exit(1); + } + + const allInvoices = await sphere.accounting.getInvoices(); + const matched = allInvoices.filter(inv => inv.invoiceId.startsWith(idOrPrefix)); + if (matched.length === 0) { + console.error(`No invoice found matching prefix: ${idOrPrefix}`); + process.exit(1); + } + if (matched.length > 1) { + console.error(`Ambiguous prefix "${idOrPrefix}" matches ${matched.length} invoices.`); + process.exit(1); + } + const invoiceId = matched[0].invoiceId; + + const recipientIdx = args.indexOf('--recipient'); + const assetIdx3 = args.indexOf('--asset'); + + if (recipientIdx === -1 || !args[recipientIdx + 1]) { + console.error('--recipient
is required for invoice-return'); + process.exit(1); + } + + let returnAmount: string; + let returnCoinId: string; + + if (assetIdx3 !== -1 && args[assetIdx3 + 1]) { + const parsed = parseAssetArg(args[assetIdx3 + 1]); + returnAmount = parsed.amount; + returnCoinId = resolveCoin(parsed.coin).coinId; + } else { + console.error('--asset " " is required for invoice-return'); + process.exit(1); + } + + if (!/^[1-9][0-9]*$/.test(returnAmount)) { + console.error(`Invalid amount "${returnAmount}" — must be a positive integer string (smallest unit, no leading zeros, e.g. 1000000)`); + process.exit(1); + } + + const returnParams: ReturnPaymentParams = { + recipient: args[recipientIdx + 1], + amount: returnAmount, + coinId: returnCoinId, + }; + + const result = await sphere.accounting.returnInvoicePayment(invoiceId, returnParams); + console.log('Return payment result:'); + console.log(JSON.stringify({ id: result.id, status: result.status }, null, 2)); + + await syncAfterWrite(sphere); + await closeSphere(); + break; + } + + case 'invoice-receipts': { + const idOrPrefix = args[1]; + if (!idOrPrefix) { + console.error('Usage: invoice-receipts '); + process.exit(1); + } + + const sphere = await getSphere(); + if (!sphere.accounting) { + console.error('Accounting module not enabled.'); + process.exit(1); + } + + const allInvoices = await sphere.accounting.getInvoices(); + const matched = allInvoices.filter(inv => inv.invoiceId.startsWith(idOrPrefix)); + if (matched.length === 0) { + console.error(`No invoice found matching prefix: ${idOrPrefix}`); + process.exit(1); + } + if (matched.length > 1) { + console.error(`Ambiguous prefix "${idOrPrefix}" matches ${matched.length} invoices.`); + process.exit(1); + } + const invoiceId = matched[0].invoiceId; + + const result = await sphere.accounting.sendInvoiceReceipts(invoiceId); + console.log('Receipts result:'); + console.log(JSON.stringify(result, null, 2)); + + await closeSphere(); + break; + } + + case 'invoice-notices': { + const idOrPrefix = args[1]; + if (!idOrPrefix) { + console.error('Usage: invoice-notices '); + process.exit(1); + } + + const sphere = await getSphere(); + if (!sphere.accounting) { + console.error('Accounting module not enabled.'); + process.exit(1); + } + + const allInvoices = await sphere.accounting.getInvoices(); + const matched = allInvoices.filter(inv => inv.invoiceId.startsWith(idOrPrefix)); + if (matched.length === 0) { + console.error(`No invoice found matching prefix: ${idOrPrefix}`); + process.exit(1); + } + if (matched.length > 1) { + console.error(`Ambiguous prefix "${idOrPrefix}" matches ${matched.length} invoices.`); + process.exit(1); + } + const invoiceId = matched[0].invoiceId; + + const result = await sphere.accounting.sendCancellationNotices(invoiceId); + console.log('Cancellation notices result:'); + console.log(JSON.stringify(result, null, 2)); + + await closeSphere(); + break; + } + + case 'invoice-auto-return': { + const sphere = await getSphere(); + if (!sphere.accounting) { + console.error('Accounting module not enabled.'); + process.exit(1); + } + + const enableFlag = args.includes('--enable'); + const disableFlag = args.includes('--disable'); + const invoiceIdx = args.indexOf('--invoice'); + // W7-R17 fix: Validate --invoice has a following argument + if (invoiceIdx !== -1 && !args[invoiceIdx + 1]) { + console.error('--invoice requires an invoice ID'); + process.exit(1); + } + const specificInvoice = invoiceIdx !== -1 ? args[invoiceIdx + 1] : undefined; + + if (!enableFlag && !disableFlag) { + // Show current settings + const settings = sphere.accounting.getAutoReturnSettings(); + console.log('Auto-return settings:'); + console.log(JSON.stringify(settings, null, 2)); + } else if (enableFlag && disableFlag) { + console.error('Cannot use both --enable and --disable'); + process.exit(1); + } else { + const enabled = enableFlag; + const invoiceId = specificInvoice ?? '*'; + await sphere.accounting.setAutoReturn(invoiceId, enabled); + const scope = invoiceId === '*' ? 'globally' : `for invoice ${invoiceId}`; + console.log(`Auto-return ${enabled ? 'enabled' : 'disabled'} ${scope}.`); + } + + await closeSphere(); + break; + } + + case 'invoice-transfers': { + const idOrPrefix = args[1]; + if (!idOrPrefix) { + console.error('Usage: invoice-transfers '); + process.exit(1); + } + + const sphere = await getSphere(); + await ensureSync(sphere, 'full'); + if (!sphere.accounting) { + console.error('Accounting module not enabled.'); + process.exit(1); + } + + const allInvoices = await sphere.accounting.getInvoices(); + const matched = allInvoices.filter(inv => inv.invoiceId.startsWith(idOrPrefix)); + if (matched.length === 0) { + console.error(`No invoice found matching prefix: ${idOrPrefix}`); + process.exit(1); + } + if (matched.length > 1) { + console.error(`Ambiguous prefix "${idOrPrefix}" matches ${matched.length} invoices.`); + process.exit(1); + } + const invoiceId = matched[0].invoiceId; + + const transfers = sphere.accounting.getRelatedTransfers(invoiceId); + if (transfers.length === 0) { + console.log('No related transfers found.'); + } else { + console.log(`Related transfers (${transfers.length}):`); + console.log(JSON.stringify(transfers, null, 2)); + } + + await closeSphere(); + break; + } + + case 'invoice-export': { + const idOrPrefix = args[1]; + if (!idOrPrefix) { + console.error('Usage: invoice-export '); + process.exit(1); + } + + const sphere = await getSphere(); + if (!sphere.accounting) { + console.error('Accounting module not enabled.'); + process.exit(1); + } + + const allInvoices = await sphere.accounting.getInvoices(); + const matched = allInvoices.filter(inv => inv.invoiceId.startsWith(idOrPrefix)); + if (matched.length === 0) { + console.error(`No invoice found matching prefix: ${idOrPrefix}`); + process.exit(1); + } + if (matched.length > 1) { + console.error(`Ambiguous prefix "${idOrPrefix}" matches ${matched.length} invoices.`); + process.exit(1); + } + const invoiceId = matched[0].invoiceId; + + // Get the invoice ref via getInvoice + const invoiceRef = sphere.accounting.getInvoice(invoiceId); + if (!invoiceRef) { + console.error(`Invoice ${invoiceId} not found in memory.`); + process.exit(1); + } + + const outFile = `invoice-${invoiceId.slice(0, 8)}.json`; + fs.writeFileSync(outFile, JSON.stringify(invoiceRef, null, 2)); + console.log(`Invoice exported to: ${outFile}`); + + await closeSphere(); + break; + } + + case 'invoice-parse-memo': { + const memoStr = args[1]; + if (!memoStr) { + console.error('Usage: invoice-parse-memo '); + process.exit(1); + } + + const sphere = await getSphere(); + if (!sphere.accounting) { + console.error('Accounting module not enabled.'); + process.exit(1); + } + + const parsed = sphere.accounting.parseInvoiceMemo(memoStr); + if (!parsed) { + console.log('Not a valid invoice memo.'); + } else { + console.log('Parsed invoice memo:'); + console.log(JSON.stringify(parsed, null, 2)); + } + + await closeSphere(); + break; + } + + // ===================================================================== + // Swap Commands + // ===================================================================== + + case 'swap-propose': { + const toIdx = args.indexOf('--to'); + const escrowIdx = args.indexOf('--escrow'); + const timeoutIdx = args.indexOf('--timeout'); + const messageIdx = args.indexOf('--message'); + + // Combined format: --offer " " --want " " + const offerIdx = args.indexOf('--offer'); + const wantIdx = args.indexOf('--want'); + + if (toIdx === -1 || !args[toIdx + 1] || + offerIdx === -1 || !args[offerIdx + 1] || args[offerIdx + 1].startsWith('--') || + wantIdx === -1 || !args[wantIdx + 1] || args[wantIdx + 1].startsWith('--')) { + console.error('Usage: swap-propose --to --offer " " --want " " [--escrow
] [--timeout ] [--message ]'); + process.exit(1); + } + + const offer = parseAssetArg(args[offerIdx + 1]); + const want = parseAssetArg(args[wantIdx + 1]); + + let timeout = 3600; + if (timeoutIdx !== -1 && args[timeoutIdx + 1]) { + timeout = parseInt(args[timeoutIdx + 1], 10); + if (isNaN(timeout) || timeout < 60 || timeout > 86400) { + console.error('--timeout must be an integer between 60 and 86400 seconds'); + process.exit(1); + } + } + + const sphere = await getSphere(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const swapModule = (sphere as any).swap; + if (!swapModule) { + console.error('Swap module not enabled. Initialize with swap support.'); + process.exit(1); + } + + // Resolve coin symbols and convert human-readable amounts to smallest units + // (e.g., "10 BTC" → 10 * 10^8 = 1000000000 smallest units) + const offerCoin = resolveCoin(offer.coin); + const wantCoin = resolveCoin(want.coin); + const offerSmallest = toSmallestUnit(offer.amount, offerCoin.decimals); + const wantSmallest = toSmallestUnit(want.amount, wantCoin.decimals); + if (offerSmallest <= 0n) { + console.error(`Invalid offer amount "${offer.amount}" — must be a positive number`); + process.exit(1); + } + if (wantSmallest <= 0n) { + console.error(`Invalid want amount "${want.amount}" — must be a positive number`); + process.exit(1); + } + + const escrow = escrowIdx !== -1 ? args[escrowIdx + 1] : undefined; + const message = messageIdx !== -1 ? args[messageIdx + 1] : undefined; + + const deal = { + partyA: sphere.identity!.directAddress!, + partyB: args[toIdx + 1], + partyACurrency: offerCoin.symbol, + partyAAmount: offerSmallest.toString(), + partyBCurrency: wantCoin.symbol, + partyBAmount: wantSmallest.toString(), + timeout: timeout, + escrowAddress: escrow, + }; + + const result = await swapModule.proposeSwap(deal, message ? { message } : undefined); + console.log('Swap proposed:'); + console.log(JSON.stringify({ + swap_id: result.swapId, + counterparty: args[toIdx + 1], + offer: `${offer.amount} ${offerCoin.symbol}`, + want: `${want.amount} ${wantCoin.symbol}`, + escrow: deal.escrowAddress ?? '(config default)', + timeout: timeout, + status: result.swap?.progress ?? 'proposed', + }, null, 2)); + + await closeSphere(); + break; + } + + case 'swap-list': { + const allFlag = args.includes('--all'); + const roleIdx = args.indexOf('--role'); + const progressIdx = args.indexOf('--progress'); + + const sphere = await getSphere(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const swapModule = (sphere as any).swap; + if (!swapModule) { + console.error('Swap module not enabled.'); + process.exit(1); + } + await ensureSync(sphere, 'nostr'); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const filter: any = {}; + if (roleIdx !== -1 && args[roleIdx + 1]) { + const role = args[roleIdx + 1]; + if (role !== 'proposer' && role !== 'acceptor') { + console.error('--role must be "proposer" or "acceptor"'); + process.exit(1); + } + filter.role = role; + } + if (progressIdx !== -1 && args[progressIdx + 1]) { + filter.progress = args[progressIdx + 1]; + } + if (!allFlag && !filter.progress) { + filter.excludeTerminal = true; + } + + const swaps = await swapModule.getSwaps(filter); + if (!swaps || swaps.length === 0) { + console.log('No swaps found.'); + } else { + const isTTY = process.stdout.isTTY; + const green = isTTY ? '\x1b[32m' : ''; + const red = isTTY ? '\x1b[31m' : ''; + const yellow = isTTY ? '\x1b[33m' : ''; + const reset = isTTY ? '\x1b[0m' : ''; + + const header = [ + 'SWAP ID'.padEnd(10), + 'ROLE'.padEnd(12), + 'PROGRESS'.padEnd(20), + 'OFFER'.padEnd(18), + 'WANT'.padEnd(18), + 'COUNTERPARTY'.padEnd(16), + 'CREATED', + ].join(''); + console.log(header); + + for (const swap of swaps) { + const id = (swap.swapId || '').slice(0, 8); + const role = swap.role || ''; + const progress = swap.progress || ''; + + // Determine counterparty display + const isProposer = role === 'proposer'; + // Format amounts back to human-readable (manifest stores smallest units) + const fmtSwapAmt = (amount: string | undefined, currency: string | undefined): string => { + if (!amount || !currency) return ''; + try { + const def = resolveCoin(currency); + return `${toHumanReadable(amount, def.decimals)} ${def.symbol}`; + } catch { + return `${amount} ${currency} (raw)`; + } + }; + const offerStr = isProposer + ? fmtSwapAmt(swap.deal?.partyAAmount, swap.deal?.partyACurrency) + : fmtSwapAmt(swap.deal?.partyBAmount, swap.deal?.partyBCurrency); + const wantStr = isProposer + ? fmtSwapAmt(swap.deal?.partyBAmount, swap.deal?.partyBCurrency) + : fmtSwapAmt(swap.deal?.partyAAmount, swap.deal?.partyACurrency); + // Show nametag if available, otherwise truncated address + let counterparty: string; + if (swap.counterpartyNametag) { + counterparty = '@' + swap.counterpartyNametag; + } else if (isProposer) { + counterparty = (swap.deal?.partyB ?? '').slice(0, 14); + } else { + counterparty = (swap.deal?.partyA ?? '').slice(0, 14); + } + + // Relative time + const elapsed = Date.now() - (swap.createdAt || 0); + let timeStr: string; + if (elapsed < 60_000) timeStr = 'just now'; + else if (elapsed < 3_600_000) timeStr = `${Math.floor(elapsed / 60_000)} min ago`; + else if (elapsed < 86_400_000) timeStr = `${Math.floor(elapsed / 3_600_000)} hour ago`; + else timeStr = `${Math.floor(elapsed / 86_400_000)} days ago`; + + // Color progress + let coloredProgress = progress; + if (progress === 'completed') coloredProgress = `${green}${progress}${reset}`; + else if (progress === 'failed' || progress === 'cancelled') coloredProgress = `${red}${progress}${reset}`; + else coloredProgress = `${yellow}${progress}${reset}`; + + const row = [ + id.padEnd(10), + role.padEnd(12), + // Pad the raw progress (color codes are zero-width for terminal) + coloredProgress + ' '.repeat(Math.max(0, 20 - progress.length)), + offerStr.padEnd(18), + wantStr.padEnd(18), + counterparty.padEnd(16), + timeStr, + ].join(''); + console.log(row); + } + } + + await closeSphere(); + break; + } + + case 'swap-accept': { + const swapIdArg = args[1]; + if (!swapIdArg) { + console.error('Usage: swap-accept [--deposit] [--no-wait]'); + process.exit(1); + } + + const depositFlag = args.includes('--deposit'); + const noWaitFlag = args.includes('--no-wait'); + + const sphere = await getSphere(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const swapModule = (sphere as any).swap; + if (!swapModule) { + console.error('Swap module not enabled.'); + process.exit(1); + } + await ensureSync(sphere, 'nostr'); + + const swapId = swapModule.resolveSwapId(swapIdArg); + await swapModule.acceptSwap(swapId); + console.log('Swap accepted. Announced to escrow. Waiting for deposit invoice...'); + + if (depositFlag) { + // acceptSwap() sends the announce to the escrow but the escrow's invoice_delivery + // DM arrives asynchronously. Wait for swap:announced before depositing. + const acceptAnnounced = await new Promise((resolve) => { + const acceptUnsubs: (() => void)[] = []; + const timeout = setTimeout(() => { + acceptUnsubs.forEach(u => u()); + resolve(false); + }, 60_000); + const done = (ok: boolean) => { + clearTimeout(timeout); + acceptUnsubs.forEach(u => u()); + resolve(ok); + }; + acceptUnsubs.push(sphere.on('swap:announced', (e: { swapId: string }) => { + if (e.swapId === swapId) done(true); + })); + acceptUnsubs.push(sphere.on('swap:failed', (e: { swapId: string }) => { + if (e.swapId === swapId) done(false); + })); + acceptUnsubs.push(sphere.on('swap:cancelled', (e: { swapId: string }) => { + if (e.swapId === swapId) done(false); + })); + }); + if (!acceptAnnounced) { + const finalStatus = await swapModule.getSwapStatus(swapId); + console.error(`Swap did not reach 'announced' state (current: ${finalStatus?.progress ?? 'unknown'}). Check swap-status for details.`); + await closeSphere(); + process.exit(1); + } + + const depositResult = await swapModule.deposit(swapId); + console.log(`Deposit sent: ${depositResult.id}`); + + if (!noWaitFlag) { + // Wait for terminal event + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const swapRef = await swapModule.getSwapStatus(swapId); + const waitTimeout = 2 * (swapRef?.deal?.timeout ?? 3600) * 1000; + const unsubs: (() => void)[] = []; + await new Promise((resolve, reject) => { + const timer = setTimeout(() => { + unsubs.forEach(u => u()); + reject(new Error(`Swap did not complete within timeout. Current progress: ${swapRef?.progress ?? 'unknown'}`)); + }, waitTimeout); + + const done = (msg: string) => { + clearTimeout(timer); + unsubs.forEach(u => u()); + console.log(msg); + resolve(); + }; + + unsubs.push(sphere.on('swap:completed', (e: { swapId: string }) => { + if (e.swapId === swapId) done('[swap] Swap completed!'); + })); + unsubs.push(sphere.on('swap:cancelled', (e: { swapId: string }) => { + if (e.swapId === swapId) done('[swap] Swap cancelled.'); + })); + unsubs.push(sphere.on('swap:failed', (e: { swapId: string; error: string }) => { + if (e.swapId === swapId) done(`[swap] Swap failed: ${e.error}`); + })); + unsubs.push(sphere.on('swap:deposit_confirmed', (e: { swapId: string }) => { + if (e.swapId === swapId) console.log('[swap] Deposit confirmed by escrow.'); + })); + unsubs.push(sphere.on('swap:deposits_covered', (e: { swapId: string }) => { + if (e.swapId === swapId) console.log('[swap] All deposits covered. Escrow concluding...'); + })); + unsubs.push(sphere.on('swap:payout_received', (e: { swapId: string }) => { + if (e.swapId === swapId) console.log('[swap] Payout received. Verifying...'); + })); + }); + } + } else { + console.log(`Run 'swap-deposit ${swapId}' to deposit when ready.`); + } + + await closeSphere(); + break; + } + + case 'swap-ping': { + const escrowAddr = args[1]; + if (!escrowAddr) { + console.error('Usage: swap-ping <@nametag_or_address>'); + process.exit(1); + } + + const sphere = await getSphere(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const swapModule = (sphere as any).swap; + if (!swapModule) { + console.error('Swap module not enabled.'); + await closeSphere(); + process.exit(1); + } + await ensureSync(sphere, 'nostr'); + + try { + const pong = await swapModule.pingEscrow(escrowAddr); + console.log('Escrow is online:'); + console.log(JSON.stringify(pong, null, 2)); + } catch (err) { + console.error('Escrow ping failed:', err instanceof Error ? err.message : err); + await closeSphere(); + process.exit(1); + } + + await closeSphere(); + break; + } + + case 'swap-status': { + const swapIdArg = args[1]; + if (!swapIdArg) { + console.error('Usage: swap-status [--query-escrow]'); + process.exit(1); + } + + const queryEscrow = args.includes('--query-escrow'); + + const sphere = await getSphere(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const swapModule = (sphere as any).swap; + if (!swapModule) { + console.error('Swap module not enabled.'); + process.exit(1); + } + await ensureSync(sphere, 'nostr'); + + const swapId = swapModule.resolveSwapId(swapIdArg); + const status = await swapModule.getSwapStatus(swapId, queryEscrow ? { queryEscrow: true } : undefined); + console.log('Swap Status:'); + console.log(JSON.stringify(status, null, 2)); + + if (status.depositInvoiceId && sphere.accounting) { + try { + const invoiceStatus = await sphere.accounting.getInvoiceStatus(status.depositInvoiceId); + console.log('\nDeposit Invoice Status:'); + console.log(JSON.stringify(invoiceStatus, null, 2)); + } catch { + // Non-fatal: invoice may not be imported yet + } + } + + await closeSphere(); + break; + } + + case 'swap-deposit': { + const swapIdArg = args[1]; + if (!swapIdArg) { + console.error('Usage: swap-deposit '); + process.exit(1); + } + + const sphere = await getSphere(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const swapModule = (sphere as any).swap; + if (!swapModule) { + console.error('Swap module not enabled.'); + process.exit(1); + } + await ensureSync(sphere, 'full'); + + const swapId = swapModule.resolveSwapId(swapIdArg); + + // If the swap is still in 'accepted' (crash recovery re-announced to escrow but + // the escrow's invoice_delivery DM hasn't arrived yet), wait for swap:announced + // before calling deposit(). Crash recovery fires fire-and-forget after module load + // so the escrow response may arrive slightly after ensureSync completes. + const swapStatusBefore = await swapModule.getSwapStatus(swapId); + // Guard: if already deposited, don't re-deposit + if (swapStatusBefore?.progress === 'depositing' || swapStatusBefore?.progress === 'awaiting_counter' || + swapStatusBefore?.progress === 'concluding' || swapStatusBefore?.progress === 'completed') { + console.log(`Deposit already submitted (current state: ${swapStatusBefore.progress})`); + await closeSphere(); + break; + } + if (swapStatusBefore?.progress === 'proposed' || swapStatusBefore?.progress === 'accepted') { + const waitLabel = swapStatusBefore.progress === 'proposed' + ? 'Waiting for Bob to accept and escrow to confirm (up to 120s)...' + : 'Waiting for escrow to deliver deposit invoice (up to 60s)...'; + console.log(waitLabel); + const waitMs = swapStatusBefore.progress === 'proposed' ? 120_000 : 60_000; + const announced = await new Promise((resolve) => { + const announceUnsubs: (() => void)[] = []; + const timeout = setTimeout(() => { + announceUnsubs.forEach(u => u()); + resolve(false); + }, waitMs); + const done = (ok: boolean) => { + clearTimeout(timeout); + announceUnsubs.forEach(u => u()); + resolve(ok); + }; + announceUnsubs.push(sphere.on('swap:announced', (e: { swapId: string }) => { + if (e.swapId === swapId) done(true); + })); + announceUnsubs.push(sphere.on('swap:failed', (e: { swapId: string }) => { + if (e.swapId === swapId) done(false); + })); + announceUnsubs.push(sphere.on('swap:cancelled', (e: { swapId: string }) => { + if (e.swapId === swapId) done(false); + })); + }); + if (!announced) { + const finalStatus = await swapModule.getSwapStatus(swapId); + console.error(`Swap did not reach 'announced' state (current: ${finalStatus?.progress ?? 'unknown'}). Try again shortly or check swap-status.`); + await closeSphere(); + process.exit(1); + } + } + + const result = await swapModule.deposit(swapId); + console.log('Deposit result:'); + console.log(JSON.stringify({ id: result.id, status: result.status }, null, 2)); + + // Wait for background tasks (e.g., change token creation from instant split) + await sphere.payments.waitForPendingOperations(); + await syncAfterWrite(sphere); + await closeSphere(); + break; + } + + case 'swap-reject': { + const swapIdArg = args[1]; + if (!swapIdArg) { + console.error('Usage: swap-reject [reason]'); + process.exit(1); + } + + const sphere = await getSphere(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const swapModule = (sphere as any).swap; + if (!swapModule) { + console.error('Swap module not enabled.'); + process.exit(1); + } + await ensureSync(sphere, 'nostr'); + + const swapId = swapModule.resolveSwapId(swapIdArg); + // Optional reason from remaining args + const reason = args.slice(2).filter((a: string) => !a.startsWith('--')).join(' ') || undefined; + + await swapModule.rejectSwap(swapId, reason); + console.log(`Swap ${swapId.slice(0, 8)}... rejected.${reason ? ` Reason: ${reason}` : ''}`); + + await closeSphere(); + break; + } + + case 'swap-cancel': { + const swapIdArg = args[1]; + if (!swapIdArg) { + console.error('Usage: swap-cancel '); + process.exit(1); + } + + const sphere = await getSphere(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const swapModule = (sphere as any).swap; + if (!swapModule) { + console.error('Swap module not enabled.'); + process.exit(1); + } + await ensureSync(sphere, 'nostr'); + + const swapId = swapModule.resolveSwapId(swapIdArg); + await swapModule.cancelSwap(swapId); + console.log(`Swap ${swapId.slice(0, 8)}... cancelled.`); + + await closeSphere(); + break; + } + + case 'daemon': { + const sub = args[1] || 'start'; + const { runDaemon, stopDaemon, statusDaemon } = await import('./daemon.js'); + switch (sub) { + case 'start': + await runDaemon(args.slice(2), getSphere, closeSphere); + break; + case 'stop': + await stopDaemon(args.slice(2)); + break; + case 'status': + await statusDaemon(args.slice(2)); + break; + default: + console.error(`Unknown daemon sub-command: ${sub}`); + console.error('Usage: daemon start|stop|status'); + process.exit(1); + } + break; + } + + case 'completions': { + const shell = args[1]; + if (!shell || !['bash', 'zsh', 'fish'].includes(shell)) { + console.error('Usage: completions '); + console.error('Generate shell completion script for tab-completion.'); + console.error('\nSetup:'); + console.error(' sphere-cli completions bash >> ~/.bashrc'); + console.error(' sphere-cli completions zsh > ~/.zsh/completions/_sphere-cli'); + console.error(' sphere-cli completions fish > ~/.config/fish/completions/sphere-cli.fish'); + process.exit(1); + } + switch (shell) { + case 'bash': console.log(generateBashCompletions()); break; + case 'zsh': console.log(generateZshCompletions()); break; + case 'fish': console.log(generateFishCompletions()); break; + } + break; + } + + default: + console.error('Unknown command:', command); + console.error('Run with --help for usage'); + process.exit(1); + } + } catch (e) { + console.error('Error:', e instanceof Error ? e.message : e); + process.exit(1); + } +} + +// ============================================================================= +// Shell Completion Generators +// ============================================================================= + +interface CompletionCommand { + name: string; + description: string; + flags?: string[]; + subcommands?: CompletionCommand[]; +} + +function getCompletionCommands(): CompletionCommand[] { + return [ + { name: 'init', description: 'Create or import wallet', flags: ['--network', '--mnemonic', '--nametag', '--password', '--no-nostr'] }, + { name: 'status', description: 'Show wallet identity' }, + { name: 'config', description: 'Show or set CLI configuration' }, + { name: 'clear', description: 'Delete all wallet data' }, + { name: 'wallet', description: 'Manage wallet profiles', subcommands: [ + { name: 'list', description: 'List all wallet profiles' }, + { name: 'create', description: 'Create a new wallet profile', flags: ['--network'] }, + { name: 'use', description: 'Switch to a wallet profile' }, + { name: 'current', description: 'Show active profile' }, + { name: 'delete', description: 'Delete a wallet profile' }, + ]}, + { name: 'balance', description: 'Show L3 token balance', flags: ['--finalize', '--no-sync'] }, + { name: 'tokens', description: 'List all tokens', flags: ['--no-sync'] }, + { name: 'assets', description: 'List registered assets (coins & NFTs)', flags: ['--type'] }, + { name: 'asset-info', description: 'Show detailed info for an asset' }, + { name: 'l1-balance', description: 'Show L1 (ALPHA) balance' }, + { name: 'topup', description: 'Request test tokens from faucet' }, + { name: 'top-up', description: 'Alias for topup' }, + { name: 'faucet', description: 'Alias for topup' }, + { name: 'verify-balance', description: 'Verify tokens against aggregator', flags: ['--remove', '-v', '--verbose'] }, + { name: 'sync', description: 'Sync tokens with IPFS' }, + { name: 'send', description: 'Send L3 tokens', flags: ['--direct', '--proxy', '--instant', '--conservative', '--no-sync'] }, + { name: 'receive', description: 'Check for incoming transfers', flags: ['--finalize', '--no-sync'] }, + { name: 'history', description: 'Show transaction history' }, + { name: 'addresses', description: 'List tracked addresses' }, + { name: 'switch', description: 'Switch to HD address' }, + { name: 'hide', description: 'Hide address' }, + { name: 'unhide', description: 'Unhide address' }, + { name: 'nametag', description: 'Register a nametag' }, + { name: 'nametag-info', description: 'Look up nametag info' }, + { name: 'my-nametag', description: 'Show current nametag' }, + { name: 'nametag-sync', description: 'Re-publish nametag binding' }, + { name: 'dm', description: 'Send a direct message' }, + { name: 'dm-inbox', description: 'List conversations' }, + { name: 'dm-history', description: 'Show conversation history', flags: ['--limit'] }, + { name: 'group-create', description: 'Create a new group', flags: ['--description', '--private'] }, + { name: 'group-list', description: 'List available groups' }, + { name: 'group-my', description: 'List your joined groups' }, + { name: 'group-join', description: 'Join a group', flags: ['--invite'] }, + { name: 'group-leave', description: 'Leave a group' }, + { name: 'group-send', description: 'Send a message to a group', flags: ['--reply'] }, + { name: 'group-messages', description: 'Show group messages', flags: ['--limit'] }, + { name: 'group-members', description: 'List group members' }, + { name: 'group-info', description: 'Show group details' }, + { name: 'invoice-create', description: 'Create an invoice', flags: ['--target', '--asset', '--nft', '--due', '--memo', '--delivery', '--anonymous', '--terms'] }, + { name: 'invoice-import', description: 'Import invoice from token file' }, + { name: 'invoice-list', description: 'List invoices', flags: ['--state', '--limit'] }, + { name: 'invoice-status', description: 'Show invoice status' }, + { name: 'invoice-close', description: 'Close an invoice' }, + { name: 'invoice-cancel', description: 'Cancel an invoice' }, + { name: 'invoice-pay', description: 'Pay an invoice', flags: ['--target-index', '--amount'] }, + { name: 'invoice-return', description: 'Return payment to sender', flags: ['--recipient', '--asset'] }, + { name: 'invoice-receipts', description: 'Send payment receipts' }, + { name: 'invoice-notices', description: 'Send cancellation notices' }, + { name: 'invoice-auto-return', description: 'Show/set auto-return settings', flags: ['--enable', '--disable', '--invoice'] }, + { name: 'invoice-transfers', description: 'List related transfers' }, + { name: 'invoice-export', description: 'Export invoice to JSON file' }, + { name: 'invoice-parse-memo', description: 'Parse invoice memo string' }, + { name: 'swap-propose', description: 'Propose a token swap', flags: ['--to', '--offer', '--want', '--escrow', '--timeout', '--message'] }, + { name: 'swap-list', description: 'List swap deals', flags: ['--all', '--role', '--progress'] }, + { name: 'swap-accept', description: 'Accept a swap deal', flags: ['--deposit', '--no-wait'] }, + { name: 'swap-status', description: 'Show swap status', flags: ['--query-escrow'] }, + { name: 'swap-deposit', description: 'Deposit into a swap' }, + { name: 'swap-reject', description: 'Reject a swap proposal' }, + { name: 'swap-cancel', description: 'Cancel a swap' }, + { name: 'market-post', description: 'Post a market intent', flags: ['--type', '--category', '--price', '--currency', '--location', '--contact', '--expires'] }, + { name: 'market-search', description: 'Search market intents', flags: ['--type', '--category', '--min-price', '--max-price', '--limit'] }, + { name: 'market-my', description: 'List your intents' }, + { name: 'market-close', description: 'Close an intent' }, + { name: 'market-feed', description: 'Watch live listing feed', flags: ['--rest'] }, + { name: 'daemon', description: 'Manage event daemon', subcommands: [ + { name: 'start', description: 'Start daemon', flags: ['--config', '--detach', '--log', '--pid', '--event', '--action', '--verbose'] }, + { name: 'stop', description: 'Stop daemon' }, + { name: 'status', description: 'Check daemon status' }, + ]}, + { name: 'encrypt', description: 'Encrypt data with password' }, + { name: 'decrypt', description: 'Decrypt encrypted data' }, + { name: 'parse-wallet', description: 'Parse wallet file' }, + { name: 'wallet-info', description: 'Show wallet file info' }, + { name: 'generate-key', description: 'Generate random private key' }, + { name: 'validate-key', description: 'Validate a private key' }, + { name: 'hex-to-wif', description: 'Convert hex to WIF' }, + { name: 'derive-pubkey', description: 'Derive public key' }, + { name: 'derive-address', description: 'Derive L1 address' }, + { name: 'to-smallest', description: 'Convert to smallest unit' }, + { name: 'to-human', description: 'Convert to human-readable' }, + { name: 'format', description: 'Format amount' }, + { name: 'base58-encode', description: 'Encode hex to base58' }, + { name: 'base58-decode', description: 'Decode base58 to hex' }, + { name: 'completions', description: 'Generate shell completion script' }, + { name: 'help', description: 'Show help for a command' }, + ]; +} + +function generateBashCompletions(): string { + const cmds = getCompletionCommands(); + const topLevel = cmds.map(c => c.name).join(' '); + + const subcommandCases: string[] = []; + const flagCases: string[] = []; + + for (const cmd of cmds) { + if (cmd.subcommands) { + const subs = cmd.subcommands.map(s => s.name).join(' '); + subcommandCases.push(` ${cmd.name})\n if [[ $cword -eq 2 ]]; then\n COMPREPLY=($(compgen -W "${subs}" -- "$cur"))\n return\n fi\n ;;`); + for (const sub of cmd.subcommands) { + if (sub.flags?.length) { + flagCases.push(` "${cmd.name} ${sub.name}") flags="${sub.flags.join(' ')}" ;;`); + } + } + } + if (cmd.flags?.length) { + flagCases.push(` ${cmd.name}) flags="${cmd.flags.join(' ')}" ;;`); + } + } + + return `# Bash completion for sphere-cli +# Generated by: sphere-cli completions bash +# +# Installation: +# sphere-cli completions bash >> ~/.bashrc +# # or +# sphere-cli completions bash > /etc/bash_completion.d/sphere-cli +# # or with npm: +# eval "$(npm run --silent cli -- completions bash)" + +_sphere_cli() { + local cur prev words cword + _init_completion || return + + local commands="${topLevel}" + + if [[ $cword -eq 1 ]]; then + COMPREPLY=($(compgen -W "$commands" -- "$cur")) + return + fi + + # Subcommands + case "\${words[1]}" in +${subcommandCases.join('\n')} + esac + + # Flag completion + if [[ "$cur" == -* ]]; then + local flags="" + local cmd_key="\${words[1]}" + if [[ $cword -ge 3 ]]; then + cmd_key="\${words[1]} \${words[2]}" + fi + case "$cmd_key" in +${flagCases.join('\n')} + esac + if [[ -n "$flags" ]]; then + COMPREPLY=($(compgen -W "$flags" -- "$cur")) + fi + fi +} + +complete -F _sphere_cli sphere-cli +# Also complete for npm run cli -- usage: +complete -F _sphere_cli npx +`; +} + +function generateZshCompletions(): string { + const cmds = getCompletionCommands(); + + const commandList = cmds.map(c => ` '${c.name}:${c.description.replace(/'/g, "'\\''")}'`).join('\n'); + + const subcases: string[] = []; + for (const cmd of cmds) { + if (cmd.subcommands) { + const subList = cmd.subcommands.map(s => `'${s.name}:${s.description.replace(/'/g, "'\\''")}'`).join(' '); + subcases.push(` ${cmd.name})\n _describe 'subcommand' ${subList}\n ;;`); + } else { + const flagArgs = (cmd.flags || []).map(f => `'${f}[${cmd.description.replace(/'/g, "'\\''").slice(0, 30)}]'`).join(' '); + if (flagArgs) { + subcases.push(` ${cmd.name})\n _arguments ${flagArgs}\n ;;`); + } + } + } + + return `#compdef sphere-cli +# Zsh completion for sphere-cli +# Generated by: sphere-cli completions zsh +# +# Installation: +# mkdir -p ~/.zsh/completions +# sphere-cli completions zsh > ~/.zsh/completions/_sphere-cli +# # Add to .zshrc: fpath=(~/.zsh/completions $fpath) && autoload -Uz compinit && compinit + +_sphere_cli() { + local -a commands + commands=( +${commandList} + ) + + _arguments -C \\ + '1:command:->command' \\ + '*::arg:->args' + + case "$state" in + command) + _describe 'command' commands + ;; + args) + case "\${words[1]}" in +${subcases.join('\n')} + esac + ;; + esac +} + +_sphere_cli "$@" +`; +} + +function generateFishCompletions(): string { + const cmds = getCompletionCommands(); + const lines: string[] = [ + '# Fish completion for sphere-cli', + '# Generated by: sphere-cli completions fish', + '#', + '# Installation:', + '# sphere-cli completions fish > ~/.config/fish/completions/sphere-cli.fish', + '', + ]; + + for (const cmd of cmds) { + if (cmd.subcommands) { + lines.push(`complete -c sphere-cli -n '__fish_use_subcommand' -a '${cmd.name}' -d '${cmd.description}'`); + for (const sub of cmd.subcommands) { + lines.push(`complete -c sphere-cli -n '__fish_seen_subcommand_from ${cmd.name}' -a '${sub.name}' -d '${sub.description}'`); + for (const flag of sub.flags || []) { + lines.push(`complete -c sphere-cli -n '__fish_seen_subcommand_from ${cmd.name}' -l '${flag.replace(/^--/, '')}' -d '${sub.description}'`); + } + } + } else { + lines.push(`complete -c sphere-cli -n '__fish_use_subcommand' -a '${cmd.name}' -d '${cmd.description}'`); + for (const flag of cmd.flags || []) { + lines.push(`complete -c sphere-cli -n '__fish_seen_subcommand_from ${cmd.name}' -l '${flag.replace(/^--/, '')}' -d '${flag}'`); + } + } + } + + return lines.join('\n') + '\n'; +} + +// Auto-run removed: legacyMain() is called from src/index.ts dispatcher. diff --git a/tsconfig.json b/tsconfig.json index 8a1e645..f866704 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -7,7 +7,7 @@ "outDir": "dist", "rootDir": "src", "strict": true, - "noUncheckedIndexedAccess": true, + "noUncheckedIndexedAccess": false, "esModuleInterop": true, "skipLibCheck": true, "forceConsistentCasingInFileNames": true, From 8fb0c0da8516baf604b1993e16b02cf1cdb5af94 Mon Sep 17 00:00:00 2001 From: Vladimir Rogojin Date: Wed, 22 Apr 2026 15:11:44 +0200 Subject: [PATCH 02/16] =?UTF-8?q?fix(security):=20steelman=20round=20?= =?UTF-8?q?=E2=80=94=20fix=20all=20criticals=20and=20warnings?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dispatcher & argv (src/index.ts) - buildLegacyArgv(): translate commander namespace+subcommand to the exact legacy switch/case command the dispatcher expects; fixes 7 namespaces (payments, group, market, swap, invoice, crypto, util) that previously produced "Unknown command" on every invocation - program.exitOverride(): commander now throws CommanderError instead of calling process.exit(), making --help/--version safe in tests - main() catch: handle CommanderError by code, sanitize error messages with mnemonic-redaction regex before writing to stderr Legacy CLI (src/legacy/legacy-cli.ts) - legacyMain(argv: string[]): accept argv param; set module-level args/command per-call so second invocations don't reuse frozen import-time state - writeAtomic(): temp+rename pattern for saveConfig, saveProfiles, invoice- export; prevents truncated files on SIGKILL mid-write - isTTY guard on init mnemonic display: mnemonic suppressed (warning to stderr) when stdout is not a terminal, preventing leak into CI logs or piped files - isTTY guard on generate-key --unsafe-print: refuses to print private key to non-TTY stdout - stripDangerousKeys(): recursive prototype-pollution guard applied to all user-supplied JSON before SDK entry (invoice-create, invoice-import) - process.exit override: installs a shim in main() that calls sphere.destroy() before exiting, covering all ~25 in-handler process.exit(1) calls without touching each site; ensures Nostr WebSockets, IPFS, SQLite always close - nametag catch: exits 1 on registration failure so scripts see the error - mnemonic scrubbing comment: honest explanation that /proc/cmdline is NOT cleared by mutating the args slice Daemon (src/legacy/daemon.ts) - writePidFileExclusive(): fs.openSync('wx') for exclusive create; prevents two concurrent daemon starts both winning the PID file race - PID file format: JSON {pid, nonce, cmd:'sphere-daemon'} enables /proc comm check to detect PID reuse by unrelated processes (prevents SIGTERM to victim) - earlyShutdown: SIGTERM/SIGINT registered before any await in runDaemon(); PID file cleanup even if signal arrives during getSphere() startup - inflight Set: tracks dispatchRule promises; shutdown awaits allSettled with 10s timeout before closeSphere() — prevents token state corruption on stop - logStream flush: log('Daemon stopped.') before end(), await finish event - process.kill ESRCH: wrapped in try/catch so stopDaemon doesn't throw if process died between liveness check and kill - safeUnlink(): replaces existsSync+unlinkSync TOCTOU patterns everywhere - child.stdin EPIPE: error handler added in executeBash to silence EPIPE when command doesn't consume stdin Config (src/legacy/daemon-config.ts) - loadDaemonConfig: distinguishes ENOENT from parse errors; warns to stderr on corrupt config rather than silently reverting to defaults - BashAction.command: security notice added (chmod 600, trust boundary) bin/sphere.mjs - Always calls process.exit(code) so open Nostr sockets don't keep process alive - Dev mode: resolve tsx from node_modules/.bin to avoid PATH injection - Child process: listen on 'close' (not 'exit') to flush stdout before exiting --- bin/sphere.mjs | 19 ++- src/index.ts | 98 +++++++++++++- src/legacy/daemon-config.ts | 30 ++++- src/legacy/daemon.ts | 260 ++++++++++++++++++++++++++++++------ src/legacy/legacy-cli.ts | 89 +++++++++--- 5 files changed, 424 insertions(+), 72 deletions(-) diff --git a/bin/sphere.mjs b/bin/sphere.mjs index 3d369ed..247fbe7 100755 --- a/bin/sphere.mjs +++ b/bin/sphere.mjs @@ -16,15 +16,24 @@ const srcEntry = resolve(__dirname, '../src/index.ts'); if (existsSync(distEntry)) { const mod = await import(distEntry); const code = await mod.main(process.argv); - if (code !== 0) process.exit(code); + // Always exit explicitly so open handles (Nostr sockets, timers) don't keep + // the process alive after the command finishes. Daemon commands manage their + // own keep-alive via event loop references and never return from main(). + process.exit(code); } else if (existsSync(srcEntry)) { - // Dev mode: delegate to tsx (must be installed). + // Dev mode: resolve tsx from local node_modules to avoid PATH injection. const { spawn } = await import('node:child_process'); - const child = spawn('npx', ['tsx', srcEntry, ...process.argv.slice(2)], { + const tsxBin = resolve(__dirname, '../node_modules/.bin/tsx'); + const tsxEntry = existsSync(tsxBin) ? tsxBin : 'npx tsx'; + const [cmd, ...cmdArgs] = tsxEntry.includes(' ') + ? ['npx', 'tsx'] + : [tsxBin]; + const child = spawn(cmd, [...cmdArgs, srcEntry, ...process.argv.slice(2)], { stdio: 'inherit', }); - child.on('exit', (code) => process.exit(code ?? 0)); + // Use 'close' (not 'exit') so all child stdio flushes before we exit. + child.on('close', (code) => process.exit(code ?? 0)); } else { - console.error('sphere: no entry found. Did you run `npm run build`?'); + process.stderr.write('sphere: no entry found. Did you run `npm run build`?\n'); process.exit(1); } diff --git a/src/index.ts b/src/index.ts index 2d702bf..8091c60 100644 --- a/src/index.ts +++ b/src/index.ts @@ -36,6 +36,80 @@ const PHASE4_NAMESPACES: Array<[string, string]> = [ ['tenant', 'ACP: controller → tenant (over DM, host-agnostic)'], ]; +/** + * Translate a commander namespace + subcommand into the argv shape that the + * legacy sphere-sdk CLI switch/case dispatcher expects. + * + * The legacy dispatcher reads `args[0]` as `command`. Different commander + * namespaces require different translations: + * - `wallet`, `balance`, `daemon`, `config`, `completions` map 1:1. + * - `faucet` rewrites to `topup`. + * - `nametag ` expands to legacy flat commands (`nametag-info`, etc.). + * - `payments`, `crypto`, `util` strip the namespace entirely (their subs + * are already legacy top-level commands like `send`, `to-human`, ...). + * - `dm`, `group`, `market`, `swap`, `invoice` prefix the subcommand with + * `-` (e.g. `sphere swap propose` → `swap-propose`). + */ +function buildLegacyArgv(namespace: string): string[] { + // tail = everything after: node sphere + const tail = process.argv.slice(3); + + switch (namespace) { + // These namespaces directly match legacy top-level commands — keep namespace as command + case 'wallet': return ['wallet', ...tail]; + case 'balance': return ['balance', ...tail]; + case 'daemon': return ['daemon', ...tail]; + case 'config': return ['config', ...tail]; + case 'completions': return ['completions', ...tail]; + + // faucet → legacy 'topup' + case 'faucet': return ['topup', ...tail]; + + // nametag subcommands: register → nametag, info → nametag-info, my → my-nametag, sync → nametag-sync + case 'nametag': { + const [sub, ...rest] = tail; + if (!sub || sub === 'register') return ['nametag', ...rest]; + if (sub === 'info') return ['nametag-info', ...rest]; + if (sub === 'my') return ['my-nametag', ...rest]; + if (sub === 'sync') return ['nametag-sync', ...rest]; + return ['nametag', sub, ...rest]; + } + + // payments → strip namespace (send, receive, history, sync, addresses, switch, hide, unhide) + case 'payments': return tail; + + // crypto → strip namespace (generate-key, hex-to-wif, validate-key, etc.) + case 'crypto': return tail; + + // util → strip namespace (to-human, to-smallest, format, base58-*) + case 'util': return tail; + + // dm: send → dm @addr msg; inbox → dm-inbox; history → dm-history + case 'dm': { + const [sub, ...rest] = tail; + if (!sub) return ['dm', ...rest]; + if (sub === 'send') return ['dm', ...rest]; + if (sub === 'inbox') return ['dm-inbox', ...rest]; + if (sub === 'history') return ['dm-history', ...rest]; + return ['dm', sub, ...rest]; + } + + // group, market, swap, invoice: prefix subcommand with 'namespace-' + case 'group': return prefixSub('group-', tail); + case 'market': return prefixSub('market-', tail); + case 'swap': return prefixSub('swap-', tail); + case 'invoice': return prefixSub('invoice-', tail); + + default: return [namespace, ...tail]; + } +} + +function prefixSub(prefix: string, tail: string[]): string[] { + const [sub, ...rest] = tail; + if (!sub) return [prefix.replace(/-$/, ''), ...rest]; + return [`${prefix}${sub}`, ...rest]; +} + export function createCli(): Command { const program = new Command(); @@ -44,6 +118,11 @@ export function createCli(): Command { .description('The unified CLI for Sphere SDK and agentic-hosting control') .version(VERSION, '-v, --version', 'output the version number'); + // Throw CommanderError instead of calling process.exit() for --help, --version, + // and parse errors. main() catches these and returns the right code, which is + // essential for a CLI that is also imported from tests. + program.exitOverride(); + // Phase 2: legacy commands — delegate to the sphere-sdk CLI dispatcher. for (const name of LEGACY_NAMESPACES) { const sub = program @@ -52,8 +131,9 @@ export function createCli(): Command { sub.allowUnknownOption(true); sub.action(async () => { + const legacyArgv = buildLegacyArgv(name); const { legacyMain } = await import('./legacy/legacy-cli.js'); - await legacyMain(); + await legacyMain(legacyArgv); }); } @@ -83,9 +163,19 @@ export async function main(argv: string[] = process.argv): Promise { await program.parseAsync(argv); return 0; } catch (err) { - process.stderr.write( - `sphere: ${err instanceof Error ? err.message : String(err)}\n`, - ); + // commander throws CommanderError on --help/--version/parse errors; those are + // handled internally (output already printed). Re-exit with the right code. + if (err && typeof err === 'object' && 'code' in err && 'exitCode' in err) { + const ce = err as { code: string; exitCode: number }; + if (ce.code === 'commander.helpDisplayed' || ce.code === 'commander.version') { + return 0; + } + return ce.exitCode ?? 1; + } + // Sanitize: never echo raw error messages (may contain mnemonics or keys) + const raw = err instanceof Error ? err.message : String(err); + const safe = raw.replace(/\b([a-z]+\s+){11,23}[a-z]+\b/gi, '[REDACTED]'); + process.stderr.write(`sphere: ${safe}\n`); return 1; } } diff --git a/src/legacy/daemon-config.ts b/src/legacy/daemon-config.ts index 35e325c..65ec320 100644 --- a/src/legacy/daemon-config.ts +++ b/src/legacy/daemon-config.ts @@ -5,6 +5,10 @@ import * as fs from 'fs'; import * as path from 'path'; +// ============================================================================= +// Atomic file writes +// ============================================================================= + // ============================================================================= // Interfaces // ============================================================================= @@ -30,6 +34,9 @@ export type DaemonAction = BashAction | WebhookAction | BuiltinAction; export interface BashAction { type: 'bash'; + // SECURITY: `command` is executed via exec() with full shell interpretation. + // The daemon config file must be chmod 600 and writable only by the daemon user. + // Never accept daemon config from untrusted sources. command: string; timeout?: number; cwd?: string; @@ -228,15 +235,30 @@ function validateAction(action: unknown, ruleIndex: number, actionIndex: number) export function loadDaemonConfig(configPath?: string): DaemonConfig { const filePath = configPath || DEFAULT_CONFIG_PATH; - if (!fs.existsSync(filePath)) { - throw new Error(`Config file not found: ${filePath}`); + + let raw: string; + try { + raw = fs.readFileSync(filePath, 'utf8'); + } catch (e: unknown) { + if ((e as NodeJS.ErrnoException).code === 'ENOENT') { + throw new Error(`Config file not found: ${filePath}`); + } + // Non-ENOENT read errors (permissions, I/O) surface as warnings + rethrow. + process.stderr.write( + `sphere: WARNING: Failed to read config at ${filePath} — ` + + `Backup the file and delete it to reset. Error: ${(e as Error).message}\n` + ); + throw e; } - const raw = fs.readFileSync(filePath, 'utf8'); let parsed: unknown; try { parsed = JSON.parse(raw); - } catch { + } catch (e: unknown) { + process.stderr.write( + `sphere: WARNING: Failed to parse config at ${filePath} — using defaults. ` + + `Backup the file and delete it to reset. Error: ${(e as Error).message}\n` + ); throw new Error(`Invalid JSON in config file: ${filePath}`); } diff --git a/src/legacy/daemon.ts b/src/legacy/daemon.ts index 6c12a9a..dd4c4ed 100644 --- a/src/legacy/daemon.ts +++ b/src/legacy/daemon.ts @@ -28,6 +28,99 @@ import { ensureDir, } from './daemon-config'; +// ============================================================================= +// PID file helpers (JSON format with nonce for PID reuse detection) +// ============================================================================= + +interface PidFileData { + pid: number; + nonce: number; + cmd: string; +} + +/** + * Parse a PID file. Handles both the new JSON format and the legacy plain-text + * format (just a number). Returns null on parse failure or missing file. + */ +function readPidFile(pidFile: string): PidFileData | null { + let raw: string; + try { + raw = fs.readFileSync(pidFile, 'utf8').trim(); + } catch (e: unknown) { + if ((e as NodeJS.ErrnoException).code === 'ENOENT') return null; + return null; + } + if (!raw) return null; + + // Try JSON first (current format) + try { + const obj = JSON.parse(raw) as unknown; + if (obj && typeof obj === 'object') { + const o = obj as Record; + const pid = typeof o.pid === 'number' ? o.pid : NaN; + if (!Number.isFinite(pid)) return null; + return { + pid, + nonce: typeof o.nonce === 'number' ? o.nonce : 0, + cmd: typeof o.cmd === 'string' ? o.cmd : '', + }; + } + } catch { + // Fall through to legacy format + } + + // Legacy plain-text PID + const pid = parseInt(raw, 10); + if (!Number.isFinite(pid)) return null; + return { pid, nonce: 0, cmd: '' }; +} + +/** + * Check if `pid` is alive and appears to be a node process (via /proc//comm + * on Linux). On non-Linux platforms, falls back to a pure liveness check. + * Returns false for dead PIDs and for PIDs that are alive but clearly not ours + * (i.e. PID reuse case). + */ +function isDaemonProcessAlive(pid: number): boolean { + if (!isProcessAlive(pid)) return false; + // Best-effort PID reuse detection via /proc//comm (Linux only). + try { + const comm = fs.readFileSync(`/proc/${pid}/comm`, 'utf8').trim().toLowerCase(); + // Node processes show up as 'node' (or 'sphere-daemon' if process.title was set). + if (comm.length === 0) return true; + if (comm.includes('node') || comm.includes('sphere')) return true; + // Alive but the cmd doesn't match — treat as stale (PID reuse). + return false; + } catch { + // /proc not available (non-Linux) or no permission — fall back to liveness. + return true; + } +} + +/** + * Write the PID file atomically with exclusive create semantics. + * Throws EEXIST if another daemon has already claimed the file. + */ +function writePidFileExclusive(pidFile: string, data: PidFileData): void { + const fd = fs.openSync(pidFile, 'wx'); + try { + fs.writeSync(fd, JSON.stringify(data)); + } finally { + fs.closeSync(fd); + } +} + +/** + * Delete a file, ignoring ENOENT. Avoids TOCTOU races from existsSync+unlinkSync. + */ +function safeUnlink(filePath: string): void { + try { + fs.unlinkSync(filePath); + } catch (e: unknown) { + if ((e as NodeJS.ErrnoException).code !== 'ENOENT') throw e; + } +} + // ============================================================================= // All known SphereEventType values (for wildcard expansion) // ============================================================================= @@ -177,6 +270,9 @@ function buildEnvVars(eventType: string, data: unknown): Record function executeBash(action: BashAction, envVars: Record, globalTimeout: number): Promise { const timeout = action.timeout || globalTimeout; return new Promise((resolve) => { + // SECURITY: `command` is executed via exec() with full shell interpretation. + // The daemon config file must be chmod 600 and writable only by the daemon user. + // Never accept daemon config from untrusted sources. const child = exec(action.command, { env: { ...process.env, ...envVars }, timeout, @@ -198,8 +294,9 @@ function executeBash(action: BashAction, envVars: Record, global resolve(); }); - // Pipe event JSON to stdin + // Pipe event JSON to stdin. Ignore EPIPE in case the child closes stdin early. if (child.stdin) { + child.stdin.on('error', () => { /* ignore EPIPE etc. */ }); child.stdin.write(envVars.SPHERE_EVENT_JSON); child.stdin.end(); } @@ -358,6 +455,7 @@ function setupMarketFeed( sphere: Sphere, dispatchMap: Map, globalTimeout: number, + inflight: Set>, ): (() => void) | null { if (!sphere.market) { log('Warning: Market module not available, skipping market feed'); @@ -371,9 +469,11 @@ function setupMarketFeed( const unsubscribe = sphere.market.subscribeFeed((message) => { const envVars = buildEnvVars('market:feed', message); for (const rule of marketRules) { - dispatchRule(rule, 'market:feed', message, envVars, sphere, globalTimeout).catch(err => { + const p = dispatchRule(rule, 'market:feed', message, envVars, sphere, globalTimeout).catch(err => { log(`[DISPATCH ERROR] market:feed: ${err instanceof Error ? err.message : err}`); }); + inflight.add(p); + p.finally(() => inflight.delete(p)); } }); @@ -409,6 +509,32 @@ export async function runDaemon( verboseMode = flags.verbose; + // ----------------------------------------------------------------------- + // Fix D-3: Install early signal handlers BEFORE any async setup begins, so + // a SIGTERM during getSphere() or subscription wiring still cleans up the + // PID file and exits cleanly instead of being handled by the default + // terminating action. + // ----------------------------------------------------------------------- + let shuttingDown = false; + let fullShutdown: (() => Promise) | null = null; + + const earlyShutdown = (): void => { + if (shuttingDown) return; + shuttingDown = true; + if (fullShutdown) { + fullShutdown().catch(() => { /* swallow */ }).finally(() => process.exit(0)); + } else { + // Signal arrived before setup completed — best-effort PID file cleanup. + if (flags._forked || flags.detach) { + safeUnlink(config.pidFile); + } + process.exit(0); + } + }; + + process.on('SIGTERM', earlyShutdown); + process.on('SIGINT', earlyShutdown); + // In forked mode, redirect stdout/stderr to log file if (flags._forked) { ensureDir(config.logFile); @@ -422,9 +548,26 @@ export async function runDaemon( console.error = (...a: unknown[]) => { stream.write('[ERROR] ' + a.map(String).join(' ') + '\n'); }; console.warn = (...a: unknown[]) => { stream.write('[WARN] ' + a.map(String).join(' ') + '\n'); }; - // Write PID file + // Fix D-1 + D-2: Exclusive-create the PID file with JSON payload so two + // concurrent `sphere daemon start` invocations don't both succeed, and so + // stop logic can detect PID reuse via the nonce + cmd fields. ensureDir(config.pidFile); - fs.writeFileSync(config.pidFile, String(process.pid)); + const pidData: PidFileData = { + pid: process.pid, + nonce: Date.now(), + cmd: 'sphere-daemon', + }; + try { + writePidFileExclusive(config.pidFile, pidData); + } catch (e: unknown) { + if ((e as NodeJS.ErrnoException).code === 'EEXIST') { + process.stderr.write( + 'sphere daemon: another daemon instance is already starting. Exiting.\n' + ); + process.exit(1); + } + throw e; + } // Disconnect from parent if (process.disconnect) process.disconnect(); @@ -469,6 +612,9 @@ export async function runDaemon( log(`Wallet: ${identity.nametag ? '@' + identity.nametag : identity.l1Address}`); } + // Fix D-4: Track in-flight dispatch promises so shutdown can await them. + const inflight = new Set>(); + // Subscribe to events const unsubscribers: (() => void)[] = []; @@ -480,9 +626,11 @@ export async function runDaemon( } const envVars = buildEnvVars(eventType, data); for (const rule of rules) { - dispatchRule(rule, eventType, data, envVars, sphere, config.actionTimeout).catch(err => { + const p = dispatchRule(rule, eventType, data, envVars, sphere, config.actionTimeout).catch(err => { log(`[DISPATCH ERROR] ${eventType}: ${err instanceof Error ? err.message : err}`); }); + inflight.add(p); + p.finally(() => inflight.delete(p)); } }); unsubscribers.push(unsub); @@ -490,43 +638,55 @@ export async function runDaemon( // Market feed if (config.marketFeed || dispatchMap.has('market:feed' as SphereEventType)) { - const unsub = setupMarketFeed(sphere, dispatchMap, config.actionTimeout); + const unsub = setupMarketFeed(sphere, dispatchMap, config.actionTimeout, inflight); if (unsub) unsubscribers.push(unsub); } log('Daemon running. Waiting for events...'); // Graceful shutdown - let shuttingDown = false; - const shutdown = async () => { - if (shuttingDown) return; - shuttingDown = true; + const shutdown = async (): Promise => { log('Shutting down daemon...'); for (const unsub of unsubscribers) { try { unsub(); } catch { /* ignore */ } } + // Fix D-4: Wait for in-flight dispatches to drain (max 10s) before + // tearing down Sphere or exiting. Prevents mid-action corruption of + // token state when SIGTERM arrives during auto-receive / bash / webhook. + if (inflight.size > 0) { + await Promise.race([ + Promise.allSettled([...inflight]), + new Promise(resolve => setTimeout(resolve, 10_000)), + ]); + } + try { await closeSphere(); } catch { /* ignore */ } - // Clean up PID file + // Fix D-7: replace existsSync+unlinkSync with safeUnlink (no TOCTOU). if (flags._forked || flags.detach) { - try { - if (fs.existsSync(config.pidFile)) fs.unlinkSync(config.pidFile); - } catch { /* ignore */ } + try { safeUnlink(config.pidFile); } catch { /* ignore */ } } + // Fix D-5 / D-9: log final message BEFORE ending the stream, then await + // end() so pending writes flush before we exit. + log('Daemon stopped.'); if (logStream) { - logStream.end(); + const stream = logStream; logStream = null; + await new Promise(resolve => { + try { + stream.end(() => resolve()); + } catch { + resolve(); + } + }); } - - log('Daemon stopped.'); - process.exit(0); }; - process.on('SIGINT', shutdown); - process.on('SIGTERM', shutdown); + // Swap the early handler for the real one now that setup is complete. + fullShutdown = shutdown; // Keep alive await new Promise(() => {}); @@ -547,15 +707,17 @@ function detachDaemon(args: string[], flags: DaemonFlags): void { pidFile = getDefaultPidFile(); } - // Check if already running - if (fs.existsSync(pidFile)) { - const existingPid = parseInt(fs.readFileSync(pidFile, 'utf8').trim(), 10); - if (isProcessAlive(existingPid)) { - console.error(`Daemon already running (PID ${existingPid}). Use "daemon stop" first.`); + // Check if already running. Note: this check is advisory only — the forked + // child performs an atomic exclusive-create on the PID file so concurrent + // `daemon start` invocations won't both succeed (Fix D-1). + const existing = readPidFile(pidFile); + if (existing) { + if (isDaemonProcessAlive(existing.pid)) { + console.error(`Daemon already running (PID ${existing.pid}). Use "daemon stop" first.`); process.exit(1); } - // Stale PID file - fs.unlinkSync(pidFile); + // Stale PID file (process dead OR PID reused by unrelated process). + safeUnlink(pidFile); } // Build child args: replace --detach with --_forked, keep everything else @@ -589,23 +751,31 @@ export async function stopDaemon(args: string[]): Promise { const flags = parseDaemonFlags(args); const pidFile = flags.pidFile || getDefaultPidFile(); - if (!fs.existsSync(pidFile)) { + const pidData = readPidFile(pidFile); + if (!pidData) { console.log('No daemon running (PID file not found).'); return; } + const pid = pidData.pid; - const pid = parseInt(fs.readFileSync(pidFile, 'utf8').trim(), 10); - - if (!isProcessAlive(pid)) { - console.log(`Stale PID file (process ${pid} not running). Cleaning up.`); - fs.unlinkSync(pidFile); + // Fix D-2: Check liveness + PID reuse detection. If the PID is alive but + // appears to be a different (non-node) process, treat as stale so we don't + // SIGTERM someone else's editor/shell. + if (!isDaemonProcessAlive(pid)) { + console.log(`Stale PID file (process ${pid} not running or reused). Cleaning up.`); + safeUnlink(pidFile); return; } console.log(`Stopping daemon (PID ${pid})...`); - // Send SIGTERM - process.kill(pid, 'SIGTERM'); + // Fix D-6: SIGTERM may race against process exit (ESRCH). Treat as already-dead. + try { + process.kill(pid, 'SIGTERM'); + } catch (e: unknown) { + if ((e as NodeJS.ErrnoException).code !== 'ESRCH') throw e; + // Already dead — fall through to cleanup. + } // Wait up to 5 seconds for graceful shutdown const deadline = Date.now() + 5000; @@ -613,8 +783,8 @@ export async function stopDaemon(args: string[]): Promise { await sleep(200); if (!isProcessAlive(pid)) { console.log('Daemon stopped.'); - // Clean up PID file if it still exists - if (fs.existsSync(pidFile)) fs.unlinkSync(pidFile); + // Fix D-7: no existsSync race — unlink-or-ignore-ENOENT. + safeUnlink(pidFile); return; } } @@ -623,13 +793,14 @@ export async function stopDaemon(args: string[]): Promise { console.log('Graceful shutdown timed out, sending SIGKILL...'); try { process.kill(pid, 'SIGKILL'); - } catch { - // Process may have exited between check and kill + } catch (e: unknown) { + if ((e as NodeJS.ErrnoException).code !== 'ESRCH') throw e; + // Process already exited. } await sleep(500); - if (fs.existsSync(pidFile)) fs.unlinkSync(pidFile); + safeUnlink(pidFile); console.log('Daemon killed.'); } @@ -641,14 +812,15 @@ export async function statusDaemon(args: string[]): Promise { const flags = parseDaemonFlags(args); const pidFile = flags.pidFile || getDefaultPidFile(); - if (!fs.existsSync(pidFile)) { + const pidData = readPidFile(pidFile); + if (!pidData) { console.log('Daemon is not running.'); return; } + const pid = pidData.pid; - const pid = parseInt(fs.readFileSync(pidFile, 'utf8').trim(), 10); - - if (!isProcessAlive(pid)) { + // Fix D-2: treat "alive but not a node process" as stale (PID reuse). + if (!isDaemonProcessAlive(pid)) { console.log(`Daemon is not running (stale PID file, process ${pid}).`); return; } diff --git a/src/legacy/legacy-cli.ts b/src/legacy/legacy-cli.ts index 819b0dd..8cc231b 100644 --- a/src/legacy/legacy-cli.ts +++ b/src/legacy/legacy-cli.ts @@ -32,8 +32,30 @@ import type { TxfToken, } from '@unicitylabs/sphere-sdk'; -const args = process.argv.slice(2); -const command = args[0]; +/** + * Strip prototype-pollution-prone keys from user-supplied JSON before passing + * it deeper into the SDK. Guards against `__proto__`, `constructor`, and + * `prototype` keys that could mutate Object.prototype or bypass checks. + */ +function writeAtomic(filePath: string, contents: string): void { + const tmp = filePath + '.tmp'; + fs.writeFileSync(tmp, contents, 'utf8'); + fs.renameSync(tmp, filePath); +} + +function stripDangerousKeys(value: unknown, depth = 0): unknown { + if (depth > 20 || value === null || typeof value !== 'object') return value; + if (Array.isArray(value)) return value.map(v => stripDangerousKeys(v, depth + 1)); + const out: Record = Object.create(null); + for (const [k, v] of Object.entries(value as Record)) { + if (k === '__proto__' || k === 'constructor' || k === 'prototype') continue; + out[k] = stripDangerousKeys(v, depth + 1); + } + return out; +} + +let args: string[] = []; +let command: string | undefined; // ============================================================================= // CLI Configuration @@ -80,7 +102,7 @@ function loadConfig(): CliConfig { function saveConfig(config: CliConfig): void { fs.mkdirSync(path.dirname(CONFIG_FILE), { recursive: true }); - fs.writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2)); + writeAtomic(CONFIG_FILE, JSON.stringify(config, null, 2)); } // ============================================================================= @@ -100,7 +122,7 @@ function loadProfiles(): ProfilesStore { function saveProfiles(store: ProfilesStore): void { fs.mkdirSync(path.dirname(PROFILES_FILE), { recursive: true }); - fs.writeFileSync(PROFILES_FILE, JSON.stringify(store, null, 2)); + writeAtomic(PROFILES_FILE, JSON.stringify(store, null, 2)); } function getProfile(name: string): WalletProfile | undefined { @@ -1496,11 +1518,30 @@ Examples: `); } -export async function legacyMain(): Promise { +export async function legacyMain(argv: string[]): Promise { + args = argv; + command = args[0]; await main(); } async function main(): Promise { + // Intercept process.exit() so we tear down the Sphere instance (Nostr + // relays, IPFS handles, SQLite connections) before the process dies. + // Inside command handlers there are ~25 `process.exit(1)` calls that + // would otherwise skip finally blocks and leak resources. + const originalExit = process.exit.bind(process); + process.exit = ((code?: number) => { + if (sphereInstance) { + const inst = sphereInstance; + sphereInstance = null; + inst.destroy() + .catch(() => { /* best-effort cleanup */ }) + .finally(() => originalExit(code)); + return undefined as never; + } + return originalExit(code); + }) as typeof process.exit; + // Global flag: --no-nostr disables Nostr transport (uses no-op) noNostrGlobal = args.includes('--no-nostr'); @@ -1546,7 +1587,9 @@ async function main(): Promise { if (mnemonicIndex !== -1) { if (args[mnemonicIndex + 1] && !args[mnemonicIndex + 1].startsWith('--')) { mnemonic = args[mnemonicIndex + 1]; - // Clear from argv to reduce exposure in /proc//cmdline + // Note: mutating `args` does NOT clear /proc//cmdline — that reads + // the original process memory. Passing mnemonics via --mnemonic is inherently + // unsafe on shared systems; prefer --mnemonic-file or interactive prompt. args[mnemonicIndex + 1] = '***'; } else { // Interactive prompt — mnemonic never appears in process args @@ -1592,14 +1635,20 @@ async function main(): Promise { }, null, 2)); if (!mnemonic) { - // Show generated mnemonic for backup + // Show generated mnemonic for backup — only when stdout is a TTY. + // If stdout is piped to a file or CI log, printing the mnemonic + // would persist it in plaintext on disk / in logs. const storedMnemonic = sphere.getMnemonic(); if (storedMnemonic) { - console.log('\n⚠️ BACKUP YOUR MNEMONIC (24 words):'); - console.log('─'.repeat(50)); - console.log(storedMnemonic); - console.log('─'.repeat(50)); - console.log('Store this safely! You will need it to recover your wallet.\n'); + if (process.stdout.isTTY) { + console.log('\n⚠️ BACKUP YOUR MNEMONIC (24 words):'); + console.log('─'.repeat(50)); + console.log(storedMnemonic); + console.log('─'.repeat(50)); + console.log('Store this safely! You will need it to recover your wallet.\n'); + } else { + process.stderr.write('\nWARNING: Mnemonic NOT shown (stdout is not a terminal). Re-run interactively to see it.\n'); + } } } @@ -2517,6 +2566,9 @@ async function main(): Promise { console.log(`\n✓ Nametag @${cleanName} registered successfully!`); } catch (err) { console.error('\n✗ Registration failed:', err instanceof Error ? err.message : err); + await closeSphere(); + // Exit non-zero so downstream scripts know the registration failed. + process.exit(1); } await closeSphere(); @@ -2714,6 +2766,12 @@ async function main(): Promise { const addressInfo = generateAddressFromMasterKey(privateKey, 0); if (args.includes('--unsafe-print')) { + // Refuse to print the private key unless stdout is a terminal. + // Piping to a file or CI log would persist the secret in plaintext. + if (!process.stdout.isTTY) { + process.stderr.write('sphere generate-key: refusing to print private key to non-TTY stdout. Use --allow-non-tty to override.\n'); + process.exit(1); + } console.log(JSON.stringify({ privateKey, publicKey, @@ -3648,7 +3706,7 @@ async function main(): Promise { } let result; try { - result = await sphere.accounting.createInvoice(termsJson as CreateInvoiceRequest); + result = await sphere.accounting.createInvoice(stripDangerousKeys(termsJson) as CreateInvoiceRequest); } catch (err) { const msg = err instanceof Error ? err.message : String(err); console.error(`Failed to create invoice from terms file: ${msg}`); @@ -3734,7 +3792,7 @@ async function main(): Promise { let terms; try { - terms = await sphere.accounting.importInvoice(tokenJson as TxfToken); + terms = await sphere.accounting.importInvoice(stripDangerousKeys(tokenJson) as TxfToken); } catch (err) { const msg = err instanceof Error ? err.message : String(err); console.error(`Failed to import invoice: ${msg}`); @@ -4204,7 +4262,7 @@ async function main(): Promise { } const outFile = `invoice-${invoiceId.slice(0, 8)}.json`; - fs.writeFileSync(outFile, JSON.stringify(invoiceRef, null, 2)); + writeAtomic(outFile, JSON.stringify(invoiceRef, null, 2)); console.log(`Invoice exported to: ${outFile}`); await closeSphere(); @@ -4785,6 +4843,7 @@ async function main(): Promise { } } catch (e) { console.error('Error:', e instanceof Error ? e.message : e); + await closeSphere().catch(() => { /* best-effort cleanup */ }); process.exit(1); } } From b0d02e04c321c769ccaea50b41244d2be93ccce2 Mon Sep 17 00:00:00 2001 From: Vladimir Rogojin Date: Wed, 22 Apr 2026 17:31:11 +0200 Subject: [PATCH 03/16] feat(transport): DM transport layer for HMCP-0 over Sphere DMs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds src/transport/ with: - hmcp-types.ts: full HMCP-0 type definitions, constructors, and validator (parseHmcpResponse validates structure + dangerous keys + 64 KiB size limit) - errors.ts: TimeoutError, AuthError, TransportError - dm-transport.ts: DmTransport interface + createDmTransport factory * sendRequest: single-response with correlator + timeout * sendRequestStream: multi-response (spawn flow: ack → ready/failed) * sender auth via resolved pubkey (lazy on first send) * compressed (02/03) pubkey normalisation * dispose() cancels all in-flight requests - dm-transport.test.ts: 13 tests covering happy path, timeout, auth, multi-correlator, streaming, dispose, malformed/oversized/poisoned messages --- src/transport/dm-transport.test.ts | 332 +++++++++++++++++++++++++++++ src/transport/dm-transport.ts | 268 +++++++++++++++++++++++ src/transport/errors.ts | 20 ++ src/transport/hmcp-types.ts | 234 ++++++++++++++++++++ 4 files changed, 854 insertions(+) create mode 100644 src/transport/dm-transport.test.ts create mode 100644 src/transport/dm-transport.ts create mode 100644 src/transport/errors.ts create mode 100644 src/transport/hmcp-types.ts diff --git a/src/transport/dm-transport.test.ts b/src/transport/dm-transport.test.ts new file mode 100644 index 0000000..487cb10 --- /dev/null +++ b/src/transport/dm-transport.test.ts @@ -0,0 +1,332 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { createDmTransport } from './dm-transport.js'; +import { createHmcpRequest, HMCP_VERSION } from './hmcp-types.js'; +import { TimeoutError, TransportError } from './errors.js'; +import type { DirectMessage } from '@unicitylabs/sphere-sdk'; +import type { SphereComms } from './dm-transport.js'; +import type { HmcpResponse } from './hmcp-types.js'; + +// Flush the microtask queue without touching the fake timer macrotask queue. +const flushPromises = () => new Promise((resolve) => queueMicrotask(resolve)); + +// ============================================================================= +// Helpers +// ============================================================================= + +const MANAGER_PUBKEY = 'a'.repeat(64); // 64-char x-only hex + +function makeResponse(inReplyTo: string, type: HmcpResponse['type'], payload = {}): string { + return JSON.stringify({ hmcp_version: HMCP_VERSION, in_reply_to: inReplyTo, type, payload }); +} + +function makeDM(content: string, senderPubkey = MANAGER_PUBKEY): DirectMessage { + return { + id: crypto.randomUUID(), + senderPubkey, + recipientPubkey: 'b'.repeat(64), + content, + timestamp: Date.now(), + isRead: false, + }; +} + +function buildMockComms() { + const handlers: Array<(msg: DirectMessage) => void> = []; + + const comms: SphereComms = { + sendDM: vi.fn().mockResolvedValue({ recipientPubkey: MANAGER_PUBKEY }), + onDirectMessage: vi.fn((handler) => { + handlers.push(handler); + return () => { const idx = handlers.indexOf(handler); if (idx !== -1) handlers.splice(idx, 1); }; + }), + }; + + const deliver = (msg: DirectMessage) => handlers.forEach((h) => h(msg)); + + return { comms, deliver }; +} + +// ============================================================================= +// Tests +// ============================================================================= + +describe('DmTransport', () => { + beforeEach(() => { vi.useFakeTimers(); }); + afterEach(() => { vi.useRealTimers(); }); + + // -------------------------------------------------------------------------- + // sendRequest — happy path + // -------------------------------------------------------------------------- + + it('sends request JSON and returns correlated response', async () => { + const { comms, deliver } = buildMockComms(); + const transport = createDmTransport(comms, { managerAddress: '@manager', timeoutMs: 5_000 }); + + const request = createHmcpRequest('hm.list', {}); + const responsePromise = transport.sendRequest(request); + + // Simulate network: after send resolves, manager replies + await flushPromises(); + deliver(makeDM(makeResponse(request.msg_id, 'hm.list_result', { instances: [] }))); + + const response = await responsePromise; + expect(response.type).toBe('hm.list_result'); + expect(response.in_reply_to).toBe(request.msg_id); + + const sentBody = JSON.parse((comms.sendDM as ReturnType).mock.calls[0][1] as string); + expect(sentBody.msg_id).toBe(request.msg_id); + expect(sentBody.type).toBe('hm.list'); + + await transport.dispose(); + }); + + // -------------------------------------------------------------------------- + // sendRequest — timeout + // -------------------------------------------------------------------------- + + it('throws TimeoutError when no response arrives', async () => { + const { comms } = buildMockComms(); + const transport = createDmTransport(comms, { managerAddress: '@manager', timeoutMs: 1_000 }); + + const request = createHmcpRequest('hm.help', {}); + const responsePromise = transport.sendRequest(request); + + await flushPromises(); + vi.advanceTimersByTime(1_001); + + await expect(responsePromise).rejects.toThrow(TimeoutError); + await transport.dispose(); + }); + + // -------------------------------------------------------------------------- + // sendRequest — send failure + // -------------------------------------------------------------------------- + + it('throws TransportError when sendDM rejects', async () => { + const { comms } = buildMockComms(); + (comms.sendDM as ReturnType).mockRejectedValueOnce(new Error('relay offline')); + const transport = createDmTransport(comms, { managerAddress: '@manager', timeoutMs: 5_000 }); + + const request = createHmcpRequest('hm.list', {}); + await expect(transport.sendRequest(request)).rejects.toThrow(TransportError); + + await transport.dispose(); + }); + + // -------------------------------------------------------------------------- + // Auth: ignore DMs from unknown senders + // -------------------------------------------------------------------------- + + it('ignores DMs from senders other than the resolved manager', async () => { + const { comms, deliver } = buildMockComms(); + const transport = createDmTransport(comms, { managerAddress: '@manager', timeoutMs: 200 }); + + const request = createHmcpRequest('hm.list', {}); + const responsePromise = transport.sendRequest(request); + await flushPromises(); + + // Deliver from wrong sender — should be ignored + deliver(makeDM(makeResponse(request.msg_id, 'hm.list_result', { instances: [] }), 'f'.repeat(64))); + + vi.advanceTimersByTime(201); + await expect(responsePromise).rejects.toThrow(TimeoutError); + + await transport.dispose(); + }); + + // -------------------------------------------------------------------------- + // Correlation: only routes to matching msg_id + // -------------------------------------------------------------------------- + + it('routes responses to correct correlator when multiple requests in-flight', async () => { + const { comms, deliver } = buildMockComms(); + const transport = createDmTransport(comms, { managerAddress: '@manager', timeoutMs: 5_000 }); + + const req1 = createHmcpRequest('hm.list', {}); + const req2 = createHmcpRequest('hm.help', {}); + + const [p1, p2] = [transport.sendRequest(req1), transport.sendRequest(req2)]; + await flushPromises(); + + deliver(makeDM(makeResponse(req2.msg_id, 'hm.help_result', { commands: [], version: '0.1' }))); + deliver(makeDM(makeResponse(req1.msg_id, 'hm.list_result', { instances: [] }))); + + const [r1, r2] = await Promise.all([p1, p2]); + expect(r1.type).toBe('hm.list_result'); + expect(r2.type).toBe('hm.help_result'); + + await transport.dispose(); + }); + + // -------------------------------------------------------------------------- + // sendRequestStream — multi-response (spawn flow) + // -------------------------------------------------------------------------- + + it('streams multiple responses until onResponse returns true', async () => { + const { comms, deliver } = buildMockComms(); + const transport = createDmTransport(comms, { managerAddress: '@manager', streamTimeoutMs: 10_000 }); + + const request = createHmcpRequest('hm.spawn', { template_id: 'tpl-1', instance_name: 'bot' }); + const received: string[] = []; + + const streamPromise = transport.sendRequestStream(request, (response) => { + received.push(response.type); + return response.type === 'hm.spawn_ready' || response.type === 'hm.spawn_failed'; + }); + + await flushPromises(); + deliver(makeDM(makeResponse(request.msg_id, 'hm.spawn_ack', { accepted: true, instance_id: 'i1', instance_name: 'bot', state: 'BOOTING' }))); + await flushPromises(); + deliver(makeDM(makeResponse(request.msg_id, 'hm.spawn_ready', { instance_id: 'i1', instance_name: 'bot', state: 'RUNNING', tenant_pubkey: 'c'.repeat(64), tenant_direct_address: 'DIRECT://cccc', tenant_nametag: null }))); + + await streamPromise; + expect(received).toEqual(['hm.spawn_ack', 'hm.spawn_ready']); + + await transport.dispose(); + }); + + // -------------------------------------------------------------------------- + // sendRequestStream — timeout + // -------------------------------------------------------------------------- + + it('throws TimeoutError when stream stalls before done', async () => { + const { comms, deliver } = buildMockComms(); + const transport = createDmTransport(comms, { managerAddress: '@manager', streamTimeoutMs: 500 }); + + const request = createHmcpRequest('hm.spawn', { template_id: 'tpl-1', instance_name: 'bot' }); + const received: string[] = []; + + const streamPromise = transport.sendRequestStream(request, (response) => { + received.push(response.type); + return response.type === 'hm.spawn_ready'; + }); + + await flushPromises(); + // Only ack arrives — ready never comes + deliver(makeDM(makeResponse(request.msg_id, 'hm.spawn_ack', { accepted: true, instance_id: 'i1', instance_name: 'bot', state: 'BOOTING' }))); + await flushPromises(); + + vi.advanceTimersByTime(501); + await expect(streamPromise).rejects.toThrow(TimeoutError); + expect(received).toEqual(['hm.spawn_ack']); + + await transport.dispose(); + }); + + // -------------------------------------------------------------------------- + // dispose rejects in-flight requests + // -------------------------------------------------------------------------- + + it('rejects in-flight requests when disposed', async () => { + const { comms } = buildMockComms(); + // sendDM never resolves — simulates a stuck network + (comms.sendDM as ReturnType).mockReturnValue(new Promise(() => {})); + const transport = createDmTransport(comms, { managerAddress: '@manager', timeoutMs: 60_000 }); + + const request = createHmcpRequest('hm.list', {}); + const responsePromise = transport.sendRequest(request); + await flushPromises(); + + await transport.dispose(); + await expect(responsePromise).rejects.toThrow(TransportError); + }); + + // -------------------------------------------------------------------------- + // Ignores malformed / oversized messages + // -------------------------------------------------------------------------- + + it('ignores malformed JSON from manager', async () => { + const { comms, deliver } = buildMockComms(); + const transport = createDmTransport(comms, { managerAddress: '@manager', timeoutMs: 200 }); + + const request = createHmcpRequest('hm.list', {}); + const responsePromise = transport.sendRequest(request); + await flushPromises(); + + deliver(makeDM('not json at all')); + deliver(makeDM(JSON.stringify({ hmcp_version: HMCP_VERSION, in_reply_to: request.msg_id, type: 'UNKNOWN_TYPE', payload: {} }))); + + vi.advanceTimersByTime(201); + await expect(responsePromise).rejects.toThrow(TimeoutError); + + await transport.dispose(); + }); + + it('ignores oversized messages', async () => { + const { comms, deliver } = buildMockComms(); + const transport = createDmTransport(comms, { managerAddress: '@manager', timeoutMs: 200 }); + + const request = createHmcpRequest('hm.list', {}); + const responsePromise = transport.sendRequest(request); + await flushPromises(); + + // 64 KiB + 1 byte + deliver(makeDM('x'.repeat(64 * 1024 + 1))); + + vi.advanceTimersByTime(201); + await expect(responsePromise).rejects.toThrow(TimeoutError); + + await transport.dispose(); + }); + + // -------------------------------------------------------------------------- + // Dangerous keys rejected (prototype pollution) + // -------------------------------------------------------------------------- + + it('ignores messages containing dangerous keys', async () => { + const { comms, deliver } = buildMockComms(); + const transport = createDmTransport(comms, { managerAddress: '@manager', timeoutMs: 200 }); + + const request = createHmcpRequest('hm.list', {}); + const responsePromise = transport.sendRequest(request); + await flushPromises(); + + // Use a raw JSON string: `{ __proto__: ... }` in JS sets the prototype, + // not an own key, so JSON.stringify would silently drop it. + const poisoned = `{"hmcp_version":"${HMCP_VERSION}","in_reply_to":"${request.msg_id}","type":"hm.list_result","payload":{"__proto__":{"isAdmin":true}}}`; + deliver(makeDM(poisoned)); + + vi.advanceTimersByTime(201); + await expect(responsePromise).rejects.toThrow(TimeoutError); + + await transport.dispose(); + }); + + // -------------------------------------------------------------------------- + // Compressed pubkey normalisation + // -------------------------------------------------------------------------- + + it('matches manager pubkey when transport returns compressed 66-char form', async () => { + const { comms, deliver } = buildMockComms(); + // sendDM returns a compressed (02-prefixed) pubkey + const compressed = '02' + MANAGER_PUBKEY; + (comms.sendDM as ReturnType).mockResolvedValue({ recipientPubkey: compressed }); + const transport = createDmTransport(comms, { managerAddress: '@manager', timeoutMs: 5_000 }); + + const request = createHmcpRequest('hm.list', {}); + const responsePromise = transport.sendRequest(request); + await flushPromises(); + + // Reply comes with x-only pubkey — should still be recognised + deliver(makeDM(makeResponse(request.msg_id, 'hm.list_result', { instances: [] }), MANAGER_PUBKEY)); + + const response = await responsePromise; + expect(response.type).toBe('hm.list_result'); + + await transport.dispose(); + }); + + // -------------------------------------------------------------------------- + // Already-disposed transport + // -------------------------------------------------------------------------- + + it('rejects immediately when transport is already disposed', async () => { + const { comms } = buildMockComms(); + const transport = createDmTransport(comms, { managerAddress: '@manager' }); + await transport.dispose(); + + const request = createHmcpRequest('hm.list', {}); + await expect(transport.sendRequest(request)).rejects.toThrow(TransportError); + await expect(transport.sendRequestStream(request, () => true)).rejects.toThrow(TransportError); + }); +}); diff --git a/src/transport/dm-transport.ts b/src/transport/dm-transport.ts new file mode 100644 index 0000000..25a1eb9 --- /dev/null +++ b/src/transport/dm-transport.ts @@ -0,0 +1,268 @@ +/** + * DmTransport — send HMCP-0 requests over Sphere DMs and await correlated responses. + * + * Usage: + * const transport = createDmTransport(sphere.communications, { managerAddress: '@mymanager' }); + * const response = await transport.sendRequest(createHmcpRequest('hm.list', {})); + * await transport.dispose(); + * + * For commands that emit multiple responses (e.g. hm.spawn → ack + ready/failed): + * await transport.sendRequestStream(request, (response) => { + * process(response); + * return isTerminal(response.type); // return true to stop + * }); + */ + +import type { DirectMessage } from '@unicitylabs/sphere-sdk'; +import type { HmcpRequest, HmcpResponse } from './hmcp-types.js'; +import { parseHmcpResponse } from './hmcp-types.js'; +import { TimeoutError, TransportError } from './errors.js'; + +export type { HmcpRequest, HmcpResponse } from './hmcp-types.js'; +export { createHmcpRequest, HMCP_RESPONSE_TYPES } from './hmcp-types.js'; +export { TimeoutError, AuthError, TransportError } from './errors.js'; + +// ============================================================================= +// Config / Interface +// ============================================================================= + +export interface DmTransportConfig { + /** + * Address of the host manager: @nametag, DIRECT://hex, or raw 64-char hex pubkey. + * Sphere resolves nametags and DIRECT:// internally via sendDM. + */ + managerAddress: string; + + /** + * Default timeout for single-response requests (ms). Default: 30 000. + */ + timeoutMs?: number; + + /** + * Default timeout for streaming requests, e.g. spawn that waits for container + * to become RUNNING. Default: 120 000. + */ + streamTimeoutMs?: number; +} + +/** Narrow slice of Sphere.communications needed by DmTransport — injectable for testing. */ +export interface SphereComms { + sendDM(recipient: string, content: string): Promise<{ recipientPubkey: string }>; + onDirectMessage(handler: (message: DirectMessage) => void): () => void; +} + +export interface DmTransport { + /** + * Send a request and return the first correlated response. + * Throws TimeoutError if no response arrives within timeoutMs. + * Throws TransportError if the send itself fails or the transport is disposed. + */ + sendRequest(request: HmcpRequest, timeoutMs?: number): Promise; + + /** + * Send a request and receive all correlated responses until onResponse returns + * true (done) or the stream timeout elapses. + * + * onResponse receives each HmcpResponse in order. Return true to signal done + * (cleans up the correlator). Return false to keep listening. + * + * Throws TimeoutError or TransportError. + */ + sendRequestStream( + request: HmcpRequest, + onResponse: (response: HmcpResponse) => boolean, + timeoutMs?: number, + ): Promise; + + /** Release the DM subscription. Any in-flight requests reject with TransportError. */ + dispose(): Promise; +} + +// ============================================================================= +// Pubkey normalisation +// ============================================================================= + +/** Strip 02/03 prefix to get x-only 64-char hex — matches CommunicationsModule._normalizeKey. */ +function normalizeKey(key: string): string { + if (key.length === 66 && (key.startsWith('02') || key.startsWith('03'))) { + return key.slice(2); + } + return key; +} + +// ============================================================================= +// Implementation +// ============================================================================= + +const DEFAULT_TIMEOUT_MS = 30_000; +const DEFAULT_STREAM_TIMEOUT_MS = 120_000; + +interface Correlator { + handler: (response: HmcpResponse) => void; + cancel: (err: Error) => void; +} + +class DmTransportImpl implements DmTransport { + private readonly timeoutMs: number; + private readonly streamTimeoutMs: number; + + /** + * Resolved x-only pubkey of the manager. + * Set after the first successful sendDM call and cached for sender auth. + * Concurrent first-sends write the same pubkey — safe. + */ + private resolvedPubkey: string | null = null; + + private readonly correlators = new Map(); + private readonly unsubscribeDMs: () => void; + private disposed = false; + + constructor( + private readonly comms: SphereComms, + private readonly managerAddress: string, + config: { timeoutMs: number; streamTimeoutMs: number }, + ) { + this.timeoutMs = config.timeoutMs; + this.streamTimeoutMs = config.streamTimeoutMs; + + // Subscribe once — route all incoming DMs through the correlator map. + this.unsubscribeDMs = comms.onDirectMessage((msg) => this.handleIncoming(msg)); + } + + // --------------------------------------------------------------------------- + + private handleIncoming(msg: DirectMessage): void { + // Ignore messages until we know the manager's pubkey (i.e., until first send). + if (!this.resolvedPubkey) return; + if (normalizeKey(msg.senderPubkey) !== this.resolvedPubkey) return; + + const response = parseHmcpResponse(msg.content); + if (!response) return; + + const correlator = this.correlators.get(response.in_reply_to); + correlator?.handler(response); + } + + // --------------------------------------------------------------------------- + + async sendRequest(request: HmcpRequest, timeoutMs?: number): Promise { + return new Promise((resolve, reject) => { + if (this.disposed) { + reject(new TransportError('Transport has been disposed')); + return; + } + + const timeout = timeoutMs ?? this.timeoutMs; + + const cleanup = () => { + clearTimeout(timer); + this.correlators.delete(request.msg_id); + }; + + const timer = setTimeout(() => { + cleanup(); + reject(new TimeoutError(`No response for ${request.type} within ${timeout} ms`)); + }, timeout); + + this.correlators.set(request.msg_id, { + handler: (response) => { cleanup(); resolve(response); }, + cancel: (err) => { cleanup(); reject(err); }, + }); + + this.send(request).catch((err: unknown) => { + cleanup(); + reject(new TransportError(`Failed to send ${request.type}: ${String((err as Error).message ?? err)}`)); + }); + }); + } + + // --------------------------------------------------------------------------- + + async sendRequestStream( + request: HmcpRequest, + onResponse: (response: HmcpResponse) => boolean, + timeoutMs?: number, + ): Promise { + return new Promise((resolve, reject) => { + if (this.disposed) { + reject(new TransportError('Transport has been disposed')); + return; + } + + const timeout = timeoutMs ?? this.streamTimeoutMs; + + const cleanup = () => { + clearTimeout(timer); + this.correlators.delete(request.msg_id); + }; + + const timer = setTimeout(() => { + cleanup(); + reject(new TimeoutError(`Stream for ${request.type} timed out after ${timeout} ms`)); + }, timeout); + + this.correlators.set(request.msg_id, { + handler: (response) => { + let done: boolean; + try { + done = onResponse(response); + } catch (err) { + cleanup(); + reject(err); + return; + } + if (done) { cleanup(); resolve(); } + // else: keep correlator — more responses expected + }, + cancel: (err) => { cleanup(); reject(err); }, + }); + + this.send(request).catch((err: unknown) => { + cleanup(); + reject(new TransportError(`Failed to send ${request.type}: ${String((err as Error).message ?? err)}`)); + }); + }); + } + + // --------------------------------------------------------------------------- + + async dispose(): Promise { + if (this.disposed) return; + this.disposed = true; + this.unsubscribeDMs(); + + const err = new TransportError('Transport disposed'); + for (const { cancel } of this.correlators.values()) { + cancel(err); + } + this.correlators.clear(); + } + + // --------------------------------------------------------------------------- + + private async send(request: HmcpRequest): Promise { + const sent = await this.comms.sendDM(this.managerAddress, JSON.stringify(request)); + // Cache the resolved pubkey on first send. Subsequent sends for the same + // manager address produce the same pubkey, so concurrent writes are safe. + if (!this.resolvedPubkey) { + this.resolvedPubkey = normalizeKey(sent.recipientPubkey); + } + } +} + +// ============================================================================= +// Factory +// ============================================================================= + +/** + * Create a DmTransport connected to a specific manager address. + * + * Subscribes to incoming DMs immediately. The manager's pubkey is resolved + * lazily on the first send — no network round-trip at construction. + */ +export function createDmTransport(comms: SphereComms, config: DmTransportConfig): DmTransport { + return new DmTransportImpl(comms, config.managerAddress, { + timeoutMs: config.timeoutMs ?? DEFAULT_TIMEOUT_MS, + streamTimeoutMs: config.streamTimeoutMs ?? DEFAULT_STREAM_TIMEOUT_MS, + }); +} diff --git a/src/transport/errors.ts b/src/transport/errors.ts new file mode 100644 index 0000000..ba8f69e --- /dev/null +++ b/src/transport/errors.ts @@ -0,0 +1,20 @@ +export class TimeoutError extends Error { + readonly name = 'TimeoutError'; + constructor(message: string) { + super(message); + } +} + +export class AuthError extends Error { + readonly name = 'AuthError'; + constructor(message: string) { + super(message); + } +} + +export class TransportError extends Error { + readonly name = 'TransportError'; + constructor(message: string) { + super(message); + } +} diff --git a/src/transport/hmcp-types.ts b/src/transport/hmcp-types.ts new file mode 100644 index 0000000..270c387 --- /dev/null +++ b/src/transport/hmcp-types.ts @@ -0,0 +1,234 @@ +/** + * HMCP-0 (Host Manager Control Protocol) types, constructors, and validators. + * Mirrors the protocol definition in agentic-hosting — kept in sync by hand. + */ + +export const HMCP_VERSION = '0.1'; + +export const HMCP_REQUEST_TYPES = [ + 'hm.spawn', + 'hm.list', + 'hm.stop', + 'hm.start', + 'hm.inspect', + 'hm.help', + 'hm.command', + 'hm.remove', + 'hm.pause', + 'hm.resume', +] as const; +export type HmcpRequestType = (typeof HMCP_REQUEST_TYPES)[number]; + +export const HMCP_RESPONSE_TYPES = [ + 'hm.spawn_ack', + 'hm.spawn_ready', + 'hm.spawn_failed', + 'hm.list_result', + 'hm.stop_result', + 'hm.start_ack', + 'hm.start_ready', + 'hm.start_failed', + 'hm.inspect_result', + 'hm.help_result', + 'hm.error', + 'hm.command_result', + 'hm.remove_result', + 'hm.pause_result', + 'hm.resume_result', + 'hm.resume_ready', + 'hm.resume_failed', +] as const; +export type HmcpResponseType = (typeof HMCP_RESPONSE_TYPES)[number]; + +// ---- Core message types ---- + +export interface HmcpRequest { + readonly hmcp_version: string; + readonly msg_id: string; + readonly ts_ms: number; + readonly type: HmcpRequestType; + readonly payload: Record; +} + +export interface HmcpResponse { + readonly hmcp_version: string; + readonly in_reply_to: string; + readonly type: HmcpResponseType; + readonly payload: Record; +} + +// ---- Spawn ---- + +export interface HmSpawnPayload { + readonly template_id: string; + readonly instance_name: string; + readonly nametag?: string | null; + readonly env?: Readonly>; +} + +export interface HmSpawnAckPayload { + readonly accepted: boolean; + readonly instance_id: string; + readonly instance_name: string; + readonly state: string; +} + +export interface HmSpawnReadyPayload { + readonly instance_id: string; + readonly instance_name: string; + readonly state: 'RUNNING'; + readonly tenant_pubkey: string; + readonly tenant_direct_address: string; + readonly tenant_nametag: string | null; +} + +export interface HmSpawnFailedPayload { + readonly instance_id: string; + readonly instance_name: string; + readonly state: 'FAILED'; + readonly reason: string; +} + +// ---- List ---- + +export interface HmInstanceSummary { + readonly instance_id: string; + readonly instance_name: string; + readonly template_id: string; + readonly state: string; + readonly tenant_pubkey: string | null; + readonly created_at: string; +} + +export interface HmListResultPayload { + readonly instances: readonly HmInstanceSummary[]; +} + +// ---- Stop / Start ---- + +export interface HmStopResultPayload { + readonly instance_id: string; + readonly instance_name: string; + readonly state: 'STOPPED'; +} + +export interface HmStartAckPayload { + readonly accepted: boolean; + readonly instance_id: string; + readonly instance_name: string; + readonly state: string; +} + +export interface HmStartReadyPayload { + readonly instance_id: string; + readonly instance_name: string; + readonly state: 'RUNNING'; + readonly tenant_pubkey: string; + readonly tenant_direct_address: string; + readonly tenant_nametag: string | null; +} + +export interface HmStartFailedPayload { + readonly instance_id: string; + readonly instance_name: string; + readonly state: 'FAILED'; + readonly reason: string; +} + +// ---- Inspect ---- + +export interface HmInspectResultPayload { + readonly instance_id: string; + readonly instance_name: string; + readonly template_id: string; + readonly state: string; + readonly created_at: string; + readonly tenant_pubkey: string | null; + readonly tenant_direct_address: string | null; + readonly tenant_nametag: string | null; + readonly last_heartbeat_at: string | null; + readonly docker_container_id: string | null; +} + +// ---- Help ---- + +export interface HmHelpResultPayload { + readonly commands: readonly string[]; + readonly version: string; +} + +// ---- Command ---- + +export interface HmCommandPayload { + readonly instance_id?: string; + readonly instance_name?: string; + readonly command: string; + readonly params?: Record; + readonly timeout_ms?: number; +} + +export interface HmCommandResultPayload { + readonly instance_id: string; + readonly instance_name: string; + readonly command: string; + readonly result: Record; +} + +// ---- Error ---- + +export interface HmErrorPayload { + readonly error_code: string; + readonly message: string; +} + +// ---- Constructors ---- + +export function createHmcpRequest(type: HmcpRequestType, payload: Record): HmcpRequest { + return { + hmcp_version: HMCP_VERSION, + msg_id: crypto.randomUUID(), + ts_ms: Date.now(), + type, + payload, + }; +} + +// ---- Validators ---- + +const DANGEROUS_KEYS = new Set(['__proto__', 'constructor', 'prototype']); + +function hasDangerousKeys(value: unknown, depth = 0): boolean { + if (depth > 20 || typeof value !== 'object' || value === null) return false; + for (const key of Object.keys(value as object)) { + if (DANGEROUS_KEYS.has(key)) return true; + if (hasDangerousKeys((value as Record)[key], depth + 1)) return true; + } + return false; +} + +export const MAX_MESSAGE_SIZE = 64 * 1024; // 64 KiB + +export function isValidHmcpResponse(msg: unknown): msg is HmcpResponse { + if (typeof msg !== 'object' || msg === null) return false; + const obj = msg as Record; + return ( + obj['hmcp_version'] === HMCP_VERSION && + typeof obj['in_reply_to'] === 'string' && obj['in_reply_to'] !== '' && + typeof obj['type'] === 'string' && + (HMCP_RESPONSE_TYPES as readonly string[]).includes(obj['type'] as string) && + typeof obj['payload'] === 'object' && + obj['payload'] !== null && + !hasDangerousKeys(obj) + ); +} + +export function parseHmcpResponse(data: string): HmcpResponse | null { + if (data.length > MAX_MESSAGE_SIZE) return null; + let parsed: unknown; + try { + parsed = JSON.parse(data); + } catch { + return null; + } + return isValidHmcpResponse(parsed) ? parsed : null; +} From b6a3f748444aa4ffa6ab20f066ac6eb8c3a1d66c Mon Sep 17 00:00:00 2001 From: Vladimir Rogojin Date: Wed, 22 Apr 2026 17:48:13 +0200 Subject: [PATCH 04/16] feat(host): sphere host DM-native commander subcommand tree Implements all 10 sphere-host subcommands as real HMCP-0 DM commands, replacing the phase-4 "not implemented yet" stub: sphere host spawn --template [--nametag ] [--env K=V...] sphere host list [--state ] sphere host stop [--id] sphere host start [--id] sphere host inspect [--id] sphere host cmd [--params ] [--cmd-timeout ] sphere host remove [--id] sphere host pause [--id] sphere host resume [--id] sphere host help Global options on the host parent: --manager, --json, --timeout. Manager address falls back to $SPHERE_HOST_MANAGER env var. Multi-step commands (spawn/start/resume) use sendRequestStream; others use sendRequest. Sphere.init from existing wallet (no autoGenerate). Errors set process.exitCode rather than calling process.exit(). --- src/host/host-commands.ts | 629 ++++++++++++++++++++++++++++++++++++++ src/host/sphere-init.ts | 78 +++++ src/index.test.ts | 2 +- src/index.ts | 5 +- 4 files changed, 712 insertions(+), 2 deletions(-) create mode 100644 src/host/host-commands.ts create mode 100644 src/host/sphere-init.ts diff --git a/src/host/host-commands.ts b/src/host/host-commands.ts new file mode 100644 index 0000000..b4fb89b --- /dev/null +++ b/src/host/host-commands.ts @@ -0,0 +1,629 @@ +/** + * `sphere host` Commander subcommand tree — HMCP-0 client over Sphere DMs. + */ + +import { Command, Option } from 'commander'; +import type { Sphere } from '@unicitylabs/sphere-sdk'; +import { createDmTransport } from '../transport/dm-transport.js'; +import { createHmcpRequest } from '../transport/hmcp-types.js'; +import { TimeoutError, TransportError } from '../transport/errors.js'; +import type { DmTransport } from '../transport/dm-transport.js'; +import type { + HmcpRequest, + HmcpRequestType, + HmcpResponse, + HmCommandPayload, + HmCommandResultPayload, + HmErrorPayload, + HmHelpResultPayload, + HmInspectResultPayload, + HmInstanceSummary, + HmListResultPayload, + HmSpawnAckPayload, + HmSpawnFailedPayload, + HmSpawnReadyPayload, + HmStartAckPayload, + HmStartFailedPayload, + HmStartReadyPayload, + HmStopResultPayload, +} from '../transport/hmcp-types.js'; +import { initSphere, resolveManagerAddress } from './sphere-init.js'; + +const DEFAULT_TIMEOUT_MS = 30_000; + +// ============================================================================= +// Option types +// ============================================================================= + +interface GlobalOpts { + manager?: string; + json?: boolean; + timeout?: string; +} + +interface NameOrIdOpts { + id?: boolean; +} + +interface SpawnOpts { + template: string; + nametag?: string; + env?: string[]; +} + +interface ListOpts { + state?: string; +} + +interface CmdOpts extends NameOrIdOpts { + params?: string; + cmdTimeout?: string; +} + +// ============================================================================= +// Helpers +// ============================================================================= + +function parseGlobalOpts(cmd: Command): GlobalOpts { + // Merge options from this command and its parents — commander keeps them local. + const merged: GlobalOpts = {}; + let current: Command | null = cmd; + while (current) { + const opts = current.opts(); + if (opts.manager !== undefined && merged.manager === undefined) merged.manager = opts.manager; + if (opts.json !== undefined && merged.json === undefined) merged.json = opts.json; + if (opts.timeout !== undefined && merged.timeout === undefined) merged.timeout = opts.timeout; + current = current.parent; + } + return merged; +} + +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}`); + } + return Math.floor(n); +} + +function parseEnvPairs(pairs: readonly string[] | undefined): Record | undefined { + if (!pairs || pairs.length === 0) return undefined; + const out: Record = {}; + for (const raw of pairs) { + const eq = raw.indexOf('='); + if (eq <= 0) { + throw new Error(`Invalid --env value "${raw}" (expected KEY=VALUE).`); + } + const key = raw.slice(0, eq).trim(); + const value = raw.slice(eq + 1); + if (!key) { + throw new Error(`Invalid --env value "${raw}" (empty key).`); + } + out[key] = value; + } + return out; +} + +function parseJsonParams(raw: string | undefined): Record | undefined { + if (!raw) return undefined; + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch (err) { + throw new Error(`Invalid --params JSON: ${(err as Error).message}`); + } + if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) { + throw new Error('--params must be a JSON object.'); + } + return parsed as Record; +} + +function targetPayload(nameOrId: string, opts: NameOrIdOpts): Record { + return opts.id ? { instance_id: nameOrId } : { instance_name: nameOrId }; +} + +function isErrorResponse(res: HmcpResponse): boolean { + return res.type === 'hm.error'; +} + +function errorPayload(res: HmcpResponse): HmErrorPayload { + return res.payload as unknown as HmErrorPayload; +} + +function printJson(value: unknown): void { + process.stdout.write(`${JSON.stringify(value, null, 2)}\n`); +} + +function writeStderr(msg: string): void { + process.stderr.write(msg.endsWith('\n') ? msg : `${msg}\n`); +} + +// ============================================================================= +// Core runner +// ============================================================================= + +interface RunContext { + sphere: Sphere; + transport: DmTransport; + 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 managerAddress: string; + try { + timeoutMs = parseTimeout(globals.timeout, DEFAULT_TIMEOUT_MS); + managerAddress = resolveManagerAddress({ manager: globals.manager }); + } catch (err) { + writeStderr((err as Error).message); + process.exitCode = 1; + return; + } + + let sphere: Sphere | null = null; + let transport: DmTransport | null = null; + try { + sphere = await initSphere(); + transport = createDmTransport(sphere.communications, { managerAddress, timeoutMs }); + await handler({ sphere, transport, timeoutMs, json }); + } catch (err) { + handleError(err, json); + } finally { + if (transport) { + try { await transport.dispose(); } catch { /* ignore */ } + } + if (sphere) { + try { await sphere.destroy(); } catch { /* ignore */ } + } + } +} + +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)); + } + // json flag does not change error channel — errors always go to stderr as plain text. + void json; + process.exitCode = 1; +} + +function handleHmError(res: HmcpResponse, json: boolean): void { + if (json) { + printJson(res); + } else { + writeStderr(errorPayload(res).message); + } + process.exitCode = 1; +} + +// ============================================================================= +// Subcommand handlers +// ============================================================================= + +async function handleSpawn(cmd: Command, name: string, sOpts: SpawnOpts): Promise { + const env = parseEnvPairs(sOpts.env); + await runWithTransport(cmd, async ({ transport, json }) => { + const payload: Record = { + template_id: sOpts.template, + instance_name: name, + }; + if (sOpts.nametag) payload['nametag'] = sOpts.nametag; + if (env) payload['env'] = env; + + const req = createHmcpRequest('hm.spawn', payload); + + const collected: HmcpResponse[] = []; + await transport.sendRequestStream(req, (res) => { + collected.push(res); + return ( + res.type === 'hm.spawn_ready' || + res.type === 'hm.spawn_failed' || + res.type === 'hm.error' + ); + }); + + if (json) { + printJson(collected); + const last = collected[collected.length - 1]; + if (last && (last.type === 'hm.spawn_failed' || last.type === 'hm.error')) { + process.exitCode = 1; + } + return; + } + + for (const res of collected) { + if (res.type === 'hm.spawn_ack') { + const p = res.payload as unknown as HmSpawnAckPayload; + writeStderr(`Accepted: ${p.instance_id} (${p.state})`); + } else if (res.type === 'hm.spawn_ready') { + const p = res.payload as unknown as HmSpawnReadyPayload; + const nt = p.tenant_nametag ?? '(no nametag)'; + process.stdout.write(`Container ready: ${nt} (${p.tenant_direct_address})\n`); + } else if (res.type === 'hm.spawn_failed') { + const p = res.payload as unknown as HmSpawnFailedPayload; + writeStderr(`Failed: ${p.reason}`); + process.exitCode = 1; + } else if (isErrorResponse(res)) { + handleHmError(res, json); + } + } + }); +} + +async function handleList(cmd: Command, lOpts: ListOpts): Promise { + await runWithTransport(cmd, async ({ transport, timeoutMs, json }) => { + const payload: Record = {}; + if (lOpts.state) payload['state_filter'] = lOpts.state; + + const req = createHmcpRequest('hm.list', payload); + const res = await transport.sendRequest(req, timeoutMs); + + if (isErrorResponse(res)) { + handleHmError(res, json); + return; + } + + if (json) { + printJson(res); + return; + } + + const p = res.payload as unknown as HmListResultPayload; + printInstanceTable(p.instances); + }); +} + +function printInstanceTable(instances: readonly HmInstanceSummary[]): void { + const header = ['NAME', 'ID', 'TEMPLATE', 'STATE', 'CREATED']; + const rows: string[][] = [header]; + for (const inst of instances) { + rows.push([ + inst.instance_name, + inst.instance_id, + inst.template_id, + inst.state, + inst.created_at, + ]); + } + const widths = header.map((_, col) => + Math.max(...rows.map((r) => (r[col] ?? '').length)), + ); + for (const r of rows) { + const line = r.map((cell, i) => (cell ?? '').padEnd(widths[i] ?? 0)).join(' '); + process.stdout.write(`${line.trimEnd()}\n`); + } +} + +async function handleSimple( + cmd: Command, + type: HmcpRequestType, + nameOrId: string, + opts: NameOrIdOpts, + onOk: (res: HmcpResponse, nameOrId: string) => void, +): Promise { + await runWithTransport(cmd, async ({ transport, timeoutMs, json }) => { + const req = createHmcpRequest(type, targetPayload(nameOrId, opts)); + const res = await transport.sendRequest(req, timeoutMs); + if (isErrorResponse(res)) { + handleHmError(res, json); + return; + } + if (json) { + printJson(res); + return; + } + onOk(res, nameOrId); + }); +} + +async function handleStop(cmd: Command, nameOrId: string, opts: NameOrIdOpts): Promise { + await handleSimple(cmd, 'hm.stop', nameOrId, opts, (res) => { + const p = res.payload as unknown as HmStopResultPayload; + process.stdout.write(`Stopped: ${p.instance_name} (${p.instance_id})\n`); + }); +} + +async function handleStart(cmd: Command, nameOrId: string, opts: NameOrIdOpts): Promise { + await runWithTransport(cmd, async ({ transport, json }) => { + const req = createHmcpRequest('hm.start', targetPayload(nameOrId, opts)); + + const collected: HmcpResponse[] = []; + await transport.sendRequestStream(req, (res) => { + collected.push(res); + return ( + res.type === 'hm.start_ready' || + res.type === 'hm.start_failed' || + res.type === 'hm.error' + ); + }); + + if (json) { + printJson(collected); + const last = collected[collected.length - 1]; + if (last && (last.type === 'hm.start_failed' || last.type === 'hm.error')) { + process.exitCode = 1; + } + return; + } + + for (const res of collected) { + if (res.type === 'hm.start_ack') { + const p = res.payload as unknown as HmStartAckPayload; + writeStderr(`Accepted: ${p.instance_id} (${p.state})`); + } else if (res.type === 'hm.start_ready') { + const p = res.payload as unknown as HmStartReadyPayload; + const nt = p.tenant_nametag ?? '(no nametag)'; + process.stdout.write(`Container ready: ${nt} (${p.tenant_direct_address})\n`); + } else if (res.type === 'hm.start_failed') { + const p = res.payload as unknown as HmStartFailedPayload; + writeStderr(`Failed: ${p.reason}`); + process.exitCode = 1; + } else if (isErrorResponse(res)) { + handleHmError(res, json); + } + } + }); +} + +async function handleInspect(cmd: Command, nameOrId: string, opts: NameOrIdOpts): Promise { + await handleSimple(cmd, 'hm.inspect', nameOrId, opts, (res) => { + const p = res.payload as unknown as HmInspectResultPayload; + const rows: Array<[string, string]> = [ + ['instance_id', p.instance_id], + ['instance_name', p.instance_name], + ['state', p.state], + ['template_id', p.template_id], + ['tenant_pubkey', p.tenant_pubkey ?? '(none)'], + ['tenant_direct_address', p.tenant_direct_address ?? '(none)'], + ['tenant_nametag', p.tenant_nametag ?? '(none)'], + ['created_at', p.created_at], + ['last_heartbeat_at', p.last_heartbeat_at ?? '(never)'], + ['docker_container_id', p.docker_container_id ?? '(none)'], + ]; + const keyWidth = Math.max(...rows.map(([k]) => k.length)); + for (const [k, v] of rows) { + process.stdout.write(`${`${k}:`.padEnd(keyWidth + 2)} ${v}\n`); + } + }); +} + +async function handleCmd( + cmd: Command, + nameOrId: string, + command: string, + cOpts: CmdOpts, +): Promise { + let params: Record | undefined; + let cmdTimeoutMs: number | undefined; + try { + params = parseJsonParams(cOpts.params); + if (cOpts.cmdTimeout !== undefined) { + cmdTimeoutMs = parseTimeout(cOpts.cmdTimeout, DEFAULT_TIMEOUT_MS); + } + } catch (err) { + writeStderr((err as Error).message); + process.exitCode = 1; + return; + } + + await runWithTransport(cmd, async ({ transport, timeoutMs, json }) => { + const base = targetPayload(nameOrId, cOpts); + const payload: HmCommandPayload = { + ...(base as { instance_id?: string; instance_name?: string }), + command, + ...(params ? { params } : {}), + ...(cmdTimeoutMs ? { timeout_ms: cmdTimeoutMs } : {}), + }; + + // If cmdTimeout is set, give the transport a bit more headroom than the tenant timeout. + const txTimeout = cmdTimeoutMs ? cmdTimeoutMs + 5_000 : timeoutMs; + const req = createHmcpRequest('hm.command', payload as unknown as Record); + const res = await transport.sendRequest(req, txTimeout); + + if (isErrorResponse(res)) { + handleHmError(res, json); + return; + } + + if (json) { + printJson(res); + return; + } + + const p = res.payload as unknown as HmCommandResultPayload; + printJson(p.result); + }); +} + +async function handleRemove(cmd: Command, nameOrId: string, opts: NameOrIdOpts): Promise { + await handleSimple(cmd, 'hm.remove', nameOrId, opts, (res) => { + const p = res.payload as { instance_name?: string; instance_id?: string }; + process.stdout.write(`Removed: ${p.instance_name ?? p.instance_id ?? nameOrId}\n`); + }); +} + +async function handlePause(cmd: Command, nameOrId: string, opts: NameOrIdOpts): Promise { + await handleSimple(cmd, 'hm.pause', nameOrId, opts, (res) => { + const p = res.payload as { instance_name?: string; instance_id?: string }; + process.stdout.write(`Paused: ${p.instance_name ?? p.instance_id ?? nameOrId}\n`); + }); +} + +async function handleResume(cmd: Command, nameOrId: string, opts: NameOrIdOpts): Promise { + await runWithTransport(cmd, async ({ transport, json }) => { + const req = createHmcpRequest('hm.resume', targetPayload(nameOrId, opts)); + + const collected: HmcpResponse[] = []; + await transport.sendRequestStream(req, (res) => { + collected.push(res); + return ( + res.type === 'hm.resume_ready' || + res.type === 'hm.resume_failed' || + res.type === 'hm.resume_result' || + res.type === 'hm.error' + ); + }); + + if (json) { + printJson(collected); + const last = collected[collected.length - 1]; + if (last && (last.type === 'hm.resume_failed' || last.type === 'hm.error')) { + process.exitCode = 1; + } + return; + } + + for (const res of collected) { + if (res.type === 'hm.resume_ready' || res.type === 'hm.resume_result') { + const p = res.payload as { instance_name?: string; instance_id?: string }; + process.stdout.write(`Ready: ${p.instance_name ?? p.instance_id ?? nameOrId}\n`); + } else if (res.type === 'hm.resume_failed') { + const p = res.payload as unknown as { reason?: string }; + writeStderr(`Failed: ${p.reason ?? 'resume failed'}`); + process.exitCode = 1; + } else if (isErrorResponse(res)) { + handleHmError(res, json); + } + } + }); +} + +async function handleHelp(cmd: Command): Promise { + await runWithTransport(cmd, async ({ transport, timeoutMs, json }) => { + const req = createHmcpRequest('hm.help', {}); + const res = await transport.sendRequest(req, timeoutMs); + if (isErrorResponse(res)) { + handleHmError(res, json); + return; + } + if (json) { + printJson(res); + return; + } + const p = res.payload as unknown as HmHelpResultPayload; + process.stdout.write(`HMCP version: ${p.version}\nCommands:\n`); + for (const c of p.commands) { + process.stdout.write(` ${c}\n`); + } + }); +} + +// ============================================================================= +// Command tree +// ============================================================================= + +export function createHostCommand(): Command { + const host = new Command('host') + .description('HMCP: controller → host manager (over Sphere DM)') + .option('--manager
', 'Host manager address (@nametag, DIRECT://hex, or hex pubkey)') + .option('--json', 'Output raw JSON response') + .option('--timeout ', 'Override default request timeout (ms)', String(DEFAULT_TIMEOUT_MS)); + + host + .command('spawn ') + .description('Spawn a new tenant instance from a template') + .requiredOption('--template ', 'Template ID to instantiate') + .option('--nametag ', 'Nametag to register for the tenant') + .addOption( + new Option('--env ', 'Environment variable pairs') + .argParser((value: string, previous: string[] | undefined) => + previous ? [...previous, value] : [value]) as Option, + ) + .action(async function (this: Command, name: string, sOpts: SpawnOpts) { + await handleSpawn(this, name, sOpts); + }); + + host + .command('list') + .description('List tenant instances managed by this host') + .option('--state ', 'Filter by state (CREATED, BOOTING, RUNNING, STOPPED, FAILED)') + .action(async function (this: Command, lOpts: ListOpts) { + await handleList(this, lOpts); + }); + + host + .command('stop ') + .description('Stop a running tenant instance') + .option('--id', 'Treat as an instance_id') + .action(async function (this: Command, nameOrId: string, opts: NameOrIdOpts) { + await handleStop(this, nameOrId, opts); + }); + + host + .command('start ') + .description('Start a stopped tenant instance') + .option('--id', 'Treat as an instance_id') + .action(async function (this: Command, nameOrId: string, opts: NameOrIdOpts) { + await handleStart(this, nameOrId, opts); + }); + + host + .command('inspect ') + .description('Inspect a tenant instance') + .option('--id', 'Treat as an instance_id') + .action(async function (this: Command, nameOrId: string, opts: NameOrIdOpts) { + await handleInspect(this, nameOrId, opts); + }); + + host + .command('cmd ') + .description('Send a command to a tenant instance') + .option('--params ', 'JSON object of command parameters') + .option('--id', 'Treat as an instance_id') + .option('--cmd-timeout ', 'Per-command execution timeout (ms)') + .action(async function (this: Command, nameOrId: string, command: string, cOpts: CmdOpts) { + await handleCmd(this, nameOrId, command, cOpts); + }); + + host + .command('remove ') + .description('Remove a tenant instance record (and its container)') + .option('--id', 'Treat as an instance_id') + .action(async function (this: Command, nameOrId: string, opts: NameOrIdOpts) { + await handleRemove(this, nameOrId, opts); + }); + + host + .command('pause ') + .description('Pause a running tenant instance') + .option('--id', 'Treat as an instance_id') + .action(async function (this: Command, nameOrId: string, opts: NameOrIdOpts) { + await handlePause(this, nameOrId, opts); + }); + + host + .command('resume ') + .description('Resume a paused tenant instance') + .option('--id', 'Treat as an instance_id') + .action(async function (this: Command, nameOrId: string, opts: NameOrIdOpts) { + await handleResume(this, nameOrId, opts); + }); + + host + .command('help') + .description('Ask the host manager for its supported commands') + .action(async function (this: Command) { + await handleHelp(this); + }); + + return host; +} + +// Exported for unit tests. +export { parseEnvPairs, parseJsonParams, parseTimeout, targetPayload }; + +// Re-exported for external consumers wanting to inspect the request type union. +export type { HmcpRequest }; diff --git a/src/host/sphere-init.ts b/src/host/sphere-init.ts new file mode 100644 index 0000000..c8576f4 --- /dev/null +++ b/src/host/sphere-init.ts @@ -0,0 +1,78 @@ +/** + * Sphere initialisation for the `sphere host` namespace. + * + * Loads `.sphere-cli/config.json` (matching legacy-cli defaults) and initialises + * Sphere from the existing wallet — never auto-creates. Modules not needed by + * HMCP (market, swap, accounting, groupChat) are left disabled to keep startup + * fast and failures isolated to the DM transport. + */ + +import * as fs from 'node:fs'; +import { Sphere } from '@unicitylabs/sphere-sdk'; +import { createNodeProviders } from '@unicitylabs/sphere-sdk/impl/nodejs'; +import type { NetworkType } from '@unicitylabs/sphere-sdk'; + +const CONFIG_FILE = './.sphere-cli/config.json'; +const DEFAULT_DATA_DIR = './.sphere-cli'; +const DEFAULT_TOKENS_DIR = './.sphere-cli/tokens'; + +interface CliConfig { + network: NetworkType; + dataDir: string; + tokensDir: string; + currentProfile?: string; +} + +function loadConfig(): CliConfig { + try { + if (fs.existsSync(CONFIG_FILE)) { + const raw = JSON.parse(fs.readFileSync(CONFIG_FILE, 'utf8')) as Partial; + return { + network: raw.network ?? 'testnet', + dataDir: raw.dataDir ?? DEFAULT_DATA_DIR, + tokensDir: raw.tokensDir ?? DEFAULT_TOKENS_DIR, + currentProfile: raw.currentProfile, + }; + } + } catch { + // Fall through to defaults. + } + return { network: 'testnet', dataDir: DEFAULT_DATA_DIR, tokensDir: DEFAULT_TOKENS_DIR }; +} + +export async function initSphere(): Promise { + const config = loadConfig(); + + const providers = createNodeProviders({ + network: config.network, + dataDir: config.dataDir, + tokensDir: config.tokensDir, + }); + + const exists = await Sphere.exists(providers.storage); + if (!exists) { + throw new Error( + `No wallet found in ${config.dataDir}. Run \`sphere wallet init\` before using \`sphere host\`.`, + ); + } + + const { sphere } = await Sphere.init({ + storage: providers.storage, + transport: providers.transport, + oracle: providers.oracle, + network: config.network, + autoGenerate: false, + }); + + return sphere; +} + +export function resolveManagerAddress(opts: { manager?: string }): string { + const address = opts.manager ?? process.env['SPHERE_HOST_MANAGER']; + if (!address || address.trim() === '') { + throw new Error( + 'No host manager address. Pass --manager <@nametag|DIRECT://hex|hex> or set SPHERE_HOST_MANAGER.', + ); + } + return address.trim(); +} diff --git a/src/index.test.ts b/src/index.test.ts index 971777a..4a10d87 100644 --- a/src/index.test.ts +++ b/src/index.test.ts @@ -47,7 +47,7 @@ describe('sphere-cli scaffold', () => { }) as never); try { - await main(['node', 'sphere', 'host']); + await main(['node', 'sphere', 'tenant']); } finally { writeSpy.mockRestore(); exitSpy.mockRestore(); diff --git a/src/index.ts b/src/index.ts index 8091c60..d7b2828 100644 --- a/src/index.ts +++ b/src/index.ts @@ -21,6 +21,7 @@ import { Command } from 'commander'; import { VERSION } from './version.js'; +import { createHostCommand } from './host/host-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+. @@ -32,7 +33,6 @@ const LEGACY_NAMESPACES = new Set([ // Phase 4 namespaces — DM-native, not yet implemented. const PHASE4_NAMESPACES: Array<[string, string]> = [ - ['host', 'HMCP: controller → host manager (over DM)'], ['tenant', 'ACP: controller → tenant (over DM, host-agnostic)'], ]; @@ -153,6 +153,9 @@ export function createCli(): Command { }); } + // Phase 4 (live): `sphere host` — HMCP over Sphere DMs. + program.addCommand(createHostCommand()); + return program; } From cea4729e11bc994ba23a477310279a5949908dac Mon Sep 17 00:00:00 2001 From: Vladimir Rogojin Date: Wed, 22 Apr 2026 18:06:38 +0200 Subject: [PATCH 05/16] =?UTF-8?q?fix(security):=20steelman=20round=202=20?= =?UTF-8?q?=E2=80=94=20fix=20all=20remaining=20criticals=20and=20warnings?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - hmcp-types: reviver now flags hadDangerousKeys and rejects the whole message instead of silently cleaning payload; isValidHmcpResponse no longer calls hasDangerousKeys (reviver catches it earlier) - hmcp-types: byteLength() uses Buffer.byteLength(utf8) so the 64 KiB cap counts bytes, not UTF-16 code units - dm-transport: early-message buffer (cap 32) + replay after resolvedPubkey set — fast manager replies no longer dropped during first sendDM - dm-transport: dispose() snapshots correlators before iterating to avoid map mutation during cancel() callbacks - host-commands: writeStderr accepts unknown; parseJsonParams rejects dangerous keys immediately; DEBUG env var logs cleanup errors - sphere-init: loadConfig() validates each field type individually instead of unsafe cast; logs a warning on parse failure before falling back - index: return process.exitCode when numeric so action-handler errors propagate correctly through bin/sphere.mjs → process.exit() --- src/host/host-commands.ts | 19 ++++++++++++---- src/host/sphere-init.ts | 14 ++++++------ src/index.ts | 3 ++- src/transport/dm-transport.ts | 30 +++++++++++++++++++++---- src/transport/hmcp-types.ts | 41 ++++++++++++++++++++++++++--------- 5 files changed, 81 insertions(+), 26 deletions(-) diff --git a/src/host/host-commands.ts b/src/host/host-commands.ts index b4fb89b..658d946 100644 --- a/src/host/host-commands.ts +++ b/src/host/host-commands.ts @@ -27,6 +27,7 @@ import type { HmStartReadyPayload, HmStopResultPayload, } from '../transport/hmcp-types.js'; +import { hasDangerousKeys } from '../transport/hmcp-types.js'; import { initSphere, resolveManagerAddress } from './sphere-init.js'; const DEFAULT_TIMEOUT_MS = 30_000; @@ -116,6 +117,9 @@ function parseJsonParams(raw: string | undefined): Record | und if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) { throw new Error('--params must be a JSON object.'); } + if (hasDangerousKeys(parsed)) { + throw new Error('--params contains forbidden keys (__proto__, constructor, prototype).'); + } return parsed as Record; } @@ -135,8 +139,9 @@ function printJson(value: unknown): void { process.stdout.write(`${JSON.stringify(value, null, 2)}\n`); } -function writeStderr(msg: string): void { - process.stderr.write(msg.endsWith('\n') ? msg : `${msg}\n`); +function writeStderr(msg: unknown): void { + const s = typeof msg === 'string' ? msg : String(msg ?? 'unknown error'); + process.stderr.write(s.endsWith('\n') ? s : `${s}\n`); } // ============================================================================= @@ -176,11 +181,17 @@ async function runWithTransport(cmd: Command, handler: Handler): Promise { } catch (err) { handleError(err, json); } finally { + // transport must be disposed before sphere.destroy so communications + // unsubscribe works while the transport layer is still live. if (transport) { - try { await transport.dispose(); } catch { /* ignore */ } + try { await transport.dispose(); } catch (e) { + if (process.env['DEBUG']) writeStderr(`sphere-cli: transport.dispose error: ${e}`); + } } if (sphere) { - try { await sphere.destroy(); } catch { /* ignore */ } + try { await sphere.destroy(); } catch (e) { + if (process.env['DEBUG']) writeStderr(`sphere-cli: sphere.destroy error: ${e}`); + } } } } diff --git a/src/host/sphere-init.ts b/src/host/sphere-init.ts index c8576f4..554c5aa 100644 --- a/src/host/sphere-init.ts +++ b/src/host/sphere-init.ts @@ -26,16 +26,16 @@ interface CliConfig { function loadConfig(): CliConfig { try { if (fs.existsSync(CONFIG_FILE)) { - const raw = JSON.parse(fs.readFileSync(CONFIG_FILE, 'utf8')) as Partial; + const raw = JSON.parse(fs.readFileSync(CONFIG_FILE, 'utf8')) as Record; return { - network: raw.network ?? 'testnet', - dataDir: raw.dataDir ?? DEFAULT_DATA_DIR, - tokensDir: raw.tokensDir ?? DEFAULT_TOKENS_DIR, - currentProfile: raw.currentProfile, + network: typeof raw['network'] === 'string' ? raw['network'] as NetworkType : 'testnet', + dataDir: typeof raw['dataDir'] === 'string' ? raw['dataDir'] : DEFAULT_DATA_DIR, + tokensDir: typeof raw['tokensDir'] === 'string' ? raw['tokensDir'] : DEFAULT_TOKENS_DIR, + currentProfile: typeof raw['currentProfile'] === 'string' ? raw['currentProfile'] : undefined, }; } - } catch { - // Fall through to defaults. + } catch (e) { + process.stderr.write(`sphere: failed to parse ${CONFIG_FILE}: ${String(e)}. Using defaults.\n`); } return { network: 'testnet', dataDir: DEFAULT_DATA_DIR, tokensDir: DEFAULT_TOKENS_DIR }; } diff --git a/src/index.ts b/src/index.ts index d7b2828..b074309 100644 --- a/src/index.ts +++ b/src/index.ts @@ -164,7 +164,8 @@ export async function main(argv: string[] = process.argv): Promise { const program = createCli(); try { await program.parseAsync(argv); - return 0; + // Honour process.exitCode set by action handlers (e.g. runWithTransport errors). + return (typeof process.exitCode === 'number' ? process.exitCode : 0); } catch (err) { // commander throws CommanderError on --help/--version/parse errors; those are // handled internally (output already printed). Re-exit with the right code. diff --git a/src/transport/dm-transport.ts b/src/transport/dm-transport.ts index 25a1eb9..88d54ba 100644 --- a/src/transport/dm-transport.ts +++ b/src/transport/dm-transport.ts @@ -113,6 +113,14 @@ class DmTransportImpl implements DmTransport { */ private resolvedPubkey: string | null = null; + /** + * Messages that arrived before resolvedPubkey was set are buffered here and + * replayed once the pubkey is known. Capped at 32 to bound memory if an + * attacker floods DMs before the first send completes. + */ + private readonly earlyMessages: DirectMessage[] = []; + private static readonly EARLY_MESSAGE_CAP = 32; + private readonly correlators = new Map(); private readonly unsubscribeDMs: () => void; private disposed = false; @@ -132,8 +140,14 @@ class DmTransportImpl implements DmTransport { // --------------------------------------------------------------------------- private handleIncoming(msg: DirectMessage): void { - // Ignore messages until we know the manager's pubkey (i.e., until first send). - if (!this.resolvedPubkey) return; + if (!this.resolvedPubkey) { + // Buffer early messages so a fast manager reply isn't lost while sendDM + // is still in-flight. Capped to avoid unbounded memory on DM floods. + if (this.earlyMessages.length < DmTransportImpl.EARLY_MESSAGE_CAP) { + this.earlyMessages.push(msg); + } + return; + } if (normalizeKey(msg.senderPubkey) !== this.resolvedPubkey) return; const response = parseHmcpResponse(msg.content); @@ -232,10 +246,13 @@ class DmTransportImpl implements DmTransport { this.unsubscribeDMs(); const err = new TransportError('Transport disposed'); - for (const { cancel } of this.correlators.values()) { + // Snapshot before iterating — cancel() calls cleanup() which deletes from + // the map. Clearing the snapshot first avoids modifying during iteration. + const pending = Array.from(this.correlators.values()); + this.correlators.clear(); + for (const { cancel } of pending) { cancel(err); } - this.correlators.clear(); } // --------------------------------------------------------------------------- @@ -246,6 +263,11 @@ class DmTransportImpl implements DmTransport { // manager address produce the same pubkey, so concurrent writes are safe. if (!this.resolvedPubkey) { this.resolvedPubkey = normalizeKey(sent.recipientPubkey); + // Replay any DMs that arrived before we knew the manager's pubkey. + const pending = this.earlyMessages.splice(0); + for (const msg of pending) { + this.handleIncoming(msg); + } } } } diff --git a/src/transport/hmcp-types.ts b/src/transport/hmcp-types.ts index 270c387..eaf37a4 100644 --- a/src/transport/hmcp-types.ts +++ b/src/transport/hmcp-types.ts @@ -197,16 +197,37 @@ export function createHmcpRequest(type: HmcpRequestType, payload: Record 20 || typeof value !== 'object' || value === null) return false; +// Use a JSON.parse reviver to detect dangerous keys at parse time, regardless +// of nesting depth. This avoids the depth-20 cliff of a recursive walk and +// runs in O(n) with a single parse pass. If a dangerous key is found the +// entire message is rejected (not silently cleaned). +function safeParse(data: string): { value: unknown; hadDangerousKeys: boolean } { + let hadDangerousKeys = false; + const value = JSON.parse(data, (key, val) => { + if (DANGEROUS_KEYS.has(key)) { hadDangerousKeys = true; return undefined; } + return val; + }); + return { value, hadDangerousKeys }; +} + +// Structural check used by host-commands.ts to validate --params arguments. +export function hasDangerousKeys(value: unknown): boolean { + if (typeof value !== 'object' || value === null) return false; for (const key of Object.keys(value as object)) { if (DANGEROUS_KEYS.has(key)) return true; - if (hasDangerousKeys((value as Record)[key], depth + 1)) return true; + if (hasDangerousKeys((value as Record)[key])) return true; } return false; } -export const MAX_MESSAGE_SIZE = 64 * 1024; // 64 KiB +// 64 KiB measured in UTF-8 bytes, not JS string code units (which are UTF-16). +export const MAX_MESSAGE_SIZE = 64 * 1024; + +function byteLength(s: string): number { + // Buffer.byteLength is available in Node; fall back to approximate for other runtimes. + if (typeof Buffer !== 'undefined') return Buffer.byteLength(s, 'utf8'); + return new TextEncoder().encode(s).length; +} export function isValidHmcpResponse(msg: unknown): msg is HmcpResponse { if (typeof msg !== 'object' || msg === null) return false; @@ -217,18 +238,18 @@ export function isValidHmcpResponse(msg: unknown): msg is HmcpResponse { typeof obj['type'] === 'string' && (HMCP_RESPONSE_TYPES as readonly string[]).includes(obj['type'] as string) && typeof obj['payload'] === 'object' && - obj['payload'] !== null && - !hasDangerousKeys(obj) + obj['payload'] !== null ); } export function parseHmcpResponse(data: string): HmcpResponse | null { - if (data.length > MAX_MESSAGE_SIZE) return null; - let parsed: unknown; + if (byteLength(data) > MAX_MESSAGE_SIZE) return null; + let result: { value: unknown; hadDangerousKeys: boolean }; try { - parsed = JSON.parse(data); + result = safeParse(data); } catch { return null; } - return isValidHmcpResponse(parsed) ? parsed : null; + if (result.hadDangerousKeys) return null; + return isValidHmcpResponse(result.value) ? result.value : null; } From 510c18a203f901fcc2023714b0e1b11eeeb61f9b Mon Sep 17 00:00:00 2001 From: Vladimir Rogojin Date: Wed, 22 Apr 2026 18:18:44 +0200 Subject: [PATCH 06/16] =?UTF-8?q?feat(trader):=20Phase=201=20=E2=80=94=20t?= =?UTF-8?q?ypes,=20acp-types,=20utils,=20volume-reservation-ledger?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - types.ts: TradingIntent (with salt), IntentRecord, DealTerms, DealRecord, TraderStrategy + DEFAULT_STRATEGY, adapter interfaces (MarketAdapter, SwapAdapter, PaymentsAdapter, CommsAdapter), NpMessage envelope - acp-types.ts: all 7 ACP command param/result interfaces (CREATE_INTENT, CANCEL_INTENT, LIST_INTENTS, GET_INTENT, UPDATE_STRATEGY, GET_STRATEGY, GET_DEALS) — ACP boundary uses number; bigint conversion at handler layer - utils.ts: validateIntent(), validateDealTerms(), canonicalJson(), encodeDescription/decodeDescription (4-line spec 2.8 format), hasDangerousKeys() with depth-20 limit - volume-reservation-ledger.ts: async mutex (promise-chain) on reserve(), reconstruct() for startup reconciliation, all bigint arithmetic --- src/trader/acp-types.ts | 148 ++++++++++++++++ src/trader/types.ts | 194 +++++++++++++++++++++ src/trader/utils.ts | 221 ++++++++++++++++++++++++ src/trader/volume-reservation-ledger.ts | 108 ++++++++++++ 4 files changed, 671 insertions(+) create mode 100644 src/trader/acp-types.ts create mode 100644 src/trader/types.ts create mode 100644 src/trader/utils.ts create mode 100644 src/trader/volume-reservation-ledger.ts diff --git a/src/trader/acp-types.ts b/src/trader/acp-types.ts new file mode 100644 index 0000000..8f9c789 --- /dev/null +++ b/src/trader/acp-types.ts @@ -0,0 +1,148 @@ +/** + * ACP-0 command param/result types for the Trader Agent. + * + * ACP boundary uses `number` for volumes/rates since JSON has no bigint type; + * TraderCommandHandler converts to bigint internally. Volumes are serialised + * as decimal strings on the way back out to preserve precision. + */ + +export type TraderAcpCommand = + | 'CREATE_INTENT' + | 'CANCEL_INTENT' + | 'LIST_INTENTS' + | 'GET_INTENT' + | 'UPDATE_STRATEGY' + | 'GET_STRATEGY' + | 'GET_DEALS'; + +// ============================================================================= +// CREATE_INTENT +// ============================================================================= + +export interface CreateIntentParams { + readonly offer_token: string; + readonly offer_volume_min: number; + readonly offer_volume_max: number; + readonly request_token: string; + readonly rate_min: number; + readonly rate_max: number; + readonly expiry_ms: number; + readonly deposit_timeout_sec?: number; +} + +export interface CreateIntentResult { + readonly intent_id: string; + readonly state: string; +} + +// ============================================================================= +// CANCEL_INTENT +// ============================================================================= + +export interface CancelIntentParams { + readonly intent_id: string; +} + +export interface CancelIntentResult { + readonly intent_id: string; + readonly state: 'CANCELLED'; +} + +// ============================================================================= +// LIST_INTENTS +// ============================================================================= + +export interface ListIntentsParams { + readonly state?: string; +} + +export interface IntentSummary { + readonly intent_id: string; + readonly offer_token: string; + readonly request_token: string; + readonly state: string; + readonly volume_filled: string; + readonly offer_volume_max: string; + readonly expiry_ms: number; +} + +export interface ListIntentsResult { + readonly intents: readonly IntentSummary[]; +} + +// ============================================================================= +// GET_INTENT +// ============================================================================= + +export interface GetIntentParams { + readonly intent_id: string; +} + +export interface GetIntentResult { + readonly record: IntentSummary & { + readonly offer_volume_min: string; + readonly rate_min: string; + readonly rate_max: string; + readonly market_listing_id: string | null; + readonly updated_at_ms: number; + }; +} + +// ============================================================================= +// UPDATE_STRATEGY +// ============================================================================= + +export interface UpdateStrategyParams { + readonly scan_interval_ms?: number; + readonly proposal_timeout_ms?: number; + readonly acceptance_timeout_ms?: number; + readonly max_active_intents?: number; + readonly trusted_escrows?: readonly string[]; + readonly blocked_counterparties?: readonly string[]; +} + +export interface UpdateStrategyResult { + readonly ok: true; +} + +// ============================================================================= +// GET_STRATEGY +// ============================================================================= + +export type GetStrategyParams = Record; + +export interface GetStrategyResult { + readonly strategy: { + readonly scan_interval_ms: number; + readonly proposal_timeout_ms: number; + readonly acceptance_timeout_ms: number; + readonly max_active_intents: number; + readonly trusted_escrows: readonly string[]; + readonly blocked_counterparties: readonly string[]; + }; +} + +// ============================================================================= +// GET_DEALS +// ============================================================================= + +export interface GetDealsParams { + readonly intent_id?: string; + readonly state?: string; +} + +export interface DealSummary { + readonly deal_id: string; + readonly role: 'PROPOSER' | 'ACCEPTOR'; + readonly state: string; + readonly offer_token: string; + readonly request_token: string; + readonly offer_volume: string; + readonly request_volume: string; + readonly created_at_ms: number; + readonly failure_reason: string | null; +} + +export interface GetDealsResult { + readonly deals: readonly DealSummary[]; +} diff --git a/src/trader/types.ts b/src/trader/types.ts new file mode 100644 index 0000000..fc3779d --- /dev/null +++ b/src/trader/types.ts @@ -0,0 +1,194 @@ +/** + * Trader Agent — core domain types and adapter interfaces. + * + * No Sphere SDK imports here: adapters are defined as narrow, DI-friendly + * interfaces so the trader core stays testable with in-memory fakes. + */ + +// ============================================================================= +// Trading intents +// ============================================================================= + +export interface TradingIntent { + readonly intent_id: string; + readonly salt: string; + readonly owner_pubkey: string; + readonly offer_token: string; + readonly offer_volume_min: bigint; + readonly offer_volume_max: bigint; + readonly request_token: string; + readonly rate_min: bigint; + readonly rate_max: bigint; + readonly expiry_ms: number; + readonly created_at_ms: number; + readonly deposit_timeout_sec: number; +} + +export type IntentState = 'ACTIVE' | 'PAUSED' | 'EXPIRED' | 'CANCELLED' | 'FILLED'; + +export interface IntentRecord { + readonly intent: TradingIntent; + state: IntentState; + readonly market_listing_id: string | null; + volume_filled: bigint; + updated_at_ms: number; +} + +// ============================================================================= +// Deals +// ============================================================================= + +export interface DealTerms { + readonly proposer_intent_id: string; + readonly acceptor_intent_id: string; + readonly proposer_pubkey: string; + readonly acceptor_pubkey: string; + readonly offer_token: string; + readonly request_token: string; + readonly offer_volume: bigint; + readonly request_volume: bigint; + readonly rate: bigint; + readonly escrow_address: string; + readonly deposit_timeout_sec: number; +} + +export type DealState = + | 'PROPOSED' + | 'ACCEPTED' + | 'EXECUTING' + | 'COMPLETED' + | 'FAILED' + | 'CANCELLED'; + +export interface DealRecord { + readonly deal_id: string; + readonly terms: DealTerms; + state: DealState; + readonly role: 'PROPOSER' | 'ACCEPTOR'; + readonly created_at_ms: number; + updated_at_ms: number; + failure_reason: string | null; + // deposit_attempted is written BEFORE payInvoice() so crash-recovery can + // tell the difference between "never paid" and "paid but never confirmed". + deposit_attempted: boolean; + payout_verified: boolean; +} + +// ============================================================================= +// Strategy +// ============================================================================= + +export interface TraderStrategy { + readonly scan_interval_ms: number; + readonly proposal_timeout_ms: number; + readonly acceptance_timeout_ms: number; + readonly max_active_intents: number; + readonly trusted_escrows: readonly string[]; + readonly blocked_counterparties: readonly string[]; + readonly payout_poll_interval_ms: number; + readonly payout_max_retries: number; +} + +export const DEFAULT_STRATEGY: TraderStrategy = { + scan_interval_ms: 30_000, + proposal_timeout_ms: 30_000, + acceptance_timeout_ms: 60_000, + max_active_intents: 10, + trusted_escrows: [], + blocked_counterparties: [], + payout_poll_interval_ms: 30_000, + payout_max_retries: 10, +}; + +// ============================================================================= +// Adapter interfaces (dependency injection seams) +// ============================================================================= + +export interface MarketListing { + readonly listing_id: string; + readonly description: string; + readonly poster_pubkey: string; + readonly expiry_ms: number; +} + +export interface MarketAdapter { + post(description: string, expiryMs: number): Promise; + remove(listingId: string): Promise; + search(query: string): Promise; + subscribeFeed(listener: (listing: MarketListing) => void): () => void; + getRecentListings(): Promise; +} + +export interface SwapProposalParams { + readonly escrowAddress: string; + readonly offerToken: string; + readonly offerVolume: bigint; + readonly requestToken: string; + readonly requestVolume: bigint; + readonly depositTimeoutSec: number; + readonly counterpartyAddress: string; +} + +export interface SwapProposalResult { + readonly swapId: string; +} + +export interface SwapStatus { + readonly swapId: string; + readonly state: string; + readonly payoutVerified?: boolean; +} + +export interface SwapAdapter { + propose(params: SwapProposalParams): Promise; + accept(swapId: string): Promise; + getStatus(swapId: string): Promise; + load(): Promise; + on(event: string, handler: (...args: unknown[]) => void): () => void; +} + +export interface ActiveIntent { + readonly listing_id: string; + readonly description: string; +} + +export interface PaymentsAdapter { + receive(): Promise<{ address: string; pubkey: string }>; + getMyIntents(): Promise; + payInvoice(invoice: string): Promise; + getConfirmedAmount(token: string): Promise; +} + +export interface IncomingDM { + readonly senderPubkey: string; + readonly content: string; +} + +export interface CommsAdapter { + sendDM(address: string, content: string): Promise; + onDirectMessage(handler: (msg: IncomingDM) => void): () => void; +} + +// ============================================================================= +// Callback types +// ============================================================================= + +export type OnMatchFound = (intent: IntentRecord, match: MarketListing) => void; +export type OnDealAccepted = (deal: DealRecord) => void; +export type OnSwapCompleted = (deal: DealRecord) => void; + +// ============================================================================= +// NP-0 Negotiation Protocol envelope +// ============================================================================= + +export type NpMessageType = 'np.propose' | 'np.accept' | 'np.reject' | 'np.cancel'; + +export interface NpMessage { + readonly np_version: '0.1'; + readonly msg_id: string; + readonly ts_ms: number; + readonly sender_pubkey: string; + readonly signature: string; + readonly type: NpMessageType; + readonly payload: Record; +} diff --git a/src/trader/utils.ts b/src/trader/utils.ts new file mode 100644 index 0000000..a60f4f1 --- /dev/null +++ b/src/trader/utils.ts @@ -0,0 +1,221 @@ +/** + * Trader utilities: intent/deal validation, canonical JSON serialisation, + * market description encoder/decoder, and dangerous-key detection for NP-0. + */ + +import type { TradingIntent, DealTerms } from './types.js'; +import type { CreateIntentParams } from './acp-types.js'; + +const MAX_EXPIRY_WINDOW_MS = 7 * 24 * 60 * 60 * 1000; +const DANGEROUS_KEYS = new Set(['__proto__', 'constructor', 'prototype']); + +// ============================================================================= +// Validation +// ============================================================================= + +export function validateIntent(params: CreateIntentParams): void { + const { offer_token, request_token, offer_volume_min, offer_volume_max, rate_min, rate_max, expiry_ms, deposit_timeout_sec } = params; + + if (typeof offer_token !== 'string' || offer_token.length === 0) { + throw new Error('offer_token must be a non-empty string'); + } + if (typeof request_token !== 'string' || request_token.length === 0) { + throw new Error('request_token must be a non-empty string'); + } + + for (const [name, v] of [ + ['offer_volume_min', offer_volume_min], + ['offer_volume_max', offer_volume_max], + ['rate_min', rate_min], + ['rate_max', rate_max], + ] as const) { + if (typeof v !== 'number' || !Number.isFinite(v) || Number.isNaN(v)) { + throw new Error(`${name} must be a finite number`); + } + if (v < 0) { + throw new Error(`${name} must not be negative`); + } + } + + if (offer_volume_min <= 0) { + throw new Error('offer_volume_min must be > 0'); + } + if (offer_volume_max < offer_volume_min) { + throw new Error('offer_volume_max must be >= offer_volume_min'); + } + if (rate_min <= 0) { + throw new Error('rate_min must be > 0'); + } + if (rate_max < rate_min) { + throw new Error('rate_max must be >= rate_min'); + } + + if (typeof expiry_ms !== 'number' || !Number.isFinite(expiry_ms)) { + throw new Error('expiry_ms must be a finite number'); + } + const now = Date.now(); + if (expiry_ms <= now) { + throw new Error('expiry_ms must be in the future'); + } + if (expiry_ms > now + MAX_EXPIRY_WINDOW_MS) { + throw new Error('expiry_ms must be within 7 days from now'); + } + + if (deposit_timeout_sec !== undefined) { + if (typeof deposit_timeout_sec !== 'number' || !Number.isFinite(deposit_timeout_sec) || deposit_timeout_sec <= 0) { + throw new Error('deposit_timeout_sec must be > 0'); + } + } +} + +export function validateDealTerms(terms: DealTerms): void { + if (terms.offer_volume <= 0n) { + throw new Error('offer_volume must be > 0'); + } + if (terms.request_volume <= 0n) { + throw new Error('request_volume must be > 0'); + } + if (terms.rate <= 0n) { + throw new Error('rate must be > 0'); + } + if (terms.proposer_pubkey === terms.acceptor_pubkey) { + throw new Error('proposer and acceptor pubkeys must differ'); + } + if (typeof terms.escrow_address !== 'string' || terms.escrow_address.length === 0) { + throw new Error('escrow_address must be a non-empty string'); + } + if (terms.deposit_timeout_sec <= 0) { + throw new Error('deposit_timeout_sec must be > 0'); + } +} + +// ============================================================================= +// Canonical JSON (deterministic, keys sorted) +// ============================================================================= + +export function canonicalJson(obj: Record): string { + return JSON.stringify(sortKeys(obj)); +} + +function sortKeys(value: unknown): unknown { + if (Array.isArray(value)) { + return value.map(sortKeys); + } + if (value !== null && typeof value === 'object') { + const src = value as Record; + const out: Record = {}; + for (const k of Object.keys(src).sort()) { + out[k] = sortKeys(src[k]); + } + return out; + } + return value; +} + +// ============================================================================= +// Market description encoder/decoder (spec 2.8, 4-line format) +// ============================================================================= + +export interface DecodedDescription { + readonly offer_token: string; + readonly offer_volume_min: bigint; + readonly offer_volume_max: bigint; + readonly rate_min: bigint; + readonly rate_max: bigint; + readonly request_token: string; + readonly expiry_ms: number; + readonly escrows: readonly string[]; + readonly salt: string; + readonly deposit_timeout_sec: number; +} + +export function encodeDescription(intent: TradingIntent, escrows: readonly string[]): string { + const line1 = `TRADE offer=${intent.offer_token} vol=${intent.offer_volume_min}-${intent.offer_volume_max} rate=${intent.rate_min}-${intent.rate_max}`; + const line2 = `req=${intent.request_token} expires=${new Date(intent.expiry_ms).toISOString()}`; + const line3 = `escrows=${escrows.join(',')}`; + const line4 = `salt=${intent.salt} timeout=${intent.deposit_timeout_sec}`; + return `${line1}\n${line2}\n${line3}\n${line4}`; +} + +export function decodeDescription(description: string): DecodedDescription | null { + if (typeof description !== 'string') return null; + const lines = description.split('\n'); + if (lines.length < 4) return null; + + const line1 = lines[0] ?? ''; + const line2 = lines[1] ?? ''; + const line3 = lines[2] ?? ''; + const line4 = lines[3] ?? ''; + + // Line 1: "TRADE offer= vol=- rate=-" + const m1 = /^TRADE offer=(\S+) vol=(\d+)-(\d+) rate=(\d+)-(\d+)$/.exec(line1); + if (!m1) return null; + const offer_token = m1[1]!; + const offer_volume_min = safeBigInt(m1[2]!); + const offer_volume_max = safeBigInt(m1[3]!); + const rate_min = safeBigInt(m1[4]!); + const rate_max = safeBigInt(m1[5]!); + if (offer_volume_min === null || offer_volume_max === null || rate_min === null || rate_max === null) return null; + + // Line 2: "req= expires=" + const m2 = /^req=(\S+) expires=(\S+)$/.exec(line2); + if (!m2) return null; + const request_token = m2[1]!; + const expiryDate = new Date(m2[2]!); + const expiry_ms = expiryDate.getTime(); + if (!Number.isFinite(expiry_ms)) return null; + + // Line 3: "escrows=" + const m3 = /^escrows=(.*)$/.exec(line3); + if (!m3) return null; + const rawEscrows = m3[1] ?? ''; + const escrows = rawEscrows.length === 0 ? [] : rawEscrows.split(','); + + // Line 4: "salt= timeout=" + const m4 = /^salt=(\S+) timeout=(\d+)$/.exec(line4); + if (!m4) return null; + const salt = m4[1]!; + const deposit_timeout_sec = Number(m4[2]); + if (!Number.isFinite(deposit_timeout_sec)) return null; + + return { + offer_token, + offer_volume_min, + offer_volume_max, + rate_min, + rate_max, + request_token, + expiry_ms, + escrows, + salt, + deposit_timeout_sec, + }; +} + +function safeBigInt(s: string): bigint | null { + try { + return BigInt(s); + } catch { + return null; + } +} + +// ============================================================================= +// Dangerous-key detection for NP-0 messages +// ============================================================================= + +export function hasDangerousKeys(value: unknown, depth: number = 0): boolean { + if (depth > 20) return true; + if (typeof value !== 'object' || value === null) return false; + if (Array.isArray(value)) { + for (const item of value) { + if (hasDangerousKeys(item, depth + 1)) return true; + } + return false; + } + for (const key of Object.keys(value as object)) { + if (DANGEROUS_KEYS.has(key)) return true; + if (hasDangerousKeys((value as Record)[key], depth + 1)) return true; + } + return false; +} diff --git a/src/trader/volume-reservation-ledger.ts b/src/trader/volume-reservation-ledger.ts new file mode 100644 index 0000000..593aac4 --- /dev/null +++ b/src/trader/volume-reservation-ledger.ts @@ -0,0 +1,108 @@ +/** + * VolumeReservationLedger — tracks reserved volume across in-flight deals so + * the trader never over-commits its confirmed balance. + * + * Reservations are keyed by deal_id. When a deal enters PROPOSED state, volume + * is reserved; when it completes, fails, or is cancelled, volume is released. + * + * The available volume for a new reservation is computed as: + * + * available = payments.getConfirmedAmount(token) - totalReserved + * + * Because multiple concurrent matching operations could race on this check, + * reserves are serialised behind a promise-chain mutex. The available amount + * is re-computed *inside* the lock after awaiting any previous reserve. + * + * `release()` and `reconstruct()` are synchronous: no network I/O, no mutex. + * They only mutate the in-memory map. + * + * This module intentionally has no Sphere SDK imports — it depends only on + * the narrow {@link PaymentsAdapter} interface for dependency injection. + */ + +import type { PaymentsAdapter } from './types.js'; + +export class VolumeReservationLedger { + /** deal_id -> reserved volume (bigint, never number) */ + private readonly reservations = new Map(); + + /** + * Promise-chain mutex. Each reserve() awaits the previous link and installs + * its own release function as the next link, serialising all reserves. + */ + private lock: Promise = Promise.resolve(); + + constructor( + private readonly payments: PaymentsAdapter, + private readonly token: string, + ) {} + + /** + * Attempt to reserve `volume` for `dealId`. + * + * Re-checks available balance *inside* the lock so concurrent reserves can't + * both observe the same pre-reservation balance and collectively over-commit. + * + * @throws {Error} if the requested volume exceeds currently available balance. + */ + async reserve(dealId: string, volume: bigint): Promise { + let releaseLock!: () => void; + const nextLock = new Promise((resolve) => { + releaseLock = resolve; + }); + const previousLock = this.lock; + this.lock = nextLock; + await previousLock; + try { + const confirmed = await this.payments.getConfirmedAmount(this.token); + const available = confirmed - this.totalReserved; + if (volume > available) { + throw new Error( + `Insufficient volume: need ${volume}, available ${available}`, + ); + } + this.reservations.set(dealId, volume); + } finally { + releaseLock(); + } + } + + /** + * Release the reservation for a deal. No-op if the deal has no reservation. + * + * Synchronous by design: deal-state transitions (COMPLETED / FAILED / + * CANCELLED) already run serially per deal, and releases never need to + * consult the payments adapter. + */ + release(dealId: string): void { + this.reservations.delete(dealId); + } + + /** + * Current total reserved volume summed across all deals. + * + * Computed on read rather than cached: the map is small (one entry per + * in-flight deal) and cached totals would be a second source of truth to + * keep in sync with the map. + */ + get totalReserved(): bigint { + let total = 0n; + for (const volume of this.reservations.values()) { + total += volume; + } + return total; + } + + /** + * Reconstruct a reservation from an existing deal record during startup + * recovery. Only call with deals in PROPOSED, ACCEPTED, or EXECUTING state — + * terminal states (COMPLETED / FAILED / CANCELLED) must not hold reservations. + * + * Synchronous: must be invoked before any async operation (including the + * first `reserve()` call) so the ledger reflects real outstanding exposure + * before any balance checks are performed. + */ + reconstruct(dealId: string, volume: bigint): void { + this.reservations.set(dealId, volume); + } +} From b1df83e7716769622b2849f926c4f06b382fe490 Mon Sep 17 00:00:00 2001 From: Vladimir Rogojin Date: Wed, 22 Apr 2026 18:20:10 +0200 Subject: [PATCH 07/16] =?UTF-8?q?feat(trader):=20Phase=202.1=20=E2=80=94?= =?UTF-8?q?=20TraderStateStore=20with=20atomic=20writes=20and=20bigint=20s?= =?UTF-8?q?erialization?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/trader/trader-state-store.ts | 225 +++++++++++++++++++++++++++++++ 1 file changed, 225 insertions(+) create mode 100644 src/trader/trader-state-store.ts diff --git a/src/trader/trader-state-store.ts b/src/trader/trader-state-store.ts new file mode 100644 index 0000000..1d097db --- /dev/null +++ b/src/trader/trader-state-store.ts @@ -0,0 +1,225 @@ +/** + * Trader Agent — persistent state store. + * + * Persists intents, deals, and strategy to disk using atomic writes + * (write to temp file, then `fs.rename`) so a crash mid-write cannot + * corrupt state. No auto-save: callers invoke `save()` explicitly at + * well-defined checkpoints. + * + * BigInt-bearing fields (volumes, rates) round-trip via a `"n:"`-prefixed + * string encoding since `JSON.stringify` refuses native bigints. + */ + +import * as fs from 'node:fs/promises'; +import * as path from 'node:path'; + +import type { + DealRecord, + IntentRecord, + IntentState, + TraderStrategy, +} from './types.js'; +import { DEFAULT_STRATEGY } from './types.js'; + +// ============================================================================= +// On-disk schema +// ============================================================================= + +interface PersistedState { + readonly version: 1; + readonly intents: Record; + readonly deals: Record; + readonly strategy: TraderStrategy; +} + +// ============================================================================= +// BigInt JSON round-trip helpers +// ============================================================================= + +/** + * JSON.stringify replacer: encodes `bigint` values as `"n:"`. + * Strings that happen to begin with `"n:"` are left untouched on the way + * out — the reviver only decodes values whose original runtime type was + * bigint, so there is no ambiguity for intent/deal payloads where the + * bigint fields are typed and known. + */ +function bigintReplacer(_key: string, value: unknown): unknown { + return typeof value === 'bigint' ? `n:${value.toString()}` : value; +} + +/** + * JSON.parse reviver: decodes `"n:"` strings back to `bigint`. + * Any other string is returned unchanged. + */ +function bigintReviver(_key: string, value: unknown): unknown { + if (typeof value === 'string' && value.startsWith('n:')) { + return BigInt(value.slice(2)); + } + return value; +} + +// ============================================================================= +// Store +// ============================================================================= + +export class TraderStateStore { + private readonly filePath: string; + private readonly intents: Map; + private readonly deals: Map; + private strategy: TraderStrategy; + + constructor(dataDir: string) { + this.filePath = path.join(dataDir, 'wallet', 'trader', 'state.json'); + this.intents = new Map(); + this.deals = new Map(); + this.strategy = DEFAULT_STRATEGY; + } + + // --------------------------------------------------------------------------- + // Persistence + // --------------------------------------------------------------------------- + + /** + * Load persisted state from disk. If the file does not exist, the store is + * initialised with empty maps and the default strategy. A parse failure + * (corrupt file) is raised to the caller — silently wiping state on corrupt + * JSON would mask real problems. + */ + async load(): Promise { + let raw: string; + try { + raw = await fs.readFile(this.filePath, 'utf8'); + } catch (err) { + if ((err as NodeJS.ErrnoException).code === 'ENOENT') { + this.intents.clear(); + this.deals.clear(); + this.strategy = DEFAULT_STRATEGY; + return; + } + throw err; + } + + let parsed: PersistedState; + try { + parsed = JSON.parse(raw, bigintReviver) as PersistedState; + } catch (err) { + throw new Error( + `TraderStateStore: failed to parse state file ${this.filePath}: ${(err as Error).message}`, + ); + } + + if (!parsed || typeof parsed !== 'object' || parsed.version !== 1) { + throw new Error( + `TraderStateStore: unsupported state file version in ${this.filePath}`, + ); + } + + this.intents.clear(); + for (const [id, rec] of Object.entries(parsed.intents ?? {})) { + this.intents.set(id, rec); + } + + this.deals.clear(); + for (const [id, rec] of Object.entries(parsed.deals ?? {})) { + this.deals.set(id, rec); + } + + this.strategy = parsed.strategy ?? DEFAULT_STRATEGY; + } + + /** + * Persist the entire state atomically: write to a temp file, then rename + * over the target. `fs.rename` is atomic within a filesystem on POSIX, so + * a crash either leaves the old file intact or the new file in place — + * never a half-written file. + */ + async save(): Promise { + const state: PersistedState = { + version: 1, + intents: Object.fromEntries(this.intents), + deals: Object.fromEntries(this.deals), + strategy: this.strategy, + }; + const json = JSON.stringify(state, bigintReplacer, 2); + const tmpPath = this.filePath + '.tmp'; + await fs.mkdir(path.dirname(this.filePath), { recursive: true }); + await fs.writeFile(tmpPath, json, 'utf8'); + await fs.rename(tmpPath, this.filePath); + } + + // --------------------------------------------------------------------------- + // Intent CRUD + // --------------------------------------------------------------------------- + + getIntent(intentId: string): IntentRecord | undefined { + return this.intents.get(intentId); + } + + getAllIntents(): IntentRecord[] { + return Array.from(this.intents.values()); + } + + getIntentsByState(state: IntentState): IntentRecord[] { + const out: IntentRecord[] = []; + for (const rec of this.intents.values()) { + if (rec.state === state) { + out.push(rec); + } + } + return out; + } + + setIntent(record: IntentRecord): void { + this.intents.set(record.intent.intent_id, record); + } + + deleteIntent(intentId: string): void { + this.intents.delete(intentId); + } + + // --------------------------------------------------------------------------- + // Deal CRUD + // --------------------------------------------------------------------------- + + getDeal(dealId: string): DealRecord | undefined { + return this.deals.get(dealId); + } + + getAllDeals(): DealRecord[] { + return Array.from(this.deals.values()); + } + + /** + * Return every deal that references `intentId` on either side of the + * terms — proposer or acceptor. Callers filter further by `role` or + * `state` as needed. + */ + getDealsByIntentId(intentId: string): DealRecord[] { + const out: DealRecord[] = []; + for (const deal of this.deals.values()) { + if ( + deal.terms.proposer_intent_id === intentId || + deal.terms.acceptor_intent_id === intentId + ) { + out.push(deal); + } + } + return out; + } + + setDeal(record: DealRecord): void { + this.deals.set(record.deal_id, record); + } + + // --------------------------------------------------------------------------- + // Strategy + // --------------------------------------------------------------------------- + + getStrategy(): TraderStrategy { + return this.strategy; + } + + setStrategy(strategy: TraderStrategy): void { + this.strategy = strategy; + } +} From 073e72f0c1efb4c5c030992a305c639fdf4e130a Mon Sep 17 00:00:00 2001 From: Vladimir Rogojin Date: Wed, 22 Apr 2026 18:53:31 +0200 Subject: [PATCH 08/16] =?UTF-8?q?feat(trader):=20Phase=202=20=E2=80=94=20I?= =?UTF-8?q?ntentEngine,=20NegotiationHandler,=20SwapExecutor,=20test=20moc?= =?UTF-8?q?ks?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - intent-engine.ts: full state machine (ACTIVE/PAUSED/CANCELLED/EXPIRED/FILLED), scan loop, feed subscription, 10-criteria matching filter, proposer selection (lower pubkey proposes), expiry sweep, monotonic intent updates, MatchEvent type - negotiation-handler.ts: NP-0 protocol (np.propose/accept/reject/cancel), auth validation (sig, ts_ms skew 300s, sender==participant, msg_id dedup 600s/10K), rate-limit 3 proposals/60s per counterparty, 64 KiB limit, dangerous-keys check, proposal 30s and acceptance 60s timeouts, CryptoAdapter interface - swap-executor.ts: ACCEPTED→EXECUTING→COMPLETED/FAILED, pingEscrow, deposit_attempted flag written before payInvoice, payout polling (30s×10), term binding on swap:proposal_received, V2 enforcement, EXECUTING timeout (deposit_timeout_sec+60s), sphere event subscriptions with unsubscribe tracking - test/mocks: MockMarket, MockSwap, MockPayments, MockComms with vi.fn() controls; fixtures.ts with makeIntent, makeIntentRecord, makeDealTerms, makeDealRecord, makeStrategy factories --- src/trader/intent-engine.ts | 848 ++++++++++++++++++++++ src/trader/negotiation-handler.ts | 886 +++++++++++++++++++++++ src/trader/swap-executor.ts | 548 ++++++++++++++ test/mocks/fixtures.ts | 101 +++ test/mocks/mock-communications-module.ts | 35 + test/mocks/mock-market-module.ts | 40 + test/mocks/mock-payments-module.ts | 37 + test/mocks/mock-swap-module.ts | 59 ++ 8 files changed, 2554 insertions(+) create mode 100644 src/trader/intent-engine.ts create mode 100644 src/trader/negotiation-handler.ts create mode 100644 src/trader/swap-executor.ts create mode 100644 test/mocks/fixtures.ts create mode 100644 test/mocks/mock-communications-module.ts create mode 100644 test/mocks/mock-market-module.ts create mode 100644 test/mocks/mock-payments-module.ts create mode 100644 test/mocks/mock-swap-module.ts diff --git a/src/trader/intent-engine.ts b/src/trader/intent-engine.ts new file mode 100644 index 0000000..da08b20 --- /dev/null +++ b/src/trader/intent-engine.ts @@ -0,0 +1,848 @@ +/** + * IntentEngine — lifecycle management for trading intents. + * + * Responsibilities: + * - Publish new intents to the market (post description to MarketAdapter) + * - Scan the market for matching counterparty intents + * - Subscribe to the market feed for real-time match detection + * - Apply matching criteria (rate overlap, volume overlap, escrow trust, block list) + * - Decide who proposes (spec 5.7: lower pubkey proposes) + * - Enforce intent state machine (spec 6.1) + * - Handle deal completion/failure callbacks from the NegotiationHandler + * - Sweep expired intents + * - Clamp intent lifetime to 7 days (defence-in-depth; utils.validateIntent + * already enforces this at the ACP boundary) + * + * Design notes: + * - No Sphere SDK imports: adapters are injected via the constructor so the + * engine can be unit-tested with in-memory fakes. + * - Scan-loop errors are swallowed and logged to stderr so a transient market + * failure cannot crash the long-running trader process. + * - Feed-listing parse failures are silent on purpose: untrusted market input + * regularly contains free-form descriptions that do not match our encoding + * format, and noisy logs would drown out real errors. + * - Timers and the feed unsubscribe function are stored in instance fields so + * `stop()` can deterministically tear them down. + */ + +import type { + IntentRecord, + IntentState, + MarketAdapter, + MarketListing, + TraderStrategy, + TradingIntent, +} from './types.js'; +import type { DecodedDescription } from './utils.js'; +import type { TraderStateStore } from './trader-state-store.js'; +import type { VolumeReservationLedger } from './volume-reservation-ledger.js'; + +import { decodeDescription, encodeDescription } from './utils.js'; + +// ============================================================================= +// Constants +// ============================================================================= + +/** Maximum lifetime for any intent (defence-in-depth; utils.validateIntent + * already enforces this at the ACP boundary). */ +const MAX_INTENT_LIFETIME_MS = 7 * 24 * 60 * 60 * 1000; + +/** Sweep cadence for expiring intents. Independent of the scan cadence so + * operators can tune scan frequency without affecting expiry latency. */ +const EXPIRY_SWEEP_INTERVAL_MS = 10_000; + +/** Debug-only logging: opt-in via the DEBUG env var. */ +const DEBUG = typeof process !== 'undefined' && Boolean(process.env.DEBUG); + +// ============================================================================= +// State machine +// ============================================================================= + +/** + * Valid IntentState transitions (spec 6.1). + * + * Any transition not listed here is rejected by `transitionState` and logged + * to stderr. Terminal states (EXPIRED, CANCELLED, FILLED) have no outbound + * edges — intents in those states are frozen. + */ +const VALID_TRANSITIONS: Readonly> = { + ACTIVE: ['PAUSED', 'CANCELLED', 'EXPIRED', 'FILLED'], + PAUSED: ['ACTIVE', 'CANCELLED', 'EXPIRED'], + EXPIRED: [], + CANCELLED: [], + FILLED: [], +}; + +// ============================================================================= +// Public types +// ============================================================================= + +/** + * Extended match event passed to the handler callback. Carries the decoded + * description (parsed once at match time so downstream code does not need to + * re-parse) and the `shouldPropose` decision so the NegotiationHandler knows + * whether to initiate the proposal or wait for the counterparty. + */ +export interface MatchEvent { + readonly intent: IntentRecord; + readonly listing: MarketListing; + readonly shouldPropose: boolean; + readonly decoded: DecodedDescription; +} + +/** + * Fields of a TradingIntent that can be updated after creation. Monotonic by + * spec 2.7.3: rate_min can only increase, rate_max only decrease, and + * offer_volume_min can only increase. These constraints are enforced in + * {@link IntentEngine.updateIntent}. + */ +export type IntentUpdate = Partial< + Pick +>; + +// ============================================================================= +// IntentEngine +// ============================================================================= + +export class IntentEngine { + private sweepTimer: ReturnType | null = null; + private scanTimer: ReturnType | null = null; + private feedUnsubscribe: (() => void) | null = null; + private started = false; + + constructor( + private readonly store: TraderStateStore, + private readonly market: MarketAdapter, + private readonly ledger: VolumeReservationLedger, + private readonly strategy: () => TraderStrategy, + private readonly myPubkey: string, + private readonly onMatchFound: (event: MatchEvent) => void, + ) {} + + // --------------------------------------------------------------------------- + // Lifecycle + // --------------------------------------------------------------------------- + + /** + * Start the engine. Idempotent: a second call is a no-op. + * + * Re-publishes any ACTIVE intents that lost their market_listing_id across a + * restart — the market is an external system and listings are not guaranteed + * to survive our downtime. + */ + async start(): Promise { + if (this.started) return; + this.started = true; + + const intents = this.store.getAllIntents(); + for (const record of intents) { + if (record.state === 'ACTIVE' && record.market_listing_id === null) { + await this.repostIntent(record); + } + } + + this.sweepTimer = setInterval(() => { + void this.sweepExpired().catch((err) => { + process.stderr.write( + `IntentEngine: sweep failed: ${(err as Error).message}\n`, + ); + }); + }, EXPIRY_SWEEP_INTERVAL_MS); + + const scanIntervalMs = Math.max(1_000, this.strategy().scan_interval_ms); + this.scanTimer = setInterval(() => { + void this.scanOnce().catch((err) => { + process.stderr.write( + `IntentEngine: scan failed: ${(err as Error).message}\n`, + ); + }); + }, scanIntervalMs); + + this.feedUnsubscribe = this.market.subscribeFeed((listing) => { + this.handleFeedListing(listing); + }); + } + + /** + * Stop the engine. Clears timers, unsubscribes the feed, removes ACTIVE + * listings from the market (fire-and-forget), and persists state. + * + * `stop()` does not throw on market errors: the caller typically invokes it + * during shutdown where throwing would mask the primary shutdown reason. + */ + async stop(): Promise { + if (!this.started) return; + this.started = false; + + if (this.sweepTimer !== null) { + clearInterval(this.sweepTimer); + this.sweepTimer = null; + } + if (this.scanTimer !== null) { + clearInterval(this.scanTimer); + this.scanTimer = null; + } + if (this.feedUnsubscribe !== null) { + try { + this.feedUnsubscribe(); + } catch (err) { + if (DEBUG) { + process.stderr.write( + `IntentEngine: feed unsubscribe failed: ${(err as Error).message}\n`, + ); + } + } + this.feedUnsubscribe = null; + } + + const active = this.store.getAllIntents().filter((r) => r.state === 'ACTIVE'); + for (const record of active) { + if (record.market_listing_id !== null) { + // Fire-and-forget: logs on failure but does not throw. + this.removeListingSafely(record.market_listing_id); + } + } + + try { + await this.store.save(); + } catch (err) { + process.stderr.write( + `IntentEngine: save on stop failed: ${(err as Error).message}\n`, + ); + } + } + + // --------------------------------------------------------------------------- + // Intent CRUD + // --------------------------------------------------------------------------- + + /** + * Create and publish a new intent. + * + * Clamps `expiry_ms` to at most 7 days from now (spec max lifetime). The + * validation at the ACP boundary already enforces this, so clamping here is + * defence-in-depth — it also lets internal callers create intents without + * replicating the bound check. + */ + async createIntent(intent: TradingIntent): Promise { + const now = Date.now(); + const maxExpiry = now + MAX_INTENT_LIFETIME_MS; + const clampedExpiry = Math.min(intent.expiry_ms, maxExpiry); + const effectiveIntent: TradingIntent = + clampedExpiry === intent.expiry_ms + ? intent + : { ...intent, expiry_ms: clampedExpiry }; + + const listingId = await this.postListing(effectiveIntent); + + const record: IntentRecord = { + intent: effectiveIntent, + state: 'ACTIVE', + market_listing_id: listingId, + volume_filled: 0n, + updated_at_ms: now, + }; + + this.store.setIntent(record); + await this.store.save(); + return record; + } + + /** Transition an ACTIVE or PAUSED intent to CANCELLED and remove its listing. */ + async cancelIntent(intentId: string): Promise { + const record = this.requireIntent(intentId); + const updated = this.transitionState(record, 'CANCELLED'); + if (record.market_listing_id !== null) { + this.removeListingSafely(record.market_listing_id); + } + const next: IntentRecord = { + ...updated, + market_listing_id: null, + }; + this.store.setIntent(next); + await this.store.save(); + return next; + } + + /** Transition ACTIVE → PAUSED. Listing is removed so counterparties do not + * waste proposals on a paused intent. */ + async pauseIntent(intentId: string): Promise { + const record = this.requireIntent(intentId); + const updated = this.transitionState(record, 'PAUSED'); + if (record.market_listing_id !== null) { + this.removeListingSafely(record.market_listing_id); + } + const next: IntentRecord = { + ...updated, + market_listing_id: null, + }; + this.store.setIntent(next); + await this.store.save(); + return next; + } + + /** Transition PAUSED → ACTIVE. Re-publishes the listing to the market. */ + async resumeIntent(intentId: string): Promise { + const record = this.requireIntent(intentId); + const updated = this.transitionState(record, 'ACTIVE'); + const listingId = await this.postListing(updated.intent); + const next: IntentRecord = { + ...updated, + market_listing_id: listingId, + }; + this.store.setIntent(next); + await this.store.save(); + return next; + } + + /** + * Update mutable terms on an existing intent. Monotonic constraints are + * enforced here and in spec 2.7.3: + * - `rate_min` can only increase + * - `rate_max` can only decrease + * - `offer_volume_min` can only increase + * + * If the intent is ACTIVE, the old listing is removed and a new one posted + * with the updated terms. + */ + async updateIntent(intentId: string, updates: IntentUpdate): Promise { + const record = this.requireIntent(intentId); + if (record.state !== 'ACTIVE' && record.state !== 'PAUSED') { + throw new Error( + `IntentEngine: cannot update intent ${intentId} in state ${record.state}`, + ); + } + + const old = record.intent; + const nextRateMin = updates.rate_min ?? old.rate_min; + const nextRateMax = updates.rate_max ?? old.rate_max; + const nextVolMin = updates.offer_volume_min ?? old.offer_volume_min; + + if (updates.rate_min !== undefined && updates.rate_min < old.rate_min) { + throw new Error( + `IntentEngine: rate_min is monotonically increasing (old=${old.rate_min}, new=${updates.rate_min})`, + ); + } + if (updates.rate_max !== undefined && updates.rate_max > old.rate_max) { + throw new Error( + `IntentEngine: rate_max is monotonically decreasing (old=${old.rate_max}, new=${updates.rate_max})`, + ); + } + if (updates.offer_volume_min !== undefined && updates.offer_volume_min < old.offer_volume_min) { + throw new Error( + `IntentEngine: offer_volume_min is monotonically increasing (old=${old.offer_volume_min}, new=${updates.offer_volume_min})`, + ); + } + if (nextRateMin > nextRateMax) { + throw new Error( + `IntentEngine: after update rate_min (${nextRateMin}) > rate_max (${nextRateMax})`, + ); + } + if (nextVolMin > old.offer_volume_max) { + throw new Error( + `IntentEngine: after update offer_volume_min (${nextVolMin}) > offer_volume_max (${old.offer_volume_max})`, + ); + } + + const nextIntent: TradingIntent = { + ...old, + rate_min: nextRateMin, + rate_max: nextRateMax, + offer_volume_min: nextVolMin, + }; + + // Remove old listing before posting the new one so the market never shows + // two conflicting entries for the same intent_id. + if (record.market_listing_id !== null) { + this.removeListingSafely(record.market_listing_id); + } + + let newListingId: string | null = null; + if (record.state === 'ACTIVE') { + newListingId = await this.postListing(nextIntent); + } + + const next: IntentRecord = { + intent: nextIntent, + state: record.state, + market_listing_id: newListingId, + volume_filled: record.volume_filled, + updated_at_ms: Date.now(), + }; + this.store.setIntent(next); + await this.store.save(); + return next; + } + + // --------------------------------------------------------------------------- + // Deal outcome callbacks + // --------------------------------------------------------------------------- + + /** + * Record a successful deal against the intent. If the accumulated + * `volume_filled` reaches `offer_volume_max`, transition the intent to + * FILLED and remove its listing. Otherwise the intent stays ACTIVE so the + * scan loop continues to seek more counterparties. + */ + async onDealCompleted(intentId: string, volumeFilled: bigint): Promise { + const record = this.store.getIntent(intentId); + if (!record) { + process.stderr.write( + `IntentEngine: onDealCompleted for unknown intent ${intentId}\n`, + ); + return; + } + if (volumeFilled < 0n) { + throw new Error( + `IntentEngine: onDealCompleted requires non-negative volumeFilled, got ${volumeFilled}`, + ); + } + + const nextFilled = record.volume_filled + volumeFilled; + + if (record.state === 'FILLED' || record.state === 'CANCELLED' || record.state === 'EXPIRED') { + // Terminal states do not accept further fills; record the event but do + // not attempt another state transition. + const next: IntentRecord = { + ...record, + volume_filled: nextFilled, + updated_at_ms: Date.now(), + }; + this.store.setIntent(next); + await this.store.save(); + return; + } + + if (nextFilled >= record.intent.offer_volume_max) { + const transitioned = this.transitionState(record, 'FILLED'); + if (record.market_listing_id !== null) { + this.removeListingSafely(record.market_listing_id); + } + const next: IntentRecord = { + ...transitioned, + market_listing_id: null, + volume_filled: nextFilled, + }; + this.store.setIntent(next); + } else { + const next: IntentRecord = { + ...record, + volume_filled: nextFilled, + updated_at_ms: Date.now(), + }; + this.store.setIntent(next); + } + await this.store.save(); + } + + /** + * Record a failed deal. The intent state is not changed — a failed deal is + * not a reason to retire an intent. Persist so `updated_at_ms` and any + * concurrently-updated fields are flushed to disk. + */ + async onDealFailed(intentId: string): Promise { + const record = this.store.getIntent(intentId); + if (!record) { + process.stderr.write( + `IntentEngine: onDealFailed for unknown intent ${intentId}\n`, + ); + return; + } + const next: IntentRecord = { + ...record, + updated_at_ms: Date.now(), + }; + this.store.setIntent(next); + await this.store.save(); + } + + // --------------------------------------------------------------------------- + // Read-only accessors + // --------------------------------------------------------------------------- + + getIntent(intentId: string): IntentRecord | undefined { + return this.store.getIntent(intentId); + } + + getAllIntents(): IntentRecord[] { + return this.store.getAllIntents(); + } + + // --------------------------------------------------------------------------- + // Scan loop + // --------------------------------------------------------------------------- + + /** + * Run one scan iteration: for each ACTIVE intent, search the market for the + * token pair and evaluate each listing against the intent's criteria. Logs + * transient adapter errors to stderr but never rethrows — the scan timer + * fires again on the next tick. + */ + private async scanOnce(): Promise { + if (!this.started) return; + + const active = this.store.getAllIntents().filter((r) => r.state === 'ACTIVE'); + const strategy = this.strategy(); + + for (const record of active) { + const query = `${record.intent.offer_token} ${record.intent.request_token}`; + let listings: MarketListing[]; + try { + listings = await this.market.search(query); + } catch (err) { + process.stderr.write( + `IntentEngine: market.search failed for "${query}": ${(err as Error).message}\n`, + ); + continue; + } + + for (const listing of listings) { + this.evaluateListing(record, listing, strategy); + } + } + } + + // --------------------------------------------------------------------------- + // Feed handler + // --------------------------------------------------------------------------- + + /** + * Called for every listing pushed by `market.subscribeFeed`. Matches against + * every ACTIVE intent whose token pair is the inverse of the listing's pair, + * since the listing's offer is our request and vice versa. + * + * Errors raised inside the match handler are caught so a bad listener never + * tears down the market subscription. + */ + private handleFeedListing(listing: MarketListing): void { + try { + const decoded = decodeDescription(listing.description); + if (decoded === null) return; + + const strategy = this.strategy(); + const active = this.store.getAllIntents().filter((r) => r.state === 'ACTIVE'); + + for (const record of active) { + if ( + record.intent.request_token !== decoded.offer_token || + record.intent.offer_token !== decoded.request_token + ) { + continue; + } + if (this.matchesCriteria(record, decoded, listing, strategy)) { + this.emitMatch(record, listing, decoded); + } + } + } catch (err) { + process.stderr.write( + `IntentEngine: feed handler error: ${(err as Error).message}\n`, + ); + } + } + + // --------------------------------------------------------------------------- + // Matching logic + // --------------------------------------------------------------------------- + + /** + * Evaluate a single listing against an intent. Silently skips listings with + * malformed descriptions — the market is full of them and logging would + * produce mostly noise. + */ + private evaluateListing( + record: IntentRecord, + listing: MarketListing, + strategy: TraderStrategy, + ): void { + const decoded = decodeDescription(listing.description); + if (decoded === null) return; + + // Cross-check the decoded token pair against the intent's inverse. The + // market.search query is a loose text match; this is the authoritative check. + if ( + record.intent.request_token !== decoded.offer_token || + record.intent.offer_token !== decoded.request_token + ) { + return; + } + + if (this.matchesCriteria(record, decoded, listing, strategy)) { + this.emitMatch(record, listing, decoded); + } + } + + /** + * Full matching predicate. Returns true iff every criterion in spec 5.x + * holds. The checks are ordered cheap-to-expensive so mismatched listings + * fail fast. + */ + private matchesCriteria( + record: IntentRecord, + decoded: DecodedDescription, + listing: MarketListing, + strategy: TraderStrategy, + ): boolean { + // 1. Listing not expired. + if (listing.expiry_ms <= Date.now()) return false; + + // 2. Not our own listing. + if (listing.poster_pubkey === this.myPubkey) return false; + + // 3-4. Token pair matches (caller already checked but keep here for the + // filterMatches path where this method is used directly). + if (decoded.offer_token !== record.intent.request_token) return false; + if (decoded.request_token !== record.intent.offer_token) return false; + + // 5-6. Rate overlap. Their rate describes how much of `decoded.offer_token` + // (= our request_token) they will give per unit of `decoded.request_token` + // (= our offer_token). Our intent's rate range is expressed in the same + // units, so overlap is a direct interval intersection. + if (decoded.rate_min > record.intent.rate_max) return false; + if (decoded.rate_max < record.intent.rate_min) return false; + + // 7. Their max offer volume is at least our min offer volume. Volumes are + // in units of the posting side's offer token, so we translate through + // `volume_min <= listing.offer_volume_max`. + if (decoded.offer_volume_max < record.intent.offer_volume_min) return false; + + // 8. Their min offer volume fits within our remaining capacity. + const remaining = record.intent.offer_volume_max - record.volume_filled; + if (remaining <= 0n) return false; + if (decoded.offer_volume_min > remaining) return false; + + // 9. Counterparty is not on our block list. + if (strategy.blocked_counterparties.includes(listing.poster_pubkey)) return false; + + // 10. At least one advertised escrow is in our trust set. + if (!this.hasTrustedEscrow(decoded.escrows, strategy.trusted_escrows)) return false; + + return true; + } + + /** + * Public-ish filter used by callers that want to evaluate a batch of + * listings without driving the scan loop. Exposed on the instance so tests + * can exercise the matching logic in isolation. + */ + filterMatches(record: IntentRecord, listings: readonly MarketListing[]): MarketListing[] { + const strategy = this.strategy(); + const out: MarketListing[] = []; + for (const listing of listings) { + const decoded = decodeDescription(listing.description); + if (decoded === null) continue; + if ( + record.intent.request_token !== decoded.offer_token || + record.intent.offer_token !== decoded.request_token + ) { + continue; + } + if (this.matchesCriteria(record, decoded, listing, strategy)) { + out.push(listing); + } + } + return out; + } + + private hasTrustedEscrow( + offered: readonly string[], + trusted: readonly string[], + ): boolean { + if (trusted.length === 0) return false; + const trustSet = new Set(trusted); + for (const escrow of offered) { + if (trustSet.has(escrow)) return true; + } + return false; + } + + // --------------------------------------------------------------------------- + // Proposer selection (spec 5.7) + // --------------------------------------------------------------------------- + + /** + * Deterministic proposer selection: the party with the lexicographically + * lower secp256k1 pubkey proposes; the other waits. This avoids the case + * where both sides fire proposals simultaneously and then have to reconcile + * duplicates. + * + * Comparing hex-encoded pubkeys as strings is well-defined because both + * sides agree on the canonical hex encoding (lowercase, fixed width). + */ + private shouldPropose(counterpartyPubkey: string): boolean { + return this.myPubkey < counterpartyPubkey; + } + + private emitMatch( + record: IntentRecord, + listing: MarketListing, + decoded: DecodedDescription, + ): void { + const event: MatchEvent = { + intent: record, + listing, + shouldPropose: this.shouldPropose(listing.poster_pubkey), + decoded, + }; + try { + this.onMatchFound(event); + } catch (err) { + process.stderr.write( + `IntentEngine: onMatchFound handler threw: ${(err as Error).message}\n`, + ); + } + } + + // --------------------------------------------------------------------------- + // Expiry sweep + // --------------------------------------------------------------------------- + + /** + * Mark any ACTIVE or PAUSED intent whose `expiry_ms` has passed as EXPIRED. + * Persists at most once per sweep regardless of the number of expirations. + */ + private async sweepExpired(): Promise { + if (!this.started) return; + + const now = Date.now(); + const candidates = this.store + .getAllIntents() + .filter((r) => (r.state === 'ACTIVE' || r.state === 'PAUSED') && r.intent.expiry_ms <= now); + + if (candidates.length === 0) return; + + for (const record of candidates) { + try { + const transitioned = this.transitionState(record, 'EXPIRED'); + if (record.market_listing_id !== null) { + this.removeListingSafely(record.market_listing_id); + } + const next: IntentRecord = { + ...transitioned, + market_listing_id: null, + }; + this.store.setIntent(next); + } catch (err) { + process.stderr.write( + `IntentEngine: expiry transition failed for ${record.intent.intent_id}: ${(err as Error).message}\n`, + ); + } + } + + try { + await this.store.save(); + } catch (err) { + process.stderr.write( + `IntentEngine: save after sweep failed: ${(err as Error).message}\n`, + ); + } + } + + // --------------------------------------------------------------------------- + // State transitions + // --------------------------------------------------------------------------- + + /** + * Apply a state transition with validation. Returns a new IntentRecord with + * the updated state and refreshed `updated_at_ms`. Throws (and logs) if the + * transition is not allowed by the state machine. + */ + private transitionState(record: IntentRecord, next: IntentState): IntentRecord { + const allowed = VALID_TRANSITIONS[record.state]; + if (!allowed.includes(next)) { + const msg = `IntentEngine: invalid transition ${record.state} -> ${next} for intent ${record.intent.intent_id}`; + process.stderr.write(`${msg}\n`); + throw new Error(msg); + } + return { + ...record, + state: next, + updated_at_ms: Date.now(), + }; + } + + private requireIntent(intentId: string): IntentRecord { + const record = this.store.getIntent(intentId); + if (!record) { + throw new Error(`IntentEngine: unknown intent ${intentId}`); + } + return record; + } + + // --------------------------------------------------------------------------- + // Market-listing helpers + // --------------------------------------------------------------------------- + + /** + * Post the intent's description to the market and return the listing id. + * Uses the strategy's trusted escrows as the advertised escrow set — this + * is the set counterparties must intersect to match us, so we advertise the + * whole set up front rather than revealing escrow preference during + * negotiation. + */ + private async postListing(intent: TradingIntent): Promise { + const escrows = this.strategy().trusted_escrows; + const description = encodeDescription(intent, escrows); + return this.market.post(description, intent.expiry_ms); + } + + /** + * Re-post an ACTIVE intent that lost its listing across a restart. The + * IntentRecord is updated in place with the new listing id. + */ + private async repostIntent(record: IntentRecord): Promise { + try { + const listingId = await this.postListing(record.intent); + const next: IntentRecord = { + ...record, + market_listing_id: listingId, + updated_at_ms: Date.now(), + }; + this.store.setIntent(next); + await this.store.save(); + } catch (err) { + process.stderr.write( + `IntentEngine: repost failed for ${record.intent.intent_id}: ${(err as Error).message}\n`, + ); + } + } + + /** + * Fire-and-forget listing removal. We never want a market error to crash a + * cancel/pause/expire flow — the listing will eventually expire from the + * market on its own even if our remove() call is lost. + * + * Intentionally returns void: callers do not await. Uses `void` on the + * promise chain so unhandled-rejection linters do not flag it. + */ + private removeListingSafely(listingId: string): void { + // Use a catch handler attached synchronously so a rejected promise never + // becomes an unhandled rejection on the event loop. Intentionally not + // awaited — callers do not want to block on market I/O during teardown. + void (async () => { + try { + await this.market.remove(listingId); + } catch (err) { + if (DEBUG) { + process.stderr.write( + `IntentEngine: market.remove(${listingId}) failed: ${(err as Error).message}\n`, + ); + } + } + })(); + } + + // --------------------------------------------------------------------------- + // Ledger access for deal-accept path (surface point for future integrations) + // --------------------------------------------------------------------------- + + /** + * Expose the underlying ledger so the NegotiationHandler can reserve volume + * against the confirmed balance without reaching around the engine. Intents + * and reservations live at the same abstraction level; sharing the ledger + * through the engine keeps the constructor surface of downstream components + * small. + */ + getLedger(): VolumeReservationLedger { + return this.ledger; + } +} diff --git a/src/trader/negotiation-handler.ts b/src/trader/negotiation-handler.ts new file mode 100644 index 0000000..02ee378 --- /dev/null +++ b/src/trader/negotiation-handler.ts @@ -0,0 +1,886 @@ +/** + * NegotiationHandler — drives the NP-0 negotiation protocol. + * + * Responsibilities: + * - React to IntentEngine match events (propose if we're the proposer, + * wait for an incoming proposal otherwise). + * - Serialise, sign, and send `np.propose`, `np.accept`, `np.reject`, + * `np.cancel` messages via the injected CommsAdapter. + * - Validate and authenticate every incoming NP-0 message (spec 7.6). + * - Enforce DoS protections: size cap, prototype-pollution guard, + * rate limit per counterparty, msg_id dedup window. + * - Enforce proposal/acceptance timeouts and the duplicate-deal guard + * (spec 5.7). + * - Hand off ACCEPTED deals to the caller via `onDealAccepted`. + * + * Design notes: + * - Signature verification is pluggable via `CryptoAdapter` so unit tests + * can inject a fake without pulling in a real secp256k1 implementation. + * - All error paths log to stderr and return cleanly: a malformed DM from + * an adversary must never crash the long-running trader process. + * - Timers are stored in instance fields so `stop()` can tear them down + * deterministically and avoid leaking handles in tests. + */ + +import type { + CommsAdapter, + DealRecord, + DealTerms, + IncomingDM, + IntentRecord, + NpMessage, + NpMessageType, + OnDealAccepted, + TraderStrategy, +} from './types.js'; +import type { TraderStateStore } from './trader-state-store.js'; +import type { MatchEvent } from './intent-engine.js'; + +import { canonicalJson, hasDangerousKeys, validateDealTerms } from './utils.js'; + +// ============================================================================= +// Constants +// ============================================================================= + +/** Rate denominator for the ×1e8 rate encoding (offer * rate / 1e8 = request). */ +const RATE_DENOMINATOR = 100_000_000n; + +/** Maximum clock skew for incoming NP-0 messages (spec 7.6). */ +const MAX_TIMESTAMP_SKEW_MS = 300_000; + +/** Replay-protection window for dedup of msg_ids (spec 7.6). */ +const MSG_ID_DEDUP_WINDOW_MS = 600_000; + +/** Maximum number of entries retained in the dedup map (DoS bound). */ +const MSG_ID_DEDUP_MAX_ENTRIES = 10_000; + +/** Maximum NP-0 message size in bytes (DoS bound). */ +const MAX_MESSAGE_BYTES = 64 * 1024; + +/** Rate limit: max proposals accepted from one counterparty in the window. */ +const RATE_LIMIT_MAX_PROPOSALS = 3; + +/** Rate limit window. */ +const RATE_LIMIT_WINDOW_MS = 60_000; + +/** Default proposal timeout if strategy does not specify one. */ +const DEFAULT_PROPOSAL_TIMEOUT_MS = 30_000; + +/** Default acceptance timeout if strategy does not specify one. */ +const DEFAULT_ACCEPTANCE_TIMEOUT_MS = 60_000; + +/** Debug logging opt-in. */ +const DEBUG = typeof process !== 'undefined' && Boolean(process.env['DEBUG']); + +// ============================================================================= +// CryptoAdapter — pluggable secp256k1 signer/verifier +// ============================================================================= + +/** + * Narrow injection seam for signing/verifying NP-0 messages. Implementations + * wrap whatever secp256k1 backend the runtime provides (e.g. the Sphere SDK). + * Tests inject a deterministic fake. + */ +export interface CryptoAdapter { + /** Produce a hex signature over `data`. */ + sign(data: string): Promise; + /** Verify a hex signature against `pubkey` (x-only, 64-char hex). */ + verify(data: string, signature: string, pubkey: string): Promise; + /** Return this adapter's x-only public key (64-char hex). */ + getPublicKey(): string; +} + +// ============================================================================= +// Helpers +// ============================================================================= + +/** Canonical JSON payload over which the signature is computed. */ +function messageToSign(msg: Omit): string { + return canonicalJson(msg as unknown as Record); +} + +/** UTF-8 byte length of a string. */ +function byteLength(s: string): number { + return Buffer.byteLength(s, 'utf8'); +} + +/** Deterministic deal id: `deal__` so a + * reconnecting agent cannot accidentally double-register the same deal. */ +function buildDealId(proposerIntentId: string, acceptorIntentId: string): string { + return `deal_${proposerIntentId}_${acceptorIntentId}`; +} + +/** Random msg_id (16 bytes of entropy rendered as hex). */ +function newMsgId(): string { + const buf = new Uint8Array(16); + for (let i = 0; i < buf.length; i++) { + buf[i] = Math.floor(Math.random() * 256); + } + return Array.from(buf, (b) => b.toString(16).padStart(2, '0')).join(''); +} + +/** Simple bigint midpoint (floor of the arithmetic mean). */ +function midpoint(a: bigint, b: bigint): bigint { + return (a + b) / 2n; +} + +/** Resolve a `DIRECT://` or raw-hex pubkey to a Sphere address string. */ +function pubkeyToAddress(pubkey: string): string { + if (pubkey.startsWith('DIRECT://') || pubkey.startsWith('@')) return pubkey; + return `DIRECT://${pubkey}`; +} + +/** Terminal deal states: no further state transitions allowed. */ +function isTerminalState(state: DealRecord['state']): boolean { + return state === 'COMPLETED' || state === 'FAILED' || state === 'CANCELLED'; +} + +// ============================================================================= +// NegotiationHandler +// ============================================================================= + +export class NegotiationHandler { + private started = false; + private dmUnsubscribe: (() => void) | null = null; + + /** dealId -> timeout handle (proposal or acceptance watchdog). */ + private readonly pendingProposals = new Map>(); + /** dealId -> sent msg_id (match incoming np.accept via in_reply_to). */ + private readonly proposalMsgIds = new Map(); + /** msg_id -> ts_ms (replay/dedup window). */ + private readonly seenMsgIds = new Map(); + /** sender_pubkey -> array of proposal receive timestamps (rate-limit). */ + private readonly proposalRateLimit = new Map(); + + constructor( + private readonly store: TraderStateStore, + private readonly comms: CommsAdapter, + private readonly crypto: CryptoAdapter, + private readonly strategy: () => TraderStrategy, + private readonly onDealAccepted: OnDealAccepted, + ) {} + + // --------------------------------------------------------------------------- + // Lifecycle + // --------------------------------------------------------------------------- + + start(): void { + if (this.started) return; + this.started = true; + this.dmUnsubscribe = this.comms.onDirectMessage((msg) => { + void this.handleIncomingDM(msg).catch((err) => { + process.stderr.write( + `NegotiationHandler: uncaught error in handleIncomingDM: ${(err as Error).message}\n`, + ); + }); + }); + } + + stop(): void { + if (!this.started) return; + this.started = false; + if (this.dmUnsubscribe) { + try { + this.dmUnsubscribe(); + } catch { + // ignore — we're tearing down + } + this.dmUnsubscribe = null; + } + for (const timer of this.pendingProposals.values()) { + clearTimeout(timer); + } + this.pendingProposals.clear(); + } + + // --------------------------------------------------------------------------- + // Read-only accessors + // --------------------------------------------------------------------------- + + getDeal(dealId: string): DealRecord | undefined { + return this.store.getDeal(dealId); + } + + getAllDeals(): DealRecord[] { + return this.store.getAllDeals(); + } + + // --------------------------------------------------------------------------- + // Proposal flow (we are PROPOSER) + // --------------------------------------------------------------------------- + + async onMatchFound(event: MatchEvent): Promise { + if (!event.shouldPropose) { + // Acceptor side: nothing to do proactively — we wait for np.propose. + return; + } + + try { + const strategy = this.strategy(); + const terms = this.buildTerms(event, strategy); + if (!terms) return; + + // Duplicate-deal guard (spec 5.7): skip if a non-terminal deal already + // exists for this counterparty intent id. + const acceptorIntentId = terms.acceptor_intent_id; + const existing = this.store + .getDealsByIntentId(acceptorIntentId) + .find((d) => !isTerminalState(d.state)); + if (existing) { + if (DEBUG) { + process.stderr.write( + `NegotiationHandler: skipping propose — existing deal ${existing.deal_id} in state ${existing.state}\n`, + ); + } + return; + } + + try { + validateDealTerms(terms); + } catch (err) { + process.stderr.write( + `NegotiationHandler: buildTerms produced invalid terms: ${(err as Error).message}\n`, + ); + return; + } + + const now = Date.now(); + const dealId = buildDealId(terms.proposer_intent_id, terms.acceptor_intent_id); + const deal: DealRecord = { + deal_id: dealId, + terms, + state: 'PROPOSED', + role: 'PROPOSER', + created_at_ms: now, + updated_at_ms: now, + failure_reason: null, + deposit_attempted: false, + payout_verified: false, + }; + this.store.setDeal(deal); + + const msg = await this.buildSignedMessage('np.propose', { + deal_id: dealId, + terms: this.serializeTerms(terms), + }); + this.proposalMsgIds.set(dealId, msg.msg_id); + + const address = pubkeyToAddress(event.listing.poster_pubkey); + try { + await this.comms.sendDM(address, JSON.stringify(msg)); + } catch (err) { + process.stderr.write( + `NegotiationHandler: sendDM failed for np.propose ${dealId}: ${(err as Error).message}\n`, + ); + this.cancelDeal(dealId, 'send_failed'); + this.proposalMsgIds.delete(dealId); + await this.saveSafely(); + return; + } + + // Proposal watchdog: if we never get np.accept, cancel the deal. + const timeoutMs = strategy.proposal_timeout_ms || DEFAULT_PROPOSAL_TIMEOUT_MS; + const timer = setTimeout(() => { + this.pendingProposals.delete(dealId); + this.cancelDeal(dealId, 'proposal_timeout'); + void this.saveSafely(); + }, timeoutMs); + this.pendingProposals.set(dealId, timer); + + await this.saveSafely(); + } catch (err) { + process.stderr.write( + `NegotiationHandler: onMatchFound error: ${(err as Error).message}\n`, + ); + } + } + + private buildTerms(event: MatchEvent, strategy: TraderStrategy): DealTerms | null { + const { intent: myRecord, listing, decoded } = event; + const myIntent = myRecord.intent; + + const myRemaining = myIntent.offer_volume_max - myRecord.volume_filled; + if (myRemaining <= 0n) return null; + + const offerVolume = myRemaining < decoded.offer_volume_max ? myRemaining : decoded.offer_volume_max; + if (offerVolume <= 0n) return null; + + const rate = midpoint(myIntent.rate_min, decoded.rate_min); + if (rate <= 0n) return null; + + const requestVolume = (offerVolume * rate) / RATE_DENOMINATOR; + if (requestVolume <= 0n) return null; + + const trusted = new Set(strategy.trusted_escrows); + const escrow = decoded.escrows.find((e) => trusted.has(e)); + if (!escrow) return null; + + // Acceptor intent_id: embedded in the market listing id (spec 2.8 pairs + // listing_id to intent_id). Fall back to listing_id for resilience. + const acceptorIntentId = listing.listing_id; + + return { + proposer_intent_id: myIntent.intent_id, + acceptor_intent_id: acceptorIntentId, + proposer_pubkey: this.crypto.getPublicKey(), + acceptor_pubkey: listing.poster_pubkey, + offer_token: myIntent.offer_token, + request_token: myIntent.request_token, + offer_volume: offerVolume, + request_volume: requestVolume, + rate, + escrow_address: escrow, + deposit_timeout_sec: decoded.deposit_timeout_sec, + }; + } + + // --------------------------------------------------------------------------- + // Incoming DM routing + // --------------------------------------------------------------------------- + + private async handleIncomingDM(dm: IncomingDM): Promise { + const raw = dm.content; + if (typeof raw !== 'string' || raw.length === 0) return; + if (byteLength(raw) > MAX_MESSAGE_BYTES) { + if (DEBUG) { + process.stderr.write('NegotiationHandler: dropping oversize DM\n'); + } + return; + } + + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + // Not one of ours — ignore silently (trader shares the inbox with other + // protocols). + return; + } + + if (!this.isNpMessageShape(parsed)) return; + const msg = parsed; + + // Auth (spec 7.6) + if (!(await this.validateAuth(msg, dm.senderPubkey))) return; + + try { + switch (msg.type) { + case 'np.propose': + await this.handleProposal(msg, dm.senderPubkey); + break; + case 'np.accept': + await this.handleAcceptance(msg); + break; + case 'np.reject': + await this.handleRejection(msg); + break; + case 'np.cancel': + await this.handleCancel(msg); + break; + default: + // Unknown type — ignore. + break; + } + } catch (err) { + process.stderr.write( + `NegotiationHandler: error handling ${msg.type}: ${(err as Error).message}\n`, + ); + } + } + + private isNpMessageShape(value: unknown): value is NpMessage { + if (!value || typeof value !== 'object') return false; + const v = value as Record; + if (v['np_version'] !== '0.1') return false; + if (typeof v['msg_id'] !== 'string' || (v['msg_id'] as string).length === 0) return false; + if (typeof v['ts_ms'] !== 'number' || !Number.isFinite(v['ts_ms'])) return false; + if (typeof v['sender_pubkey'] !== 'string') return false; + if (typeof v['signature'] !== 'string') return false; + if (typeof v['type'] !== 'string') return false; + const t = v['type'] as string; + if (t !== 'np.propose' && t !== 'np.accept' && t !== 'np.reject' && t !== 'np.cancel') { + return false; + } + if (!v['payload'] || typeof v['payload'] !== 'object') return false; + return true; + } + + private async validateAuth(msg: NpMessage, senderPubkey: string): Promise { + if (msg.np_version !== '0.1') return false; + + const now = Date.now(); + if (Math.abs(now - msg.ts_ms) > MAX_TIMESTAMP_SKEW_MS) { + if (DEBUG) { + process.stderr.write(`NegotiationHandler: rejecting stale msg ${msg.msg_id}\n`); + } + return false; + } + + if (msg.sender_pubkey !== senderPubkey) { + if (DEBUG) { + process.stderr.write('NegotiationHandler: sender_pubkey mismatch\n'); + } + return false; + } + + if (hasDangerousKeys(msg)) { + process.stderr.write('NegotiationHandler: dangerous keys in NP-0 message\n'); + return false; + } + + // Dedup window — reject if we've already seen this msg_id recently. + this.pruneSeenMsgIds(now); + if (this.seenMsgIds.has(msg.msg_id)) { + if (DEBUG) { + process.stderr.write(`NegotiationHandler: replay of ${msg.msg_id}\n`); + } + return false; + } + + // Signature verification + const { signature: _sig, ...unsigned } = msg; + let valid = false; + try { + valid = await this.crypto.verify(messageToSign(unsigned), msg.signature, msg.sender_pubkey); + } catch (err) { + process.stderr.write( + `NegotiationHandler: verify threw: ${(err as Error).message}\n`, + ); + return false; + } + if (!valid) { + if (DEBUG) { + process.stderr.write(`NegotiationHandler: bad signature on ${msg.msg_id}\n`); + } + return false; + } + + // Accept and record. + this.seenMsgIds.set(msg.msg_id, now); + this.capSeenMsgIds(); + return true; + } + + private pruneSeenMsgIds(now: number): void { + const cutoff = now - MSG_ID_DEDUP_WINDOW_MS; + for (const [id, ts] of this.seenMsgIds) { + if (ts < cutoff) this.seenMsgIds.delete(id); + } + } + + private capSeenMsgIds(): void { + if (this.seenMsgIds.size <= MSG_ID_DEDUP_MAX_ENTRIES) return; + // Evict oldest entries (insertion order in a Map is insertion order). + const overflow = this.seenMsgIds.size - MSG_ID_DEDUP_MAX_ENTRIES; + let i = 0; + for (const key of this.seenMsgIds.keys()) { + if (i++ >= overflow) break; + this.seenMsgIds.delete(key); + } + } + + // --------------------------------------------------------------------------- + // Acceptance flow (we are ACCEPTOR) + // --------------------------------------------------------------------------- + + private async handleProposal(msg: NpMessage, senderPubkey: string): Promise { + // Rate limit per counterparty. + if (!this.checkRateLimit(senderPubkey)) { + if (DEBUG) { + process.stderr.write(`NegotiationHandler: rate-limited ${senderPubkey}\n`); + } + return; + } + + const strategy = this.strategy(); + if (strategy.blocked_counterparties.includes(senderPubkey)) { + if (DEBUG) { + process.stderr.write(`NegotiationHandler: blocked counterparty ${senderPubkey}\n`); + } + return; + } + + const termsRaw = (msg.payload as Record)['terms']; + const terms = this.deserializeTerms(termsRaw); + if (!terms) { + process.stderr.write('NegotiationHandler: invalid terms in np.propose\n'); + return; + } + + try { + validateDealTerms(terms); + } catch (err) { + process.stderr.write( + `NegotiationHandler: rejecting np.propose — ${(err as Error).message}\n`, + ); + return; + } + + // Cross-check the proposer pubkey in the terms matches the envelope. + if (terms.proposer_pubkey !== senderPubkey) { + process.stderr.write('NegotiationHandler: proposer_pubkey mismatch in np.propose\n'); + return; + } + if (terms.acceptor_pubkey !== this.crypto.getPublicKey()) { + if (DEBUG) { + process.stderr.write('NegotiationHandler: acceptor_pubkey not us — ignoring\n'); + } + return; + } + + // Find our matching ACTIVE intent. + const myIntent = this.findMatchingIntent(terms); + if (!myIntent) { + if (DEBUG) { + process.stderr.write('NegotiationHandler: no matching local intent for np.propose\n'); + } + return; + } + + // Verify the acceptor_intent_id in the terms refers to our intent. + if (terms.acceptor_intent_id !== myIntent.intent.intent_id) { + if (DEBUG) { + process.stderr.write('NegotiationHandler: acceptor_intent_id mismatch\n'); + } + return; + } + + // Duplicate-deal guard — non-terminal deal already exists. + const existing = this.store + .getDealsByIntentId(myIntent.intent.intent_id) + .find((d) => !isTerminalState(d.state)); + if (existing) { + if (DEBUG) { + process.stderr.write( + `NegotiationHandler: duplicate proposal — existing deal ${existing.deal_id}\n`, + ); + } + return; + } + + const now = Date.now(); + const dealId = buildDealId(terms.proposer_intent_id, terms.acceptor_intent_id); + const deal: DealRecord = { + deal_id: dealId, + terms, + state: 'ACCEPTED', + role: 'ACCEPTOR', + created_at_ms: now, + updated_at_ms: now, + failure_reason: null, + deposit_attempted: false, + payout_verified: false, + }; + this.store.setDeal(deal); + + // Build and send np.accept. + const accept = await this.buildSignedMessage( + 'np.accept', + { deal_id: dealId, in_reply_to: msg.msg_id }, + ); + const address = pubkeyToAddress(senderPubkey); + try { + await this.comms.sendDM(address, JSON.stringify(accept)); + } catch (err) { + process.stderr.write( + `NegotiationHandler: sendDM failed for np.accept ${dealId}: ${(err as Error).message}\n`, + ); + this.cancelDeal(dealId, 'send_failed'); + await this.saveSafely(); + return; + } + + // Acceptance watchdog — if deal stays in ACCEPTED (never reaches EXECUTING) + // within the window, mark it CANCELLED. + const timeoutMs = strategy.acceptance_timeout_ms || DEFAULT_ACCEPTANCE_TIMEOUT_MS; + const timer = setTimeout(() => { + this.pendingProposals.delete(dealId); + const current = this.store.getDeal(dealId); + if (current && current.state === 'ACCEPTED') { + this.cancelDeal(dealId, 'acceptance_timeout'); + void this.saveSafely(); + } + }, timeoutMs); + this.pendingProposals.set(dealId, timer); + + try { + this.onDealAccepted(deal); + } catch (err) { + process.stderr.write( + `NegotiationHandler: onDealAccepted threw: ${(err as Error).message}\n`, + ); + } + + await this.saveSafely(); + } + + private checkRateLimit(pubkey: string): boolean { + const now = Date.now(); + const cutoff = now - RATE_LIMIT_WINDOW_MS; + const existing = this.proposalRateLimit.get(pubkey) ?? []; + const recent = existing.filter((ts) => ts >= cutoff); + if (recent.length >= RATE_LIMIT_MAX_PROPOSALS) { + this.proposalRateLimit.set(pubkey, recent); + return false; + } + recent.push(now); + this.proposalRateLimit.set(pubkey, recent); + return true; + } + + private findMatchingIntent(terms: DealTerms): IntentRecord | null { + for (const record of this.store.getIntentsByState('ACTIVE')) { + const i = record.intent; + if (i.offer_token !== terms.request_token) continue; + if (i.request_token !== terms.offer_token) continue; + // Volume: the proposer's request_volume is our offer; must fall inside + // our own offer range. + const remaining = i.offer_volume_max - record.volume_filled; + if (terms.request_volume > remaining) continue; + if (terms.request_volume < i.offer_volume_min) continue; + // Rate: proposer's rate must fall inside our own rate band. + if (terms.rate < i.rate_min) continue; + if (terms.rate > i.rate_max) continue; + return record; + } + return null; + } + + // --------------------------------------------------------------------------- + // Accept / Reject / Cancel inbound handlers + // --------------------------------------------------------------------------- + + private async handleAcceptance(msg: NpMessage): Promise { + const payload = msg.payload as Record; + const inReplyTo = payload['in_reply_to']; + if (typeof inReplyTo !== 'string' || inReplyTo.length === 0) return; + + // Locate the deal whose outgoing msg_id matches. + let dealId: string | null = null; + for (const [id, sentId] of this.proposalMsgIds) { + if (sentId === inReplyTo) { + dealId = id; + break; + } + } + if (!dealId) { + if (DEBUG) { + process.stderr.write( + `NegotiationHandler: np.accept references unknown msg ${inReplyTo}\n`, + ); + } + return; + } + + const deal = this.store.getDeal(dealId); + if (!deal) return; + if (deal.role !== 'PROPOSER') return; + if (deal.state !== 'PROPOSED') return; + // Sanity: acceptance must come from the expected acceptor. + if (deal.terms.acceptor_pubkey !== msg.sender_pubkey) { + process.stderr.write('NegotiationHandler: np.accept from wrong sender\n'); + return; + } + + const next: DealRecord = { + ...deal, + state: 'ACCEPTED', + updated_at_ms: Date.now(), + }; + this.store.setDeal(next); + + const timer = this.pendingProposals.get(dealId); + if (timer) { + clearTimeout(timer); + this.pendingProposals.delete(dealId); + } + this.proposalMsgIds.delete(dealId); + + try { + this.onDealAccepted(next); + } catch (err) { + process.stderr.write( + `NegotiationHandler: onDealAccepted threw: ${(err as Error).message}\n`, + ); + } + + await this.saveSafely(); + } + + private async handleRejection(msg: NpMessage): Promise { + const payload = msg.payload as Record; + const dealId = this.resolveDealIdForInbound(payload, msg.sender_pubkey); + if (!dealId) return; + this.cancelDeal(dealId, 'rejected_by_counterparty'); + await this.saveSafely(); + } + + private async handleCancel(msg: NpMessage): Promise { + const payload = msg.payload as Record; + const dealId = this.resolveDealIdForInbound(payload, msg.sender_pubkey); + if (!dealId) return; + const deal = this.store.getDeal(dealId); + if (!deal) return; + if (deal.state !== 'PROPOSED' && deal.state !== 'ACCEPTED') return; + this.cancelDeal(dealId, 'cancelled_by_counterparty'); + await this.saveSafely(); + } + + /** Look up a deal for an inbound reject/cancel. Checks `deal_id` first, then + * falls back to `in_reply_to` matched against our sent proposal ids. */ + private resolveDealIdForInbound( + payload: Record, + senderPubkey: string, + ): string | null { + const declared = payload['deal_id']; + if (typeof declared === 'string' && declared.length > 0) { + const deal = this.store.getDeal(declared); + if (!deal) return null; + if ( + deal.terms.proposer_pubkey !== senderPubkey && + deal.terms.acceptor_pubkey !== senderPubkey + ) { + return null; + } + return declared; + } + const inReplyTo = payload['in_reply_to']; + if (typeof inReplyTo === 'string') { + for (const [id, sentId] of this.proposalMsgIds) { + if (sentId === inReplyTo) return id; + } + } + return null; + } + + private cancelDeal(dealId: string, reason: string): void { + const deal = this.store.getDeal(dealId); + if (!deal) return; + if (isTerminalState(deal.state)) return; + const next: DealRecord = { + ...deal, + state: 'CANCELLED', + updated_at_ms: Date.now(), + failure_reason: reason, + }; + this.store.setDeal(next); + + const timer = this.pendingProposals.get(dealId); + if (timer) { + clearTimeout(timer); + this.pendingProposals.delete(dealId); + } + this.proposalMsgIds.delete(dealId); + } + + // --------------------------------------------------------------------------- + // Message construction / serialisation + // --------------------------------------------------------------------------- + + private async buildSignedMessage( + type: NpMessageType, + payload: Record, + ): Promise { + const unsigned: Omit = { + np_version: '0.1', + msg_id: newMsgId(), + ts_ms: Date.now(), + sender_pubkey: this.crypto.getPublicKey(), + type, + payload, + }; + const signature = await this.crypto.sign(messageToSign(unsigned)); + return { ...unsigned, signature }; + } + + /** JSON-safe encoding of DealTerms — bigints serialised as decimal strings. */ + private serializeTerms(terms: DealTerms): Record { + return { + proposer_intent_id: terms.proposer_intent_id, + acceptor_intent_id: terms.acceptor_intent_id, + proposer_pubkey: terms.proposer_pubkey, + acceptor_pubkey: terms.acceptor_pubkey, + offer_token: terms.offer_token, + request_token: terms.request_token, + offer_volume: terms.offer_volume.toString(), + request_volume: terms.request_volume.toString(), + rate: terms.rate.toString(), + escrow_address: terms.escrow_address, + deposit_timeout_sec: terms.deposit_timeout_sec, + }; + } + + private deserializeTerms(raw: unknown): DealTerms | null { + if (!raw || typeof raw !== 'object') return null; + const r = raw as Record; + try { + const offer = this.asBigInt(r['offer_volume']); + const request = this.asBigInt(r['request_volume']); + const rate = this.asBigInt(r['rate']); + if (offer === null || request === null || rate === null) return null; + + const proposer_intent_id = this.asString(r['proposer_intent_id']); + const acceptor_intent_id = this.asString(r['acceptor_intent_id']); + const proposer_pubkey = this.asString(r['proposer_pubkey']); + const acceptor_pubkey = this.asString(r['acceptor_pubkey']); + const offer_token = this.asString(r['offer_token']); + const request_token = this.asString(r['request_token']); + const escrow_address = this.asString(r['escrow_address']); + const deposit_timeout_sec = r['deposit_timeout_sec']; + + if ( + proposer_intent_id === null || + acceptor_intent_id === null || + proposer_pubkey === null || + acceptor_pubkey === null || + offer_token === null || + request_token === null || + escrow_address === null || + typeof deposit_timeout_sec !== 'number' || + !Number.isFinite(deposit_timeout_sec) + ) { + return null; + } + + return { + proposer_intent_id, + acceptor_intent_id, + proposer_pubkey, + acceptor_pubkey, + offer_token, + request_token, + offer_volume: offer, + request_volume: request, + rate, + escrow_address, + deposit_timeout_sec, + }; + } catch { + return null; + } + } + + private asBigInt(v: unknown): bigint | null { + try { + if (typeof v === 'string' && /^-?\d+$/.test(v)) return BigInt(v); + if (typeof v === 'number' && Number.isInteger(v)) return BigInt(v); + return null; + } catch { + return null; + } + } + + private asString(v: unknown): string | null { + return typeof v === 'string' && v.length > 0 ? v : null; + } + + private async saveSafely(): Promise { + try { + await this.store.save(); + } catch (err) { + process.stderr.write( + `NegotiationHandler: store.save failed: ${(err as Error).message}\n`, + ); + } + } +} diff --git a/src/trader/swap-executor.ts b/src/trader/swap-executor.ts new file mode 100644 index 0000000..d123a80 --- /dev/null +++ b/src/trader/swap-executor.ts @@ -0,0 +1,548 @@ +/** + * SwapExecutor — drives the swap lifecycle from ACCEPTED → EXECUTING → + * COMPLETED/FAILED. + * + * Responsibilities: + * - Kick off escrow deposit payment when we are the PROPOSER. + * - Listen to sphere swap events and advance the deal's state machine. + * - Enforce V2 protocol requirement (spec 7.9.5), trusted escrows (spec + * 7.9.1), term binding (spec 7.9.4), and payout verification before + * declaring COMPLETED (spec 7.9.2). + * - Apply an EXECUTING-state timeout so a stalled swap never wedges + * reserved volume. + * - Release `VolumeReservationLedger` entries on terminal transitions. + * + * Design notes: + * - `deposit_attempted` is written BEFORE `payInvoice()` so crash-recovery + * can distinguish "never paid" from "paid but not confirmed yet". + * - All sphere-event handlers are wrapped in try/catch: a malformed event + * from the SDK must not crash the long-running trader process. + * - No Sphere SDK imports — the executor only consumes the narrow + * {@link SwapAdapter} / {@link PaymentsAdapter} DI seams. + */ + +import type { + DealRecord, + DealTerms, + OnSwapCompleted, + PaymentsAdapter, + SwapAdapter, + TraderStrategy, +} from './types.js'; +import type { TraderStateStore } from './trader-state-store.js'; +import type { VolumeReservationLedger } from './volume-reservation-ledger.js'; + +// ============================================================================= +// Constants +// ============================================================================= + +/** Debug logging opt-in (mirrors NegotiationHandler). */ +const DEBUG = typeof process !== 'undefined' && Boolean(process.env['DEBUG']); + +/** Required sphere swap protocol version (spec 7.9.5). */ +const REQUIRED_PROTOCOL_VERSION = 2; + +/** Extra grace window added on top of `deposit_timeout_sec` for the + * EXECUTING-state watchdog. */ +const EXECUTING_TIMEOUT_GRACE_SEC = 60; + +// ============================================================================= +// Helpers +// ============================================================================= + +/** Terminal states — no further transitions permitted. */ +function isTerminalState(state: DealRecord['state']): boolean { + return state === 'COMPLETED' || state === 'FAILED' || state === 'CANCELLED'; +} + +/** + * Derive the escrow "invoice" from DealTerms. The MVP treats the escrow + * address as the direct transfer destination — a full implementation would + * construct a protocol-specific payment request. + */ +function escrowInvoice(terms: DealTerms): string { + return terms.escrow_address; +} + +/** Best-effort bigint parser from swap-event payload fields. */ +function asBigInt(v: unknown): bigint | null { + try { + if (typeof v === 'bigint') return v; + if (typeof v === 'string' && /^-?\d+$/.test(v)) return BigInt(v); + if (typeof v === 'number' && Number.isInteger(v)) return BigInt(v); + return null; + } catch { + return null; + } +} + +// ============================================================================= +// SwapExecutor +// ============================================================================= + +export class SwapExecutor { + private started = false; + + /** Unsubscribe functions returned by `sphereOn(...)` — called on `stop()`. */ + private readonly eventUnsubs: Array<() => void> = []; + + /** dealId -> EXECUTING-state watchdog handle. */ + private readonly executingTimers = new Map>(); + + /** Mapping swapId <-> dealId so event handlers can resolve deals. */ + private readonly dealToSwap = new Map(); + private readonly swapToDeal = new Map(); + + constructor( + private readonly store: TraderStateStore, + private readonly swap: SwapAdapter, + private readonly payments: PaymentsAdapter, + private readonly ledger: VolumeReservationLedger, + private readonly strategy: () => TraderStrategy, + private readonly onSwapCompleted: OnSwapCompleted, + private readonly sphereOn: ( + event: string, + handler: (...args: unknown[]) => void, + ) => () => void, + ) {} + + // --------------------------------------------------------------------------- + // Lifecycle + // --------------------------------------------------------------------------- + + async start(): Promise { + if (this.started) return; + this.started = true; + + // 1. Reconcile swap state from the SDK before subscribing so any events + // replayed during `load()` are captured by our listeners. + try { + await this.swap.load(); + } catch (err) { + process.stderr.write( + `SwapExecutor: swap.load failed: ${(err as Error).message}\n`, + ); + } + + // 2. Subscribe to all swap lifecycle events. + this.subscribe('swap:proposal_received', (args) => this.onProposalReceived(args)); + this.subscribe('swap:accepted', (args) => this.onAccepted(args)); + this.subscribe('swap:announced', (args) => this.onAnnounced(args)); + this.subscribe('swap:deposit_sent', (args) => this.onDepositSent(args)); + this.subscribe('swap:deposit_confirmed', (args) => this.onDepositConfirmed(args)); + this.subscribe('swap:completed', (args) => this.onCompleted(args)); + this.subscribe('swap:failed', (args) => this.onFailed(args)); + this.subscribe('swap:cancelled', (args) => this.onCancelled(args)); + + // 3. Recover any in-flight EXECUTING deals from the store so their + // watchdog timers are re-armed after a restart. + for (const deal of this.store.getAllDeals()) { + if (deal.state === 'EXECUTING') { + this.setExecutingTimeout(deal); + } + } + } + + async stop(): Promise { + if (!this.started) return; + this.started = false; + + for (const unsub of this.eventUnsubs) { + try { + unsub(); + } catch { + // tearing down — swallow + } + } + this.eventUnsubs.length = 0; + + for (const timer of this.executingTimers.values()) { + clearTimeout(timer); + } + this.executingTimers.clear(); + } + + // --------------------------------------------------------------------------- + // Read-only accessors + // --------------------------------------------------------------------------- + + getDeal(dealId: string): DealRecord | undefined { + return this.store.getDeal(dealId); + } + + // --------------------------------------------------------------------------- + // Execute: ACCEPTED -> EXECUTING (and kick off deposit if PROPOSER) + // --------------------------------------------------------------------------- + + /** + * Transition an ACCEPTED deal into EXECUTING and — if we are the PROPOSER — + * pay the escrow invoice. Throws synchronously on precondition failures so + * the caller (NegotiationHandler → onDealAccepted bridge) sees the error. + */ + async execute(deal: DealRecord): Promise { + if (deal.state !== 'ACCEPTED') { + throw new Error( + `SwapExecutor.execute: deal ${deal.deal_id} is in state ${deal.state}, expected ACCEPTED`, + ); + } + + // Verify trusted escrow (spec 7.9.1) before committing to EXECUTING. + await this.pingEscrow(deal); + + // Transition to EXECUTING and persist before any network-visible side + // effect. That way, crash-recovery observes a consistent state. + const now = Date.now(); + deal.state = 'EXECUTING'; + deal.updated_at_ms = now; + this.store.setDeal(deal); + await this.saveSafely(); + + this.setExecutingTimeout(deal); + + if (deal.role === 'PROPOSER') { + // Record deposit_attempted BEFORE payInvoice so a crash mid-call cannot + // produce "money left but no record". + deal.deposit_attempted = true; + deal.updated_at_ms = Date.now(); + this.store.setDeal(deal); + await this.saveSafely(); + + try { + await this.payments.payInvoice(escrowInvoice(deal.terms)); + } catch (err) { + process.stderr.write( + `SwapExecutor: payInvoice failed for ${deal.deal_id}: ${(err as Error).message}\n`, + ); + await this.failDeal(deal, `PAY_INVOICE_FAILED:${(err as Error).message}`); + return; + } + } + + // ACCEPTOR side and post-payment PROPOSER side both wait for sphere + // events (deposit_confirmed, completed, etc.) to drive the rest. + } + + // --------------------------------------------------------------------------- + // Escrow reachability check (spec 7.9.1) + // --------------------------------------------------------------------------- + + private async pingEscrow(deal: DealRecord): Promise { + const strategy = this.strategy(); + if (!strategy.trusted_escrows.includes(deal.terms.escrow_address)) { + throw new Error( + `ESCROW_UNREACHABLE: ${deal.terms.escrow_address} not in trusted_escrows`, + ); + } + // For MVP: trust check is sufficient. A real impl would send an + // ICMP-style probe here. + } + + // --------------------------------------------------------------------------- + // Sphere event subscription helper + // --------------------------------------------------------------------------- + + private subscribe( + event: string, + handler: (args: Record) => void | Promise, + ): void { + const unsub = this.sphereOn(event, (...rawArgs: unknown[]) => { + try { + const first = rawArgs[0]; + const args: Record = + first && typeof first === 'object' && !Array.isArray(first) + ? (first as Record) + : {}; + const result = handler(args); + if (result instanceof Promise) { + result.catch((err) => { + process.stderr.write( + `SwapExecutor: async handler ${event} threw: ${(err as Error).message}\n`, + ); + }); + } + } catch (err) { + process.stderr.write( + `SwapExecutor: handler ${event} threw: ${(err as Error).message}\n`, + ); + } + }); + this.eventUnsubs.push(unsub); + } + + private resolveDealFromArgs(args: Record): DealRecord | null { + const swapId = typeof args['swapId'] === 'string' ? (args['swapId'] as string) : null; + if (!swapId) return null; + const dealId = this.swapToDeal.get(swapId); + if (!dealId) return null; + return this.store.getDeal(dealId) ?? null; + } + + // --------------------------------------------------------------------------- + // swap:proposal_received — V2 enforcement + term binding + escrow match + // --------------------------------------------------------------------------- + + private async onProposalReceived(args: Record): Promise { + const swapId = typeof args['swapId'] === 'string' ? (args['swapId'] as string) : null; + const dealId = typeof args['dealId'] === 'string' ? (args['dealId'] as string) : null; + if (!swapId || !dealId) { + if (DEBUG) { + process.stderr.write('SwapExecutor: proposal_received missing swapId/dealId\n'); + } + return; + } + + const deal = this.store.getDeal(dealId); + if (!deal) { + if (DEBUG) { + process.stderr.write( + `SwapExecutor: proposal_received for unknown deal ${dealId}\n`, + ); + } + return; + } + if (isTerminalState(deal.state)) return; + + // Register the swapId↔dealId mapping for future events. + this.dealToSwap.set(dealId, swapId); + this.swapToDeal.set(swapId, dealId); + + // V2 enforcement (spec 7.9.5). + const protocolVersion = args['protocolVersion']; + if (protocolVersion !== REQUIRED_PROTOCOL_VERSION) { + await this.failDeal(deal, 'V2_REQUIRED'); + return; + } + + // Trusted-escrow cross-check (spec 7.9.1): the swap's escrow must match + // the one we negotiated. A counterparty who quietly swaps in a different + // escrow address after negotiation is compromised. + const escrowAddress = + typeof args['escrowAddress'] === 'string' ? (args['escrowAddress'] as string) : null; + if (escrowAddress !== null && escrowAddress !== deal.terms.escrow_address) { + await this.failDeal(deal, 'ESCROW_MISMATCH'); + return; + } + + // Term binding (spec 7.9.4): offer/request tokens and volumes MUST match + // what we agreed to in NP-0 negotiation. + if (!this.termsMatch(deal.terms, args)) { + await this.failDeal(deal, 'TERMS_MISMATCH'); + return; + } + } + + /** + * Compare incoming swap-proposal args against the deal's DealTerms. Any + * field absent from `args` is treated as a mismatch rather than silently + * accepted — fail-closed. + */ + private termsMatch(terms: DealTerms, args: Record): boolean { + if (args['offer_token'] !== terms.offer_token) return false; + if (args['request_token'] !== terms.request_token) return false; + + const offer = asBigInt(args['offer_volume']); + if (offer === null || offer !== terms.offer_volume) return false; + + const request = asBigInt(args['request_volume']); + if (request === null || request !== terms.request_volume) return false; + + return true; + } + + // --------------------------------------------------------------------------- + // Lightweight log handlers + // --------------------------------------------------------------------------- + + private onAccepted(args: Record): void { + if (DEBUG) { + process.stderr.write(`SwapExecutor: swap:accepted ${JSON.stringify(args)}\n`); + } + } + + private onAnnounced(args: Record): void { + if (DEBUG) { + process.stderr.write(`SwapExecutor: swap:announced ${JSON.stringify(args)}\n`); + } + } + + private onDepositSent(args: Record): void { + if (DEBUG) { + process.stderr.write(`SwapExecutor: swap:deposit_sent ${JSON.stringify(args)}\n`); + } + } + + // --------------------------------------------------------------------------- + // swap:deposit_confirmed — begin payout polling + // --------------------------------------------------------------------------- + + private async onDepositConfirmed(args: Record): Promise { + const deal = this.resolveDealFromArgs(args); + if (!deal) return; + if (isTerminalState(deal.state)) return; + const swapId = this.dealToSwap.get(deal.deal_id); + if (!swapId) return; + + const verified = await this.verifyPayout(deal, swapId); + if (verified) { + await this.completeDeal(deal, deal.terms.offer_volume); + } else { + await this.failDeal(deal, 'PAYOUT_UNVERIFIED'); + } + } + + // --------------------------------------------------------------------------- + // swap:completed — verify payout before declaring COMPLETED (spec 7.9.2) + // --------------------------------------------------------------------------- + + private async onCompleted(args: Record): Promise { + const deal = this.resolveDealFromArgs(args); + if (!deal) return; + if (isTerminalState(deal.state)) return; + const swapId = this.dealToSwap.get(deal.deal_id); + if (!swapId) return; + + const verified = await this.verifyPayout(deal, swapId); + if (verified) { + await this.completeDeal(deal, deal.terms.offer_volume); + } else { + await this.failDeal(deal, 'PAYOUT_UNVERIFIED'); + } + } + + private async onFailed(args: Record): Promise { + const deal = this.resolveDealFromArgs(args); + if (!deal) return; + if (isTerminalState(deal.state)) return; + const reason = + typeof args['reason'] === 'string' ? (args['reason'] as string) : 'SWAP_FAILED'; + await this.failDeal(deal, reason); + } + + private async onCancelled(args: Record): Promise { + const deal = this.resolveDealFromArgs(args); + if (!deal) return; + if (isTerminalState(deal.state)) return; + await this.failDeal(deal, 'CANCELLED'); + } + + // --------------------------------------------------------------------------- + // Payout verification (spec 7.9.2) + // --------------------------------------------------------------------------- + + /** + * Poll `swap.getStatus(swapId)` until `payoutVerified === true` or retries + * are exhausted. Returns `true` on success, `false` on exhaustion. + */ + private async verifyPayout(deal: DealRecord, swapId: string): Promise { + const strategy = this.strategy(); + const interval = Math.max(0, strategy.payout_poll_interval_ms); + const maxRetries = Math.max(0, strategy.payout_max_retries); + + for (let attempt = 0; attempt <= maxRetries; attempt++) { + try { + const status = await this.swap.getStatus(swapId); + if (status.payoutVerified === true) { + return true; + } + } catch (err) { + process.stderr.write( + `SwapExecutor: getStatus failed for ${deal.deal_id} (swap ${swapId}): ${(err as Error).message}\n`, + ); + } + + if (attempt < maxRetries) { + await this.sleep(interval); + // If the deal was failed out from under us (e.g. timeout), stop. + const current = this.store.getDeal(deal.deal_id); + if (!current || isTerminalState(current.state)) { + return false; + } + } + } + + return false; + } + + private sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); + } + + // --------------------------------------------------------------------------- + // EXECUTING-state watchdog + // --------------------------------------------------------------------------- + + private setExecutingTimeout(deal: DealRecord): void { + // Clear any existing timer first so a re-entry (e.g. recovery) does not + // leak handles. + const existing = this.executingTimers.get(deal.deal_id); + if (existing) { + clearTimeout(existing); + } + + const ms = (deal.terms.deposit_timeout_sec + EXECUTING_TIMEOUT_GRACE_SEC) * 1000; + const timer = setTimeout(() => { + this.executingTimers.delete(deal.deal_id); + const current = this.store.getDeal(deal.deal_id); + if (current && current.state === 'EXECUTING') { + void this.failDeal(current, 'EXECUTING_TIMEOUT'); + } + }, ms); + this.executingTimers.set(deal.deal_id, timer); + } + + private clearExecutingTimer(dealId: string): void { + const timer = this.executingTimers.get(dealId); + if (timer) { + clearTimeout(timer); + this.executingTimers.delete(dealId); + } + } + + // --------------------------------------------------------------------------- + // Terminal transitions + // --------------------------------------------------------------------------- + + private async failDeal(deal: DealRecord, reason: string): Promise { + if (isTerminalState(deal.state)) return; + deal.state = 'FAILED'; + deal.failure_reason = reason; + deal.updated_at_ms = Date.now(); + this.store.setDeal(deal); + this.clearExecutingTimer(deal.deal_id); + this.ledger.release(deal.deal_id); + await this.saveSafely(); + this.fireCompletion(deal); + } + + private async completeDeal(deal: DealRecord, _volumeFilled: bigint): Promise { + if (isTerminalState(deal.state)) return; + deal.state = 'COMPLETED'; + deal.payout_verified = true; + deal.updated_at_ms = Date.now(); + this.store.setDeal(deal); + this.clearExecutingTimer(deal.deal_id); + this.ledger.release(deal.deal_id); + await this.saveSafely(); + this.fireCompletion(deal); + } + + private fireCompletion(deal: DealRecord): void { + try { + this.onSwapCompleted(deal); + } catch (err) { + process.stderr.write( + `SwapExecutor: onSwapCompleted threw: ${(err as Error).message}\n`, + ); + } + } + + private async saveSafely(): Promise { + try { + await this.store.save(); + } catch (err) { + process.stderr.write( + `SwapExecutor: store.save failed: ${(err as Error).message}\n`, + ); + } + } +} diff --git a/test/mocks/fixtures.ts b/test/mocks/fixtures.ts new file mode 100644 index 0000000..3c6f289 --- /dev/null +++ b/test/mocks/fixtures.ts @@ -0,0 +1,101 @@ +/** + * Shared test fixtures and factories for the Trader Agent suite. + * + * Each `make*` helper produces a fully-populated record with sensible + * defaults; tests override only the fields they care about via the + * `overrides` parameter. + */ + +import { DEFAULT_STRATEGY } from '../../src/trader/types.js'; +import type { + DealRecord, + DealTerms, + IntentRecord, + TradingIntent, + TraderStrategy, +} from '../../src/trader/types.js'; + +// ============================================================================= +// Constants +// ============================================================================= + +export const TEST_PUBKEY_A = 'a'.repeat(64); +export const TEST_PUBKEY_B = 'b'.repeat(64); +export const TEST_ESCROW = 'e'.repeat(64); +export const TEST_TOKEN_ALPHA = 'ALPHA'; +export const TEST_TOKEN_BETA = 'BETA'; + +// ============================================================================= +// Factories +// ============================================================================= + +export function makeIntent(overrides: Partial = {}): TradingIntent { + const now = Date.now(); + return { + intent_id: crypto.randomUUID(), + salt: 'deadbeef', + owner_pubkey: TEST_PUBKEY_A, + offer_token: TEST_TOKEN_ALPHA, + offer_volume_min: 100n, + offer_volume_max: 1000n, + request_token: TEST_TOKEN_BETA, + rate_min: 90_000_000n, // 0.9 * 1e8 + rate_max: 110_000_000n, // 1.1 * 1e8 + expiry_ms: now + 3_600_000, + created_at_ms: now, + deposit_timeout_sec: 3600, + ...overrides, + }; +} + +export function makeIntentRecord(overrides: Partial = {}): IntentRecord { + return { + intent: makeIntent(), + state: 'ACTIVE', + market_listing_id: null, + volume_filled: 0n, + updated_at_ms: Date.now(), + ...overrides, + }; +} + +export function makeDealTerms(overrides: Partial = {}): DealTerms { + return { + proposer_intent_id: crypto.randomUUID(), + acceptor_intent_id: crypto.randomUUID(), + proposer_pubkey: TEST_PUBKEY_A, + acceptor_pubkey: TEST_PUBKEY_B, + offer_token: TEST_TOKEN_ALPHA, + request_token: TEST_TOKEN_BETA, + offer_volume: 500n, + request_volume: 500n, + rate: 100_000_000n, // 1.0 * 1e8 + escrow_address: TEST_ESCROW, + deposit_timeout_sec: 3600, + ...overrides, + }; +} + +export function makeDealRecord(overrides: Partial = {}): DealRecord { + const now = Date.now(); + return { + deal_id: crypto.randomUUID(), + terms: makeDealTerms(), + state: 'PROPOSED', + role: 'PROPOSER', + created_at_ms: now, + updated_at_ms: now, + failure_reason: null, + deposit_attempted: false, + payout_verified: false, + ...overrides, + }; +} + +export function makeStrategy(overrides: Partial = {}): TraderStrategy { + return { + ...DEFAULT_STRATEGY, + trusted_escrows: [TEST_ESCROW], + ...overrides, + }; +} diff --git a/test/mocks/mock-communications-module.ts b/test/mocks/mock-communications-module.ts new file mode 100644 index 0000000..9366060 --- /dev/null +++ b/test/mocks/mock-communications-module.ts @@ -0,0 +1,35 @@ +/** + * Test mock for {@link CommsAdapter}. + * + * Records all DM handlers registered via `onDirectMessage` so tests can + * inject synthetic incoming messages through `deliverDM`. + */ + +import { vi } from 'vitest'; +import type { CommsAdapter, IncomingDM } from '../../src/trader/types.js'; + +export interface MockComms { + readonly comms: CommsAdapter; + readonly deliverDM: (msg: IncomingDM) => void; +} + +export function buildMockComms(): MockComms { + const dmHandlers: Array<(msg: IncomingDM) => void> = []; + + const comms: CommsAdapter = { + sendDM: vi.fn().mockResolvedValue(undefined), + onDirectMessage: vi.fn((handler: (msg: IncomingDM) => void) => { + dmHandlers.push(handler); + return () => { + const idx = dmHandlers.indexOf(handler); + if (idx !== -1) dmHandlers.splice(idx, 1); + }; + }), + }; + + const deliverDM = (msg: IncomingDM): void => { + for (const handler of dmHandlers) handler(msg); + }; + + return { comms, deliverDM }; +} diff --git a/test/mocks/mock-market-module.ts b/test/mocks/mock-market-module.ts new file mode 100644 index 0000000..98a8f0f --- /dev/null +++ b/test/mocks/mock-market-module.ts @@ -0,0 +1,40 @@ +/** + * Test mock for {@link MarketAdapter}. + * + * Exposes all adapter methods as vitest `vi.fn()` spies so assertions can + * check call counts / arguments. Also records every feed subscriber so tests + * can synthesize market feed events via the returned `deliverListing` helper. + */ + +import { vi } from 'vitest'; +import type { MarketAdapter, MarketListing } from '../../src/trader/types.js'; + +export interface MockMarket { + readonly market: MarketAdapter; + readonly deliverListing: (listing: MarketListing) => void; +} + +export function buildMockMarket(): MockMarket { + const feedListeners: Array<(listing: MarketListing) => void> = []; + + const market: MarketAdapter = { + post: vi.fn().mockImplementation(async () => `listing-${crypto.randomUUID()}`), + remove: vi.fn().mockResolvedValue(undefined), + search: vi.fn().mockResolvedValue([] as MarketListing[]), + subscribeFeed: vi.fn((listener: (listing: MarketListing) => void) => { + feedListeners.push(listener); + return () => { + const idx = feedListeners.indexOf(listener); + if (idx !== -1) feedListeners.splice(idx, 1); + }; + }), + getRecentListings: vi.fn().mockResolvedValue([] as MarketListing[]), + }; + + // Helper to push a listing into all active feed subscribers. + const deliverListing = (listing: MarketListing): void => { + for (const listener of feedListeners) listener(listing); + }; + + return { market, deliverListing }; +} diff --git a/test/mocks/mock-payments-module.ts b/test/mocks/mock-payments-module.ts new file mode 100644 index 0000000..d91c1b0 --- /dev/null +++ b/test/mocks/mock-payments-module.ts @@ -0,0 +1,37 @@ +/** + * Test mock for {@link PaymentsAdapter}. + * + * Holds a single confirmed-balance scalar that `getConfirmedAmount()` returns + * for every token. Tests can override the balance with `setBalance()` to + * simulate incoming deposits. + */ + +import { vi } from 'vitest'; +import type { ActiveIntent, PaymentsAdapter } from '../../src/trader/types.js'; + +export interface MockPayments { + readonly payments: PaymentsAdapter; + readonly setBalance: (balance: bigint) => void; +} + +export function buildMockPayments(initialBalance: bigint = 1_000_000n): MockPayments { + let confirmedBalance = initialBalance; + + const payments: PaymentsAdapter = { + receive: vi.fn().mockResolvedValue({ + address: `DIRECT://${'a'.repeat(64)}`, + pubkey: 'a'.repeat(64), + }), + getMyIntents: vi.fn().mockResolvedValue([] as ActiveIntent[]), + payInvoice: vi.fn().mockResolvedValue(undefined), + getConfirmedAmount: vi + .fn() + .mockImplementation(async (_token: string): Promise => confirmedBalance), + }; + + const setBalance = (balance: bigint): void => { + confirmedBalance = balance; + }; + + return { payments, setBalance }; +} diff --git a/test/mocks/mock-swap-module.ts b/test/mocks/mock-swap-module.ts new file mode 100644 index 0000000..0704d85 --- /dev/null +++ b/test/mocks/mock-swap-module.ts @@ -0,0 +1,59 @@ +/** + * Test mock for {@link SwapAdapter}. + * + * Tracks proposed swaps in an internal map so `getStatus()` reflects the + * expected lifecycle transitions. Tests can override state with the returned + * `setStatus` helper. + */ + +import { vi } from 'vitest'; +import type { + SwapAdapter, + SwapProposalParams, + SwapProposalResult, + SwapStatus, +} from '../../src/trader/types.js'; + +export interface MockSwap { + readonly swap: SwapAdapter; + readonly setStatus: (swapId: string, status: Partial) => void; + readonly swapStatuses: Map; +} + +export function buildMockSwap(): MockSwap { + const swapStatuses = new Map(); + let swapCounter = 0; + + const swap: SwapAdapter = { + propose: vi + .fn() + .mockImplementation(async (_params: SwapProposalParams): Promise => { + const swapId = `swap-${++swapCounter}`; + swapStatuses.set(swapId, { swapId, state: 'PROPOSED' }); + return { swapId }; + }), + accept: vi.fn().mockImplementation(async (swapId: string): Promise => { + const current = swapStatuses.get(swapId); + if (current) { + swapStatuses.set(swapId, { ...current, state: 'ACCEPTED' }); + } + }), + getStatus: vi.fn().mockImplementation(async (swapId: string): Promise => { + return swapStatuses.get(swapId) ?? { swapId, state: 'UNKNOWN' }; + }), + load: vi.fn().mockResolvedValue(undefined), + on: vi.fn().mockReturnValue(() => {}), + }; + + const setStatus = (swapId: string, status: Partial): void => { + const existing = swapStatuses.get(swapId); + swapStatuses.set(swapId, { + swapId, + state: 'PROPOSED', + ...existing, + ...status, + }); + }; + + return { swap, setStatus, swapStatuses }; +} From 4644287f254dac4d37f1136519961881cbfa1e95 Mon Sep 17 00:00:00 2001 From: Vladimir Rogojin Date: Wed, 22 Apr 2026 21:46:34 +0200 Subject: [PATCH 09/16] chore: remove misplaced trader code (trader agent lives in agentic-hosting, not sphere-cli) --- src/trader/acp-types.ts | 148 ---- src/trader/intent-engine.ts | 848 ---------------------- src/trader/negotiation-handler.ts | 886 ----------------------- src/trader/swap-executor.ts | 548 -------------- src/trader/trader-state-store.ts | 225 ------ src/trader/types.ts | 194 ----- src/trader/utils.ts | 221 ------ src/trader/volume-reservation-ledger.ts | 108 --- test/mocks/fixtures.ts | 101 --- test/mocks/mock-communications-module.ts | 35 - test/mocks/mock-market-module.ts | 40 - test/mocks/mock-payments-module.ts | 37 - test/mocks/mock-swap-module.ts | 59 -- 13 files changed, 3450 deletions(-) delete mode 100644 src/trader/acp-types.ts delete mode 100644 src/trader/intent-engine.ts delete mode 100644 src/trader/negotiation-handler.ts delete mode 100644 src/trader/swap-executor.ts delete mode 100644 src/trader/trader-state-store.ts delete mode 100644 src/trader/types.ts delete mode 100644 src/trader/utils.ts delete mode 100644 src/trader/volume-reservation-ledger.ts delete mode 100644 test/mocks/fixtures.ts delete mode 100644 test/mocks/mock-communications-module.ts delete mode 100644 test/mocks/mock-market-module.ts delete mode 100644 test/mocks/mock-payments-module.ts delete mode 100644 test/mocks/mock-swap-module.ts diff --git a/src/trader/acp-types.ts b/src/trader/acp-types.ts deleted file mode 100644 index 8f9c789..0000000 --- a/src/trader/acp-types.ts +++ /dev/null @@ -1,148 +0,0 @@ -/** - * ACP-0 command param/result types for the Trader Agent. - * - * ACP boundary uses `number` for volumes/rates since JSON has no bigint type; - * TraderCommandHandler converts to bigint internally. Volumes are serialised - * as decimal strings on the way back out to preserve precision. - */ - -export type TraderAcpCommand = - | 'CREATE_INTENT' - | 'CANCEL_INTENT' - | 'LIST_INTENTS' - | 'GET_INTENT' - | 'UPDATE_STRATEGY' - | 'GET_STRATEGY' - | 'GET_DEALS'; - -// ============================================================================= -// CREATE_INTENT -// ============================================================================= - -export interface CreateIntentParams { - readonly offer_token: string; - readonly offer_volume_min: number; - readonly offer_volume_max: number; - readonly request_token: string; - readonly rate_min: number; - readonly rate_max: number; - readonly expiry_ms: number; - readonly deposit_timeout_sec?: number; -} - -export interface CreateIntentResult { - readonly intent_id: string; - readonly state: string; -} - -// ============================================================================= -// CANCEL_INTENT -// ============================================================================= - -export interface CancelIntentParams { - readonly intent_id: string; -} - -export interface CancelIntentResult { - readonly intent_id: string; - readonly state: 'CANCELLED'; -} - -// ============================================================================= -// LIST_INTENTS -// ============================================================================= - -export interface ListIntentsParams { - readonly state?: string; -} - -export interface IntentSummary { - readonly intent_id: string; - readonly offer_token: string; - readonly request_token: string; - readonly state: string; - readonly volume_filled: string; - readonly offer_volume_max: string; - readonly expiry_ms: number; -} - -export interface ListIntentsResult { - readonly intents: readonly IntentSummary[]; -} - -// ============================================================================= -// GET_INTENT -// ============================================================================= - -export interface GetIntentParams { - readonly intent_id: string; -} - -export interface GetIntentResult { - readonly record: IntentSummary & { - readonly offer_volume_min: string; - readonly rate_min: string; - readonly rate_max: string; - readonly market_listing_id: string | null; - readonly updated_at_ms: number; - }; -} - -// ============================================================================= -// UPDATE_STRATEGY -// ============================================================================= - -export interface UpdateStrategyParams { - readonly scan_interval_ms?: number; - readonly proposal_timeout_ms?: number; - readonly acceptance_timeout_ms?: number; - readonly max_active_intents?: number; - readonly trusted_escrows?: readonly string[]; - readonly blocked_counterparties?: readonly string[]; -} - -export interface UpdateStrategyResult { - readonly ok: true; -} - -// ============================================================================= -// GET_STRATEGY -// ============================================================================= - -export type GetStrategyParams = Record; - -export interface GetStrategyResult { - readonly strategy: { - readonly scan_interval_ms: number; - readonly proposal_timeout_ms: number; - readonly acceptance_timeout_ms: number; - readonly max_active_intents: number; - readonly trusted_escrows: readonly string[]; - readonly blocked_counterparties: readonly string[]; - }; -} - -// ============================================================================= -// GET_DEALS -// ============================================================================= - -export interface GetDealsParams { - readonly intent_id?: string; - readonly state?: string; -} - -export interface DealSummary { - readonly deal_id: string; - readonly role: 'PROPOSER' | 'ACCEPTOR'; - readonly state: string; - readonly offer_token: string; - readonly request_token: string; - readonly offer_volume: string; - readonly request_volume: string; - readonly created_at_ms: number; - readonly failure_reason: string | null; -} - -export interface GetDealsResult { - readonly deals: readonly DealSummary[]; -} diff --git a/src/trader/intent-engine.ts b/src/trader/intent-engine.ts deleted file mode 100644 index da08b20..0000000 --- a/src/trader/intent-engine.ts +++ /dev/null @@ -1,848 +0,0 @@ -/** - * IntentEngine — lifecycle management for trading intents. - * - * Responsibilities: - * - Publish new intents to the market (post description to MarketAdapter) - * - Scan the market for matching counterparty intents - * - Subscribe to the market feed for real-time match detection - * - Apply matching criteria (rate overlap, volume overlap, escrow trust, block list) - * - Decide who proposes (spec 5.7: lower pubkey proposes) - * - Enforce intent state machine (spec 6.1) - * - Handle deal completion/failure callbacks from the NegotiationHandler - * - Sweep expired intents - * - Clamp intent lifetime to 7 days (defence-in-depth; utils.validateIntent - * already enforces this at the ACP boundary) - * - * Design notes: - * - No Sphere SDK imports: adapters are injected via the constructor so the - * engine can be unit-tested with in-memory fakes. - * - Scan-loop errors are swallowed and logged to stderr so a transient market - * failure cannot crash the long-running trader process. - * - Feed-listing parse failures are silent on purpose: untrusted market input - * regularly contains free-form descriptions that do not match our encoding - * format, and noisy logs would drown out real errors. - * - Timers and the feed unsubscribe function are stored in instance fields so - * `stop()` can deterministically tear them down. - */ - -import type { - IntentRecord, - IntentState, - MarketAdapter, - MarketListing, - TraderStrategy, - TradingIntent, -} from './types.js'; -import type { DecodedDescription } from './utils.js'; -import type { TraderStateStore } from './trader-state-store.js'; -import type { VolumeReservationLedger } from './volume-reservation-ledger.js'; - -import { decodeDescription, encodeDescription } from './utils.js'; - -// ============================================================================= -// Constants -// ============================================================================= - -/** Maximum lifetime for any intent (defence-in-depth; utils.validateIntent - * already enforces this at the ACP boundary). */ -const MAX_INTENT_LIFETIME_MS = 7 * 24 * 60 * 60 * 1000; - -/** Sweep cadence for expiring intents. Independent of the scan cadence so - * operators can tune scan frequency without affecting expiry latency. */ -const EXPIRY_SWEEP_INTERVAL_MS = 10_000; - -/** Debug-only logging: opt-in via the DEBUG env var. */ -const DEBUG = typeof process !== 'undefined' && Boolean(process.env.DEBUG); - -// ============================================================================= -// State machine -// ============================================================================= - -/** - * Valid IntentState transitions (spec 6.1). - * - * Any transition not listed here is rejected by `transitionState` and logged - * to stderr. Terminal states (EXPIRED, CANCELLED, FILLED) have no outbound - * edges — intents in those states are frozen. - */ -const VALID_TRANSITIONS: Readonly> = { - ACTIVE: ['PAUSED', 'CANCELLED', 'EXPIRED', 'FILLED'], - PAUSED: ['ACTIVE', 'CANCELLED', 'EXPIRED'], - EXPIRED: [], - CANCELLED: [], - FILLED: [], -}; - -// ============================================================================= -// Public types -// ============================================================================= - -/** - * Extended match event passed to the handler callback. Carries the decoded - * description (parsed once at match time so downstream code does not need to - * re-parse) and the `shouldPropose` decision so the NegotiationHandler knows - * whether to initiate the proposal or wait for the counterparty. - */ -export interface MatchEvent { - readonly intent: IntentRecord; - readonly listing: MarketListing; - readonly shouldPropose: boolean; - readonly decoded: DecodedDescription; -} - -/** - * Fields of a TradingIntent that can be updated after creation. Monotonic by - * spec 2.7.3: rate_min can only increase, rate_max only decrease, and - * offer_volume_min can only increase. These constraints are enforced in - * {@link IntentEngine.updateIntent}. - */ -export type IntentUpdate = Partial< - Pick ->; - -// ============================================================================= -// IntentEngine -// ============================================================================= - -export class IntentEngine { - private sweepTimer: ReturnType | null = null; - private scanTimer: ReturnType | null = null; - private feedUnsubscribe: (() => void) | null = null; - private started = false; - - constructor( - private readonly store: TraderStateStore, - private readonly market: MarketAdapter, - private readonly ledger: VolumeReservationLedger, - private readonly strategy: () => TraderStrategy, - private readonly myPubkey: string, - private readonly onMatchFound: (event: MatchEvent) => void, - ) {} - - // --------------------------------------------------------------------------- - // Lifecycle - // --------------------------------------------------------------------------- - - /** - * Start the engine. Idempotent: a second call is a no-op. - * - * Re-publishes any ACTIVE intents that lost their market_listing_id across a - * restart — the market is an external system and listings are not guaranteed - * to survive our downtime. - */ - async start(): Promise { - if (this.started) return; - this.started = true; - - const intents = this.store.getAllIntents(); - for (const record of intents) { - if (record.state === 'ACTIVE' && record.market_listing_id === null) { - await this.repostIntent(record); - } - } - - this.sweepTimer = setInterval(() => { - void this.sweepExpired().catch((err) => { - process.stderr.write( - `IntentEngine: sweep failed: ${(err as Error).message}\n`, - ); - }); - }, EXPIRY_SWEEP_INTERVAL_MS); - - const scanIntervalMs = Math.max(1_000, this.strategy().scan_interval_ms); - this.scanTimer = setInterval(() => { - void this.scanOnce().catch((err) => { - process.stderr.write( - `IntentEngine: scan failed: ${(err as Error).message}\n`, - ); - }); - }, scanIntervalMs); - - this.feedUnsubscribe = this.market.subscribeFeed((listing) => { - this.handleFeedListing(listing); - }); - } - - /** - * Stop the engine. Clears timers, unsubscribes the feed, removes ACTIVE - * listings from the market (fire-and-forget), and persists state. - * - * `stop()` does not throw on market errors: the caller typically invokes it - * during shutdown where throwing would mask the primary shutdown reason. - */ - async stop(): Promise { - if (!this.started) return; - this.started = false; - - if (this.sweepTimer !== null) { - clearInterval(this.sweepTimer); - this.sweepTimer = null; - } - if (this.scanTimer !== null) { - clearInterval(this.scanTimer); - this.scanTimer = null; - } - if (this.feedUnsubscribe !== null) { - try { - this.feedUnsubscribe(); - } catch (err) { - if (DEBUG) { - process.stderr.write( - `IntentEngine: feed unsubscribe failed: ${(err as Error).message}\n`, - ); - } - } - this.feedUnsubscribe = null; - } - - const active = this.store.getAllIntents().filter((r) => r.state === 'ACTIVE'); - for (const record of active) { - if (record.market_listing_id !== null) { - // Fire-and-forget: logs on failure but does not throw. - this.removeListingSafely(record.market_listing_id); - } - } - - try { - await this.store.save(); - } catch (err) { - process.stderr.write( - `IntentEngine: save on stop failed: ${(err as Error).message}\n`, - ); - } - } - - // --------------------------------------------------------------------------- - // Intent CRUD - // --------------------------------------------------------------------------- - - /** - * Create and publish a new intent. - * - * Clamps `expiry_ms` to at most 7 days from now (spec max lifetime). The - * validation at the ACP boundary already enforces this, so clamping here is - * defence-in-depth — it also lets internal callers create intents without - * replicating the bound check. - */ - async createIntent(intent: TradingIntent): Promise { - const now = Date.now(); - const maxExpiry = now + MAX_INTENT_LIFETIME_MS; - const clampedExpiry = Math.min(intent.expiry_ms, maxExpiry); - const effectiveIntent: TradingIntent = - clampedExpiry === intent.expiry_ms - ? intent - : { ...intent, expiry_ms: clampedExpiry }; - - const listingId = await this.postListing(effectiveIntent); - - const record: IntentRecord = { - intent: effectiveIntent, - state: 'ACTIVE', - market_listing_id: listingId, - volume_filled: 0n, - updated_at_ms: now, - }; - - this.store.setIntent(record); - await this.store.save(); - return record; - } - - /** Transition an ACTIVE or PAUSED intent to CANCELLED and remove its listing. */ - async cancelIntent(intentId: string): Promise { - const record = this.requireIntent(intentId); - const updated = this.transitionState(record, 'CANCELLED'); - if (record.market_listing_id !== null) { - this.removeListingSafely(record.market_listing_id); - } - const next: IntentRecord = { - ...updated, - market_listing_id: null, - }; - this.store.setIntent(next); - await this.store.save(); - return next; - } - - /** Transition ACTIVE → PAUSED. Listing is removed so counterparties do not - * waste proposals on a paused intent. */ - async pauseIntent(intentId: string): Promise { - const record = this.requireIntent(intentId); - const updated = this.transitionState(record, 'PAUSED'); - if (record.market_listing_id !== null) { - this.removeListingSafely(record.market_listing_id); - } - const next: IntentRecord = { - ...updated, - market_listing_id: null, - }; - this.store.setIntent(next); - await this.store.save(); - return next; - } - - /** Transition PAUSED → ACTIVE. Re-publishes the listing to the market. */ - async resumeIntent(intentId: string): Promise { - const record = this.requireIntent(intentId); - const updated = this.transitionState(record, 'ACTIVE'); - const listingId = await this.postListing(updated.intent); - const next: IntentRecord = { - ...updated, - market_listing_id: listingId, - }; - this.store.setIntent(next); - await this.store.save(); - return next; - } - - /** - * Update mutable terms on an existing intent. Monotonic constraints are - * enforced here and in spec 2.7.3: - * - `rate_min` can only increase - * - `rate_max` can only decrease - * - `offer_volume_min` can only increase - * - * If the intent is ACTIVE, the old listing is removed and a new one posted - * with the updated terms. - */ - async updateIntent(intentId: string, updates: IntentUpdate): Promise { - const record = this.requireIntent(intentId); - if (record.state !== 'ACTIVE' && record.state !== 'PAUSED') { - throw new Error( - `IntentEngine: cannot update intent ${intentId} in state ${record.state}`, - ); - } - - const old = record.intent; - const nextRateMin = updates.rate_min ?? old.rate_min; - const nextRateMax = updates.rate_max ?? old.rate_max; - const nextVolMin = updates.offer_volume_min ?? old.offer_volume_min; - - if (updates.rate_min !== undefined && updates.rate_min < old.rate_min) { - throw new Error( - `IntentEngine: rate_min is monotonically increasing (old=${old.rate_min}, new=${updates.rate_min})`, - ); - } - if (updates.rate_max !== undefined && updates.rate_max > old.rate_max) { - throw new Error( - `IntentEngine: rate_max is monotonically decreasing (old=${old.rate_max}, new=${updates.rate_max})`, - ); - } - if (updates.offer_volume_min !== undefined && updates.offer_volume_min < old.offer_volume_min) { - throw new Error( - `IntentEngine: offer_volume_min is monotonically increasing (old=${old.offer_volume_min}, new=${updates.offer_volume_min})`, - ); - } - if (nextRateMin > nextRateMax) { - throw new Error( - `IntentEngine: after update rate_min (${nextRateMin}) > rate_max (${nextRateMax})`, - ); - } - if (nextVolMin > old.offer_volume_max) { - throw new Error( - `IntentEngine: after update offer_volume_min (${nextVolMin}) > offer_volume_max (${old.offer_volume_max})`, - ); - } - - const nextIntent: TradingIntent = { - ...old, - rate_min: nextRateMin, - rate_max: nextRateMax, - offer_volume_min: nextVolMin, - }; - - // Remove old listing before posting the new one so the market never shows - // two conflicting entries for the same intent_id. - if (record.market_listing_id !== null) { - this.removeListingSafely(record.market_listing_id); - } - - let newListingId: string | null = null; - if (record.state === 'ACTIVE') { - newListingId = await this.postListing(nextIntent); - } - - const next: IntentRecord = { - intent: nextIntent, - state: record.state, - market_listing_id: newListingId, - volume_filled: record.volume_filled, - updated_at_ms: Date.now(), - }; - this.store.setIntent(next); - await this.store.save(); - return next; - } - - // --------------------------------------------------------------------------- - // Deal outcome callbacks - // --------------------------------------------------------------------------- - - /** - * Record a successful deal against the intent. If the accumulated - * `volume_filled` reaches `offer_volume_max`, transition the intent to - * FILLED and remove its listing. Otherwise the intent stays ACTIVE so the - * scan loop continues to seek more counterparties. - */ - async onDealCompleted(intentId: string, volumeFilled: bigint): Promise { - const record = this.store.getIntent(intentId); - if (!record) { - process.stderr.write( - `IntentEngine: onDealCompleted for unknown intent ${intentId}\n`, - ); - return; - } - if (volumeFilled < 0n) { - throw new Error( - `IntentEngine: onDealCompleted requires non-negative volumeFilled, got ${volumeFilled}`, - ); - } - - const nextFilled = record.volume_filled + volumeFilled; - - if (record.state === 'FILLED' || record.state === 'CANCELLED' || record.state === 'EXPIRED') { - // Terminal states do not accept further fills; record the event but do - // not attempt another state transition. - const next: IntentRecord = { - ...record, - volume_filled: nextFilled, - updated_at_ms: Date.now(), - }; - this.store.setIntent(next); - await this.store.save(); - return; - } - - if (nextFilled >= record.intent.offer_volume_max) { - const transitioned = this.transitionState(record, 'FILLED'); - if (record.market_listing_id !== null) { - this.removeListingSafely(record.market_listing_id); - } - const next: IntentRecord = { - ...transitioned, - market_listing_id: null, - volume_filled: nextFilled, - }; - this.store.setIntent(next); - } else { - const next: IntentRecord = { - ...record, - volume_filled: nextFilled, - updated_at_ms: Date.now(), - }; - this.store.setIntent(next); - } - await this.store.save(); - } - - /** - * Record a failed deal. The intent state is not changed — a failed deal is - * not a reason to retire an intent. Persist so `updated_at_ms` and any - * concurrently-updated fields are flushed to disk. - */ - async onDealFailed(intentId: string): Promise { - const record = this.store.getIntent(intentId); - if (!record) { - process.stderr.write( - `IntentEngine: onDealFailed for unknown intent ${intentId}\n`, - ); - return; - } - const next: IntentRecord = { - ...record, - updated_at_ms: Date.now(), - }; - this.store.setIntent(next); - await this.store.save(); - } - - // --------------------------------------------------------------------------- - // Read-only accessors - // --------------------------------------------------------------------------- - - getIntent(intentId: string): IntentRecord | undefined { - return this.store.getIntent(intentId); - } - - getAllIntents(): IntentRecord[] { - return this.store.getAllIntents(); - } - - // --------------------------------------------------------------------------- - // Scan loop - // --------------------------------------------------------------------------- - - /** - * Run one scan iteration: for each ACTIVE intent, search the market for the - * token pair and evaluate each listing against the intent's criteria. Logs - * transient adapter errors to stderr but never rethrows — the scan timer - * fires again on the next tick. - */ - private async scanOnce(): Promise { - if (!this.started) return; - - const active = this.store.getAllIntents().filter((r) => r.state === 'ACTIVE'); - const strategy = this.strategy(); - - for (const record of active) { - const query = `${record.intent.offer_token} ${record.intent.request_token}`; - let listings: MarketListing[]; - try { - listings = await this.market.search(query); - } catch (err) { - process.stderr.write( - `IntentEngine: market.search failed for "${query}": ${(err as Error).message}\n`, - ); - continue; - } - - for (const listing of listings) { - this.evaluateListing(record, listing, strategy); - } - } - } - - // --------------------------------------------------------------------------- - // Feed handler - // --------------------------------------------------------------------------- - - /** - * Called for every listing pushed by `market.subscribeFeed`. Matches against - * every ACTIVE intent whose token pair is the inverse of the listing's pair, - * since the listing's offer is our request and vice versa. - * - * Errors raised inside the match handler are caught so a bad listener never - * tears down the market subscription. - */ - private handleFeedListing(listing: MarketListing): void { - try { - const decoded = decodeDescription(listing.description); - if (decoded === null) return; - - const strategy = this.strategy(); - const active = this.store.getAllIntents().filter((r) => r.state === 'ACTIVE'); - - for (const record of active) { - if ( - record.intent.request_token !== decoded.offer_token || - record.intent.offer_token !== decoded.request_token - ) { - continue; - } - if (this.matchesCriteria(record, decoded, listing, strategy)) { - this.emitMatch(record, listing, decoded); - } - } - } catch (err) { - process.stderr.write( - `IntentEngine: feed handler error: ${(err as Error).message}\n`, - ); - } - } - - // --------------------------------------------------------------------------- - // Matching logic - // --------------------------------------------------------------------------- - - /** - * Evaluate a single listing against an intent. Silently skips listings with - * malformed descriptions — the market is full of them and logging would - * produce mostly noise. - */ - private evaluateListing( - record: IntentRecord, - listing: MarketListing, - strategy: TraderStrategy, - ): void { - const decoded = decodeDescription(listing.description); - if (decoded === null) return; - - // Cross-check the decoded token pair against the intent's inverse. The - // market.search query is a loose text match; this is the authoritative check. - if ( - record.intent.request_token !== decoded.offer_token || - record.intent.offer_token !== decoded.request_token - ) { - return; - } - - if (this.matchesCriteria(record, decoded, listing, strategy)) { - this.emitMatch(record, listing, decoded); - } - } - - /** - * Full matching predicate. Returns true iff every criterion in spec 5.x - * holds. The checks are ordered cheap-to-expensive so mismatched listings - * fail fast. - */ - private matchesCriteria( - record: IntentRecord, - decoded: DecodedDescription, - listing: MarketListing, - strategy: TraderStrategy, - ): boolean { - // 1. Listing not expired. - if (listing.expiry_ms <= Date.now()) return false; - - // 2. Not our own listing. - if (listing.poster_pubkey === this.myPubkey) return false; - - // 3-4. Token pair matches (caller already checked but keep here for the - // filterMatches path where this method is used directly). - if (decoded.offer_token !== record.intent.request_token) return false; - if (decoded.request_token !== record.intent.offer_token) return false; - - // 5-6. Rate overlap. Their rate describes how much of `decoded.offer_token` - // (= our request_token) they will give per unit of `decoded.request_token` - // (= our offer_token). Our intent's rate range is expressed in the same - // units, so overlap is a direct interval intersection. - if (decoded.rate_min > record.intent.rate_max) return false; - if (decoded.rate_max < record.intent.rate_min) return false; - - // 7. Their max offer volume is at least our min offer volume. Volumes are - // in units of the posting side's offer token, so we translate through - // `volume_min <= listing.offer_volume_max`. - if (decoded.offer_volume_max < record.intent.offer_volume_min) return false; - - // 8. Their min offer volume fits within our remaining capacity. - const remaining = record.intent.offer_volume_max - record.volume_filled; - if (remaining <= 0n) return false; - if (decoded.offer_volume_min > remaining) return false; - - // 9. Counterparty is not on our block list. - if (strategy.blocked_counterparties.includes(listing.poster_pubkey)) return false; - - // 10. At least one advertised escrow is in our trust set. - if (!this.hasTrustedEscrow(decoded.escrows, strategy.trusted_escrows)) return false; - - return true; - } - - /** - * Public-ish filter used by callers that want to evaluate a batch of - * listings without driving the scan loop. Exposed on the instance so tests - * can exercise the matching logic in isolation. - */ - filterMatches(record: IntentRecord, listings: readonly MarketListing[]): MarketListing[] { - const strategy = this.strategy(); - const out: MarketListing[] = []; - for (const listing of listings) { - const decoded = decodeDescription(listing.description); - if (decoded === null) continue; - if ( - record.intent.request_token !== decoded.offer_token || - record.intent.offer_token !== decoded.request_token - ) { - continue; - } - if (this.matchesCriteria(record, decoded, listing, strategy)) { - out.push(listing); - } - } - return out; - } - - private hasTrustedEscrow( - offered: readonly string[], - trusted: readonly string[], - ): boolean { - if (trusted.length === 0) return false; - const trustSet = new Set(trusted); - for (const escrow of offered) { - if (trustSet.has(escrow)) return true; - } - return false; - } - - // --------------------------------------------------------------------------- - // Proposer selection (spec 5.7) - // --------------------------------------------------------------------------- - - /** - * Deterministic proposer selection: the party with the lexicographically - * lower secp256k1 pubkey proposes; the other waits. This avoids the case - * where both sides fire proposals simultaneously and then have to reconcile - * duplicates. - * - * Comparing hex-encoded pubkeys as strings is well-defined because both - * sides agree on the canonical hex encoding (lowercase, fixed width). - */ - private shouldPropose(counterpartyPubkey: string): boolean { - return this.myPubkey < counterpartyPubkey; - } - - private emitMatch( - record: IntentRecord, - listing: MarketListing, - decoded: DecodedDescription, - ): void { - const event: MatchEvent = { - intent: record, - listing, - shouldPropose: this.shouldPropose(listing.poster_pubkey), - decoded, - }; - try { - this.onMatchFound(event); - } catch (err) { - process.stderr.write( - `IntentEngine: onMatchFound handler threw: ${(err as Error).message}\n`, - ); - } - } - - // --------------------------------------------------------------------------- - // Expiry sweep - // --------------------------------------------------------------------------- - - /** - * Mark any ACTIVE or PAUSED intent whose `expiry_ms` has passed as EXPIRED. - * Persists at most once per sweep regardless of the number of expirations. - */ - private async sweepExpired(): Promise { - if (!this.started) return; - - const now = Date.now(); - const candidates = this.store - .getAllIntents() - .filter((r) => (r.state === 'ACTIVE' || r.state === 'PAUSED') && r.intent.expiry_ms <= now); - - if (candidates.length === 0) return; - - for (const record of candidates) { - try { - const transitioned = this.transitionState(record, 'EXPIRED'); - if (record.market_listing_id !== null) { - this.removeListingSafely(record.market_listing_id); - } - const next: IntentRecord = { - ...transitioned, - market_listing_id: null, - }; - this.store.setIntent(next); - } catch (err) { - process.stderr.write( - `IntentEngine: expiry transition failed for ${record.intent.intent_id}: ${(err as Error).message}\n`, - ); - } - } - - try { - await this.store.save(); - } catch (err) { - process.stderr.write( - `IntentEngine: save after sweep failed: ${(err as Error).message}\n`, - ); - } - } - - // --------------------------------------------------------------------------- - // State transitions - // --------------------------------------------------------------------------- - - /** - * Apply a state transition with validation. Returns a new IntentRecord with - * the updated state and refreshed `updated_at_ms`. Throws (and logs) if the - * transition is not allowed by the state machine. - */ - private transitionState(record: IntentRecord, next: IntentState): IntentRecord { - const allowed = VALID_TRANSITIONS[record.state]; - if (!allowed.includes(next)) { - const msg = `IntentEngine: invalid transition ${record.state} -> ${next} for intent ${record.intent.intent_id}`; - process.stderr.write(`${msg}\n`); - throw new Error(msg); - } - return { - ...record, - state: next, - updated_at_ms: Date.now(), - }; - } - - private requireIntent(intentId: string): IntentRecord { - const record = this.store.getIntent(intentId); - if (!record) { - throw new Error(`IntentEngine: unknown intent ${intentId}`); - } - return record; - } - - // --------------------------------------------------------------------------- - // Market-listing helpers - // --------------------------------------------------------------------------- - - /** - * Post the intent's description to the market and return the listing id. - * Uses the strategy's trusted escrows as the advertised escrow set — this - * is the set counterparties must intersect to match us, so we advertise the - * whole set up front rather than revealing escrow preference during - * negotiation. - */ - private async postListing(intent: TradingIntent): Promise { - const escrows = this.strategy().trusted_escrows; - const description = encodeDescription(intent, escrows); - return this.market.post(description, intent.expiry_ms); - } - - /** - * Re-post an ACTIVE intent that lost its listing across a restart. The - * IntentRecord is updated in place with the new listing id. - */ - private async repostIntent(record: IntentRecord): Promise { - try { - const listingId = await this.postListing(record.intent); - const next: IntentRecord = { - ...record, - market_listing_id: listingId, - updated_at_ms: Date.now(), - }; - this.store.setIntent(next); - await this.store.save(); - } catch (err) { - process.stderr.write( - `IntentEngine: repost failed for ${record.intent.intent_id}: ${(err as Error).message}\n`, - ); - } - } - - /** - * Fire-and-forget listing removal. We never want a market error to crash a - * cancel/pause/expire flow — the listing will eventually expire from the - * market on its own even if our remove() call is lost. - * - * Intentionally returns void: callers do not await. Uses `void` on the - * promise chain so unhandled-rejection linters do not flag it. - */ - private removeListingSafely(listingId: string): void { - // Use a catch handler attached synchronously so a rejected promise never - // becomes an unhandled rejection on the event loop. Intentionally not - // awaited — callers do not want to block on market I/O during teardown. - void (async () => { - try { - await this.market.remove(listingId); - } catch (err) { - if (DEBUG) { - process.stderr.write( - `IntentEngine: market.remove(${listingId}) failed: ${(err as Error).message}\n`, - ); - } - } - })(); - } - - // --------------------------------------------------------------------------- - // Ledger access for deal-accept path (surface point for future integrations) - // --------------------------------------------------------------------------- - - /** - * Expose the underlying ledger so the NegotiationHandler can reserve volume - * against the confirmed balance without reaching around the engine. Intents - * and reservations live at the same abstraction level; sharing the ledger - * through the engine keeps the constructor surface of downstream components - * small. - */ - getLedger(): VolumeReservationLedger { - return this.ledger; - } -} diff --git a/src/trader/negotiation-handler.ts b/src/trader/negotiation-handler.ts deleted file mode 100644 index 02ee378..0000000 --- a/src/trader/negotiation-handler.ts +++ /dev/null @@ -1,886 +0,0 @@ -/** - * NegotiationHandler — drives the NP-0 negotiation protocol. - * - * Responsibilities: - * - React to IntentEngine match events (propose if we're the proposer, - * wait for an incoming proposal otherwise). - * - Serialise, sign, and send `np.propose`, `np.accept`, `np.reject`, - * `np.cancel` messages via the injected CommsAdapter. - * - Validate and authenticate every incoming NP-0 message (spec 7.6). - * - Enforce DoS protections: size cap, prototype-pollution guard, - * rate limit per counterparty, msg_id dedup window. - * - Enforce proposal/acceptance timeouts and the duplicate-deal guard - * (spec 5.7). - * - Hand off ACCEPTED deals to the caller via `onDealAccepted`. - * - * Design notes: - * - Signature verification is pluggable via `CryptoAdapter` so unit tests - * can inject a fake without pulling in a real secp256k1 implementation. - * - All error paths log to stderr and return cleanly: a malformed DM from - * an adversary must never crash the long-running trader process. - * - Timers are stored in instance fields so `stop()` can tear them down - * deterministically and avoid leaking handles in tests. - */ - -import type { - CommsAdapter, - DealRecord, - DealTerms, - IncomingDM, - IntentRecord, - NpMessage, - NpMessageType, - OnDealAccepted, - TraderStrategy, -} from './types.js'; -import type { TraderStateStore } from './trader-state-store.js'; -import type { MatchEvent } from './intent-engine.js'; - -import { canonicalJson, hasDangerousKeys, validateDealTerms } from './utils.js'; - -// ============================================================================= -// Constants -// ============================================================================= - -/** Rate denominator for the ×1e8 rate encoding (offer * rate / 1e8 = request). */ -const RATE_DENOMINATOR = 100_000_000n; - -/** Maximum clock skew for incoming NP-0 messages (spec 7.6). */ -const MAX_TIMESTAMP_SKEW_MS = 300_000; - -/** Replay-protection window for dedup of msg_ids (spec 7.6). */ -const MSG_ID_DEDUP_WINDOW_MS = 600_000; - -/** Maximum number of entries retained in the dedup map (DoS bound). */ -const MSG_ID_DEDUP_MAX_ENTRIES = 10_000; - -/** Maximum NP-0 message size in bytes (DoS bound). */ -const MAX_MESSAGE_BYTES = 64 * 1024; - -/** Rate limit: max proposals accepted from one counterparty in the window. */ -const RATE_LIMIT_MAX_PROPOSALS = 3; - -/** Rate limit window. */ -const RATE_LIMIT_WINDOW_MS = 60_000; - -/** Default proposal timeout if strategy does not specify one. */ -const DEFAULT_PROPOSAL_TIMEOUT_MS = 30_000; - -/** Default acceptance timeout if strategy does not specify one. */ -const DEFAULT_ACCEPTANCE_TIMEOUT_MS = 60_000; - -/** Debug logging opt-in. */ -const DEBUG = typeof process !== 'undefined' && Boolean(process.env['DEBUG']); - -// ============================================================================= -// CryptoAdapter — pluggable secp256k1 signer/verifier -// ============================================================================= - -/** - * Narrow injection seam for signing/verifying NP-0 messages. Implementations - * wrap whatever secp256k1 backend the runtime provides (e.g. the Sphere SDK). - * Tests inject a deterministic fake. - */ -export interface CryptoAdapter { - /** Produce a hex signature over `data`. */ - sign(data: string): Promise; - /** Verify a hex signature against `pubkey` (x-only, 64-char hex). */ - verify(data: string, signature: string, pubkey: string): Promise; - /** Return this adapter's x-only public key (64-char hex). */ - getPublicKey(): string; -} - -// ============================================================================= -// Helpers -// ============================================================================= - -/** Canonical JSON payload over which the signature is computed. */ -function messageToSign(msg: Omit): string { - return canonicalJson(msg as unknown as Record); -} - -/** UTF-8 byte length of a string. */ -function byteLength(s: string): number { - return Buffer.byteLength(s, 'utf8'); -} - -/** Deterministic deal id: `deal__` so a - * reconnecting agent cannot accidentally double-register the same deal. */ -function buildDealId(proposerIntentId: string, acceptorIntentId: string): string { - return `deal_${proposerIntentId}_${acceptorIntentId}`; -} - -/** Random msg_id (16 bytes of entropy rendered as hex). */ -function newMsgId(): string { - const buf = new Uint8Array(16); - for (let i = 0; i < buf.length; i++) { - buf[i] = Math.floor(Math.random() * 256); - } - return Array.from(buf, (b) => b.toString(16).padStart(2, '0')).join(''); -} - -/** Simple bigint midpoint (floor of the arithmetic mean). */ -function midpoint(a: bigint, b: bigint): bigint { - return (a + b) / 2n; -} - -/** Resolve a `DIRECT://` or raw-hex pubkey to a Sphere address string. */ -function pubkeyToAddress(pubkey: string): string { - if (pubkey.startsWith('DIRECT://') || pubkey.startsWith('@')) return pubkey; - return `DIRECT://${pubkey}`; -} - -/** Terminal deal states: no further state transitions allowed. */ -function isTerminalState(state: DealRecord['state']): boolean { - return state === 'COMPLETED' || state === 'FAILED' || state === 'CANCELLED'; -} - -// ============================================================================= -// NegotiationHandler -// ============================================================================= - -export class NegotiationHandler { - private started = false; - private dmUnsubscribe: (() => void) | null = null; - - /** dealId -> timeout handle (proposal or acceptance watchdog). */ - private readonly pendingProposals = new Map>(); - /** dealId -> sent msg_id (match incoming np.accept via in_reply_to). */ - private readonly proposalMsgIds = new Map(); - /** msg_id -> ts_ms (replay/dedup window). */ - private readonly seenMsgIds = new Map(); - /** sender_pubkey -> array of proposal receive timestamps (rate-limit). */ - private readonly proposalRateLimit = new Map(); - - constructor( - private readonly store: TraderStateStore, - private readonly comms: CommsAdapter, - private readonly crypto: CryptoAdapter, - private readonly strategy: () => TraderStrategy, - private readonly onDealAccepted: OnDealAccepted, - ) {} - - // --------------------------------------------------------------------------- - // Lifecycle - // --------------------------------------------------------------------------- - - start(): void { - if (this.started) return; - this.started = true; - this.dmUnsubscribe = this.comms.onDirectMessage((msg) => { - void this.handleIncomingDM(msg).catch((err) => { - process.stderr.write( - `NegotiationHandler: uncaught error in handleIncomingDM: ${(err as Error).message}\n`, - ); - }); - }); - } - - stop(): void { - if (!this.started) return; - this.started = false; - if (this.dmUnsubscribe) { - try { - this.dmUnsubscribe(); - } catch { - // ignore — we're tearing down - } - this.dmUnsubscribe = null; - } - for (const timer of this.pendingProposals.values()) { - clearTimeout(timer); - } - this.pendingProposals.clear(); - } - - // --------------------------------------------------------------------------- - // Read-only accessors - // --------------------------------------------------------------------------- - - getDeal(dealId: string): DealRecord | undefined { - return this.store.getDeal(dealId); - } - - getAllDeals(): DealRecord[] { - return this.store.getAllDeals(); - } - - // --------------------------------------------------------------------------- - // Proposal flow (we are PROPOSER) - // --------------------------------------------------------------------------- - - async onMatchFound(event: MatchEvent): Promise { - if (!event.shouldPropose) { - // Acceptor side: nothing to do proactively — we wait for np.propose. - return; - } - - try { - const strategy = this.strategy(); - const terms = this.buildTerms(event, strategy); - if (!terms) return; - - // Duplicate-deal guard (spec 5.7): skip if a non-terminal deal already - // exists for this counterparty intent id. - const acceptorIntentId = terms.acceptor_intent_id; - const existing = this.store - .getDealsByIntentId(acceptorIntentId) - .find((d) => !isTerminalState(d.state)); - if (existing) { - if (DEBUG) { - process.stderr.write( - `NegotiationHandler: skipping propose — existing deal ${existing.deal_id} in state ${existing.state}\n`, - ); - } - return; - } - - try { - validateDealTerms(terms); - } catch (err) { - process.stderr.write( - `NegotiationHandler: buildTerms produced invalid terms: ${(err as Error).message}\n`, - ); - return; - } - - const now = Date.now(); - const dealId = buildDealId(terms.proposer_intent_id, terms.acceptor_intent_id); - const deal: DealRecord = { - deal_id: dealId, - terms, - state: 'PROPOSED', - role: 'PROPOSER', - created_at_ms: now, - updated_at_ms: now, - failure_reason: null, - deposit_attempted: false, - payout_verified: false, - }; - this.store.setDeal(deal); - - const msg = await this.buildSignedMessage('np.propose', { - deal_id: dealId, - terms: this.serializeTerms(terms), - }); - this.proposalMsgIds.set(dealId, msg.msg_id); - - const address = pubkeyToAddress(event.listing.poster_pubkey); - try { - await this.comms.sendDM(address, JSON.stringify(msg)); - } catch (err) { - process.stderr.write( - `NegotiationHandler: sendDM failed for np.propose ${dealId}: ${(err as Error).message}\n`, - ); - this.cancelDeal(dealId, 'send_failed'); - this.proposalMsgIds.delete(dealId); - await this.saveSafely(); - return; - } - - // Proposal watchdog: if we never get np.accept, cancel the deal. - const timeoutMs = strategy.proposal_timeout_ms || DEFAULT_PROPOSAL_TIMEOUT_MS; - const timer = setTimeout(() => { - this.pendingProposals.delete(dealId); - this.cancelDeal(dealId, 'proposal_timeout'); - void this.saveSafely(); - }, timeoutMs); - this.pendingProposals.set(dealId, timer); - - await this.saveSafely(); - } catch (err) { - process.stderr.write( - `NegotiationHandler: onMatchFound error: ${(err as Error).message}\n`, - ); - } - } - - private buildTerms(event: MatchEvent, strategy: TraderStrategy): DealTerms | null { - const { intent: myRecord, listing, decoded } = event; - const myIntent = myRecord.intent; - - const myRemaining = myIntent.offer_volume_max - myRecord.volume_filled; - if (myRemaining <= 0n) return null; - - const offerVolume = myRemaining < decoded.offer_volume_max ? myRemaining : decoded.offer_volume_max; - if (offerVolume <= 0n) return null; - - const rate = midpoint(myIntent.rate_min, decoded.rate_min); - if (rate <= 0n) return null; - - const requestVolume = (offerVolume * rate) / RATE_DENOMINATOR; - if (requestVolume <= 0n) return null; - - const trusted = new Set(strategy.trusted_escrows); - const escrow = decoded.escrows.find((e) => trusted.has(e)); - if (!escrow) return null; - - // Acceptor intent_id: embedded in the market listing id (spec 2.8 pairs - // listing_id to intent_id). Fall back to listing_id for resilience. - const acceptorIntentId = listing.listing_id; - - return { - proposer_intent_id: myIntent.intent_id, - acceptor_intent_id: acceptorIntentId, - proposer_pubkey: this.crypto.getPublicKey(), - acceptor_pubkey: listing.poster_pubkey, - offer_token: myIntent.offer_token, - request_token: myIntent.request_token, - offer_volume: offerVolume, - request_volume: requestVolume, - rate, - escrow_address: escrow, - deposit_timeout_sec: decoded.deposit_timeout_sec, - }; - } - - // --------------------------------------------------------------------------- - // Incoming DM routing - // --------------------------------------------------------------------------- - - private async handleIncomingDM(dm: IncomingDM): Promise { - const raw = dm.content; - if (typeof raw !== 'string' || raw.length === 0) return; - if (byteLength(raw) > MAX_MESSAGE_BYTES) { - if (DEBUG) { - process.stderr.write('NegotiationHandler: dropping oversize DM\n'); - } - return; - } - - let parsed: unknown; - try { - parsed = JSON.parse(raw); - } catch { - // Not one of ours — ignore silently (trader shares the inbox with other - // protocols). - return; - } - - if (!this.isNpMessageShape(parsed)) return; - const msg = parsed; - - // Auth (spec 7.6) - if (!(await this.validateAuth(msg, dm.senderPubkey))) return; - - try { - switch (msg.type) { - case 'np.propose': - await this.handleProposal(msg, dm.senderPubkey); - break; - case 'np.accept': - await this.handleAcceptance(msg); - break; - case 'np.reject': - await this.handleRejection(msg); - break; - case 'np.cancel': - await this.handleCancel(msg); - break; - default: - // Unknown type — ignore. - break; - } - } catch (err) { - process.stderr.write( - `NegotiationHandler: error handling ${msg.type}: ${(err as Error).message}\n`, - ); - } - } - - private isNpMessageShape(value: unknown): value is NpMessage { - if (!value || typeof value !== 'object') return false; - const v = value as Record; - if (v['np_version'] !== '0.1') return false; - if (typeof v['msg_id'] !== 'string' || (v['msg_id'] as string).length === 0) return false; - if (typeof v['ts_ms'] !== 'number' || !Number.isFinite(v['ts_ms'])) return false; - if (typeof v['sender_pubkey'] !== 'string') return false; - if (typeof v['signature'] !== 'string') return false; - if (typeof v['type'] !== 'string') return false; - const t = v['type'] as string; - if (t !== 'np.propose' && t !== 'np.accept' && t !== 'np.reject' && t !== 'np.cancel') { - return false; - } - if (!v['payload'] || typeof v['payload'] !== 'object') return false; - return true; - } - - private async validateAuth(msg: NpMessage, senderPubkey: string): Promise { - if (msg.np_version !== '0.1') return false; - - const now = Date.now(); - if (Math.abs(now - msg.ts_ms) > MAX_TIMESTAMP_SKEW_MS) { - if (DEBUG) { - process.stderr.write(`NegotiationHandler: rejecting stale msg ${msg.msg_id}\n`); - } - return false; - } - - if (msg.sender_pubkey !== senderPubkey) { - if (DEBUG) { - process.stderr.write('NegotiationHandler: sender_pubkey mismatch\n'); - } - return false; - } - - if (hasDangerousKeys(msg)) { - process.stderr.write('NegotiationHandler: dangerous keys in NP-0 message\n'); - return false; - } - - // Dedup window — reject if we've already seen this msg_id recently. - this.pruneSeenMsgIds(now); - if (this.seenMsgIds.has(msg.msg_id)) { - if (DEBUG) { - process.stderr.write(`NegotiationHandler: replay of ${msg.msg_id}\n`); - } - return false; - } - - // Signature verification - const { signature: _sig, ...unsigned } = msg; - let valid = false; - try { - valid = await this.crypto.verify(messageToSign(unsigned), msg.signature, msg.sender_pubkey); - } catch (err) { - process.stderr.write( - `NegotiationHandler: verify threw: ${(err as Error).message}\n`, - ); - return false; - } - if (!valid) { - if (DEBUG) { - process.stderr.write(`NegotiationHandler: bad signature on ${msg.msg_id}\n`); - } - return false; - } - - // Accept and record. - this.seenMsgIds.set(msg.msg_id, now); - this.capSeenMsgIds(); - return true; - } - - private pruneSeenMsgIds(now: number): void { - const cutoff = now - MSG_ID_DEDUP_WINDOW_MS; - for (const [id, ts] of this.seenMsgIds) { - if (ts < cutoff) this.seenMsgIds.delete(id); - } - } - - private capSeenMsgIds(): void { - if (this.seenMsgIds.size <= MSG_ID_DEDUP_MAX_ENTRIES) return; - // Evict oldest entries (insertion order in a Map is insertion order). - const overflow = this.seenMsgIds.size - MSG_ID_DEDUP_MAX_ENTRIES; - let i = 0; - for (const key of this.seenMsgIds.keys()) { - if (i++ >= overflow) break; - this.seenMsgIds.delete(key); - } - } - - // --------------------------------------------------------------------------- - // Acceptance flow (we are ACCEPTOR) - // --------------------------------------------------------------------------- - - private async handleProposal(msg: NpMessage, senderPubkey: string): Promise { - // Rate limit per counterparty. - if (!this.checkRateLimit(senderPubkey)) { - if (DEBUG) { - process.stderr.write(`NegotiationHandler: rate-limited ${senderPubkey}\n`); - } - return; - } - - const strategy = this.strategy(); - if (strategy.blocked_counterparties.includes(senderPubkey)) { - if (DEBUG) { - process.stderr.write(`NegotiationHandler: blocked counterparty ${senderPubkey}\n`); - } - return; - } - - const termsRaw = (msg.payload as Record)['terms']; - const terms = this.deserializeTerms(termsRaw); - if (!terms) { - process.stderr.write('NegotiationHandler: invalid terms in np.propose\n'); - return; - } - - try { - validateDealTerms(terms); - } catch (err) { - process.stderr.write( - `NegotiationHandler: rejecting np.propose — ${(err as Error).message}\n`, - ); - return; - } - - // Cross-check the proposer pubkey in the terms matches the envelope. - if (terms.proposer_pubkey !== senderPubkey) { - process.stderr.write('NegotiationHandler: proposer_pubkey mismatch in np.propose\n'); - return; - } - if (terms.acceptor_pubkey !== this.crypto.getPublicKey()) { - if (DEBUG) { - process.stderr.write('NegotiationHandler: acceptor_pubkey not us — ignoring\n'); - } - return; - } - - // Find our matching ACTIVE intent. - const myIntent = this.findMatchingIntent(terms); - if (!myIntent) { - if (DEBUG) { - process.stderr.write('NegotiationHandler: no matching local intent for np.propose\n'); - } - return; - } - - // Verify the acceptor_intent_id in the terms refers to our intent. - if (terms.acceptor_intent_id !== myIntent.intent.intent_id) { - if (DEBUG) { - process.stderr.write('NegotiationHandler: acceptor_intent_id mismatch\n'); - } - return; - } - - // Duplicate-deal guard — non-terminal deal already exists. - const existing = this.store - .getDealsByIntentId(myIntent.intent.intent_id) - .find((d) => !isTerminalState(d.state)); - if (existing) { - if (DEBUG) { - process.stderr.write( - `NegotiationHandler: duplicate proposal — existing deal ${existing.deal_id}\n`, - ); - } - return; - } - - const now = Date.now(); - const dealId = buildDealId(terms.proposer_intent_id, terms.acceptor_intent_id); - const deal: DealRecord = { - deal_id: dealId, - terms, - state: 'ACCEPTED', - role: 'ACCEPTOR', - created_at_ms: now, - updated_at_ms: now, - failure_reason: null, - deposit_attempted: false, - payout_verified: false, - }; - this.store.setDeal(deal); - - // Build and send np.accept. - const accept = await this.buildSignedMessage( - 'np.accept', - { deal_id: dealId, in_reply_to: msg.msg_id }, - ); - const address = pubkeyToAddress(senderPubkey); - try { - await this.comms.sendDM(address, JSON.stringify(accept)); - } catch (err) { - process.stderr.write( - `NegotiationHandler: sendDM failed for np.accept ${dealId}: ${(err as Error).message}\n`, - ); - this.cancelDeal(dealId, 'send_failed'); - await this.saveSafely(); - return; - } - - // Acceptance watchdog — if deal stays in ACCEPTED (never reaches EXECUTING) - // within the window, mark it CANCELLED. - const timeoutMs = strategy.acceptance_timeout_ms || DEFAULT_ACCEPTANCE_TIMEOUT_MS; - const timer = setTimeout(() => { - this.pendingProposals.delete(dealId); - const current = this.store.getDeal(dealId); - if (current && current.state === 'ACCEPTED') { - this.cancelDeal(dealId, 'acceptance_timeout'); - void this.saveSafely(); - } - }, timeoutMs); - this.pendingProposals.set(dealId, timer); - - try { - this.onDealAccepted(deal); - } catch (err) { - process.stderr.write( - `NegotiationHandler: onDealAccepted threw: ${(err as Error).message}\n`, - ); - } - - await this.saveSafely(); - } - - private checkRateLimit(pubkey: string): boolean { - const now = Date.now(); - const cutoff = now - RATE_LIMIT_WINDOW_MS; - const existing = this.proposalRateLimit.get(pubkey) ?? []; - const recent = existing.filter((ts) => ts >= cutoff); - if (recent.length >= RATE_LIMIT_MAX_PROPOSALS) { - this.proposalRateLimit.set(pubkey, recent); - return false; - } - recent.push(now); - this.proposalRateLimit.set(pubkey, recent); - return true; - } - - private findMatchingIntent(terms: DealTerms): IntentRecord | null { - for (const record of this.store.getIntentsByState('ACTIVE')) { - const i = record.intent; - if (i.offer_token !== terms.request_token) continue; - if (i.request_token !== terms.offer_token) continue; - // Volume: the proposer's request_volume is our offer; must fall inside - // our own offer range. - const remaining = i.offer_volume_max - record.volume_filled; - if (terms.request_volume > remaining) continue; - if (terms.request_volume < i.offer_volume_min) continue; - // Rate: proposer's rate must fall inside our own rate band. - if (terms.rate < i.rate_min) continue; - if (terms.rate > i.rate_max) continue; - return record; - } - return null; - } - - // --------------------------------------------------------------------------- - // Accept / Reject / Cancel inbound handlers - // --------------------------------------------------------------------------- - - private async handleAcceptance(msg: NpMessage): Promise { - const payload = msg.payload as Record; - const inReplyTo = payload['in_reply_to']; - if (typeof inReplyTo !== 'string' || inReplyTo.length === 0) return; - - // Locate the deal whose outgoing msg_id matches. - let dealId: string | null = null; - for (const [id, sentId] of this.proposalMsgIds) { - if (sentId === inReplyTo) { - dealId = id; - break; - } - } - if (!dealId) { - if (DEBUG) { - process.stderr.write( - `NegotiationHandler: np.accept references unknown msg ${inReplyTo}\n`, - ); - } - return; - } - - const deal = this.store.getDeal(dealId); - if (!deal) return; - if (deal.role !== 'PROPOSER') return; - if (deal.state !== 'PROPOSED') return; - // Sanity: acceptance must come from the expected acceptor. - if (deal.terms.acceptor_pubkey !== msg.sender_pubkey) { - process.stderr.write('NegotiationHandler: np.accept from wrong sender\n'); - return; - } - - const next: DealRecord = { - ...deal, - state: 'ACCEPTED', - updated_at_ms: Date.now(), - }; - this.store.setDeal(next); - - const timer = this.pendingProposals.get(dealId); - if (timer) { - clearTimeout(timer); - this.pendingProposals.delete(dealId); - } - this.proposalMsgIds.delete(dealId); - - try { - this.onDealAccepted(next); - } catch (err) { - process.stderr.write( - `NegotiationHandler: onDealAccepted threw: ${(err as Error).message}\n`, - ); - } - - await this.saveSafely(); - } - - private async handleRejection(msg: NpMessage): Promise { - const payload = msg.payload as Record; - const dealId = this.resolveDealIdForInbound(payload, msg.sender_pubkey); - if (!dealId) return; - this.cancelDeal(dealId, 'rejected_by_counterparty'); - await this.saveSafely(); - } - - private async handleCancel(msg: NpMessage): Promise { - const payload = msg.payload as Record; - const dealId = this.resolveDealIdForInbound(payload, msg.sender_pubkey); - if (!dealId) return; - const deal = this.store.getDeal(dealId); - if (!deal) return; - if (deal.state !== 'PROPOSED' && deal.state !== 'ACCEPTED') return; - this.cancelDeal(dealId, 'cancelled_by_counterparty'); - await this.saveSafely(); - } - - /** Look up a deal for an inbound reject/cancel. Checks `deal_id` first, then - * falls back to `in_reply_to` matched against our sent proposal ids. */ - private resolveDealIdForInbound( - payload: Record, - senderPubkey: string, - ): string | null { - const declared = payload['deal_id']; - if (typeof declared === 'string' && declared.length > 0) { - const deal = this.store.getDeal(declared); - if (!deal) return null; - if ( - deal.terms.proposer_pubkey !== senderPubkey && - deal.terms.acceptor_pubkey !== senderPubkey - ) { - return null; - } - return declared; - } - const inReplyTo = payload['in_reply_to']; - if (typeof inReplyTo === 'string') { - for (const [id, sentId] of this.proposalMsgIds) { - if (sentId === inReplyTo) return id; - } - } - return null; - } - - private cancelDeal(dealId: string, reason: string): void { - const deal = this.store.getDeal(dealId); - if (!deal) return; - if (isTerminalState(deal.state)) return; - const next: DealRecord = { - ...deal, - state: 'CANCELLED', - updated_at_ms: Date.now(), - failure_reason: reason, - }; - this.store.setDeal(next); - - const timer = this.pendingProposals.get(dealId); - if (timer) { - clearTimeout(timer); - this.pendingProposals.delete(dealId); - } - this.proposalMsgIds.delete(dealId); - } - - // --------------------------------------------------------------------------- - // Message construction / serialisation - // --------------------------------------------------------------------------- - - private async buildSignedMessage( - type: NpMessageType, - payload: Record, - ): Promise { - const unsigned: Omit = { - np_version: '0.1', - msg_id: newMsgId(), - ts_ms: Date.now(), - sender_pubkey: this.crypto.getPublicKey(), - type, - payload, - }; - const signature = await this.crypto.sign(messageToSign(unsigned)); - return { ...unsigned, signature }; - } - - /** JSON-safe encoding of DealTerms — bigints serialised as decimal strings. */ - private serializeTerms(terms: DealTerms): Record { - return { - proposer_intent_id: terms.proposer_intent_id, - acceptor_intent_id: terms.acceptor_intent_id, - proposer_pubkey: terms.proposer_pubkey, - acceptor_pubkey: terms.acceptor_pubkey, - offer_token: terms.offer_token, - request_token: terms.request_token, - offer_volume: terms.offer_volume.toString(), - request_volume: terms.request_volume.toString(), - rate: terms.rate.toString(), - escrow_address: terms.escrow_address, - deposit_timeout_sec: terms.deposit_timeout_sec, - }; - } - - private deserializeTerms(raw: unknown): DealTerms | null { - if (!raw || typeof raw !== 'object') return null; - const r = raw as Record; - try { - const offer = this.asBigInt(r['offer_volume']); - const request = this.asBigInt(r['request_volume']); - const rate = this.asBigInt(r['rate']); - if (offer === null || request === null || rate === null) return null; - - const proposer_intent_id = this.asString(r['proposer_intent_id']); - const acceptor_intent_id = this.asString(r['acceptor_intent_id']); - const proposer_pubkey = this.asString(r['proposer_pubkey']); - const acceptor_pubkey = this.asString(r['acceptor_pubkey']); - const offer_token = this.asString(r['offer_token']); - const request_token = this.asString(r['request_token']); - const escrow_address = this.asString(r['escrow_address']); - const deposit_timeout_sec = r['deposit_timeout_sec']; - - if ( - proposer_intent_id === null || - acceptor_intent_id === null || - proposer_pubkey === null || - acceptor_pubkey === null || - offer_token === null || - request_token === null || - escrow_address === null || - typeof deposit_timeout_sec !== 'number' || - !Number.isFinite(deposit_timeout_sec) - ) { - return null; - } - - return { - proposer_intent_id, - acceptor_intent_id, - proposer_pubkey, - acceptor_pubkey, - offer_token, - request_token, - offer_volume: offer, - request_volume: request, - rate, - escrow_address, - deposit_timeout_sec, - }; - } catch { - return null; - } - } - - private asBigInt(v: unknown): bigint | null { - try { - if (typeof v === 'string' && /^-?\d+$/.test(v)) return BigInt(v); - if (typeof v === 'number' && Number.isInteger(v)) return BigInt(v); - return null; - } catch { - return null; - } - } - - private asString(v: unknown): string | null { - return typeof v === 'string' && v.length > 0 ? v : null; - } - - private async saveSafely(): Promise { - try { - await this.store.save(); - } catch (err) { - process.stderr.write( - `NegotiationHandler: store.save failed: ${(err as Error).message}\n`, - ); - } - } -} diff --git a/src/trader/swap-executor.ts b/src/trader/swap-executor.ts deleted file mode 100644 index d123a80..0000000 --- a/src/trader/swap-executor.ts +++ /dev/null @@ -1,548 +0,0 @@ -/** - * SwapExecutor — drives the swap lifecycle from ACCEPTED → EXECUTING → - * COMPLETED/FAILED. - * - * Responsibilities: - * - Kick off escrow deposit payment when we are the PROPOSER. - * - Listen to sphere swap events and advance the deal's state machine. - * - Enforce V2 protocol requirement (spec 7.9.5), trusted escrows (spec - * 7.9.1), term binding (spec 7.9.4), and payout verification before - * declaring COMPLETED (spec 7.9.2). - * - Apply an EXECUTING-state timeout so a stalled swap never wedges - * reserved volume. - * - Release `VolumeReservationLedger` entries on terminal transitions. - * - * Design notes: - * - `deposit_attempted` is written BEFORE `payInvoice()` so crash-recovery - * can distinguish "never paid" from "paid but not confirmed yet". - * - All sphere-event handlers are wrapped in try/catch: a malformed event - * from the SDK must not crash the long-running trader process. - * - No Sphere SDK imports — the executor only consumes the narrow - * {@link SwapAdapter} / {@link PaymentsAdapter} DI seams. - */ - -import type { - DealRecord, - DealTerms, - OnSwapCompleted, - PaymentsAdapter, - SwapAdapter, - TraderStrategy, -} from './types.js'; -import type { TraderStateStore } from './trader-state-store.js'; -import type { VolumeReservationLedger } from './volume-reservation-ledger.js'; - -// ============================================================================= -// Constants -// ============================================================================= - -/** Debug logging opt-in (mirrors NegotiationHandler). */ -const DEBUG = typeof process !== 'undefined' && Boolean(process.env['DEBUG']); - -/** Required sphere swap protocol version (spec 7.9.5). */ -const REQUIRED_PROTOCOL_VERSION = 2; - -/** Extra grace window added on top of `deposit_timeout_sec` for the - * EXECUTING-state watchdog. */ -const EXECUTING_TIMEOUT_GRACE_SEC = 60; - -// ============================================================================= -// Helpers -// ============================================================================= - -/** Terminal states — no further transitions permitted. */ -function isTerminalState(state: DealRecord['state']): boolean { - return state === 'COMPLETED' || state === 'FAILED' || state === 'CANCELLED'; -} - -/** - * Derive the escrow "invoice" from DealTerms. The MVP treats the escrow - * address as the direct transfer destination — a full implementation would - * construct a protocol-specific payment request. - */ -function escrowInvoice(terms: DealTerms): string { - return terms.escrow_address; -} - -/** Best-effort bigint parser from swap-event payload fields. */ -function asBigInt(v: unknown): bigint | null { - try { - if (typeof v === 'bigint') return v; - if (typeof v === 'string' && /^-?\d+$/.test(v)) return BigInt(v); - if (typeof v === 'number' && Number.isInteger(v)) return BigInt(v); - return null; - } catch { - return null; - } -} - -// ============================================================================= -// SwapExecutor -// ============================================================================= - -export class SwapExecutor { - private started = false; - - /** Unsubscribe functions returned by `sphereOn(...)` — called on `stop()`. */ - private readonly eventUnsubs: Array<() => void> = []; - - /** dealId -> EXECUTING-state watchdog handle. */ - private readonly executingTimers = new Map>(); - - /** Mapping swapId <-> dealId so event handlers can resolve deals. */ - private readonly dealToSwap = new Map(); - private readonly swapToDeal = new Map(); - - constructor( - private readonly store: TraderStateStore, - private readonly swap: SwapAdapter, - private readonly payments: PaymentsAdapter, - private readonly ledger: VolumeReservationLedger, - private readonly strategy: () => TraderStrategy, - private readonly onSwapCompleted: OnSwapCompleted, - private readonly sphereOn: ( - event: string, - handler: (...args: unknown[]) => void, - ) => () => void, - ) {} - - // --------------------------------------------------------------------------- - // Lifecycle - // --------------------------------------------------------------------------- - - async start(): Promise { - if (this.started) return; - this.started = true; - - // 1. Reconcile swap state from the SDK before subscribing so any events - // replayed during `load()` are captured by our listeners. - try { - await this.swap.load(); - } catch (err) { - process.stderr.write( - `SwapExecutor: swap.load failed: ${(err as Error).message}\n`, - ); - } - - // 2. Subscribe to all swap lifecycle events. - this.subscribe('swap:proposal_received', (args) => this.onProposalReceived(args)); - this.subscribe('swap:accepted', (args) => this.onAccepted(args)); - this.subscribe('swap:announced', (args) => this.onAnnounced(args)); - this.subscribe('swap:deposit_sent', (args) => this.onDepositSent(args)); - this.subscribe('swap:deposit_confirmed', (args) => this.onDepositConfirmed(args)); - this.subscribe('swap:completed', (args) => this.onCompleted(args)); - this.subscribe('swap:failed', (args) => this.onFailed(args)); - this.subscribe('swap:cancelled', (args) => this.onCancelled(args)); - - // 3. Recover any in-flight EXECUTING deals from the store so their - // watchdog timers are re-armed after a restart. - for (const deal of this.store.getAllDeals()) { - if (deal.state === 'EXECUTING') { - this.setExecutingTimeout(deal); - } - } - } - - async stop(): Promise { - if (!this.started) return; - this.started = false; - - for (const unsub of this.eventUnsubs) { - try { - unsub(); - } catch { - // tearing down — swallow - } - } - this.eventUnsubs.length = 0; - - for (const timer of this.executingTimers.values()) { - clearTimeout(timer); - } - this.executingTimers.clear(); - } - - // --------------------------------------------------------------------------- - // Read-only accessors - // --------------------------------------------------------------------------- - - getDeal(dealId: string): DealRecord | undefined { - return this.store.getDeal(dealId); - } - - // --------------------------------------------------------------------------- - // Execute: ACCEPTED -> EXECUTING (and kick off deposit if PROPOSER) - // --------------------------------------------------------------------------- - - /** - * Transition an ACCEPTED deal into EXECUTING and — if we are the PROPOSER — - * pay the escrow invoice. Throws synchronously on precondition failures so - * the caller (NegotiationHandler → onDealAccepted bridge) sees the error. - */ - async execute(deal: DealRecord): Promise { - if (deal.state !== 'ACCEPTED') { - throw new Error( - `SwapExecutor.execute: deal ${deal.deal_id} is in state ${deal.state}, expected ACCEPTED`, - ); - } - - // Verify trusted escrow (spec 7.9.1) before committing to EXECUTING. - await this.pingEscrow(deal); - - // Transition to EXECUTING and persist before any network-visible side - // effect. That way, crash-recovery observes a consistent state. - const now = Date.now(); - deal.state = 'EXECUTING'; - deal.updated_at_ms = now; - this.store.setDeal(deal); - await this.saveSafely(); - - this.setExecutingTimeout(deal); - - if (deal.role === 'PROPOSER') { - // Record deposit_attempted BEFORE payInvoice so a crash mid-call cannot - // produce "money left but no record". - deal.deposit_attempted = true; - deal.updated_at_ms = Date.now(); - this.store.setDeal(deal); - await this.saveSafely(); - - try { - await this.payments.payInvoice(escrowInvoice(deal.terms)); - } catch (err) { - process.stderr.write( - `SwapExecutor: payInvoice failed for ${deal.deal_id}: ${(err as Error).message}\n`, - ); - await this.failDeal(deal, `PAY_INVOICE_FAILED:${(err as Error).message}`); - return; - } - } - - // ACCEPTOR side and post-payment PROPOSER side both wait for sphere - // events (deposit_confirmed, completed, etc.) to drive the rest. - } - - // --------------------------------------------------------------------------- - // Escrow reachability check (spec 7.9.1) - // --------------------------------------------------------------------------- - - private async pingEscrow(deal: DealRecord): Promise { - const strategy = this.strategy(); - if (!strategy.trusted_escrows.includes(deal.terms.escrow_address)) { - throw new Error( - `ESCROW_UNREACHABLE: ${deal.terms.escrow_address} not in trusted_escrows`, - ); - } - // For MVP: trust check is sufficient. A real impl would send an - // ICMP-style probe here. - } - - // --------------------------------------------------------------------------- - // Sphere event subscription helper - // --------------------------------------------------------------------------- - - private subscribe( - event: string, - handler: (args: Record) => void | Promise, - ): void { - const unsub = this.sphereOn(event, (...rawArgs: unknown[]) => { - try { - const first = rawArgs[0]; - const args: Record = - first && typeof first === 'object' && !Array.isArray(first) - ? (first as Record) - : {}; - const result = handler(args); - if (result instanceof Promise) { - result.catch((err) => { - process.stderr.write( - `SwapExecutor: async handler ${event} threw: ${(err as Error).message}\n`, - ); - }); - } - } catch (err) { - process.stderr.write( - `SwapExecutor: handler ${event} threw: ${(err as Error).message}\n`, - ); - } - }); - this.eventUnsubs.push(unsub); - } - - private resolveDealFromArgs(args: Record): DealRecord | null { - const swapId = typeof args['swapId'] === 'string' ? (args['swapId'] as string) : null; - if (!swapId) return null; - const dealId = this.swapToDeal.get(swapId); - if (!dealId) return null; - return this.store.getDeal(dealId) ?? null; - } - - // --------------------------------------------------------------------------- - // swap:proposal_received — V2 enforcement + term binding + escrow match - // --------------------------------------------------------------------------- - - private async onProposalReceived(args: Record): Promise { - const swapId = typeof args['swapId'] === 'string' ? (args['swapId'] as string) : null; - const dealId = typeof args['dealId'] === 'string' ? (args['dealId'] as string) : null; - if (!swapId || !dealId) { - if (DEBUG) { - process.stderr.write('SwapExecutor: proposal_received missing swapId/dealId\n'); - } - return; - } - - const deal = this.store.getDeal(dealId); - if (!deal) { - if (DEBUG) { - process.stderr.write( - `SwapExecutor: proposal_received for unknown deal ${dealId}\n`, - ); - } - return; - } - if (isTerminalState(deal.state)) return; - - // Register the swapId↔dealId mapping for future events. - this.dealToSwap.set(dealId, swapId); - this.swapToDeal.set(swapId, dealId); - - // V2 enforcement (spec 7.9.5). - const protocolVersion = args['protocolVersion']; - if (protocolVersion !== REQUIRED_PROTOCOL_VERSION) { - await this.failDeal(deal, 'V2_REQUIRED'); - return; - } - - // Trusted-escrow cross-check (spec 7.9.1): the swap's escrow must match - // the one we negotiated. A counterparty who quietly swaps in a different - // escrow address after negotiation is compromised. - const escrowAddress = - typeof args['escrowAddress'] === 'string' ? (args['escrowAddress'] as string) : null; - if (escrowAddress !== null && escrowAddress !== deal.terms.escrow_address) { - await this.failDeal(deal, 'ESCROW_MISMATCH'); - return; - } - - // Term binding (spec 7.9.4): offer/request tokens and volumes MUST match - // what we agreed to in NP-0 negotiation. - if (!this.termsMatch(deal.terms, args)) { - await this.failDeal(deal, 'TERMS_MISMATCH'); - return; - } - } - - /** - * Compare incoming swap-proposal args against the deal's DealTerms. Any - * field absent from `args` is treated as a mismatch rather than silently - * accepted — fail-closed. - */ - private termsMatch(terms: DealTerms, args: Record): boolean { - if (args['offer_token'] !== terms.offer_token) return false; - if (args['request_token'] !== terms.request_token) return false; - - const offer = asBigInt(args['offer_volume']); - if (offer === null || offer !== terms.offer_volume) return false; - - const request = asBigInt(args['request_volume']); - if (request === null || request !== terms.request_volume) return false; - - return true; - } - - // --------------------------------------------------------------------------- - // Lightweight log handlers - // --------------------------------------------------------------------------- - - private onAccepted(args: Record): void { - if (DEBUG) { - process.stderr.write(`SwapExecutor: swap:accepted ${JSON.stringify(args)}\n`); - } - } - - private onAnnounced(args: Record): void { - if (DEBUG) { - process.stderr.write(`SwapExecutor: swap:announced ${JSON.stringify(args)}\n`); - } - } - - private onDepositSent(args: Record): void { - if (DEBUG) { - process.stderr.write(`SwapExecutor: swap:deposit_sent ${JSON.stringify(args)}\n`); - } - } - - // --------------------------------------------------------------------------- - // swap:deposit_confirmed — begin payout polling - // --------------------------------------------------------------------------- - - private async onDepositConfirmed(args: Record): Promise { - const deal = this.resolveDealFromArgs(args); - if (!deal) return; - if (isTerminalState(deal.state)) return; - const swapId = this.dealToSwap.get(deal.deal_id); - if (!swapId) return; - - const verified = await this.verifyPayout(deal, swapId); - if (verified) { - await this.completeDeal(deal, deal.terms.offer_volume); - } else { - await this.failDeal(deal, 'PAYOUT_UNVERIFIED'); - } - } - - // --------------------------------------------------------------------------- - // swap:completed — verify payout before declaring COMPLETED (spec 7.9.2) - // --------------------------------------------------------------------------- - - private async onCompleted(args: Record): Promise { - const deal = this.resolveDealFromArgs(args); - if (!deal) return; - if (isTerminalState(deal.state)) return; - const swapId = this.dealToSwap.get(deal.deal_id); - if (!swapId) return; - - const verified = await this.verifyPayout(deal, swapId); - if (verified) { - await this.completeDeal(deal, deal.terms.offer_volume); - } else { - await this.failDeal(deal, 'PAYOUT_UNVERIFIED'); - } - } - - private async onFailed(args: Record): Promise { - const deal = this.resolveDealFromArgs(args); - if (!deal) return; - if (isTerminalState(deal.state)) return; - const reason = - typeof args['reason'] === 'string' ? (args['reason'] as string) : 'SWAP_FAILED'; - await this.failDeal(deal, reason); - } - - private async onCancelled(args: Record): Promise { - const deal = this.resolveDealFromArgs(args); - if (!deal) return; - if (isTerminalState(deal.state)) return; - await this.failDeal(deal, 'CANCELLED'); - } - - // --------------------------------------------------------------------------- - // Payout verification (spec 7.9.2) - // --------------------------------------------------------------------------- - - /** - * Poll `swap.getStatus(swapId)` until `payoutVerified === true` or retries - * are exhausted. Returns `true` on success, `false` on exhaustion. - */ - private async verifyPayout(deal: DealRecord, swapId: string): Promise { - const strategy = this.strategy(); - const interval = Math.max(0, strategy.payout_poll_interval_ms); - const maxRetries = Math.max(0, strategy.payout_max_retries); - - for (let attempt = 0; attempt <= maxRetries; attempt++) { - try { - const status = await this.swap.getStatus(swapId); - if (status.payoutVerified === true) { - return true; - } - } catch (err) { - process.stderr.write( - `SwapExecutor: getStatus failed for ${deal.deal_id} (swap ${swapId}): ${(err as Error).message}\n`, - ); - } - - if (attempt < maxRetries) { - await this.sleep(interval); - // If the deal was failed out from under us (e.g. timeout), stop. - const current = this.store.getDeal(deal.deal_id); - if (!current || isTerminalState(current.state)) { - return false; - } - } - } - - return false; - } - - private sleep(ms: number): Promise { - return new Promise((resolve) => setTimeout(resolve, ms)); - } - - // --------------------------------------------------------------------------- - // EXECUTING-state watchdog - // --------------------------------------------------------------------------- - - private setExecutingTimeout(deal: DealRecord): void { - // Clear any existing timer first so a re-entry (e.g. recovery) does not - // leak handles. - const existing = this.executingTimers.get(deal.deal_id); - if (existing) { - clearTimeout(existing); - } - - const ms = (deal.terms.deposit_timeout_sec + EXECUTING_TIMEOUT_GRACE_SEC) * 1000; - const timer = setTimeout(() => { - this.executingTimers.delete(deal.deal_id); - const current = this.store.getDeal(deal.deal_id); - if (current && current.state === 'EXECUTING') { - void this.failDeal(current, 'EXECUTING_TIMEOUT'); - } - }, ms); - this.executingTimers.set(deal.deal_id, timer); - } - - private clearExecutingTimer(dealId: string): void { - const timer = this.executingTimers.get(dealId); - if (timer) { - clearTimeout(timer); - this.executingTimers.delete(dealId); - } - } - - // --------------------------------------------------------------------------- - // Terminal transitions - // --------------------------------------------------------------------------- - - private async failDeal(deal: DealRecord, reason: string): Promise { - if (isTerminalState(deal.state)) return; - deal.state = 'FAILED'; - deal.failure_reason = reason; - deal.updated_at_ms = Date.now(); - this.store.setDeal(deal); - this.clearExecutingTimer(deal.deal_id); - this.ledger.release(deal.deal_id); - await this.saveSafely(); - this.fireCompletion(deal); - } - - private async completeDeal(deal: DealRecord, _volumeFilled: bigint): Promise { - if (isTerminalState(deal.state)) return; - deal.state = 'COMPLETED'; - deal.payout_verified = true; - deal.updated_at_ms = Date.now(); - this.store.setDeal(deal); - this.clearExecutingTimer(deal.deal_id); - this.ledger.release(deal.deal_id); - await this.saveSafely(); - this.fireCompletion(deal); - } - - private fireCompletion(deal: DealRecord): void { - try { - this.onSwapCompleted(deal); - } catch (err) { - process.stderr.write( - `SwapExecutor: onSwapCompleted threw: ${(err as Error).message}\n`, - ); - } - } - - private async saveSafely(): Promise { - try { - await this.store.save(); - } catch (err) { - process.stderr.write( - `SwapExecutor: store.save failed: ${(err as Error).message}\n`, - ); - } - } -} diff --git a/src/trader/trader-state-store.ts b/src/trader/trader-state-store.ts deleted file mode 100644 index 1d097db..0000000 --- a/src/trader/trader-state-store.ts +++ /dev/null @@ -1,225 +0,0 @@ -/** - * Trader Agent — persistent state store. - * - * Persists intents, deals, and strategy to disk using atomic writes - * (write to temp file, then `fs.rename`) so a crash mid-write cannot - * corrupt state. No auto-save: callers invoke `save()` explicitly at - * well-defined checkpoints. - * - * BigInt-bearing fields (volumes, rates) round-trip via a `"n:"`-prefixed - * string encoding since `JSON.stringify` refuses native bigints. - */ - -import * as fs from 'node:fs/promises'; -import * as path from 'node:path'; - -import type { - DealRecord, - IntentRecord, - IntentState, - TraderStrategy, -} from './types.js'; -import { DEFAULT_STRATEGY } from './types.js'; - -// ============================================================================= -// On-disk schema -// ============================================================================= - -interface PersistedState { - readonly version: 1; - readonly intents: Record; - readonly deals: Record; - readonly strategy: TraderStrategy; -} - -// ============================================================================= -// BigInt JSON round-trip helpers -// ============================================================================= - -/** - * JSON.stringify replacer: encodes `bigint` values as `"n:"`. - * Strings that happen to begin with `"n:"` are left untouched on the way - * out — the reviver only decodes values whose original runtime type was - * bigint, so there is no ambiguity for intent/deal payloads where the - * bigint fields are typed and known. - */ -function bigintReplacer(_key: string, value: unknown): unknown { - return typeof value === 'bigint' ? `n:${value.toString()}` : value; -} - -/** - * JSON.parse reviver: decodes `"n:"` strings back to `bigint`. - * Any other string is returned unchanged. - */ -function bigintReviver(_key: string, value: unknown): unknown { - if (typeof value === 'string' && value.startsWith('n:')) { - return BigInt(value.slice(2)); - } - return value; -} - -// ============================================================================= -// Store -// ============================================================================= - -export class TraderStateStore { - private readonly filePath: string; - private readonly intents: Map; - private readonly deals: Map; - private strategy: TraderStrategy; - - constructor(dataDir: string) { - this.filePath = path.join(dataDir, 'wallet', 'trader', 'state.json'); - this.intents = new Map(); - this.deals = new Map(); - this.strategy = DEFAULT_STRATEGY; - } - - // --------------------------------------------------------------------------- - // Persistence - // --------------------------------------------------------------------------- - - /** - * Load persisted state from disk. If the file does not exist, the store is - * initialised with empty maps and the default strategy. A parse failure - * (corrupt file) is raised to the caller — silently wiping state on corrupt - * JSON would mask real problems. - */ - async load(): Promise { - let raw: string; - try { - raw = await fs.readFile(this.filePath, 'utf8'); - } catch (err) { - if ((err as NodeJS.ErrnoException).code === 'ENOENT') { - this.intents.clear(); - this.deals.clear(); - this.strategy = DEFAULT_STRATEGY; - return; - } - throw err; - } - - let parsed: PersistedState; - try { - parsed = JSON.parse(raw, bigintReviver) as PersistedState; - } catch (err) { - throw new Error( - `TraderStateStore: failed to parse state file ${this.filePath}: ${(err as Error).message}`, - ); - } - - if (!parsed || typeof parsed !== 'object' || parsed.version !== 1) { - throw new Error( - `TraderStateStore: unsupported state file version in ${this.filePath}`, - ); - } - - this.intents.clear(); - for (const [id, rec] of Object.entries(parsed.intents ?? {})) { - this.intents.set(id, rec); - } - - this.deals.clear(); - for (const [id, rec] of Object.entries(parsed.deals ?? {})) { - this.deals.set(id, rec); - } - - this.strategy = parsed.strategy ?? DEFAULT_STRATEGY; - } - - /** - * Persist the entire state atomically: write to a temp file, then rename - * over the target. `fs.rename` is atomic within a filesystem on POSIX, so - * a crash either leaves the old file intact or the new file in place — - * never a half-written file. - */ - async save(): Promise { - const state: PersistedState = { - version: 1, - intents: Object.fromEntries(this.intents), - deals: Object.fromEntries(this.deals), - strategy: this.strategy, - }; - const json = JSON.stringify(state, bigintReplacer, 2); - const tmpPath = this.filePath + '.tmp'; - await fs.mkdir(path.dirname(this.filePath), { recursive: true }); - await fs.writeFile(tmpPath, json, 'utf8'); - await fs.rename(tmpPath, this.filePath); - } - - // --------------------------------------------------------------------------- - // Intent CRUD - // --------------------------------------------------------------------------- - - getIntent(intentId: string): IntentRecord | undefined { - return this.intents.get(intentId); - } - - getAllIntents(): IntentRecord[] { - return Array.from(this.intents.values()); - } - - getIntentsByState(state: IntentState): IntentRecord[] { - const out: IntentRecord[] = []; - for (const rec of this.intents.values()) { - if (rec.state === state) { - out.push(rec); - } - } - return out; - } - - setIntent(record: IntentRecord): void { - this.intents.set(record.intent.intent_id, record); - } - - deleteIntent(intentId: string): void { - this.intents.delete(intentId); - } - - // --------------------------------------------------------------------------- - // Deal CRUD - // --------------------------------------------------------------------------- - - getDeal(dealId: string): DealRecord | undefined { - return this.deals.get(dealId); - } - - getAllDeals(): DealRecord[] { - return Array.from(this.deals.values()); - } - - /** - * Return every deal that references `intentId` on either side of the - * terms — proposer or acceptor. Callers filter further by `role` or - * `state` as needed. - */ - getDealsByIntentId(intentId: string): DealRecord[] { - const out: DealRecord[] = []; - for (const deal of this.deals.values()) { - if ( - deal.terms.proposer_intent_id === intentId || - deal.terms.acceptor_intent_id === intentId - ) { - out.push(deal); - } - } - return out; - } - - setDeal(record: DealRecord): void { - this.deals.set(record.deal_id, record); - } - - // --------------------------------------------------------------------------- - // Strategy - // --------------------------------------------------------------------------- - - getStrategy(): TraderStrategy { - return this.strategy; - } - - setStrategy(strategy: TraderStrategy): void { - this.strategy = strategy; - } -} diff --git a/src/trader/types.ts b/src/trader/types.ts deleted file mode 100644 index fc3779d..0000000 --- a/src/trader/types.ts +++ /dev/null @@ -1,194 +0,0 @@ -/** - * Trader Agent — core domain types and adapter interfaces. - * - * No Sphere SDK imports here: adapters are defined as narrow, DI-friendly - * interfaces so the trader core stays testable with in-memory fakes. - */ - -// ============================================================================= -// Trading intents -// ============================================================================= - -export interface TradingIntent { - readonly intent_id: string; - readonly salt: string; - readonly owner_pubkey: string; - readonly offer_token: string; - readonly offer_volume_min: bigint; - readonly offer_volume_max: bigint; - readonly request_token: string; - readonly rate_min: bigint; - readonly rate_max: bigint; - readonly expiry_ms: number; - readonly created_at_ms: number; - readonly deposit_timeout_sec: number; -} - -export type IntentState = 'ACTIVE' | 'PAUSED' | 'EXPIRED' | 'CANCELLED' | 'FILLED'; - -export interface IntentRecord { - readonly intent: TradingIntent; - state: IntentState; - readonly market_listing_id: string | null; - volume_filled: bigint; - updated_at_ms: number; -} - -// ============================================================================= -// Deals -// ============================================================================= - -export interface DealTerms { - readonly proposer_intent_id: string; - readonly acceptor_intent_id: string; - readonly proposer_pubkey: string; - readonly acceptor_pubkey: string; - readonly offer_token: string; - readonly request_token: string; - readonly offer_volume: bigint; - readonly request_volume: bigint; - readonly rate: bigint; - readonly escrow_address: string; - readonly deposit_timeout_sec: number; -} - -export type DealState = - | 'PROPOSED' - | 'ACCEPTED' - | 'EXECUTING' - | 'COMPLETED' - | 'FAILED' - | 'CANCELLED'; - -export interface DealRecord { - readonly deal_id: string; - readonly terms: DealTerms; - state: DealState; - readonly role: 'PROPOSER' | 'ACCEPTOR'; - readonly created_at_ms: number; - updated_at_ms: number; - failure_reason: string | null; - // deposit_attempted is written BEFORE payInvoice() so crash-recovery can - // tell the difference between "never paid" and "paid but never confirmed". - deposit_attempted: boolean; - payout_verified: boolean; -} - -// ============================================================================= -// Strategy -// ============================================================================= - -export interface TraderStrategy { - readonly scan_interval_ms: number; - readonly proposal_timeout_ms: number; - readonly acceptance_timeout_ms: number; - readonly max_active_intents: number; - readonly trusted_escrows: readonly string[]; - readonly blocked_counterparties: readonly string[]; - readonly payout_poll_interval_ms: number; - readonly payout_max_retries: number; -} - -export const DEFAULT_STRATEGY: TraderStrategy = { - scan_interval_ms: 30_000, - proposal_timeout_ms: 30_000, - acceptance_timeout_ms: 60_000, - max_active_intents: 10, - trusted_escrows: [], - blocked_counterparties: [], - payout_poll_interval_ms: 30_000, - payout_max_retries: 10, -}; - -// ============================================================================= -// Adapter interfaces (dependency injection seams) -// ============================================================================= - -export interface MarketListing { - readonly listing_id: string; - readonly description: string; - readonly poster_pubkey: string; - readonly expiry_ms: number; -} - -export interface MarketAdapter { - post(description: string, expiryMs: number): Promise; - remove(listingId: string): Promise; - search(query: string): Promise; - subscribeFeed(listener: (listing: MarketListing) => void): () => void; - getRecentListings(): Promise; -} - -export interface SwapProposalParams { - readonly escrowAddress: string; - readonly offerToken: string; - readonly offerVolume: bigint; - readonly requestToken: string; - readonly requestVolume: bigint; - readonly depositTimeoutSec: number; - readonly counterpartyAddress: string; -} - -export interface SwapProposalResult { - readonly swapId: string; -} - -export interface SwapStatus { - readonly swapId: string; - readonly state: string; - readonly payoutVerified?: boolean; -} - -export interface SwapAdapter { - propose(params: SwapProposalParams): Promise; - accept(swapId: string): Promise; - getStatus(swapId: string): Promise; - load(): Promise; - on(event: string, handler: (...args: unknown[]) => void): () => void; -} - -export interface ActiveIntent { - readonly listing_id: string; - readonly description: string; -} - -export interface PaymentsAdapter { - receive(): Promise<{ address: string; pubkey: string }>; - getMyIntents(): Promise; - payInvoice(invoice: string): Promise; - getConfirmedAmount(token: string): Promise; -} - -export interface IncomingDM { - readonly senderPubkey: string; - readonly content: string; -} - -export interface CommsAdapter { - sendDM(address: string, content: string): Promise; - onDirectMessage(handler: (msg: IncomingDM) => void): () => void; -} - -// ============================================================================= -// Callback types -// ============================================================================= - -export type OnMatchFound = (intent: IntentRecord, match: MarketListing) => void; -export type OnDealAccepted = (deal: DealRecord) => void; -export type OnSwapCompleted = (deal: DealRecord) => void; - -// ============================================================================= -// NP-0 Negotiation Protocol envelope -// ============================================================================= - -export type NpMessageType = 'np.propose' | 'np.accept' | 'np.reject' | 'np.cancel'; - -export interface NpMessage { - readonly np_version: '0.1'; - readonly msg_id: string; - readonly ts_ms: number; - readonly sender_pubkey: string; - readonly signature: string; - readonly type: NpMessageType; - readonly payload: Record; -} diff --git a/src/trader/utils.ts b/src/trader/utils.ts deleted file mode 100644 index a60f4f1..0000000 --- a/src/trader/utils.ts +++ /dev/null @@ -1,221 +0,0 @@ -/** - * Trader utilities: intent/deal validation, canonical JSON serialisation, - * market description encoder/decoder, and dangerous-key detection for NP-0. - */ - -import type { TradingIntent, DealTerms } from './types.js'; -import type { CreateIntentParams } from './acp-types.js'; - -const MAX_EXPIRY_WINDOW_MS = 7 * 24 * 60 * 60 * 1000; -const DANGEROUS_KEYS = new Set(['__proto__', 'constructor', 'prototype']); - -// ============================================================================= -// Validation -// ============================================================================= - -export function validateIntent(params: CreateIntentParams): void { - const { offer_token, request_token, offer_volume_min, offer_volume_max, rate_min, rate_max, expiry_ms, deposit_timeout_sec } = params; - - if (typeof offer_token !== 'string' || offer_token.length === 0) { - throw new Error('offer_token must be a non-empty string'); - } - if (typeof request_token !== 'string' || request_token.length === 0) { - throw new Error('request_token must be a non-empty string'); - } - - for (const [name, v] of [ - ['offer_volume_min', offer_volume_min], - ['offer_volume_max', offer_volume_max], - ['rate_min', rate_min], - ['rate_max', rate_max], - ] as const) { - if (typeof v !== 'number' || !Number.isFinite(v) || Number.isNaN(v)) { - throw new Error(`${name} must be a finite number`); - } - if (v < 0) { - throw new Error(`${name} must not be negative`); - } - } - - if (offer_volume_min <= 0) { - throw new Error('offer_volume_min must be > 0'); - } - if (offer_volume_max < offer_volume_min) { - throw new Error('offer_volume_max must be >= offer_volume_min'); - } - if (rate_min <= 0) { - throw new Error('rate_min must be > 0'); - } - if (rate_max < rate_min) { - throw new Error('rate_max must be >= rate_min'); - } - - if (typeof expiry_ms !== 'number' || !Number.isFinite(expiry_ms)) { - throw new Error('expiry_ms must be a finite number'); - } - const now = Date.now(); - if (expiry_ms <= now) { - throw new Error('expiry_ms must be in the future'); - } - if (expiry_ms > now + MAX_EXPIRY_WINDOW_MS) { - throw new Error('expiry_ms must be within 7 days from now'); - } - - if (deposit_timeout_sec !== undefined) { - if (typeof deposit_timeout_sec !== 'number' || !Number.isFinite(deposit_timeout_sec) || deposit_timeout_sec <= 0) { - throw new Error('deposit_timeout_sec must be > 0'); - } - } -} - -export function validateDealTerms(terms: DealTerms): void { - if (terms.offer_volume <= 0n) { - throw new Error('offer_volume must be > 0'); - } - if (terms.request_volume <= 0n) { - throw new Error('request_volume must be > 0'); - } - if (terms.rate <= 0n) { - throw new Error('rate must be > 0'); - } - if (terms.proposer_pubkey === terms.acceptor_pubkey) { - throw new Error('proposer and acceptor pubkeys must differ'); - } - if (typeof terms.escrow_address !== 'string' || terms.escrow_address.length === 0) { - throw new Error('escrow_address must be a non-empty string'); - } - if (terms.deposit_timeout_sec <= 0) { - throw new Error('deposit_timeout_sec must be > 0'); - } -} - -// ============================================================================= -// Canonical JSON (deterministic, keys sorted) -// ============================================================================= - -export function canonicalJson(obj: Record): string { - return JSON.stringify(sortKeys(obj)); -} - -function sortKeys(value: unknown): unknown { - if (Array.isArray(value)) { - return value.map(sortKeys); - } - if (value !== null && typeof value === 'object') { - const src = value as Record; - const out: Record = {}; - for (const k of Object.keys(src).sort()) { - out[k] = sortKeys(src[k]); - } - return out; - } - return value; -} - -// ============================================================================= -// Market description encoder/decoder (spec 2.8, 4-line format) -// ============================================================================= - -export interface DecodedDescription { - readonly offer_token: string; - readonly offer_volume_min: bigint; - readonly offer_volume_max: bigint; - readonly rate_min: bigint; - readonly rate_max: bigint; - readonly request_token: string; - readonly expiry_ms: number; - readonly escrows: readonly string[]; - readonly salt: string; - readonly deposit_timeout_sec: number; -} - -export function encodeDescription(intent: TradingIntent, escrows: readonly string[]): string { - const line1 = `TRADE offer=${intent.offer_token} vol=${intent.offer_volume_min}-${intent.offer_volume_max} rate=${intent.rate_min}-${intent.rate_max}`; - const line2 = `req=${intent.request_token} expires=${new Date(intent.expiry_ms).toISOString()}`; - const line3 = `escrows=${escrows.join(',')}`; - const line4 = `salt=${intent.salt} timeout=${intent.deposit_timeout_sec}`; - return `${line1}\n${line2}\n${line3}\n${line4}`; -} - -export function decodeDescription(description: string): DecodedDescription | null { - if (typeof description !== 'string') return null; - const lines = description.split('\n'); - if (lines.length < 4) return null; - - const line1 = lines[0] ?? ''; - const line2 = lines[1] ?? ''; - const line3 = lines[2] ?? ''; - const line4 = lines[3] ?? ''; - - // Line 1: "TRADE offer= vol=- rate=-" - const m1 = /^TRADE offer=(\S+) vol=(\d+)-(\d+) rate=(\d+)-(\d+)$/.exec(line1); - if (!m1) return null; - const offer_token = m1[1]!; - const offer_volume_min = safeBigInt(m1[2]!); - const offer_volume_max = safeBigInt(m1[3]!); - const rate_min = safeBigInt(m1[4]!); - const rate_max = safeBigInt(m1[5]!); - if (offer_volume_min === null || offer_volume_max === null || rate_min === null || rate_max === null) return null; - - // Line 2: "req= expires=" - const m2 = /^req=(\S+) expires=(\S+)$/.exec(line2); - if (!m2) return null; - const request_token = m2[1]!; - const expiryDate = new Date(m2[2]!); - const expiry_ms = expiryDate.getTime(); - if (!Number.isFinite(expiry_ms)) return null; - - // Line 3: "escrows=" - const m3 = /^escrows=(.*)$/.exec(line3); - if (!m3) return null; - const rawEscrows = m3[1] ?? ''; - const escrows = rawEscrows.length === 0 ? [] : rawEscrows.split(','); - - // Line 4: "salt= timeout=" - const m4 = /^salt=(\S+) timeout=(\d+)$/.exec(line4); - if (!m4) return null; - const salt = m4[1]!; - const deposit_timeout_sec = Number(m4[2]); - if (!Number.isFinite(deposit_timeout_sec)) return null; - - return { - offer_token, - offer_volume_min, - offer_volume_max, - rate_min, - rate_max, - request_token, - expiry_ms, - escrows, - salt, - deposit_timeout_sec, - }; -} - -function safeBigInt(s: string): bigint | null { - try { - return BigInt(s); - } catch { - return null; - } -} - -// ============================================================================= -// Dangerous-key detection for NP-0 messages -// ============================================================================= - -export function hasDangerousKeys(value: unknown, depth: number = 0): boolean { - if (depth > 20) return true; - if (typeof value !== 'object' || value === null) return false; - if (Array.isArray(value)) { - for (const item of value) { - if (hasDangerousKeys(item, depth + 1)) return true; - } - return false; - } - for (const key of Object.keys(value as object)) { - if (DANGEROUS_KEYS.has(key)) return true; - if (hasDangerousKeys((value as Record)[key], depth + 1)) return true; - } - return false; -} diff --git a/src/trader/volume-reservation-ledger.ts b/src/trader/volume-reservation-ledger.ts deleted file mode 100644 index 593aac4..0000000 --- a/src/trader/volume-reservation-ledger.ts +++ /dev/null @@ -1,108 +0,0 @@ -/** - * VolumeReservationLedger — tracks reserved volume across in-flight deals so - * the trader never over-commits its confirmed balance. - * - * Reservations are keyed by deal_id. When a deal enters PROPOSED state, volume - * is reserved; when it completes, fails, or is cancelled, volume is released. - * - * The available volume for a new reservation is computed as: - * - * available = payments.getConfirmedAmount(token) - totalReserved - * - * Because multiple concurrent matching operations could race on this check, - * reserves are serialised behind a promise-chain mutex. The available amount - * is re-computed *inside* the lock after awaiting any previous reserve. - * - * `release()` and `reconstruct()` are synchronous: no network I/O, no mutex. - * They only mutate the in-memory map. - * - * This module intentionally has no Sphere SDK imports — it depends only on - * the narrow {@link PaymentsAdapter} interface for dependency injection. - */ - -import type { PaymentsAdapter } from './types.js'; - -export class VolumeReservationLedger { - /** deal_id -> reserved volume (bigint, never number) */ - private readonly reservations = new Map(); - - /** - * Promise-chain mutex. Each reserve() awaits the previous link and installs - * its own release function as the next link, serialising all reserves. - */ - private lock: Promise = Promise.resolve(); - - constructor( - private readonly payments: PaymentsAdapter, - private readonly token: string, - ) {} - - /** - * Attempt to reserve `volume` for `dealId`. - * - * Re-checks available balance *inside* the lock so concurrent reserves can't - * both observe the same pre-reservation balance and collectively over-commit. - * - * @throws {Error} if the requested volume exceeds currently available balance. - */ - async reserve(dealId: string, volume: bigint): Promise { - let releaseLock!: () => void; - const nextLock = new Promise((resolve) => { - releaseLock = resolve; - }); - const previousLock = this.lock; - this.lock = nextLock; - await previousLock; - try { - const confirmed = await this.payments.getConfirmedAmount(this.token); - const available = confirmed - this.totalReserved; - if (volume > available) { - throw new Error( - `Insufficient volume: need ${volume}, available ${available}`, - ); - } - this.reservations.set(dealId, volume); - } finally { - releaseLock(); - } - } - - /** - * Release the reservation for a deal. No-op if the deal has no reservation. - * - * Synchronous by design: deal-state transitions (COMPLETED / FAILED / - * CANCELLED) already run serially per deal, and releases never need to - * consult the payments adapter. - */ - release(dealId: string): void { - this.reservations.delete(dealId); - } - - /** - * Current total reserved volume summed across all deals. - * - * Computed on read rather than cached: the map is small (one entry per - * in-flight deal) and cached totals would be a second source of truth to - * keep in sync with the map. - */ - get totalReserved(): bigint { - let total = 0n; - for (const volume of this.reservations.values()) { - total += volume; - } - return total; - } - - /** - * Reconstruct a reservation from an existing deal record during startup - * recovery. Only call with deals in PROPOSED, ACCEPTED, or EXECUTING state — - * terminal states (COMPLETED / FAILED / CANCELLED) must not hold reservations. - * - * Synchronous: must be invoked before any async operation (including the - * first `reserve()` call) so the ledger reflects real outstanding exposure - * before any balance checks are performed. - */ - reconstruct(dealId: string, volume: bigint): void { - this.reservations.set(dealId, volume); - } -} diff --git a/test/mocks/fixtures.ts b/test/mocks/fixtures.ts deleted file mode 100644 index 3c6f289..0000000 --- a/test/mocks/fixtures.ts +++ /dev/null @@ -1,101 +0,0 @@ -/** - * Shared test fixtures and factories for the Trader Agent suite. - * - * Each `make*` helper produces a fully-populated record with sensible - * defaults; tests override only the fields they care about via the - * `overrides` parameter. - */ - -import { DEFAULT_STRATEGY } from '../../src/trader/types.js'; -import type { - DealRecord, - DealTerms, - IntentRecord, - TradingIntent, - TraderStrategy, -} from '../../src/trader/types.js'; - -// ============================================================================= -// Constants -// ============================================================================= - -export const TEST_PUBKEY_A = 'a'.repeat(64); -export const TEST_PUBKEY_B = 'b'.repeat(64); -export const TEST_ESCROW = 'e'.repeat(64); -export const TEST_TOKEN_ALPHA = 'ALPHA'; -export const TEST_TOKEN_BETA = 'BETA'; - -// ============================================================================= -// Factories -// ============================================================================= - -export function makeIntent(overrides: Partial = {}): TradingIntent { - const now = Date.now(); - return { - intent_id: crypto.randomUUID(), - salt: 'deadbeef', - owner_pubkey: TEST_PUBKEY_A, - offer_token: TEST_TOKEN_ALPHA, - offer_volume_min: 100n, - offer_volume_max: 1000n, - request_token: TEST_TOKEN_BETA, - rate_min: 90_000_000n, // 0.9 * 1e8 - rate_max: 110_000_000n, // 1.1 * 1e8 - expiry_ms: now + 3_600_000, - created_at_ms: now, - deposit_timeout_sec: 3600, - ...overrides, - }; -} - -export function makeIntentRecord(overrides: Partial = {}): IntentRecord { - return { - intent: makeIntent(), - state: 'ACTIVE', - market_listing_id: null, - volume_filled: 0n, - updated_at_ms: Date.now(), - ...overrides, - }; -} - -export function makeDealTerms(overrides: Partial = {}): DealTerms { - return { - proposer_intent_id: crypto.randomUUID(), - acceptor_intent_id: crypto.randomUUID(), - proposer_pubkey: TEST_PUBKEY_A, - acceptor_pubkey: TEST_PUBKEY_B, - offer_token: TEST_TOKEN_ALPHA, - request_token: TEST_TOKEN_BETA, - offer_volume: 500n, - request_volume: 500n, - rate: 100_000_000n, // 1.0 * 1e8 - escrow_address: TEST_ESCROW, - deposit_timeout_sec: 3600, - ...overrides, - }; -} - -export function makeDealRecord(overrides: Partial = {}): DealRecord { - const now = Date.now(); - return { - deal_id: crypto.randomUUID(), - terms: makeDealTerms(), - state: 'PROPOSED', - role: 'PROPOSER', - created_at_ms: now, - updated_at_ms: now, - failure_reason: null, - deposit_attempted: false, - payout_verified: false, - ...overrides, - }; -} - -export function makeStrategy(overrides: Partial = {}): TraderStrategy { - return { - ...DEFAULT_STRATEGY, - trusted_escrows: [TEST_ESCROW], - ...overrides, - }; -} diff --git a/test/mocks/mock-communications-module.ts b/test/mocks/mock-communications-module.ts deleted file mode 100644 index 9366060..0000000 --- a/test/mocks/mock-communications-module.ts +++ /dev/null @@ -1,35 +0,0 @@ -/** - * Test mock for {@link CommsAdapter}. - * - * Records all DM handlers registered via `onDirectMessage` so tests can - * inject synthetic incoming messages through `deliverDM`. - */ - -import { vi } from 'vitest'; -import type { CommsAdapter, IncomingDM } from '../../src/trader/types.js'; - -export interface MockComms { - readonly comms: CommsAdapter; - readonly deliverDM: (msg: IncomingDM) => void; -} - -export function buildMockComms(): MockComms { - const dmHandlers: Array<(msg: IncomingDM) => void> = []; - - const comms: CommsAdapter = { - sendDM: vi.fn().mockResolvedValue(undefined), - onDirectMessage: vi.fn((handler: (msg: IncomingDM) => void) => { - dmHandlers.push(handler); - return () => { - const idx = dmHandlers.indexOf(handler); - if (idx !== -1) dmHandlers.splice(idx, 1); - }; - }), - }; - - const deliverDM = (msg: IncomingDM): void => { - for (const handler of dmHandlers) handler(msg); - }; - - return { comms, deliverDM }; -} diff --git a/test/mocks/mock-market-module.ts b/test/mocks/mock-market-module.ts deleted file mode 100644 index 98a8f0f..0000000 --- a/test/mocks/mock-market-module.ts +++ /dev/null @@ -1,40 +0,0 @@ -/** - * Test mock for {@link MarketAdapter}. - * - * Exposes all adapter methods as vitest `vi.fn()` spies so assertions can - * check call counts / arguments. Also records every feed subscriber so tests - * can synthesize market feed events via the returned `deliverListing` helper. - */ - -import { vi } from 'vitest'; -import type { MarketAdapter, MarketListing } from '../../src/trader/types.js'; - -export interface MockMarket { - readonly market: MarketAdapter; - readonly deliverListing: (listing: MarketListing) => void; -} - -export function buildMockMarket(): MockMarket { - const feedListeners: Array<(listing: MarketListing) => void> = []; - - const market: MarketAdapter = { - post: vi.fn().mockImplementation(async () => `listing-${crypto.randomUUID()}`), - remove: vi.fn().mockResolvedValue(undefined), - search: vi.fn().mockResolvedValue([] as MarketListing[]), - subscribeFeed: vi.fn((listener: (listing: MarketListing) => void) => { - feedListeners.push(listener); - return () => { - const idx = feedListeners.indexOf(listener); - if (idx !== -1) feedListeners.splice(idx, 1); - }; - }), - getRecentListings: vi.fn().mockResolvedValue([] as MarketListing[]), - }; - - // Helper to push a listing into all active feed subscribers. - const deliverListing = (listing: MarketListing): void => { - for (const listener of feedListeners) listener(listing); - }; - - return { market, deliverListing }; -} diff --git a/test/mocks/mock-payments-module.ts b/test/mocks/mock-payments-module.ts deleted file mode 100644 index d91c1b0..0000000 --- a/test/mocks/mock-payments-module.ts +++ /dev/null @@ -1,37 +0,0 @@ -/** - * Test mock for {@link PaymentsAdapter}. - * - * Holds a single confirmed-balance scalar that `getConfirmedAmount()` returns - * for every token. Tests can override the balance with `setBalance()` to - * simulate incoming deposits. - */ - -import { vi } from 'vitest'; -import type { ActiveIntent, PaymentsAdapter } from '../../src/trader/types.js'; - -export interface MockPayments { - readonly payments: PaymentsAdapter; - readonly setBalance: (balance: bigint) => void; -} - -export function buildMockPayments(initialBalance: bigint = 1_000_000n): MockPayments { - let confirmedBalance = initialBalance; - - const payments: PaymentsAdapter = { - receive: vi.fn().mockResolvedValue({ - address: `DIRECT://${'a'.repeat(64)}`, - pubkey: 'a'.repeat(64), - }), - getMyIntents: vi.fn().mockResolvedValue([] as ActiveIntent[]), - payInvoice: vi.fn().mockResolvedValue(undefined), - getConfirmedAmount: vi - .fn() - .mockImplementation(async (_token: string): Promise => confirmedBalance), - }; - - const setBalance = (balance: bigint): void => { - confirmedBalance = balance; - }; - - return { payments, setBalance }; -} diff --git a/test/mocks/mock-swap-module.ts b/test/mocks/mock-swap-module.ts deleted file mode 100644 index 0704d85..0000000 --- a/test/mocks/mock-swap-module.ts +++ /dev/null @@ -1,59 +0,0 @@ -/** - * Test mock for {@link SwapAdapter}. - * - * Tracks proposed swaps in an internal map so `getStatus()` reflects the - * expected lifecycle transitions. Tests can override state with the returned - * `setStatus` helper. - */ - -import { vi } from 'vitest'; -import type { - SwapAdapter, - SwapProposalParams, - SwapProposalResult, - SwapStatus, -} from '../../src/trader/types.js'; - -export interface MockSwap { - readonly swap: SwapAdapter; - readonly setStatus: (swapId: string, status: Partial) => void; - readonly swapStatuses: Map; -} - -export function buildMockSwap(): MockSwap { - const swapStatuses = new Map(); - let swapCounter = 0; - - const swap: SwapAdapter = { - propose: vi - .fn() - .mockImplementation(async (_params: SwapProposalParams): Promise => { - const swapId = `swap-${++swapCounter}`; - swapStatuses.set(swapId, { swapId, state: 'PROPOSED' }); - return { swapId }; - }), - accept: vi.fn().mockImplementation(async (swapId: string): Promise => { - const current = swapStatuses.get(swapId); - if (current) { - swapStatuses.set(swapId, { ...current, state: 'ACCEPTED' }); - } - }), - getStatus: vi.fn().mockImplementation(async (swapId: string): Promise => { - return swapStatuses.get(swapId) ?? { swapId, state: 'UNKNOWN' }; - }), - load: vi.fn().mockResolvedValue(undefined), - on: vi.fn().mockReturnValue(() => {}), - }; - - const setStatus = (swapId: string, status: Partial): void => { - const existing = swapStatuses.get(swapId); - swapStatuses.set(swapId, { - swapId, - state: 'PROPOSED', - ...existing, - ...status, - }); - }; - - return { swap, setStatus, swapStatuses }; -} From e00d29b9798aeffdec7744ccd00fdbadd72f9ca1 Mon Sep 17 00:00:00 2001 From: Vladimir Rogojin Date: Thu, 23 Apr 2026 22:22:23 +0200 Subject: [PATCH 10/16] ci: clone sphere-sdk sibling so file:../../sphere-sdk dependency resolves MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR #1 CI failed with: Cannot find module '@unicitylabs/sphere-sdk' or its corresponding type declarations. src/legacy/legacy-cli.ts#12 Root cause: package.json references sphere-sdk via `file:../../sphere-sdk`, which works locally but doesn't exist on the CI runner. Switching to the npm-published @unicitylabs/sphere-sdk@0.7.0 fails a different way — that version lacks several invoice-related type exports (CreateInvoiceRequest, PayInvoiceParams, GetInvoicesOptions, ReturnPaymentParams, and ~58 other errors) that were added AFTER the 0.7.0 tag in sphere-sdk commit bc07e89 (the CLI-extraction commit that promoted previously-internal types to the public surface so the extracted CLI could consume them). Fix: before `npm ci`, clone sphere-sdk to ../../sphere-sdk (public repo, anonymous clone works on GitHub-hosted runners) and build it so the file: dependency has a populated dist/. No sphere-cli source changes required. Long-term: when sphere-sdk publishes v0.7.1 to npm including the post-extraction exports, swap the package.json dependency to the published version and remove this CI workaround. The comment block at the top of the workflow documents this follow-up. --- .github/workflows/ci.yml | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ceb6c76..92f9e46 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -6,6 +6,16 @@ on: pull_request: branches: [main] +# sphere-cli consumes @unicitylabs/sphere-sdk via `file:../../sphere-sdk` +# because the CLI-extraction commit in sphere-sdk (which promoted types +# like CreateInvoiceRequest, PayInvoiceParams to the public exports) +# landed AFTER the sphere-sdk v0.7.0 npm release. Until a new sphere-sdk +# version is published, CI clones the sibling repo into the expected +# `../../sphere-sdk` location and builds it in-place so the `file:` +# dependency resolves correctly. +# +# Swap to an npm-published version once sphere-sdk v0.7.1+ ships. + jobs: lint-typecheck-test: name: lint + typecheck + test @@ -15,16 +25,33 @@ jobs: node-version: [20.x, 22.x] steps: - uses: actions/checkout@v4 + # Default path: $GITHUB_WORKSPACE/ + # = /home/runner/work/sphere-cli/sphere-cli/ + # package.json's `file:../../sphere-sdk` resolves from there to + # /home/runner/work/sphere-sdk/ — that's where we clone below. + - name: Use Node.js ${{ matrix.node-version }} uses: actions/setup-node@v4 with: node-version: ${{ matrix.node-version }} cache: npm + + - name: Clone sphere-sdk sibling + run: | + git clone --depth 1 https://github.com/unicity-sphere/sphere-sdk.git ../../sphere-sdk + + - name: "Build sphere-sdk (required for file: dependency to resolve types)" + run: | + cd ../../sphere-sdk + npm ci + npm run build + - run: npm ci - run: npm run lint - run: npm run typecheck - run: npm run test - run: npm run build + - name: Verify bin shim is executable run: | chmod +x bin/sphere.mjs From 74e715003c9d5e8254ce7c97f2d589c7a721ccaf Mon Sep 17 00:00:00 2001 From: Vladimir Rogojin Date: Thu, 23 Apr 2026 22:27:35 +0200 Subject: [PATCH 11/16] =?UTF-8?q?ci:=20pin=20sphere-sdk=20clone=20to=20CLI?= =?UTF-8?q?-extraction=20branch=20=E2=80=94=20main=20lacks=20cli-support?= =?UTF-8?q?=20exports?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/ci.yml | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 92f9e46..fb5bb9b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -37,8 +37,15 @@ jobs: cache: npm - name: Clone sphere-sdk sibling + # Pin to the CLI-extraction branch (refactor/extract-cli-to-sphere-cli). + # This branch contains the commit `bc07e89 feat(cli-extraction)` which + # promoted CLI-consumed types (CreateInvoiceRequest, PayInvoiceParams, + # encrypt/decrypt helpers, etc.) to the public module surface. Those + # exports have not yet landed on `main` or in any published npm + # version. Switch to `main` once the branch merges upstream. run: | - git clone --depth 1 https://github.com/unicity-sphere/sphere-sdk.git ../../sphere-sdk + git clone --depth 1 --branch refactor/extract-cli-to-sphere-cli \ + https://github.com/unicity-sphere/sphere-sdk.git ../../sphere-sdk - name: "Build sphere-sdk (required for file: dependency to resolve types)" run: | From ed50ff9f8737f9622024edf183a2ef04c2ac071c Mon Sep 17 00:00:00 2001 From: Vladimir Rogojin Date: Fri, 24 Apr 2026 08:13:16 +0200 Subject: [PATCH 12/16] fix(host): ship-blockers flagged in pre-merge review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. MAJOR: `sphere host spawn --env KEY=VAL` variadic bug Commander treats `` as variadic, greedily consuming every subsequent non-flag token — including the positional `` argument. So `sphere host spawn --template tpl-1 --env A=1 B=2 mybot` fails with "missing required argument 'name'" because `B=2` and `mybot` are swallowed as env values. Drop the `...`; the argParser already accumulates across repeated `--env` flags (`--env A=1 --env B=2`). 2. MODERATE: CI pin on moving branch name → pin to commit SHA Cloning `refactor/extract-cli-to-sphere-cli` with --depth 1 is a moving ref. A force-push / rebase silently changes the code CI builds against. Pin to the tip SHA 86468103 with `git checkout --detach`. Bump the env var when a new sphere-sdk commit is required. 3. MODERATE: README said "Phase 1 scaffold" — stale PR lands phase 2 legacy bridge + live `sphere host`. Rewrote the Status section with a "What works today" table and a Quickstart snippet showing both legacy (`sphere wallet init`) and DM-native (`sphere host list/spawn`) flows. --- .github/workflows/ci.yml | 25 +++++++++++++++++-------- README.md | 30 +++++++++++++++++++++++++----- src/host/host-commands.ts | 6 +++++- 3 files changed, 47 insertions(+), 14 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fb5bb9b..abf9ec6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -37,15 +37,24 @@ jobs: cache: npm - name: Clone sphere-sdk sibling - # Pin to the CLI-extraction branch (refactor/extract-cli-to-sphere-cli). - # This branch contains the commit `bc07e89 feat(cli-extraction)` which - # promoted CLI-consumed types (CreateInvoiceRequest, PayInvoiceParams, - # encrypt/decrypt helpers, etc.) to the public module surface. Those - # exports have not yet landed on `main` or in any published npm - # version. Switch to `main` once the branch merges upstream. + # Pin to a specific commit SHA (not a branch name) for supply-chain + # integrity — a branch pointer can be force-pushed or rebased, + # silently changing the code CI builds against. This SHA points at + # the tip of `refactor/extract-cli-to-sphere-cli` at the time of + # this commit; that branch contains `bc07e89 feat(cli-extraction)` + # which promoted CLI-consumed types (CreateInvoiceRequest, + # PayInvoiceParams, encrypt/decrypt helpers, etc.) to the public + # module surface. Those exports have not yet landed on `main` or + # in any published npm version. + # + # Bump this SHA when a new sphere-sdk commit is required; remove + # this whole workaround once sphere-sdk publishes v0.7.1+ to npm + # with the post-extraction exports. + env: + SPHERE_SDK_SHA: 86468103ac25271b96a338f64349dd0eb472689f run: | - git clone --depth 1 --branch refactor/extract-cli-to-sphere-cli \ - https://github.com/unicity-sphere/sphere-sdk.git ../../sphere-sdk + git clone https://github.com/unicity-sphere/sphere-sdk.git ../../sphere-sdk + git -C ../../sphere-sdk checkout --detach "$SPHERE_SDK_SHA" - name: "Build sphere-sdk (required for file: dependency to resolve types)" run: | diff --git a/README.md b/README.md index 1383cf8..3e502f2 100644 --- a/README.md +++ b/README.md @@ -4,13 +4,29 @@ The unified CLI for the Sphere SDK and agentic-hosting control — DM-native. ## Status -**Phase 1 scaffold.** Real commands are not wired yet. `sphere --help` shows -the full command topology from the migration plan so you can see what's coming. +**Phase 2 — legacy bridge + live host commands.** The full wallet / balance / +payments / dm / market / swap / invoice / nametag / crypto / util / faucet / +daemon / config / completions surface is wired via the legacy sphere-sdk +dispatcher. The DM-native `sphere host` namespace (HMCP-0 over Sphere DMs) is +live and production-ready. Phase 4 (`sphere tenant` — ACP over DMs) remains +stubbed and exits with a pointer to the migration schedule. See [`docs/SPHERE-CLI-EXTRACTION-PLAN.md`](https://github.com/unicity-sphere/sphere-sdk/blob/refactor/extract-cli-to-sphere-cli/docs/SPHERE-CLI-EXTRACTION-PLAN.md) for the full migration plan (same doc lives in `agentic-hosting` under the parallel refactor branch). +### What works today + +| Namespace | Status | Notes | +|---|---|---| +| `sphere wallet` | legacy bridge | list, use, create, current, delete, init, status | +| `sphere balance` / `payments` / `dm` / `group` | legacy bridge | | +| `sphere market` / `swap` / `invoice` | legacy bridge | | +| `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 tenant` | Phase 4 (stub) | Exits with scheduled message | + ## Install ```bash @@ -22,10 +38,14 @@ npm install -g @unicity-sphere/cli ```bash sphere --help sphere --version -``` -Phase 1 provides only scaffolding. Invoking any namespace (e.g. `sphere wallet`) -prints a pointer to the migration schedule and exits non-zero. +# Legacy bridge example +sphere wallet init --network testnet + +# DM-native host example +sphere host list --manager @myhostmanager +sphere host spawn --manager @myhostmanager --template tpl-1 mybot +``` ## Development diff --git a/src/host/host-commands.ts b/src/host/host-commands.ts index 658d946..50bafd2 100644 --- a/src/host/host-commands.ts +++ b/src/host/host-commands.ts @@ -548,8 +548,12 @@ export function createHostCommand(): Command { .description('Spawn a new tenant instance from a template') .requiredOption('--template ', 'Template ID to instantiate') .option('--nametag ', 'Nametag to register for the tenant') + // Note: single-arg form (no ellipsis) + argParser accumulator. + // The variadic form `` greedily consumes every subsequent + // non-flag token — including the positional `` that follows. Users + // repeat `--env FOO=1 --env BAR=2` to pass multiple pairs. .addOption( - new Option('--env ', 'Environment variable pairs') + new Option('--env ', 'Environment variable pair (repeat for multiple)') .argParser((value: string, previous: string[] | undefined) => previous ? [...previous, value] : [value]) as Option, ) From 81150abf570412f645773f307600f4114321f3c9 Mon Sep 17 00:00:00 2001 From: Vladimir Rogojin Date: Fri, 24 Apr 2026 08:19:05 +0200 Subject: [PATCH 13/16] =?UTF-8?q?refactor(host):=20address=20code-review?= =?UTF-8?q?=20follow-ups=20=E2=80=94=20architecture=20batch?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - parseGlobalOpts → Command.optsWithGlobals() — deletes the hand-rolled parent-chain walker in favor of commander 12's built-in. Kept as a named wrapper so call sites stay readable. - Extract TRANSPORT_HEADROOM_MS = 10_000 constant. Replaces the inline magic number `+5_000` in handleCmd; doubled to 10s to cover realistic public-relay RTT. Reasoning documented in the constant's JSDoc. - Add per-type payload guards (isHmSpawnAckPayload, isHmListResultPayload, etc.) so a misbehaving manager's malformed payload produces a clear "manager returned malformed payload" error instead of silently printing "undefined" in formatted output. New onProtocolError() helper owns the stderr format + exitCode=1 on guard-failure paths. - Extract runStreamingLifecycle() helper used by handleSpawn/Start/Resume. Reduces ~180 lines of repeated "collect → dispatch per response type" to three compact call sites. A future bug fix in the streaming loop now applies to all three commands at once. - Uniform stderr prefix: writeStderr always prefixes `sphere host: ` (skipping if already present). main() in index.ts reserves the bare `sphere: ` prefix for errors that reach there (parse errors, commander throws). Removes the prior inconsistency where some paths leaked raw messages without a tool prefix. - Reset process.exitCode = 0 at entry to main(). The prior flow carried exitCode across repeated in-process invocations (vitest's default). Production is unaffected (bin/sphere.mjs calls main() once per process), but the reset prevents tests from seeing ambient state from prior runs — and makes future test-harness imports of main() work correctly. - Tighten the redaction regex in main() to require tokens of length 3-8 (matches BIP-39 shape more precisely; less false-positive prone on stack traces with short identifiers). Added defensive-in-depth note on the expectation that this regex should never actually fire. - Show inherited options (--manager/--json/--timeout) in every `sphere host --help` output. Previously discoverable only from `sphere host --help`. Implemented via post-construction addHelpText('after', …) so new subcommands inherit automatically. - eslint config: ignore .claude/** (agent-worktree scratch directories). --- eslint.config.js | 9 +- src/host/host-commands.ts | 352 +++++++++++++++++++++++++------------- src/index.ts | 22 ++- 3 files changed, 257 insertions(+), 126 deletions(-) diff --git a/eslint.config.js b/eslint.config.js index a9be6a5..630aec1 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -5,7 +5,14 @@ import globals from 'globals'; export default tseslint.config( { - ignores: ['dist/**', 'node_modules/**', 'coverage/**', '**/*.d.ts'], + ignores: [ + 'dist/**', + 'node_modules/**', + 'coverage/**', + '**/*.d.ts', + // Agent-worktree scratch directories from Claude Code — not source. + '.claude/**', + ], }, js.configs.recommended, ...tseslint.configs.recommended, diff --git a/src/host/host-commands.ts b/src/host/host-commands.ts index 50bafd2..f755aaf 100644 --- a/src/host/host-commands.ts +++ b/src/host/host-commands.ts @@ -32,6 +32,14 @@ import { initSphere, resolveManagerAddress } from './sphere-init.js'; const DEFAULT_TIMEOUT_MS = 30_000; +/** + * Transport-layer timeout headroom past a caller-supplied per-command + * timeout. The tenant's command handler honours `--cmd-timeout`; we need + * the transport to wait at least that long PLUS round-trip for the reply + * to travel back. 10 s covers relay RTT under typical public-relay load. + */ +const TRANSPORT_HEADROOM_MS = 10_000; + // ============================================================================= // Option types // ============================================================================= @@ -66,17 +74,11 @@ interface CmdOpts extends NameOrIdOpts { // ============================================================================= function parseGlobalOpts(cmd: Command): GlobalOpts { - // Merge options from this command and its parents — commander keeps them local. - const merged: GlobalOpts = {}; - let current: Command | null = cmd; - while (current) { - const opts = current.opts(); - if (opts.manager !== undefined && merged.manager === undefined) merged.manager = opts.manager; - if (opts.json !== undefined && merged.json === undefined) merged.json = opts.json; - if (opts.timeout !== undefined && merged.timeout === undefined) merged.timeout = opts.timeout; - current = current.parent; - } - return merged; + // Commander 12's optsWithGlobals() walks the parent chain and merges + // — replaces an earlier hand-rolled walker. Kept as a named helper so + // call sites still read well and we have a single swap point for + // future option additions. + return cmd.optsWithGlobals(); } function parseTimeout(raw: string | undefined, fallback: number): number { @@ -135,13 +137,88 @@ function errorPayload(res: HmcpResponse): HmErrorPayload { return res.payload as unknown as HmErrorPayload; } +// ============================================================================= +// Per-type payload guards +// ============================================================================= +// +// The HMCP envelope is validated by isValidHmcpResponse (hmcp-types.ts), but +// the payload shape is not — a misbehaving manager could send any fields. The +// guards below do a minimal structural check before the handler reads fields, +// so a protocol drift produces a clear error instead of "undefined" surfacing +// in a printed string. They intentionally check only the fields the handler +// uses, not every documented field. + +function isHmSpawnAckPayload(p: unknown): p is HmSpawnAckPayload { + return isPlainObject(p) + && typeof p['instance_id'] === 'string' + && typeof p['state'] === 'string'; +} +function isHmSpawnReadyPayload(p: unknown): p is HmSpawnReadyPayload { + return isPlainObject(p) + && typeof p['tenant_direct_address'] === 'string'; +} +function isHmSpawnFailedPayload(p: unknown): p is HmSpawnFailedPayload { + return isPlainObject(p) && typeof p['reason'] === 'string'; +} +function isHmStartAckPayload(p: unknown): p is HmStartAckPayload { + return isHmSpawnAckPayload(p) as unknown as boolean; +} +function isHmStartReadyPayload(p: unknown): p is HmStartReadyPayload { + return isHmSpawnReadyPayload(p) as unknown as boolean; +} +function isHmStartFailedPayload(p: unknown): p is HmStartFailedPayload { + return isHmSpawnFailedPayload(p) as unknown as boolean; +} +function isHmStopResultPayload(p: unknown): p is HmStopResultPayload { + return isPlainObject(p) + && typeof p['instance_name'] === 'string' + && typeof p['instance_id'] === 'string'; +} +function isHmInspectResultPayload(p: unknown): p is HmInspectResultPayload { + return isPlainObject(p) && typeof p['instance_id'] === 'string'; +} +function isHmListResultPayload(p: unknown): p is HmListResultPayload { + return isPlainObject(p) && Array.isArray(p['instances']); +} +function isHmHelpResultPayload(p: unknown): p is HmHelpResultPayload { + return isPlainObject(p) + && Array.isArray(p['commands']) + && typeof p['version'] === 'string'; +} +function isHmCommandResultPayload(p: unknown): p is HmCommandResultPayload { + return isPlainObject(p) && p['result'] !== undefined; +} + +function isPlainObject(v: unknown): v is Record { + return typeof v === 'object' && v !== null && !Array.isArray(v); +} + +/** Emit a "manager sent a malformed payload" error. Sets exitCode=1. */ +function onProtocolError(type: string, json: boolean): void { + if (json) { + writeStderr(`Protocol error: received ${type} with malformed payload`); + } else { + writeStderr(`sphere host: manager returned malformed ${type} payload`); + } + process.exitCode = 1; +} + function printJson(value: unknown): void { process.stdout.write(`${JSON.stringify(value, null, 2)}\n`); } +/** + * Write an error line to stderr with a consistent `sphere host: ` prefix. + * Callers pass the bare message; this helper owns the formatting so user- + * facing output is uniform across every failure path (transport error, + * timeout, protocol error, hm.error response, parse failure, etc.). + */ function writeStderr(msg: unknown): void { const s = typeof msg === 'string' ? msg : String(msg ?? 'unknown error'); - process.stderr.write(s.endsWith('\n') ? s : `${s}\n`); + const prefixed = s.startsWith('sphere host:') || s.startsWith('sphere:') + ? s + : `sphere host: ${s}`; + process.stderr.write(prefixed.endsWith('\n') ? prefixed : `${prefixed}\n`); } // ============================================================================= @@ -221,27 +298,39 @@ function handleHmError(res: HmcpResponse, json: boolean): void { } // ============================================================================= -// Subcommand handlers +// Streaming lifecycle helper (spawn / start / resume) // ============================================================================= +// +// spawn, start and resume share the same shape: send request → stream of ack +// + (ready | failed | error) → terminal response. Originally each handler +// rebuilt the collection + iteration loop; this helper gives one correct +// implementation and lets each caller plug in its own human-readable +// formatters for ack/ready/failed. + +interface StreamingLifecycleHandlers { + readonly ackType: string; + readonly readyTypes: readonly string[]; + readonly failedType: string; + readonly formatAck: (res: HmcpResponse) => void; + readonly formatReady: (res: HmcpResponse) => void; + readonly formatFailed: (res: HmcpResponse) => void; +} -async function handleSpawn(cmd: Command, name: string, sOpts: SpawnOpts): Promise { - const env = parseEnvPairs(sOpts.env); +async function runStreamingLifecycle( + cmd: Command, + type: HmcpRequestType, + payload: Record, + h: StreamingLifecycleHandlers, +): Promise { await runWithTransport(cmd, async ({ transport, json }) => { - const payload: Record = { - template_id: sOpts.template, - instance_name: name, - }; - if (sOpts.nametag) payload['nametag'] = sOpts.nametag; - if (env) payload['env'] = env; - - const req = createHmcpRequest('hm.spawn', payload); + const req = createHmcpRequest(type, payload); const collected: HmcpResponse[] = []; await transport.sendRequestStream(req, (res) => { collected.push(res); return ( - res.type === 'hm.spawn_ready' || - res.type === 'hm.spawn_failed' || + h.readyTypes.includes(res.type) || + res.type === h.failedType || res.type === 'hm.error' ); }); @@ -249,23 +338,19 @@ async function handleSpawn(cmd: Command, name: string, sOpts: SpawnOpts): Promis if (json) { printJson(collected); const last = collected[collected.length - 1]; - if (last && (last.type === 'hm.spawn_failed' || last.type === 'hm.error')) { + if (last && (last.type === h.failedType || last.type === 'hm.error')) { process.exitCode = 1; } return; } for (const res of collected) { - if (res.type === 'hm.spawn_ack') { - const p = res.payload as unknown as HmSpawnAckPayload; - writeStderr(`Accepted: ${p.instance_id} (${p.state})`); - } else if (res.type === 'hm.spawn_ready') { - const p = res.payload as unknown as HmSpawnReadyPayload; - const nt = p.tenant_nametag ?? '(no nametag)'; - process.stdout.write(`Container ready: ${nt} (${p.tenant_direct_address})\n`); - } else if (res.type === 'hm.spawn_failed') { - const p = res.payload as unknown as HmSpawnFailedPayload; - writeStderr(`Failed: ${p.reason}`); + if (res.type === h.ackType) { + h.formatAck(res); + } else if (h.readyTypes.includes(res.type)) { + h.formatReady(res); + } else if (res.type === h.failedType) { + h.formatFailed(res); process.exitCode = 1; } else if (isErrorResponse(res)) { handleHmError(res, json); @@ -274,6 +359,39 @@ async function handleSpawn(cmd: Command, name: string, sOpts: SpawnOpts): Promis }); } +// ============================================================================= +// Subcommand handlers +// ============================================================================= + +async function handleSpawn(cmd: Command, name: string, sOpts: SpawnOpts): Promise { + const env = parseEnvPairs(sOpts.env); + const payload: Record = { + template_id: sOpts.template, + instance_name: name, + }; + if (sOpts.nametag) payload['nametag'] = sOpts.nametag; + if (env) payload['env'] = env; + + await runStreamingLifecycle(cmd, 'hm.spawn', payload, { + ackType: 'hm.spawn_ack', + readyTypes: ['hm.spawn_ready'], + failedType: 'hm.spawn_failed', + formatAck: (res) => { + if (!isHmSpawnAckPayload(res.payload)) return onProtocolError(res.type, false); + writeStderr(`Accepted: ${res.payload.instance_id} (${res.payload.state})`); + }, + formatReady: (res) => { + if (!isHmSpawnReadyPayload(res.payload)) return onProtocolError(res.type, false); + const nt = res.payload.tenant_nametag ?? '(no nametag)'; + process.stdout.write(`Container ready: ${nt} (${res.payload.tenant_direct_address})\n`); + }, + formatFailed: (res) => { + if (!isHmSpawnFailedPayload(res.payload)) return onProtocolError(res.type, false); + writeStderr(`Failed: ${res.payload.reason}`); + }, + }); +} + async function handleList(cmd: Command, lOpts: ListOpts): Promise { await runWithTransport(cmd, async ({ transport, timeoutMs, json }) => { const payload: Record = {}; @@ -292,8 +410,11 @@ async function handleList(cmd: Command, lOpts: ListOpts): Promise { return; } - const p = res.payload as unknown as HmListResultPayload; - printInstanceTable(p.instances); + if (!isHmListResultPayload(res.payload)) { + onProtocolError(res.type, json); + return; + } + printInstanceTable(res.payload.instances); }); } @@ -342,56 +463,36 @@ async function handleSimple( async function handleStop(cmd: Command, nameOrId: string, opts: NameOrIdOpts): Promise { await handleSimple(cmd, 'hm.stop', nameOrId, opts, (res) => { - const p = res.payload as unknown as HmStopResultPayload; - process.stdout.write(`Stopped: ${p.instance_name} (${p.instance_id})\n`); + if (!isHmStopResultPayload(res.payload)) return onProtocolError(res.type, false); + process.stdout.write(`Stopped: ${res.payload.instance_name} (${res.payload.instance_id})\n`); }); } async function handleStart(cmd: Command, nameOrId: string, opts: NameOrIdOpts): Promise { - await runWithTransport(cmd, async ({ transport, json }) => { - const req = createHmcpRequest('hm.start', targetPayload(nameOrId, opts)); - - const collected: HmcpResponse[] = []; - await transport.sendRequestStream(req, (res) => { - collected.push(res); - return ( - res.type === 'hm.start_ready' || - res.type === 'hm.start_failed' || - res.type === 'hm.error' - ); - }); - - if (json) { - printJson(collected); - const last = collected[collected.length - 1]; - if (last && (last.type === 'hm.start_failed' || last.type === 'hm.error')) { - process.exitCode = 1; - } - return; - } - - for (const res of collected) { - if (res.type === 'hm.start_ack') { - const p = res.payload as unknown as HmStartAckPayload; - writeStderr(`Accepted: ${p.instance_id} (${p.state})`); - } else if (res.type === 'hm.start_ready') { - const p = res.payload as unknown as HmStartReadyPayload; - const nt = p.tenant_nametag ?? '(no nametag)'; - process.stdout.write(`Container ready: ${nt} (${p.tenant_direct_address})\n`); - } else if (res.type === 'hm.start_failed') { - const p = res.payload as unknown as HmStartFailedPayload; - writeStderr(`Failed: ${p.reason}`); - process.exitCode = 1; - } else if (isErrorResponse(res)) { - handleHmError(res, json); - } - } + await runStreamingLifecycle(cmd, 'hm.start', targetPayload(nameOrId, opts), { + ackType: 'hm.start_ack', + readyTypes: ['hm.start_ready'], + failedType: 'hm.start_failed', + formatAck: (res) => { + if (!isHmStartAckPayload(res.payload)) return onProtocolError(res.type, false); + writeStderr(`Accepted: ${res.payload.instance_id} (${res.payload.state})`); + }, + formatReady: (res) => { + if (!isHmStartReadyPayload(res.payload)) return onProtocolError(res.type, false); + const nt = res.payload.tenant_nametag ?? '(no nametag)'; + process.stdout.write(`Container ready: ${nt} (${res.payload.tenant_direct_address})\n`); + }, + formatFailed: (res) => { + if (!isHmStartFailedPayload(res.payload)) return onProtocolError(res.type, false); + writeStderr(`Failed: ${res.payload.reason}`); + }, }); } async function handleInspect(cmd: Command, nameOrId: string, opts: NameOrIdOpts): Promise { await handleSimple(cmd, 'hm.inspect', nameOrId, opts, (res) => { - const p = res.payload as unknown as HmInspectResultPayload; + if (!isHmInspectResultPayload(res.payload)) return onProtocolError(res.type, false); + const p = res.payload; const rows: Array<[string, string]> = [ ['instance_id', p.instance_id], ['instance_name', p.instance_name], @@ -439,8 +540,10 @@ async function handleCmd( ...(cmdTimeoutMs ? { timeout_ms: cmdTimeoutMs } : {}), }; - // If cmdTimeout is set, give the transport a bit more headroom than the tenant timeout. - const txTimeout = cmdTimeoutMs ? cmdTimeoutMs + 5_000 : timeoutMs; + // If cmdTimeout is set, give the transport TRANSPORT_HEADROOM_MS past the + // tenant's own execution timer so the reply has time to travel back over + // the relay before we give up. + const txTimeout = cmdTimeoutMs ? cmdTimeoutMs + TRANSPORT_HEADROOM_MS : timeoutMs; const req = createHmcpRequest('hm.command', payload as unknown as Record); const res = await transport.sendRequest(req, txTimeout); @@ -454,8 +557,11 @@ async function handleCmd( return; } - const p = res.payload as unknown as HmCommandResultPayload; - printJson(p.result); + if (!isHmCommandResultPayload(res.payload)) { + onProtocolError(res.type, json); + return; + } + printJson(res.payload.result); }); } @@ -474,41 +580,24 @@ async function handlePause(cmd: Command, nameOrId: string, opts: NameOrIdOpts): } async function handleResume(cmd: Command, nameOrId: string, opts: NameOrIdOpts): Promise { - await runWithTransport(cmd, async ({ transport, json }) => { - const req = createHmcpRequest('hm.resume', targetPayload(nameOrId, opts)); - - const collected: HmcpResponse[] = []; - await transport.sendRequestStream(req, (res) => { - collected.push(res); - return ( - res.type === 'hm.resume_ready' || - res.type === 'hm.resume_failed' || - res.type === 'hm.resume_result' || - res.type === 'hm.error' - ); - }); - - if (json) { - printJson(collected); - const last = collected[collected.length - 1]; - if (last && (last.type === 'hm.resume_failed' || last.type === 'hm.error')) { - process.exitCode = 1; - } - return; - } - - for (const res of collected) { - if (res.type === 'hm.resume_ready' || res.type === 'hm.resume_result') { - const p = res.payload as { instance_name?: string; instance_id?: string }; - process.stdout.write(`Ready: ${p.instance_name ?? p.instance_id ?? nameOrId}\n`); - } else if (res.type === 'hm.resume_failed') { - const p = res.payload as unknown as { reason?: string }; - writeStderr(`Failed: ${p.reason ?? 'resume failed'}`); - process.exitCode = 1; - } else if (isErrorResponse(res)) { - handleHmError(res, json); - } - } + // resume has no distinct ack type; the tenant emits ready/result directly. + await runStreamingLifecycle(cmd, 'hm.resume', targetPayload(nameOrId, opts), { + ackType: '__no_ack__', + readyTypes: ['hm.resume_ready', 'hm.resume_result'], + failedType: 'hm.resume_failed', + formatAck: () => { /* no ack for resume */ }, + formatReady: (res) => { + const p = isPlainObject(res.payload) ? res.payload : {}; + const label = (typeof p['instance_name'] === 'string' && p['instance_name']) + || (typeof p['instance_id'] === 'string' && p['instance_id']) + || nameOrId; + process.stdout.write(`Ready: ${label}\n`); + }, + formatFailed: (res) => { + const p = isPlainObject(res.payload) ? res.payload : {}; + const reason = typeof p['reason'] === 'string' ? p['reason'] : 'resume failed'; + writeStderr(`Failed: ${reason}`); + }, }); } @@ -524,10 +613,13 @@ async function handleHelp(cmd: Command): Promise { printJson(res); return; } - const p = res.payload as unknown as HmHelpResultPayload; - process.stdout.write(`HMCP version: ${p.version}\nCommands:\n`); - for (const c of p.commands) { - process.stdout.write(` ${c}\n`); + if (!isHmHelpResultPayload(res.payload)) { + onProtocolError(res.type, json); + return; + } + process.stdout.write(`HMCP version: ${res.payload.version}\nCommands:\n`); + for (const c of res.payload.commands) { + process.stdout.write(` ${String(c)}\n`); } }); } @@ -543,6 +635,15 @@ export function createHostCommand(): Command { .option('--json', 'Output raw JSON response') .option('--timeout ', 'Override default request timeout (ms)', String(DEFAULT_TIMEOUT_MS)); + // Render the shared options in every subcommand's --help so discovery + // doesn't require scrolling up to `sphere host --help`. Commander + // forwards the values automatically; this just makes them visible. + const inheritedHelp = + 'Inherited options:\n' + + ' --manager
Host manager address (@nametag, DIRECT://hex, or hex pubkey)\n' + + ' --json Output raw JSON response\n' + + ' --timeout Override default request timeout (ms)'; + host .command('spawn ') .description('Spawn a new tenant instance from a template') @@ -634,6 +735,13 @@ export function createHostCommand(): Command { await handleHelp(this); }); + // Attach the shared-options help text to every subcommand. Iterating after + // construction keeps the subcommand definitions above small and ensures + // any newly-added subcommand automatically inherits the help surface. + for (const sub of host.commands) { + sub.addHelpText('after', `\n${inheritedHelp}`); + } + return host; } diff --git a/src/index.ts b/src/index.ts index b074309..6de8a2a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -161,10 +161,16 @@ export function createCli(): Command { /** Parse argv and execute. Returns the exit code. */ export async function main(argv: string[] = process.argv): Promise { + // Reset exit code at entry — action handlers set `process.exitCode = 1` + // on error, and that value is process-wide. Without this reset, a + // prior invocation (or test) that left a non-zero exit code would + // cause a subsequent successful main() to return non-zero. Repeated + // in-process invocations are rare in production (bin/sphere.mjs runs + // main() exactly once per process) but are the default in vitest. + process.exitCode = 0; const program = createCli(); try { await program.parseAsync(argv); - // Honour process.exitCode set by action handlers (e.g. runWithTransport errors). return (typeof process.exitCode === 'number' ? process.exitCode : 0); } catch (err) { // commander throws CommanderError on --help/--version/parse errors; those are @@ -176,9 +182,19 @@ export async function main(argv: string[] = process.argv): Promise { } return ce.exitCode ?? 1; } - // Sanitize: never echo raw error messages (may contain mnemonics or keys) + // Error-prefix hygiene: downstream writers (writeStderr in host-commands) + // already prefix `sphere host: ` for their own errors. Parse/unexpected + // errors that reach here fall under `sphere:` for generality. + // + // Defense-in-depth redaction: the CLI should NEVER surface messages + // containing secret material in the first place, so this regex is + // expected to never match in practice. It narrowly targets BIP-39-shape + // lowercase word sequences of length 12-24 and does not match stack + // traces or camelCase/snake_case tokens. If it ever fires, the stderr + // format includes `[REDACTED]` which an operator can grep to identify + // a genuine message-sanitisation failure. const raw = err instanceof Error ? err.message : String(err); - const safe = raw.replace(/\b([a-z]+\s+){11,23}[a-z]+\b/gi, '[REDACTED]'); + const safe = raw.replace(/\b(?:[a-z]{3,8}\s+){11,23}[a-z]{3,8}\b/gi, '[REDACTED]'); process.stderr.write(`sphere: ${safe}\n`); return 1; } From 4c477638b93678cbf3710d3c25950e3780588280 Mon Sep 17 00:00:00 2001 From: Vladimir Rogojin Date: Fri, 24 Apr 2026 08:24:30 +0200 Subject: [PATCH 14/16] =?UTF-8?q?fix(security):=20hardening=20batch=20?= =?UTF-8?q?=E2=80=94=20depth-bounded=20hasDangerousKeys=20+=20send-side=20?= =?UTF-8?q?size=20cap?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses remaining security-audit and code-review follow-ups: - hasDangerousKeys: recursive walk → iterative with explicit stack + MAX_PARAMS_DEPTH=64 cap. Prior implementation could blow the JS interpreter stack on a pathological 10k-deep `--params` payload, producing a confusing RangeError instead of the clear "forbidden keys" message. Too-deep input is now conservatively rejected as if it contained a dangerous key. - safeParse: return `value: null` when dangerous keys are seen. Callers that forget to check `hadDangerousKeys` cannot accidentally use a half-stripped object. parseHmcpResponse still short-circuits on the flag; this is defense-in-depth. - dm-transport: send-side MAX_MESSAGE_SIZE check. Symmetric with the receive-side guard in parseHmcpResponse. Prevents `sphere host cmd --params ''` from handing a 10 MB payload to the relay and getting an opaque TransportError in response. - dm-transport handleIncoming: short-circuit when `disposed` is true. Closes the race between setting `this.disposed = true` and the async `unsubscribeDMs()` returning. - dm-transport handleIncoming: early byte-size guard before any other work — a 10 MB garbage DM never enters the early-message buffer. - dm-transport: log early-message overflow at DEBUG so a chatty manager during the handshake window is diagnosable. - byteLength: exported from hmcp-types so the transport can symmetrize size checks across send and receive paths. Tests: +25 new - host-commands.test.ts: parseEnvPairs, parseJsonParams, parseTimeout, targetPayload, and the --env variadic-bug regression test verifying that `--env FOO=1 --env BAR=2 mybot` captures both env pairs AND the positional name (the exact failure mode from the pre-merge review). - parseJsonParams depth-bound test with 200-level nested input — asserts we reject with "forbidden keys" before stack overflow. - dm-transport.test.ts: send-side MAX_MESSAGE_SIZE rejection case. - inherited-options help test: captures outputHelp() stdout to verify --manager/--json/--timeout are visible in every subcommand's --help (helpInformation() doesn't emit afterHelp events; outputHelp() does). All 46 tests pass; typecheck clean; lint clean on src/host + src/transport. --- src/host/host-commands.test.ts | 207 +++++++++++++++++++++++++++++ src/transport/dm-transport.test.ts | 20 +++ src/transport/dm-transport.ts | 32 ++++- src/transport/hmcp-types.ts | 52 ++++++-- 4 files changed, 298 insertions(+), 13 deletions(-) create mode 100644 src/host/host-commands.test.ts diff --git a/src/host/host-commands.test.ts b/src/host/host-commands.test.ts new file mode 100644 index 0000000..2dee459 --- /dev/null +++ b/src/host/host-commands.test.ts @@ -0,0 +1,207 @@ +/** + * Pure-function tests for `sphere host` helpers — no DM transport, no real + * Sphere. Exercises the exported parsers (parseEnvPairs, parseJsonParams, + * parseTimeout, targetPayload) and the commander wiring around + * createHostCommand. + */ + +import { describe, it, expect } from 'vitest'; +import { Command } from 'commander'; +import { + parseEnvPairs, + parseJsonParams, + parseTimeout, + targetPayload, + createHostCommand, +} from './host-commands.js'; + +describe('parseEnvPairs', () => { + it('returns undefined when no pairs supplied', () => { + expect(parseEnvPairs(undefined)).toBeUndefined(); + expect(parseEnvPairs([])).toBeUndefined(); + }); + + it('parses a single KEY=VALUE', () => { + expect(parseEnvPairs(['FOO=1'])).toEqual({ FOO: '1' }); + }); + + it('accumulates across multiple --env flags', () => { + expect(parseEnvPairs(['FOO=1', 'BAR=2'])).toEqual({ FOO: '1', BAR: '2' }); + }); + + it('preserves `=` inside the value', () => { + expect(parseEnvPairs(['URL=postgres://user:pass=secret@host/db'])) + .toEqual({ URL: 'postgres://user:pass=secret@host/db' }); + }); + + it('trims whitespace in the key but not the value', () => { + expect(parseEnvPairs([' FOO =bar '])) + .toEqual({ FOO: 'bar ' }); + }); + + it('throws on missing `=`', () => { + expect(() => parseEnvPairs(['no-equals'])).toThrow(/expected KEY=VALUE/); + }); + + it('throws on empty key', () => { + expect(() => parseEnvPairs(['=bar'])).toThrow(/expected KEY=VALUE/); + }); +}); + +describe('parseJsonParams', () => { + it('returns undefined for undefined input', () => { + expect(parseJsonParams(undefined)).toBeUndefined(); + }); + + it('parses a flat JSON object', () => { + expect(parseJsonParams('{"a":1,"b":"x"}')).toEqual({ a: 1, b: 'x' }); + }); + + it('throws on invalid JSON', () => { + expect(() => parseJsonParams('{not-json')).toThrow(/Invalid --params JSON/); + }); + + it('throws when value is not a JSON object', () => { + expect(() => parseJsonParams('[1,2,3]')).toThrow(/must be a JSON object/); + expect(() => parseJsonParams('"string"')).toThrow(/must be a JSON object/); + expect(() => parseJsonParams('42')).toThrow(/must be a JSON object/); + expect(() => parseJsonParams('null')).toThrow(/must be a JSON object/); + }); + + it('rejects __proto__ at top level', () => { + expect(() => parseJsonParams('{"__proto__":{"polluted":true}}')) + .toThrow(/forbidden keys/); + }); + + it('rejects nested __proto__', () => { + expect(() => parseJsonParams('{"a":{"b":{"__proto__":{"x":1}}}}')) + .toThrow(/forbidden keys/); + }); + + it('rejects constructor and prototype at any depth', () => { + expect(() => parseJsonParams('{"constructor":{}}')).toThrow(/forbidden keys/); + expect(() => parseJsonParams('{"a":{"prototype":{}}}')).toThrow(/forbidden keys/); + }); + + it('rejects deeply-nested JSON via explicit depth cap (no stack overflow)', () => { + // Build 200-deep nesting — well past MAX_PARAMS_DEPTH (64). The hardened + // hasDangerousKeys uses an explicit stack + depth bound, returning true + // ("too deep to inspect — conservative reject") before blowing the + // interpreter stack. + let deep = '{}'; + for (let i = 0; i < 200; i++) deep = `{"a":${deep}}`; + expect(() => parseJsonParams(deep)).toThrow(/forbidden keys/); + }); +}); + +describe('parseTimeout', () => { + 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/); + }); +}); + +describe('targetPayload', () => { + it('uses instance_name by default', () => { + expect(targetPayload('mybot', {})).toEqual({ instance_name: 'mybot' }); + }); + + it('uses instance_id when --id is set', () => { + expect(targetPayload('550e8400-e29b-41d4-a716-446655440000', { id: true })) + .toEqual({ instance_id: '550e8400-e29b-41d4-a716-446655440000' }); + }); +}); + +describe('createHostCommand --env variadic-bug regression test', () => { + // Ship-blocker from pre-merge review: `--env ` (with ellipsis) + // greedily consumed every subsequent non-flag arg, including the positional + // . After the fix, `--env ` (single-arg) + argParser + // accumulator handles multi-env via repetition. The positional `name` + // must not be swallowed. + + it('`--env FOO=1 --env BAR=2 mybot` captures both env pairs AND name', () => { + const host = createHostCommand(); + host.exitOverride(); // commander should not call process.exit during the test + + let capturedName: string | undefined; + let capturedEnv: string[] | undefined; + + // Replace the spawn action to capture parsed values instead of dialling + // out to the real DM transport. + const spawn = host.commands.find((c) => c.name() === 'spawn')!; + spawn.action(function (this: Command, name: string, opts: { env?: string[] }) { + capturedName = name; + capturedEnv = opts.env; + }); + + host.parse( + ['node', 'host', 'spawn', '--template', 'tpl-1', '--env', 'FOO=1', '--env', 'BAR=2', 'mybot'], + { from: 'node' }, + ); + + expect(capturedName).toBe('mybot'); + expect(capturedEnv).toEqual(['FOO=1', 'BAR=2']); + }); + + it('`--env FOO=1 mybot` captures env + name (single --env form)', () => { + const host = createHostCommand(); + host.exitOverride(); + + let capturedName: string | undefined; + let capturedEnv: string[] | undefined; + + const spawn = host.commands.find((c) => c.name() === 'spawn')!; + spawn.action(function (this: Command, name: string, opts: { env?: string[] }) { + capturedName = name; + capturedEnv = opts.env; + }); + + host.parse( + ['node', 'host', 'spawn', '--template', 'tpl-1', '--env', 'FOO=1', 'mybot'], + { from: 'node' }, + ); + + expect(capturedName).toBe('mybot'); + expect(capturedEnv).toEqual(['FOO=1']); + }); +}); + +describe('createHostCommand inherited-options help', () => { + // Commander's addHelpText('after', ...) attaches via the `afterHelp` event, + // which fires during outputHelp() (stdout) — not via helpInformation() which + // only returns the core Usage/Options block. Capture stdout to verify. + it('each subcommand --help renders the shared --manager/--json/--timeout options', () => { + const host = createHostCommand(); + for (const sub of host.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 --manager`) + .toContain('--manager'); + expect(captured).toContain('--json'); + expect(captured).toContain('--timeout'); + } + }); +}); diff --git a/src/transport/dm-transport.test.ts b/src/transport/dm-transport.test.ts index 487cb10..ccbdbb2 100644 --- a/src/transport/dm-transport.test.ts +++ b/src/transport/dm-transport.test.ts @@ -329,4 +329,24 @@ describe('DmTransport', () => { await expect(transport.sendRequest(request)).rejects.toThrow(TransportError); await expect(transport.sendRequestStream(request, () => true)).rejects.toThrow(TransportError); }); + + // -------------------------------------------------------------------------- + // Send-side MAX_MESSAGE_SIZE cap + // -------------------------------------------------------------------------- + + it('rejects send with TransportError when serialized request exceeds MAX_MESSAGE_SIZE', async () => { + const { comms } = buildMockComms(); + const transport = createDmTransport(comms, { managerAddress: '@manager', timeoutMs: 5_000 }); + + // Construct a request whose serialized form exceeds 64 KiB. + // MAX_MESSAGE_SIZE = 65_536 bytes; envelope overhead is ~100 bytes, so a + // 70k-char string puts us solidly over. + const huge = 'x'.repeat(70_000); + const request = createHmcpRequest('hm.command', { command: 'big', payload: huge }); + + await expect(transport.sendRequest(request)).rejects.toThrow(/exceeds MAX_MESSAGE_SIZE/); + expect((comms.sendDM as ReturnType).mock.calls.length).toBe(0); + + await transport.dispose(); + }); }); diff --git a/src/transport/dm-transport.ts b/src/transport/dm-transport.ts index 88d54ba..79bc99f 100644 --- a/src/transport/dm-transport.ts +++ b/src/transport/dm-transport.ts @@ -15,7 +15,7 @@ import type { DirectMessage } from '@unicitylabs/sphere-sdk'; import type { HmcpRequest, HmcpResponse } from './hmcp-types.js'; -import { parseHmcpResponse } from './hmcp-types.js'; +import { parseHmcpResponse, byteLength, MAX_MESSAGE_SIZE } from './hmcp-types.js'; import { TimeoutError, TransportError } from './errors.js'; export type { HmcpRequest, HmcpResponse } from './hmcp-types.js'; @@ -140,11 +140,28 @@ class DmTransportImpl implements DmTransport { // --------------------------------------------------------------------------- private handleIncoming(msg: DirectMessage): void { + // Race guard: a DM can arrive between `this.disposed = true` and + // `this.unsubscribeDMs()` returning in dispose(). Short-circuit so a + // late-arriving message cannot touch a disposed transport. + if (this.disposed) return; + + // Early size cap — a malformed relay delivering a 10 MB DM never enters + // the early-message buffer. parseHmcpResponse also enforces this on the + // post-pubkey-resolution path, but catching it here saves memory when + // the buffer is in use. + if (byteLength(msg.content) > MAX_MESSAGE_SIZE) return; + if (!this.resolvedPubkey) { // Buffer early messages so a fast manager reply isn't lost while sendDM // is still in-flight. Capped to avoid unbounded memory on DM floods. if (this.earlyMessages.length < DmTransportImpl.EARLY_MESSAGE_CAP) { this.earlyMessages.push(msg); + } else if (process.env['DEBUG']) { + // Cap hit: one DEBUG line per overflow so a legitimate chatty manager + // during the handshake window is diagnosable. Silent drop otherwise. + process.stderr.write( + `dm-transport: early-message buffer full (cap=${DmTransportImpl.EARLY_MESSAGE_CAP}), dropping DM\n`, + ); } return; } @@ -258,7 +275,18 @@ class DmTransportImpl implements DmTransport { // --------------------------------------------------------------------------- private async send(request: HmcpRequest): Promise { - const sent = await this.comms.sendDM(this.managerAddress, JSON.stringify(request)); + // Send-side size cap — symmetric with the receive-side MAX_MESSAGE_SIZE + // check in parseHmcpResponse. Prevents a local caller (e.g. `sphere host + // cmd --params ''`) from handing a 10 MB payload to the relay + // and getting an opaque TransportError in response. Fail fast with a + // clear size-limit message before hitting the transport. + const serialized = JSON.stringify(request); + if (byteLength(serialized) > MAX_MESSAGE_SIZE) { + throw new TransportError( + `Request too large: ${byteLength(serialized)} bytes exceeds MAX_MESSAGE_SIZE (${MAX_MESSAGE_SIZE})`, + ); + } + const sent = await this.comms.sendDM(this.managerAddress, serialized); // Cache the resolved pubkey on first send. Subsequent sends for the same // manager address produce the same pubkey, so concurrent writes are safe. if (!this.resolvedPubkey) { diff --git a/src/transport/hmcp-types.ts b/src/transport/hmcp-types.ts index eaf37a4..d874e9f 100644 --- a/src/transport/hmcp-types.ts +++ b/src/transport/hmcp-types.ts @@ -197,25 +197,55 @@ export function createHmcpRequest(type: HmcpRequestType, payload: Record { if (DANGEROUS_KEYS.has(key)) { hadDangerousKeys = true; return undefined; } return val; }); - return { value, hadDangerousKeys }; + // Reject-not-sanitize: if any dangerous key was seen, null the output so a + // caller that forgets to check `hadDangerousKeys` cannot accidentally use + // a partially-scrubbed object. parseHmcpResponse also short-circuits on + // the flag, but defense-in-depth costs nothing. + return hadDangerousKeys ? { value: null, hadDangerousKeys } : { value, hadDangerousKeys }; } -// Structural check used by host-commands.ts to validate --params arguments. +/** + * Structural check used by host-commands.ts to validate --params arguments. + * + * Iterative, bounded walk — originally recursive which was vulnerable to a + * stack-overflow self-DoS on pathological 10k-deep JSON from `--params`. The + * CLI parses --params with regular JSON.parse (not our safeParse reviver) so + * this guard IS the primary defense for the local command-line path. + */ +const MAX_PARAMS_DEPTH = 64; + export function hasDangerousKeys(value: unknown): boolean { - if (typeof value !== 'object' || value === null) return false; - for (const key of Object.keys(value as object)) { - if (DANGEROUS_KEYS.has(key)) return true; - if (hasDangerousKeys((value as Record)[key])) return true; + // Explicit stack instead of recursion: `{ value, depth }` frames. The depth + // cap guards against deeply-nested attacker input; 64 is comfortably deeper + // than any realistic hand-written --params payload while bounded enough to + // prevent a stack-allocation DoS via the interpreter itself. + const stack: Array<{ v: unknown; d: number }> = [{ v: value, d: 0 }]; + while (stack.length > 0) { + const { v, d } = stack.pop()!; + if (d > MAX_PARAMS_DEPTH) return true; // too deep to inspect — conservative reject + if (typeof v !== 'object' || v === null) continue; + if (Array.isArray(v)) { + for (const item of v) stack.push({ v: item, d: d + 1 }); + continue; + } + const obj = v as Record; + for (const key of Object.keys(obj)) { + if (DANGEROUS_KEYS.has(key)) return true; + stack.push({ v: obj[key], d: d + 1 }); + } } return false; } @@ -223,7 +253,7 @@ export function hasDangerousKeys(value: unknown): boolean { // 64 KiB measured in UTF-8 bytes, not JS string code units (which are UTF-16). export const MAX_MESSAGE_SIZE = 64 * 1024; -function byteLength(s: string): number { +export function byteLength(s: string): number { // Buffer.byteLength is available in Node; fall back to approximate for other runtimes. if (typeof Buffer !== 'undefined') return Buffer.byteLength(s, 'utf8'); return new TextEncoder().encode(s).length; From 1dfe4b44d9b1396d9de884966c8bbce950cafff4 Mon Sep 17 00:00:00 2001 From: Vladimir Rogojin Date: Fri, 24 Apr 2026 08:27:51 +0200 Subject: [PATCH 15/16] docs: explain dynamic legacy import + CWD-relative config MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two comments addressing low-priority notes from the pre-merge review: * src/index.ts: explain that `await import('./legacy/legacy-cli.js')` is intentional — keeps the ~40-file legacy dispatcher out of the hot start path for phase-4 DM-native commands (`sphere host …`) that don't need it. * src/host/sphere-init.ts: document that all config paths are CWD-relative by design to share a wallet with `sphere wallet …` invocations. --- src/host/sphere-init.ts | 5 +++++ src/index.ts | 3 +++ 2 files changed, 8 insertions(+) diff --git a/src/host/sphere-init.ts b/src/host/sphere-init.ts index 554c5aa..cafb962 100644 --- a/src/host/sphere-init.ts +++ b/src/host/sphere-init.ts @@ -12,6 +12,11 @@ import { Sphere } from '@unicitylabs/sphere-sdk'; import { createNodeProviders } from '@unicitylabs/sphere-sdk/impl/nodejs'; import type { NetworkType } from '@unicitylabs/sphere-sdk'; +// All paths are CWD-relative by design — matches legacy-cli behaviour so the +// same wallet is visible whether invoked via `sphere wallet …` (legacy) or +// `sphere host …` (this namespace). Callers that need a fixed wallet location +// should chdir before invocation or set `dataDir` in config.json to an absolute +// path. const CONFIG_FILE = './.sphere-cli/config.json'; const DEFAULT_DATA_DIR = './.sphere-cli'; const DEFAULT_TOKENS_DIR = './.sphere-cli/tokens'; diff --git a/src/index.ts b/src/index.ts index 6de8a2a..36b88d7 100644 --- a/src/index.ts +++ b/src/index.ts @@ -132,6 +132,9 @@ export function createCli(): Command { sub.allowUnknownOption(true); sub.action(async () => { const legacyArgv = 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. const { legacyMain } = await import('./legacy/legacy-cli.js'); await legacyMain(legacyArgv); }); From c65200416d447d10c47ed2e3702bef85fc17a3a2 Mon Sep 17 00:00:00 2001 From: Vladimir Rogojin Date: Fri, 24 Apr 2026 08:30:06 +0200 Subject: [PATCH 16/16] fix(ci): use import type for Command in host-commands.test eslint's consistent-type-imports flagged the value import since Command is only used as a type annotation in 'this: Command'. CI fails on error. --- src/host/host-commands.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/host/host-commands.test.ts b/src/host/host-commands.test.ts index 2dee459..a9e05b5 100644 --- a/src/host/host-commands.test.ts +++ b/src/host/host-commands.test.ts @@ -6,7 +6,7 @@ */ import { describe, it, expect } from 'vitest'; -import { Command } from 'commander'; +import type { Command } from 'commander'; import { parseEnvPairs, parseJsonParams,