diff --git a/package-lock.json b/package-lock.json index dd8b93b..13cf320 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,11 +9,12 @@ "version": "0.1.0", "dependencies": { "@cashu/cashu-ts": "^3.1.1", - "@routstr/sdk": "^0.1.4", + "@routstr/sdk": "^0.2.0", "applesauce-core": "^5.1.0", "applesauce-relay": "^5.1.0", "better-sqlite3": "^11.7.2", "commander": "^14.0.2", + "qrcode": "^1.5.4", "rxjs": "^7.8.1", "zustand": "^5.0.5" }, @@ -21,7 +22,8 @@ "routstrd": "src/index.ts" }, "devDependencies": { - "@types/bun": "latest" + "@types/bun": "latest", + "@types/qrcode": "^1.5.6" }, "peerDependencies": { "typescript": "^5" @@ -71,9 +73,9 @@ } }, "node_modules/@routstr/sdk": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/@routstr/sdk/-/sdk-0.1.4.tgz", - "integrity": "sha512-4NQV7IcB1UkiO2HQa0as5OBu0v3pZ7TO265oTjMNYPtYMdYOSXjF+uPBcHCRJLfVhnTN4OOCmUQnnhYM0gR6Zw==", + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/@routstr/sdk/-/sdk-0.2.1.tgz", + "integrity": "sha512-sRgLQ07WX5W2NC9GRO14P8aj2rmOgvNr4/8Apazy659whxsnrmRIUNp9caaQb+9t+lyJ9NEN8RFqfqw0SntXgA==", "license": "MIT", "dependencies": { "@cashu/cashu-ts": "^3.1.1", @@ -157,6 +159,40 @@ "undici-types": "~7.18.0" } }, + "node_modules/@types/qrcode": { + "version": "1.5.6", + "resolved": "https://registry.npmjs.org/@types/qrcode/-/qrcode-1.5.6.tgz", + "integrity": "sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, "node_modules/applesauce-core": { "version": "5.1.0", "license": "MIT", @@ -271,10 +307,48 @@ "@types/node": "*" } }, + "node_modules/camelcase": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", + "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/chownr": { "version": "1.1.4", "license": "ISC" }, + "node_modules/cliui": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz", + "integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==", + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.0", + "wrap-ansi": "^6.2.0" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "license": "MIT" + }, "node_modules/commander": { "version": "14.0.3", "license": "MIT", @@ -297,6 +371,15 @@ } } }, + "node_modules/decamelize": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz", + "integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/decompress-response": { "version": "6.0.0", "license": "MIT", @@ -324,6 +407,18 @@ "node": ">=8" } }, + "node_modules/dijkstrajs": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz", + "integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==", + "license": "MIT" + }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, "node_modules/end-of-stream": { "version": "1.4.5", "license": "MIT", @@ -346,10 +441,32 @@ "version": "1.0.0", "license": "MIT" }, + "node_modules/find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "license": "MIT", + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/fs-constants": { "version": "1.0.0", "license": "MIT" }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, "node_modules/github-from-package": { "version": "0.0.0", "license": "MIT" @@ -384,6 +501,27 @@ "version": "1.3.8", "license": "ISC" }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "license": "MIT", + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/mimic-response": { "version": "3.1.0", "license": "MIT", @@ -553,6 +691,60 @@ "wrappy": "1" } }, + "node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "license": "MIT", + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "license": "MIT", + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/p-try": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", + "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/pngjs": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz", + "integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==", + "license": "MIT", + "engines": { + "node": ">=10.13.0" + } + }, "node_modules/prebuild-install": { "version": "7.1.3", "license": "MIT", @@ -585,6 +777,23 @@ "once": "^1.3.1" } }, + "node_modules/qrcode": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz", + "integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==", + "license": "MIT", + "dependencies": { + "dijkstrajs": "^1.0.1", + "pngjs": "^5.0.0", + "yargs": "^15.3.1" + }, + "bin": { + "qrcode": "bin/qrcode" + }, + "engines": { + "node": ">=10.13.0" + } + }, "node_modules/rc": { "version": "1.2.8", "license": "(BSD-2-Clause OR MIT OR Apache-2.0)", @@ -610,6 +819,21 @@ "node": ">= 6" } }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/require-main-filename": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz", + "integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==", + "license": "ISC" + }, "node_modules/rxjs": { "version": "7.8.2", "license": "Apache-2.0", @@ -645,6 +869,12 @@ "node": ">=10" } }, + "node_modules/set-blocking": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", + "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==", + "license": "ISC" + }, "node_modules/simple-concat": { "version": "1.0.1", "funding": [ @@ -693,6 +923,32 @@ "safe-buffer": "~5.2.0" } }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/strip-json-comments": { "version": "2.0.1", "license": "MIT", @@ -759,10 +1015,71 @@ "version": "1.0.2", "license": "MIT" }, + "node_modules/which-module": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz", + "integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==", + "license": "ISC" + }, + "node_modules/wrap-ansi": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", + "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/wrappy": { "version": "1.0.2", "license": "ISC" }, + "node_modules/y18n": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz", + "integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==", + "license": "ISC" + }, + "node_modules/yargs": { + "version": "15.4.1", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz", + "integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==", + "license": "MIT", + "dependencies": { + "cliui": "^6.0.0", + "decamelize": "^1.2.0", + "find-up": "^4.1.0", + "get-caller-file": "^2.0.1", + "require-directory": "^2.1.1", + "require-main-filename": "^2.0.0", + "set-blocking": "^2.0.0", + "string-width": "^4.2.0", + "which-module": "^2.0.0", + "y18n": "^4.0.0", + "yargs-parser": "^18.1.2" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs-parser": { + "version": "18.1.3", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz", + "integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==", + "license": "ISC", + "dependencies": { + "camelcase": "^5.0.0", + "decamelize": "^1.2.0" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/zustand": { "version": "5.0.11", "license": "MIT", diff --git a/package.json b/package.json index a4d6833..3a364d6 100644 --- a/package.json +++ b/package.json @@ -16,7 +16,8 @@ "prepublishOnly": "bun run build" }, "devDependencies": { - "@types/bun": "latest" + "@types/bun": "latest", + "@types/qrcode": "^1.5.6" }, "peerDependencies": { "typescript": "^5" @@ -27,6 +28,7 @@ "applesauce-core": "^5.1.0", "applesauce-relay": "^5.1.0", "commander": "^14.0.2", + "qrcode": "^1.5.4", "rxjs": "^7.8.1", "zustand": "^5.0.5" } diff --git a/src/cli.ts b/src/cli.ts index 7600f27..ded7dab 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -20,6 +20,11 @@ import { logger } from "./utils/logger"; import { setupIntegration } from "./integrations"; import { createSdkStore } from "@routstr/sdk"; import { createBunSqliteDriver } from "@routstr/sdk/storage"; +import * as QRCode from "qrcode"; +import { + isCocodInstalled, + resolveCocodExecutable, +} from "./daemon/wallet/cocod-client"; type RoutstrModel = { id: string; @@ -44,6 +49,25 @@ type UsageEntry = { const cliVersion = "0.1.1"; +function parsePositiveIntOrExit(value: string, fieldName: string): number { + const parsed = Number.parseInt(value, 10); + if (!Number.isFinite(parsed) || parsed <= 0) { + console.error(`Invalid ${fieldName}: ${value}`); + process.exit(1); + } + return parsed; +} + +async function printLightningInvoice(invoice: string): Promise { + const paymentUri = `lightning:${invoice}`; + const qr = await QRCode.toString(paymentUri, { + type: "terminal", + small: true, + }); + + console.log(`${qr}\nInvoice:\n${invoice}`); +} + async function initDaemon(): Promise { logger.log("Initializing routstrd..."); @@ -82,10 +106,39 @@ async function initDaemon(): Promise { logger.log(`Created config file: ${CONFIG_FILE}`); } + const config = await loadConfig(); + const cocodExecutable = resolveCocodExecutable(config.cocodPath); + + if (!(await isCocodInstalled(config.cocodPath))) { + if (config.cocodPath) { + logger.error( + `Configured cocod executable was not found: ${config.cocodPath}`, + ); + return; + } + + logger.log("cocod not found. Installing globally with bun..."); + + const installProc = Bun.spawn(["bun", "install", "--global", "cocod"], { + stdout: "inherit", + stderr: "inherit", + }); + + const installCode = await installProc.exited; + if (installCode !== 0 || !(await isCocodInstalled(config.cocodPath))) { + logger.error( + "Failed to install cocod. Please run 'bun install --global cocod' manually.", + ); + return; + } + + logger.log("cocod installed successfully."); + } + console.log(`Database will be stored at: ${DB_PATH}`); console.log("\nInitializing cocod..."); - const initProc = Bun.spawn(["cocod", "init"], { + const initProc = Bun.spawn([cocodExecutable, "init"], { stdout: "pipe", stderr: "pipe", }); @@ -138,7 +191,6 @@ async function initDaemon(): Promise { logger.log("cocod initialized successfully."); } - const config = await loadConfig(); await startDaemon({ port: String(config.port || 8008) }); // Create SDK store for integrations @@ -149,7 +201,7 @@ async function initDaemon(): Promise { logger.log("\nInitialization complete!"); logger.log( - "\n use 'cocod receive cashu' or 'cocod receive bolt11 2100' to top up your local wallet!", + "\n use 'routstrd wallet receive cashu ' or 'routstrd wallet receive bolt11 2100' to top up your local wallet!", ); } @@ -188,13 +240,14 @@ program .option("--port ", "Port to listen on") .option("-p, --provider ", "Default provider to use") .action(async (options: { port?: string; provider?: string }) => { - if (!(await checkCocodInstalled())) { - logger.error( - "cocod is not installed. Run 'routstrd onboard' first to install cocod.", - ); + const config = await loadConfig(); + if (!(await isCocodInstalled(config.cocodPath))) { + const installHint = config.cocodPath + ? `Configured cocod executable was not found: ${config.cocodPath}` + : "cocod is not installed. Run 'routstrd onboard' first to install cocod."; + logger.error(installHint); process.exit(1); } - const config = await loadConfig(); await startDaemon({ port: options.port || String(config.port || 8008), provider: options.provider, @@ -620,6 +673,143 @@ program await runUsageTui(); }); +const walletCmd = program.command("wallet").description("Wallet operations"); + +walletCmd + .command("status") + .description("Check wallet status") + .action(async () => { + await handleDaemonCommand("/wallet/status"); + }); + +walletCmd + .command("unlock ") + .description("Unlock the wallet") + .action(async (passphrase: string) => { + await handleDaemonCommand("/wallet/unlock", { + method: "POST", + body: { passphrase }, + }); + }); + +walletCmd + .command("balance") + .description("Get wallet balance") + .action(async () => { + await handleDaemonCommand("/wallet/balance"); + }); + +const walletReceiveCmd = walletCmd + .command("receive") + .description("Wallet receive operations"); + +walletReceiveCmd + .command("cashu ") + .description("Receive a Cashu token") + .action(async (token: string) => { + await handleDaemonCommand("/wallet/receive/cashu", { + method: "POST", + body: { token }, + }); + }); + +walletReceiveCmd + .command("bolt11 ") + .description("Create a Lightning invoice") + .option("--mint-url ", "Mint URL to use") + .action(async (amount: string, options: { mintUrl?: string }) => { + try { + await ensureDaemonRunning(); + + const result = await callDaemon("/wallet/receive/bolt11", { + method: "POST", + body: { + amount: parsePositiveIntOrExit(amount, "amount"), + mintUrl: options.mintUrl, + }, + }); + + const output = result.output as + | { invoice?: string; amount?: number; mintUrl?: string } + | undefined; + + if (typeof output?.invoice === "string" && output.invoice) { + await printLightningInvoice(output.invoice); + return; + } + + if (result.output !== undefined) { + console.log(JSON.stringify(result.output, null, 2)); + } + } catch (error) { + console.error((error as Error).message); + process.exit(1); + } + }); + +const walletSendCmd = walletCmd + .command("send") + .description("Wallet send operations"); + +walletSendCmd + .command("cashu ") + .description("Create a Cashu token to send") + .option("--mint-url ", "Mint URL to use") + .action(async (amount: string, options: { mintUrl?: string }) => { + await handleDaemonCommand("/wallet/send/cashu", { + method: "POST", + body: { + amount: parsePositiveIntOrExit(amount, "amount"), + mintUrl: options.mintUrl, + }, + }); + }); + +walletSendCmd + .command("bolt11 ") + .description("Pay a Lightning invoice") + .option("--mint-url ", "Mint URL to use") + .action(async (invoice: string, options: { mintUrl?: string }) => { + await handleDaemonCommand("/wallet/send/bolt11", { + method: "POST", + body: { + invoice, + mintUrl: options.mintUrl, + }, + }); + }); + +const walletMintsCmd = walletCmd + .command("mints") + .description("Wallet mint operations"); + +walletMintsCmd + .command("list") + .description("List configured wallet mints") + .action(async () => { + await handleDaemonCommand("/wallet/mints"); + }); + +walletMintsCmd + .command("add ") + .description("Add a wallet mint") + .action(async (url: string) => { + await handleDaemonCommand("/wallet/mints", { + method: "POST", + body: { url }, + }); + }); + +walletMintsCmd + .command("info ") + .description("Get wallet mint info") + .action(async (url: string) => { + await handleDaemonCommand("/wallet/mints/info", { + method: "POST", + body: { url }, + }); + }); + // Stop program .command("stop") diff --git a/src/daemon/http/index.ts b/src/daemon/http/index.ts index 5040463..552019e 100644 --- a/src/daemon/http/index.ts +++ b/src/daemon/http/index.ts @@ -6,7 +6,68 @@ import { } from "@routstr/sdk"; import type { UsageTrackingDriver } from "@routstr/sdk"; import { logger } from "../../utils/logger"; +import { + CocodHttpError, + type CocodClient, + type CocodState, +} from "../wallet/cocod-client"; +import { decodeCashuTokenAmount } from "../wallet"; +type ClientMode = "xcashu" | "lazyrefund" | "apikeys"; + +type WalletStatusOutput = { + daemon: "running"; + wallet: "connected" | "error"; + walletState: CocodState; + balances?: Record; + mode: ClientMode; + error?: string; +}; + +type DaemonDeps = { + provider: string | null; + server: { close(cb?: () => void): void }; + store: any; + walletClient: CocodClient; + walletAdapter: any; + storageAdapter: any; + providerRegistry: any; + discoveryAdapter: any; + modelManager: any; + ensureProvidersBootstrapped: () => Promise; + getRoutstr21Models: (forceRefresh?: boolean) => Promise; + mode?: ClientMode; +}; + +/** + * Extracts the client ID from an incoming request by looking up the API key + * in the store's clientIds list. + */ +function getClientIdFromRequest( + req: IncomingMessage, + store: { getState(): any }, +): string | undefined { + const authHeader = req.headers.authorization; + + if (!authHeader || !authHeader.startsWith("Bearer ")) { + return undefined; + } + + const apiKey = authHeader.slice(7); // Remove "Bearer " prefix + + if (!apiKey.startsWith("sk-")) { + return undefined; + } + + const state = store.getState(); + const clientIds = state.clientIds || []; + + const matchingClient = ( + clientIds as { clientId: string; apiKey: string }[] + ).find((c) => c.apiKey === apiKey); + + return matchingClient?.clientId; +} function generateApiKey(): string { const bytes = randomBytes(24); return `sk-${bytes.toString("hex")}`; @@ -23,6 +84,21 @@ async function readBody(req: IncomingMessage): Promise { }); } +async function readJsonBody( + req: IncomingMessage, +): Promise> { + const bodyText = await readBody(req); + if (!bodyText) { + return {}; + } + + try { + return JSON.parse(bodyText) as Record; + } catch { + throw new CocodHttpError(400, "Invalid JSON body."); + } +} + function parseLimit(value: string | null, fallback = 10): number { const requested = Number.parseInt(value || String(fallback), 10); return Number.isFinite(requested) && requested > 0 @@ -30,6 +106,164 @@ function parseLimit(value: string | null, fallback = 10): number { : fallback; } +function sendJson( + res: ServerResponse, + status: number, + payload: Record, +): void { + res.writeHead(status, { "Content-Type": "application/json" }); + res.end(JSON.stringify(payload)); +} + +function toErrorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +function getWalletStateMessage(state: CocodState): string { + switch (state) { + case "LOCKED": + return "Wallet is locked. Unlock it before performing wallet operations."; + case "UNINITIALIZED": + return "Wallet is not initialized. Run 'routstrd onboard' first."; + case "ERROR": + return "Wallet is in an error state."; + default: + return "Wallet is unavailable."; + } +} + +function respondWithError( + res: ServerResponse, + error: unknown, + fallbackStatus = 500, +): void { + if (error instanceof CocodHttpError) { + sendJson(res, error.status, { error: error.message }); + return; + } + + sendJson(res, fallbackStatus, { error: toErrorMessage(error) }); +} + +async function respond( + res: ServerResponse, + getPayload: () => Promise>, +): Promise { + try { + sendJson(res, 200, await getPayload()); + } catch (error) { + respondWithError(res, error); + } +} + +function requireStringField( + body: Record, + field: string, +): string | null { + const value = body[field]; + return typeof value === "string" && value.trim() ? value.trim() : null; +} + +function getRequiredStringField( + body: Record, + field: string, +): string { + const value = requireStringField(body, field); + if (!value) { + throw new CocodHttpError(400, `Missing required '${field}' field.`); + } + return value; +} + +function getRequiredPositiveNumberField( + body: Record, + field: string, +): number { + const value = body[field]; + if (typeof value === "number" && Number.isFinite(value) && value > 0) { + return value; + } + if (typeof value === "string" && value.trim()) { + const parsed = Number.parseInt(value.trim(), 10); + if (Number.isFinite(parsed) && parsed > 0) { + return parsed; + } + } + throw new CocodHttpError(400, `Missing required '${field}' field.`); +} + +function optionalStringField( + body: Record, + field: string, +): string | undefined { + const value = body[field]; + return typeof value === "string" && value.trim() ? value.trim() : undefined; +} + +function getCurrentMode(deps: DaemonDeps): ClientMode { + const stateMode = deps.store.getState()?.mode; + return stateMode || deps.mode || "apikeys"; +} + +async function buildStatusOutput(deps: DaemonDeps): Promise { + const mode = getCurrentMode(deps); + + try { + const walletState = await deps.walletClient.getStatus(); + if (walletState !== "UNLOCKED") { + return { + daemon: "running", + wallet: "error", + walletState, + mode, + error: getWalletStateMessage(walletState), + }; + } + + const balances = await deps.walletAdapter.getBalances(); + return { + daemon: "running", + wallet: "connected", + walletState, + balances, + mode, + }; + } catch (error) { + return { + daemon: "running", + wallet: "error", + walletState: "ERROR", + mode, + error: toErrorMessage(error), + }; + } +} + +async function buildWalletDetails(deps: DaemonDeps): Promise<{ + state: CocodState; + ready: boolean; + balances?: Record; + unit?: "sat"; + activeMint?: string | null; +}> { + const state = await deps.walletClient.getStatus(); + if (state !== "UNLOCKED") { + return { state, ready: false }; + } + + const balances = await deps.walletAdapter.getBalances(); + return { + state, + ready: true, + balances, + unit: "sat", + activeMint: deps.walletAdapter.getActiveMintUrl(), + }; +} + +export function createDaemonRequestHandler(deps: DaemonDeps) { + const usageTracker = createUsageTracker(deps.store); + export function createDaemonRequestHandler(deps: { provider: string | null; server: { close(cb?: () => void): void }; @@ -51,46 +285,128 @@ export function createDaemonRequestHandler(deps: { const url = new URL(req.url || "/", `http://${host}`); if (req.method === "GET" && url.pathname === "/health") { - res.writeHead(200, { "Content-Type": "application/json" }); - res.end(JSON.stringify({ ok: true })); + sendJson(res, 200, { ok: true }); return; } if (req.method === "GET" && url.pathname === "/ping") { - res.writeHead(200, { "Content-Type": "application/json" }); - res.end(JSON.stringify({ output: "pong" })); + sendJson(res, 200, { output: "pong" }); return; } if (req.method === "GET" && url.pathname === "/status") { - try { - const balancesOutput = await deps.runWalletCommand(["balance"]); - const balances = deps.parseBalances(balancesOutput); - const state = deps.store.getState(); - const mode = state.mode || deps.mode || "apikeys"; - res.writeHead(200, { "Content-Type": "application/json" }); - res.end( - JSON.stringify({ - output: { - daemon: "running", - wallet: "connected", - mode, - balances, - }, - }), - ); - } catch (error) { - res.writeHead(200, { "Content-Type": "application/json" }); - res.end( - JSON.stringify({ - output: { - daemon: "running", - wallet: "error", - error: String(error), - }, - }), - ); - } + const output = await buildStatusOutput(deps); + sendJson(res, 200, { output }); + return; + } + + if (req.method === "GET" && url.pathname === "/wallet/status") { + await respond(res, async () => ({ + output: await buildWalletDetails(deps), + })); + return; + } + + if (req.method === "POST" && url.pathname === "/wallet/unlock") { + await respond(res, async () => { + const body = await readJsonBody(req); + const passphrase = getRequiredStringField(body, "passphrase"); + const message = await deps.walletClient.unlock(passphrase); + const state = await deps.walletClient.getStatus(); + return { output: { message, state } }; + }); + return; + } + + if (req.method === "GET" && url.pathname === "/wallet/balance") { + await respond(res, async () => { + const balances = await deps.walletAdapter.getBalances(); + return { + output: { + balances, + unit: "sat", + activeMint: deps.walletAdapter.getActiveMintUrl(), + walletState: "UNLOCKED", + }, + }; + }); + return; + } + + if (req.method === "POST" && url.pathname === "/wallet/receive/cashu") { + await respond(res, async () => { + const body = await readJsonBody(req); + const token = getRequiredStringField(body, "token"); + const message = await deps.walletClient.receiveCashu(token); + const { amount, unit } = decodeCashuTokenAmount(token); + return { output: { message, amount, unit } }; + }); + return; + } + + if (req.method === "POST" && url.pathname === "/wallet/receive/bolt11") { + await respond(res, async () => { + const body = await readJsonBody(req); + const amount = getRequiredPositiveNumberField(body, "amount"); + const mintUrl = optionalStringField(body, "mintUrl"); + const invoice = await deps.walletClient.receiveBolt11(amount, mintUrl); + return { output: { invoice, amount, mintUrl } }; + }); + return; + } + + if (req.method === "POST" && url.pathname === "/wallet/send/cashu") { + await respond(res, async () => { + const body = await readJsonBody(req); + const amount = getRequiredPositiveNumberField(body, "amount"); + const mintUrl = optionalStringField(body, "mintUrl"); + const token = await deps.walletClient.sendCashu(amount, mintUrl); + return { output: { token, amount, mintUrl } }; + }); + return; + } + + if (req.method === "POST" && url.pathname === "/wallet/send/bolt11") { + await respond(res, async () => { + const body = await readJsonBody(req); + const invoice = getRequiredStringField(body, "invoice"); + const mintUrl = optionalStringField(body, "mintUrl"); + const message = await deps.walletClient.sendBolt11(invoice, mintUrl); + return { output: { message, invoice, mintUrl } }; + }); + return; + } + + if (req.method === "GET" && url.pathname === "/wallet/mints") { + await respond(res, async () => { + const mints = await deps.walletClient.listMints(); + return { + output: { + mints, + activeMint: mints[0] || null, + }, + }; + }); + return; + } + + if (req.method === "POST" && url.pathname === "/wallet/mints") { + await respond(res, async () => { + const body = await readJsonBody(req); + const mintUrl = getRequiredStringField(body, "url"); + const message = await deps.walletClient.addMint(mintUrl); + return { output: { message, url: mintUrl } }; + }); + return; + } + + if (req.method === "POST" && url.pathname === "/wallet/mints/info") { + await respond(res, async () => { + const body = await readJsonBody(req); + const mintUrl = getRequiredStringField(body, "url"); + const info = await deps.walletClient.getMintInfo(mintUrl); + return { output: { url: mintUrl, info } }; + }); return; } @@ -99,11 +415,9 @@ export function createDaemonRequestHandler(deps: { const forceRefresh = url.searchParams.get("refresh")?.toLowerCase() === "true"; const models = await deps.getRoutstr21Models(forceRefresh); - res.writeHead(200, { "Content-Type": "application/json" }); - res.end(JSON.stringify({ output: { models } })); + sendJson(res, 200, { output: { models } }); } catch (error) { - res.writeHead(500, { "Content-Type": "application/json" }); - res.end(JSON.stringify({ error: String(error) })); + sendJson(res, 500, { error: toErrorMessage(error) }); } return; } @@ -113,23 +427,18 @@ export function createDaemonRequestHandler(deps: { const forceRefresh = url.searchParams.get("refresh")?.toLowerCase() === "true"; const models = await deps.getRoutstr21Models(forceRefresh); - res.writeHead(200, { "Content-Type": "application/json" }); - res.end( - JSON.stringify({ - object: "list", - data: models.map((model) => ({ ...model, object: "model" })), - }), - ); + sendJson(res, 200, { + object: "list", + data: models.map((model) => ({ ...model, object: "model" })), + }); } catch (error) { - res.writeHead(500, { "Content-Type": "application/json" }); - res.end(JSON.stringify({ error: String(error) })); + sendJson(res, 500, { error: toErrorMessage(error) }); } return; } if (req.method === "POST" && url.pathname === "/stop") { - res.writeHead(200, { "Content-Type": "application/json" }); - res.end(JSON.stringify({ output: "stopping" })); + sendJson(res, 200, { output: "stopping" }); setTimeout(() => { deps.server.close(() => { process.exit(0); @@ -140,17 +449,8 @@ export function createDaemonRequestHandler(deps: { if (req.method === "POST" && url.pathname === "/refund") { try { - const bodyText = await readBody(req); - const body = bodyText ? JSON.parse(bodyText) : {}; - const mintUrl = body.mintUrl as string | undefined; - - if (!mintUrl) { - res.writeHead(400, { "Content-Type": "application/json" }); - res.end( - JSON.stringify({ error: "Missing required 'mintUrl' field." }), - ); - return; - } + const body = await readJsonBody(req); + const mintUrl = getRequiredStringField(body, "mintUrl"); const state = deps.store.getState(); const pendingDistribution = (state.cachedTokens || []).map( @@ -167,12 +467,9 @@ export function createDaemonRequestHandler(deps: { ); if (pendingDistribution.length === 0 && apiKeysStored.length === 0) { - res.writeHead(200, { "Content-Type": "application/json" }); - res.end( - JSON.stringify({ - output: { message: "No pending tokens to refund", results: [] }, - }), - ); + sendJson(res, 200, { + output: { message: "No pending tokens to refund", results: [] }, + }); return; } @@ -192,27 +489,22 @@ export function createDaemonRequestHandler(deps: { const spender = client.getCashuSpender(); const results = await spender.refundProviders(mintUrl, true); - res.writeHead(200, { "Content-Type": "application/json" }); - res.end( - JSON.stringify({ - output: { - message: `Refunded to ${mintUrl}`, - pendingTokens: pendingDistribution.length, - apiKeys: apiKeysStored.length, - results: results.map( - (r: { baseUrl: string; success: boolean }) => ({ - baseUrl: r.baseUrl, - success: r.success, - }), - ), - }, - }), - ); + sendJson(res, 200, { + output: { + message: `Refunded to ${mintUrl}`, + pendingTokens: pendingDistribution.length, + apiKeys: apiKeysStored.length, + results: results.map( + (r: { baseUrl: string; success: boolean }) => ({ + baseUrl: r.baseUrl, + success: r.success, + }), + ), + }, + }); } catch (error) { - const message = error instanceof Error ? error.message : String(error); - logger.error(`Refund error: ${message}`); - res.writeHead(500, { "Content-Type": "application/json" }); - res.end(JSON.stringify({ error: message })); + logger.error(`Refund error: ${toErrorMessage(error)}`); + respondWithError(res, error); } return; } @@ -220,19 +512,15 @@ export function createDaemonRequestHandler(deps: { if (req.method === "GET" && url.pathname === "/balance") { try { const balances = await deps.walletAdapter.getBalances(); - res.writeHead(200, { "Content-Type": "application/json" }); - res.end( - JSON.stringify({ - output: { - balances, - unit: "sat", - activeMint: deps.walletAdapter.getActiveMintUrl(), - }, - }), - ); + sendJson(res, 200, { + output: { + balances, + unit: "sat", + activeMint: deps.walletAdapter.getActiveMintUrl(), + }, + }); } catch (error) { - res.writeHead(500, { "Content-Type": "application/json" }); - res.end(JSON.stringify({ error: String(error) })); + respondWithError(res, error); } return; } @@ -272,14 +560,66 @@ export function createDaemonRequestHandler(deps: { })), ]; + sendJson(res, 200, { + output: { + keys, + total: totalWallet + totalCached + totalApiKeys, + unit: "sat", + apikeysCalled: apiKeys.length, + }, + }); + } catch (error) { + res.writeHead(500, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ error: String(error) })); + } + return; + } + + if (req.method === "POST" && url.pathname === "/providers/disable") { + try { + const bodyText = await readBody(req); + const body = bodyText ? JSON.parse(bodyText) : {}; + const indices = body.indices as number[] | undefined; + + if (!Array.isArray(indices)) { + res.writeHead(400, { "Content-Type": "application/json" }); + res.end( + JSON.stringify({ + error: "Missing or invalid 'indices' field (expected number[]).", + }), + ); + return; + } + + const state = deps.store.getState(); + const baseUrlsList: string[] = state.baseUrlsList || []; + const disabledProviders: string[] = [ + ...(state.disabledProviders || []), + ]; + + const toDisable: string[] = []; + for (const idx of indices) { + if ( + typeof idx === "number" && + idx >= 0 && + idx < baseUrlsList.length + ) { + const baseUrl = baseUrlsList[idx]!; + if (!disabledProviders.includes(baseUrl)) { + disabledProviders.push(baseUrl); + toDisable.push(baseUrl); + } + } + } + + deps.store.getState().setDisabledProviders(disabledProviders); + res.writeHead(200, { "Content-Type": "application/json" }); res.end( JSON.stringify({ output: { - keys, - total: totalWallet + totalCached + totalApiKeys, - unit: "sat", - apikeysCalled: apiKeys.length, + message: `Disabled ${toDisable.length} provider(s)`, + disabled: toDisable, }, }), ); @@ -290,6 +630,187 @@ export function createDaemonRequestHandler(deps: { return; } + if (req.method === "POST" && url.pathname === "/providers/enable") { + try { + const bodyText = await readBody(req); + const body = bodyText ? JSON.parse(bodyText) : {}; + const indices = body.indices as number[] | undefined; + + if (!Array.isArray(indices)) { + res.writeHead(400, { "Content-Type": "application/json" }); + res.end( + JSON.stringify({ + error: "Missing or invalid 'indices' field (expected number[]).", + }), + ); + return; + } + + const state = deps.store.getState(); + const baseUrlsList: string[] = state.baseUrlsList || []; + const disabledProviders: string[] = [ + ...(state.disabledProviders || []), + ]; + + const toEnable: string[] = []; + for (const idx of indices) { + if ( + typeof idx === "number" && + idx >= 0 && + idx < baseUrlsList.length + ) { + const baseUrl = baseUrlsList[idx]!; + const pos = disabledProviders.indexOf(baseUrl); + if (pos !== -1) { + disabledProviders.splice(pos, 1); + toEnable.push(baseUrl); + } + } + } + + deps.store.getState().setDisabledProviders(disabledProviders); + + res.writeHead(200, { "Content-Type": "application/json" }); + res.end( + JSON.stringify({ + output: { + message: `Enabled ${toEnable.length} provider(s)`, + enabled: toEnable, + }, + }), + ); + } catch (error) { + res.writeHead(500, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ error: String(error) })); + } + return; + } + + // Client management endpoints + if (req.method === "GET" && url.pathname === "/clients") { + try { + const state = deps.store.getState(); + const clientIds = state.clientIds || []; + + const clients = clientIds.map( + (c: { + clientId: string; + name: string; + apiKey: string; + createdAt: number; + lastUsed?: number | null; + }) => ({ + id: c.clientId, + name: c.name, + apiKey: c.apiKey, + createdAt: c.createdAt, + lastUsed: c.lastUsed, + }), + ); + + res.writeHead(200, { "Content-Type": "application/json" }); + res.end( + JSON.stringify({ + output: { + clients, + totalCount: clients.length, + }, + }), + ); + } catch (error) { + res.writeHead(500, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ error: String(error) })); + } + return; + } + + if (req.method === "POST" && url.pathname === "/clients/add") { + try { + const bodyText = await readBody(req); + const body = bodyText ? JSON.parse(bodyText) : {}; + const name = body.name as string | undefined; + + if (!name || typeof name !== "string" || name.trim() === "") { + res.writeHead(400, { "Content-Type": "application/json" }); + res.end( + JSON.stringify({ + error: + "Missing required 'name' field (must be a non-empty string).", + }), + ); + return; + } + + const clientId = name + .toLowerCase() + .replace(/\s+/g, "-") + .replace(/[^a-z0-9-]/g, ""); + + if (!clientId) { + res.writeHead(400, { "Content-Type": "application/json" }); + res.end( + JSON.stringify({ + error: + "Invalid client name. Must contain alphanumeric characters.", + }), + ); + return; + } + + const state = deps.store.getState(); + const existingClients = state.clientIds || []; + const existingClient = existingClients.find( + (c: { clientId: string }) => c.clientId === clientId, + ); + + if (existingClient) { + res.writeHead(409, { "Content-Type": "application/json" }); + res.end( + JSON.stringify({ + error: `Client with id '${clientId}' already exists.`, + }), + ); + return; + } + + const apiKey = generateApiKey(); + const newClient = { + clientId, + name: name.trim(), + apiKey, + createdAt: Date.now(), + }; + + deps.store + .getState() + .setClientIds((prev: typeof existingClients) => [ + ...(prev || []), + newClient, + ]); + + logger.log(`Added client '${name}' with id '${clientId}'`); + + + res.writeHead(200, { "Content-Type": "application/json" }); + res.end( + JSON.stringify({ + output: { + message: `Client '${name}' added successfully`, + client: { + id: clientId, + name: name.trim(), + apiKey, + createdAt: newClient.createdAt, + }, + }, + }), + ); + } catch (error) { + respondWithError(res, error); + } + return; + } + if (req.method === "GET" && url.pathname === "/providers") { try { const state = deps.store.getState(); @@ -557,34 +1078,13 @@ export function createDaemonRequestHandler(deps: { if (req.method === "GET" && url.pathname === "/usage") { try { - const usageDriver = deps.usageTrackingDriver; - const limit = parseLimit(url.searchParams.get("limit")); - const entries = await usageDriver.list({ limit }); - const totalEntries = await usageDriver.count(); - const totalSatsCost = (await usageDriver.list()).reduce( - (sum, entry) => sum + (entry.satsCost || 0), - 0, + const output = usageTracker.listRecent( + parseLimit(url.searchParams.get("limit")), ); - const recentSatsCost = entries.reduce( - (sum, entry) => sum + (entry.satsCost || 0), - 0, - ); - res.writeHead(200, { "Content-Type": "application/json" }); - res.end( - JSON.stringify({ - output: { - entries, - totalEntries, - totalSatsCost, - recentSatsCost, - limit, - }, - }), - ); + res.end(JSON.stringify({ output })); } catch (error) { - res.writeHead(500, { "Content-Type": "application/json" }); - res.end(JSON.stringify({ error: String(error) })); + sendJson(res, 500, { error: toErrorMessage(error) }); } return; } @@ -593,12 +1093,9 @@ export function createDaemonRequestHandler(deps: { try { const timestamp = (url.searchParams.get("timestamp") || "").trim(); if (!timestamp) { - res.writeHead(400, { "Content-Type": "application/json" }); - res.end( - JSON.stringify({ - error: "Missing required 'timestamp' query parameter.", - }), - ); + sendJson(res, 400, { + error: "Missing required 'timestamp' query parameter.", + }); return; } @@ -634,8 +1131,7 @@ export function createDaemonRequestHandler(deps: { }), ); } catch (error) { - res.writeHead(500, { "Content-Type": "application/json" }); - res.end(JSON.stringify({ error: String(error) })); + sendJson(res, 500, { error: toErrorMessage(error) }); } return; } @@ -649,16 +1145,12 @@ export function createDaemonRequestHandler(deps: { let requestBody: unknown = {}; try { - const bodyText = await readBody(req); - requestBody = bodyText ? JSON.parse(bodyText) : {}; + requestBody = await readJsonBody(req); } catch (error) { - res.writeHead(400, { "Content-Type": "application/json" }); - res.end( - JSON.stringify({ - error: "Invalid JSON body.", - details: error instanceof Error ? error.message : String(error), - }), - ); + sendJson(res, 400, { + error: "Invalid JSON body.", + details: toErrorMessage(error), + }); return; } @@ -666,8 +1158,7 @@ export function createDaemonRequestHandler(deps: { const modelId = typeof bodyObj.model === "string" ? bodyObj.model : ""; if (!modelId) { - res.writeHead(400, { "Content-Type": "application/json" }); - res.end(JSON.stringify({ error: "Missing required 'model' field." })); + sendJson(res, 400, { error: "Missing required 'model' field." }); return; } @@ -703,13 +1194,90 @@ export function createDaemonRequestHandler(deps: { modelManager: deps.modelManager, debugLevel: "DEBUG", mode: deps.mode, - usageTrackingDriver: deps.usageTrackingDriver, - sdkStore: deps.store, - res, }); - return; + + const isStream = bodyObj.stream === true; + const requestId = + response.headers.get("x-routstr-request-id") || undefined; + logger.log("Request ID, ", requestId, " with path: ", url.pathname); + const usageBaseUrl = resolveUsageBaseUrl(response, forcedProvider); + + if (isStream) { + res.statusCode = response.status; + response.headers.forEach((value, key) => { + res.setHeader(key, value); + }); + + const body = response.body; + if (body) { + let capturedUsage: UsageData | null = null; + let capturedResponseId: string | undefined; + const nodeReadable = Readable.fromWeb( + body as unknown as WebReadableStream, + ); + const sseParser = createSSEParserTransform( + (usage) => { + capturedUsage = usage; + }, + (responseId) => { + capturedResponseId = responseId; + }, + ); + nodeReadable.pipe(sseParser).pipe(res); + + res.on("finish", () => { + if (capturedUsage) { + const usageRequestId = + capturedResponseId || requestId || "unknown"; + logger.log(req); + usageTracker.append({ + id: + usageRequestId === "unknown" + ? `req-${Date.now()}-${modelId}` + : usageRequestId, + timestamp: Date.now(), + modelId, + baseUrl: usageBaseUrl, + requestId: usageRequestId, + client: getClientIdFromRequest(req, deps.store), + ...capturedUsage, + }); + logger.log( + "Streaming request usage:", + JSON.stringify(capturedUsage), + ); + } + }); + } else { + res.end(); + } + return; + } + + const responseBody = await response.json(); + const nonStreamUsage = extractUsageFromResponseBody(responseBody); + if (nonStreamUsage) { + const responseRequestId = + extractResponseId(responseBody) || requestId || "unknown"; + usageTracker.append({ + id: + responseRequestId === "unknown" + ? `req-${Date.now()}-${modelId}` + : responseRequestId, + timestamp: Date.now(), + modelId, + baseUrl: usageBaseUrl, + requestId: responseRequestId, + client: getClientIdFromRequest(req, deps.store), + ...nonStreamUsage, + }); + } + res.writeHead(response.status, { + "Content-Type": "application/json", + }); + res.end(JSON.stringify(responseBody)); } catch (error) { - const message = error instanceof Error ? error.message : String(error); + const message = toErrorMessage(error); logger.error(`[daemon] Error: ${message}`); if (error instanceof InsufficientBalanceError) { @@ -719,22 +1287,18 @@ export function createDaemonRequestHandler(deps: { maxMintBalance?: number; maxMintUrl?: string; }; - res.writeHead(402, { "Content-Type": "application/json" }); - res.end( - JSON.stringify({ - error: message, - error_type: "insufficient_balance", - required: balanceError.required, - available: balanceError.available, - maxMintBalance: balanceError.maxMintBalance, - maxMintUrl: balanceError.maxMintUrl, - }), - ); + sendJson(res, 402, { + error: message, + error_type: "insufficient_balance", + required: balanceError.required, + available: balanceError.available, + maxMintBalance: balanceError.maxMintBalance, + maxMintUrl: balanceError.maxMintUrl, + }); return; } - res.writeHead(500, { "Content-Type": "application/json" }); - res.end(JSON.stringify({ error: message })); + respondWithError(res, error); } }; } diff --git a/src/daemon/index.ts b/src/daemon/index.ts index 79bfaab..62b971b 100644 --- a/src/daemon/index.ts +++ b/src/daemon/index.ts @@ -16,6 +16,7 @@ import { createBunSqliteUsageTrackingDriver, } from "@routstr/sdk/storage"; import { createWalletAdapter, parseBalances, runWalletCommand } from "./wallet"; +import { createCocodClient } from "./wallet/cocod-client"; import { createModelService } from "./models"; import { createDaemonRequestHandler } from "./http"; import { RoutstrClient } from "@routstr/sdk"; @@ -48,7 +49,11 @@ async function main(): Promise { const { ensureProvidersBootstrapped, getRoutstr21Models } = createModelService(modelManager); - const walletAdapter = await createWalletAdapter(); + const walletClient = createCocodClient({ cocodPath: config.cocodPath }); + const walletAdapter = await createWalletAdapter({ + cocodPath: config.cocodPath, + walletClient, + }); const server = createServer(); server.on( @@ -57,6 +62,7 @@ async function main(): Promise { provider, server, store, + walletClient, walletAdapter, storageAdapter, providerRegistry, @@ -64,8 +70,6 @@ async function main(): Promise { modelManager, ensureProvidersBootstrapped, getRoutstr21Models, - runWalletCommand, - parseBalances, mode: config.mode || "apikeys", usageTrackingDriver, }), diff --git a/src/daemon/wallet/cocod-client.ts b/src/daemon/wallet/cocod-client.ts new file mode 100644 index 0000000..a8f92ea --- /dev/null +++ b/src/daemon/wallet/cocod-client.ts @@ -0,0 +1,267 @@ +import { existsSync } from "fs"; +import { logger } from "../../utils/logger"; + +const DEFAULT_CONFIG_DIR = `${process.env.HOME || process.env.USERPROFILE || ""}/.cocod`; +const DEFAULT_SOCKET_PATH = + process.env.COCOD_SOCKET || `${DEFAULT_CONFIG_DIR}/cocod.sock`; + +type UnixRequestInit = RequestInit & { unix: string }; + +type CommandResponse = { + output?: T; + error?: string; +}; + +type CocodFetch = ( + input: string | URL | Request, + init?: UnixRequestInit, +) => Promise; + +type SpawnedProcess = { + exited: Promise; + unref?: () => void; +}; + +type SpawnDaemon = (args: string[], env: Record) => SpawnedProcess; + +export type CocodState = "UNINITIALIZED" | "LOCKED" | "UNLOCKED" | "ERROR"; + +export type CocodBalanceOutput = Record; + +export class CocodHttpError extends Error { + status: number; + + constructor(status: number, message: string) { + super(message); + this.name = "CocodHttpError"; + this.status = status; + } +} + +export interface CocodClient { + ping(): Promise; + getStatus(): Promise; + unlock(passphrase: string): Promise; + getBalances(): Promise>; + receiveCashu(token: string): Promise; + receiveBolt11(amount: number, mintUrl?: string): Promise; + sendCashu(amount: number, mintUrl?: string): Promise; + sendBolt11(invoice: string, mintUrl?: string): Promise; + listMints(): Promise; + addMint(url: string): Promise; + getMintInfo(url: string): Promise; +} + +export function resolveCocodExecutable( + cocodPath?: string | null, +): string { + const trimmed = cocodPath?.trim(); + return trimmed || "cocod"; +} + +export async function isCocodInstalled( + cocodPath?: string | null, +): Promise { + const executable = resolveCocodExecutable(cocodPath); + + if (executable.includes("/")) { + return existsSync(executable); + } + + try { + const proc = Bun.spawn({ + cmd: ["which", executable], + stdout: "ignore", + stderr: "ignore", + }); + return (await proc.exited) === 0; + } catch { + return false; + } +} + +export function normalizeBalances( + output: CocodBalanceOutput | undefined, +): Record { + if (!output) return {}; + + return Object.fromEntries( + Object.entries(output).map(([mintUrl, value]) => { + if (typeof value === "number") { + return [mintUrl, value]; + } + return [mintUrl, Number(value?.sats ?? 0)]; + }), + ); +} + +function parseMintList(output: string | undefined): string[] { + return (output || "") + .split("\n") + .map((line) => line.trim()) + .filter(Boolean); +} + +function delay(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +export function createCocodClient(options: { + cocodPath?: string | null; + socketPath?: string; + fetchImpl?: CocodFetch; + spawnDaemon?: SpawnDaemon; + pollIntervalMs?: number; + startupTimeoutMs?: number; +} = {}): CocodClient { + const executable = resolveCocodExecutable(options.cocodPath); + const socketPath = options.socketPath || DEFAULT_SOCKET_PATH; + const fetchImpl = options.fetchImpl || (fetch as CocodFetch); + const pollIntervalMs = options.pollIntervalMs ?? 100; + const startupTimeoutMs = options.startupTimeoutMs ?? 5000; + + const spawnDaemon: SpawnDaemon = + options.spawnDaemon || + ((args, env) => { + const proc = Bun.spawn(args, { + stdin: "ignore", + stdout: "ignore", + stderr: "ignore", + detached: true, + env, + }); + proc.unref(); + return proc; + }); + + let startPromise: Promise | null = null; + + async function fetchJson( + path: string, + init: Omit = {}, + ): Promise> { + const requestInit: UnixRequestInit = { + ...init, + unix: socketPath, + }; + + const response = await fetchImpl(`http://localhost${path}`, requestInit); + const data = (await response.json()) as CommandResponse; + + if (!response.ok) { + throw new CocodHttpError( + response.status, + data.error || response.statusText || `HTTP ${response.status}`, + ); + } + + return data; + } + + async function pingInternal(): Promise { + try { + await fetchJson("/ping"); + return true; + } catch { + return false; + } + } + + async function startDaemon(): Promise { + const env = { ...process.env, COCOD_SOCKET: socketPath }; + const proc = spawnDaemon([executable, "daemon"], env); + const maxPolls = Math.ceil(startupTimeoutMs / pollIntervalMs); + let exitCode: number | null = null; + + void proc.exited.then((code) => { + exitCode = code; + }); + + for (let i = 0; i < maxPolls; i++) { + await delay(pollIntervalMs); + + if (exitCode !== null) { + throw new Error(`cocod daemon exited early with code ${exitCode}`); + } + + if (await pingInternal()) { + logger.log(`Connected to cocod daemon on ${socketPath}`); + return; + } + } + + throw new Error( + `cocod daemon failed to start within ${Math.round(startupTimeoutMs / 1000)} seconds`, + ); + } + + async function ensureDaemonRunning(): Promise { + if (await pingInternal()) { + return; + } + + if (!startPromise) { + logger.log(`Starting cocod daemon via ${executable}...`); + startPromise = startDaemon().finally(() => { + startPromise = null; + }); + } + + await startPromise; + } + + async function callDaemon( + path: string, + init: Omit = {}, + ): Promise { + await ensureDaemonRunning(); + const response = await fetchJson(path, init); + return response.output as T; + } + + function post(path: string, body: Record): Promise { + return callDaemon(path, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + } + + return { + async ping(): Promise { + return pingInternal(); + }, + async getStatus(): Promise { + return callDaemon("/status"); + }, + async unlock(passphrase: string): Promise { + return post("/unlock", { passphrase }); + }, + async getBalances(): Promise> { + const output = await callDaemon("/balance"); + return normalizeBalances(output); + }, + async receiveCashu(token: string): Promise { + return post("/receive/cashu", { token }); + }, + async receiveBolt11(amount: number, mintUrl?: string): Promise { + return post("/receive/bolt11", { amount, mintUrl }); + }, + async sendCashu(amount: number, mintUrl?: string): Promise { + return post("/send/cashu", { amount, mintUrl }); + }, + async sendBolt11(invoice: string, mintUrl?: string): Promise { + return post("/send/bolt11", { invoice, mintUrl }); + }, + async listMints(): Promise { + const output = await callDaemon("/mints/list"); + return parseMintList(output); + }, + async addMint(url: string): Promise { + return post("/mints/add", { url }); + }, + async getMintInfo(url: string): Promise { + return post("/mints/info", { url }); + }, + }; +} diff --git a/src/daemon/wallet/index.ts b/src/daemon/wallet/index.ts index df4ee1b..2626754 100644 --- a/src/daemon/wallet/index.ts +++ b/src/daemon/wallet/index.ts @@ -1,119 +1,52 @@ -import { spawn } from "child_process"; import { getDecodedToken } from "@cashu/cashu-ts"; import { logger } from "../../utils/logger"; +import { createCocodClient, type CocodClient } from "./cocod-client"; -export async function runWalletCommand(args: string[]): Promise { - return new Promise((resolve, reject) => { - const child = spawn("cocod", args, { - stdio: ["ignore", "pipe", "pipe"], - }); - let stdout = ""; - let stderr = ""; - - child.stdout.on("data", (chunk) => { - stdout += chunk.toString(); - }); - child.stderr.on("data", (chunk) => { - stderr += chunk.toString(); - }); - child.on("error", (error) => reject(error)); - child.on("close", (code) => { - if (code && code !== 0) { - reject( - new Error(stderr.trim() || stdout.trim() || "Wallet CLI failed"), - ); - return; - } - resolve(stdout.trim()); - }); - }); +export function decodeCashuTokenAmount(token: string): { + amount: number; + unit: "sat" | "msat"; +} { + const decoded = getDecodedToken(token); + const amount = + decoded?.proofs?.reduce((sum, proof) => sum + proof.amount, 0) ?? 0; + const unit = decoded?.unit === "msat" ? "msat" : "sat"; + return { amount, unit }; } -export function parseBalances(output: string): Record { - const trimmed = output.trim(); - if (!trimmed) return {}; - - try { - const parsed = JSON.parse(trimmed) as Record< - string, - { sats?: number } | number - >; - if (parsed && typeof parsed === "object") { - return Object.fromEntries( - Object.entries(parsed).map(([mintUrl, value]) => { - if (typeof value === "number") { - return [mintUrl, value]; - } - if (value && typeof value === "object" && "sats" in value) { - return [mintUrl, Number(value.sats ?? 0)]; - } - return [mintUrl, 0]; - }), - ); - } - } catch { - // Fall back to line parsing. - } - - const balances: Record = {}; - trimmed - .split("\n") - .map((line) => line.trim()) - .forEach((line) => { - const match = line.match(/^(\S+):\s+(\d+)\s+s$/); - const mintUrl = match?.[1]; - const amount = match?.[2]; - if (mintUrl && amount) { - balances[mintUrl] = Number.parseInt(amount, 10); - } - }); - return balances; -} - -export function parseMints( - output: string, -): Array<{ url: string; trusted: boolean }> { - return output - .split("\n") - .map((line) => line.trim()) - .map((line) => { - const urlMatch = line.match(/https?:\/\/\S+/i); - if (!urlMatch) return null; - const trustedMatch = line.match(/trusted:\s*(true|false)/i); - const trustedValue = trustedMatch?.[1]; - return { - url: urlMatch[0], - trusted: trustedMatch ? trustedValue?.toLowerCase() === "true" : false, - }; - }) - .filter((entry): entry is { url: string; trusted: boolean } => - Boolean(entry), - ); -} - -export function pickTokenLine(output: string): string { - const lines = output - .split("\n") - .map((line) => line.trim()) - .filter(Boolean); - return lines[lines.length - 1] || ""; -} - -export async function createWalletAdapter() { +export async function createWalletAdapter(options: { + cocodPath?: string | null; + walletClient?: CocodClient; +} = {}) { + const client = + options.walletClient || createCocodClient({ cocodPath: options.cocodPath }); let activeMintUrl: string | null = null; let mintUnits: Record = {}; + async function syncMintState( + balances?: Record, + ): Promise> { + const nextBalances = balances || (await client.getBalances()); + + mintUnits = Object.fromEntries( + Object.keys(nextBalances).map((mintUrl) => [mintUrl, "sat"]), + ); + + try { + const mints = await client.listMints(); + activeMintUrl = mints[0] || Object.keys(nextBalances)[0] || null; + } catch (error) { + logger.error("Failed to list cocod mints:", error); + if (!activeMintUrl) { + activeMintUrl = Object.keys(nextBalances)[0] || null; + } + } + + return nextBalances; + } + const walletAdapter = { async getBalances(): Promise> { - const output = await runWalletCommand(["balance"]); - const balances = parseBalances(output); - mintUnits = Object.fromEntries( - Object.keys(balances).map((mintUrl) => [mintUrl, "sat"]), - ); - if (!activeMintUrl) { - activeMintUrl = Object.keys(balances)[0] || null; - } - return balances; + return syncMintState(); }, getMintUnits(): Record { return mintUnits; @@ -128,18 +61,7 @@ export async function createWalletAdapter() { for (let attempt = 0; attempt <= maxRetries; attempt++) { try { - const output = await runWalletCommand([ - "send", - "cashu", - String(amount), - "--mint-url", - mintUrl, - ]); - const token = pickTokenLine(output); - if (!token) { - throw new Error("Wallet CLI did not return a token."); - } - return token; + return await client.sendCashu(amount, mintUrl); } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); @@ -152,9 +74,7 @@ export async function createWalletAdapter() { logger.log( `sendToken attempt ${attempt + 1} failed with reserved proof error, retrying in ${retryDelayMs / 1000}s...`, ); - await new Promise((resolve) => - setTimeout(resolve, retryDelayMs), - ); + await new Promise((resolve) => setTimeout(resolve, retryDelayMs)); continue; } @@ -162,6 +82,7 @@ export async function createWalletAdapter() { throw error; } } + throw new Error("sendToken failed after max retries"); }, async receiveToken(token: string): Promise<{ @@ -171,16 +92,10 @@ export async function createWalletAdapter() { message?: string; }> { try { - await runWalletCommand(["receive", "cashu", token]); - const decoded = getDecodedToken(token); - const amount = decoded?.proofs?.reduce( - (sum, proof) => sum + proof.amount, - 0, - ); - const unit = decoded?.unit === "msat" ? "msat" : "sat"; - return { success: true, amount: amount ?? 0, unit }; + await client.receiveCashu(token); + const { amount, unit } = decodeCashuTokenAmount(token); + return { success: true, amount, unit }; } catch (error) { - console.log("Eerro in receive", error); const errorMessage = error instanceof Error ? error.message : String(error); const message = errorMessage.includes("Failed to fetch mint") @@ -189,18 +104,19 @@ export async function createWalletAdapter() { return { success: false, amount: 0, unit: "sat", message }; } }, - isUsingNip60(): boolean { - return false; - }, }; try { - const mintsOutput = await runWalletCommand(["mints", "list"]); - const mints = parseMints(mintsOutput); - activeMintUrl = - mints.find((mint) => mint.trusted)?.url || mints[0]?.url || null; + const [balances, mints] = await Promise.all([ + client.getBalances(), + client.listMints().catch(() => []), + ]); + mintUnits = Object.fromEntries( + Object.keys(balances).map((mintUrl) => [mintUrl, "sat"]), + ); + activeMintUrl = mints[0] || Object.keys(balances)[0] || null; } catch (error) { - logger.error("Failed to read mints from wallet:", error); + logger.error("Failed to initialize wallet adapter state:", error); } return walletAdapter;