From 76ce27d51888f2c3a62ff4dd7f9befc75ae1c4eb Mon Sep 17 00:00:00 2001 From: Evan Yang Date: Sat, 21 Mar 2026 12:40:47 +0800 Subject: [PATCH 1/2] Add cocod-backed wallet integration --- package-lock.json | 327 +++++++++++++++++- package.json | 4 +- src/cli.ts | 232 +++++++++++-- src/daemon/http/index.ts | 530 ++++++++++++++++++++---------- src/daemon/index.ts | 20 +- src/daemon/wallet/cocod-client.ts | 267 +++++++++++++++ src/daemon/wallet/index.ts | 192 +++-------- 7 files changed, 1217 insertions(+), 355 deletions(-) create mode 100644 src/daemon/wallet/cocod-client.ts 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 82a6d03..f36276a 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,8 @@ "test": "bun test" }, "devDependencies": { - "@types/bun": "latest" + "@types/bun": "latest", + "@types/qrcode": "^1.5.6" }, "peerDependencies": { "typescript": "^5" @@ -25,6 +26,7 @@ "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" } diff --git a/src/cli.ts b/src/cli.ts index f278544..f15ab56 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -18,6 +18,11 @@ import { } from "./utils/config"; import { logger } from "./utils/logger"; import { setupIntegration } from "./integrations"; +import * as QRCode from "qrcode"; +import { + isCocodInstalled, + resolveCocodExecutable, +} from "./daemon/wallet/cocod-client"; type RoutstrModel = { id: string; @@ -41,26 +46,28 @@ type UsageEntry = { const cliVersion = "0.1.0"; +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..."); - if (!(await checkCocodInstalled())) { - 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 checkCocodInstalled())) { - logger.error("Failed to install cocod. Please run 'bun install --global cocod' manually."); - return; - } - - logger.log("cocod installed successfully."); - } - // Create config directory if (!existsSync(CONFIG_DIR)) { mkdirSync(CONFIG_DIR, { recursive: true }); @@ -77,10 +84,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", }); @@ -127,25 +163,13 @@ async function initDaemon(): Promise { logger.log("cocod initialized successfully."); } - const config = await loadConfig(); await startDaemon({ port: String(config.port || 8008) }); await setupIntegration(config); logger.log("\nInitialization complete!"); - logger.log("\n use 'cocod receive cashu' or 'cocod receive bolt11 2100' to top up your local wallet!"); -} - -async function checkCocodInstalled(): Promise { - try { - const proc = Bun.spawn({ - cmd: ["which", "cocod"], - stdout: "pipe", - }); - const code = await proc.exited; - return code === 0; - } catch { - return false; - } + logger.log( + "\n use 'routstrd wallet receive cashu ' or 'routstrd wallet receive bolt11 2100' to top up your local wallet!", + ); } program @@ -168,11 +192,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, @@ -357,6 +384,139 @@ program }); }); +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 a5d8d6d..020a6c6 100644 --- a/src/daemon/http/index.ts +++ b/src/daemon/http/index.ts @@ -11,6 +11,34 @@ import { } from "../usage"; import type { UsageData } from "../types"; import { logger } from "../../utils/logger"; +import { + CocodHttpError, + type CocodClient, + type CocodState, +} from "../wallet/cocod-client"; +import { decodeCashuTokenAmount } from "../wallet"; + +type WalletStatusOutput = { + daemon: "running"; + wallet: "connected" | "error"; + walletState: CocodState; + balances?: Record; + 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; +}; async function readBody(req: IncomingMessage): Promise { return new Promise((resolve, reject) => { @@ -23,6 +51,11 @@ async function readBody(req: IncomingMessage): Promise { }); } +async function readJsonBody(req: IncomingMessage): Promise> { + const bodyText = await readBody(req); + return bodyText ? (JSON.parse(bodyText) as Record) : {}; +} + function parseLimit(value: string | null, fallback = 10): number { const requested = Number.parseInt(value || String(fallback), 10); return Number.isFinite(requested) && requested > 0 @@ -30,20 +63,152 @@ function parseLimit(value: string | null, fallback = 10): number { : fallback; } -export function createDaemonRequestHandler(deps: { - provider: string | null; - server: { close(cb?: () => void): void }; - store: any; - walletAdapter: any; - storageAdapter: any; - providerRegistry: any; - discoveryAdapter: any; - modelManager: any; - ensureProvidersBootstrapped: () => Promise; - getRoutstr21Models: (forceRefresh?: boolean) => Promise; - runWalletCommand: (args: string[]) => Promise; - parseBalances: (output: string) => Record; -}) { +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; +} + +async function buildStatusOutput(deps: DaemonDeps): Promise { + try { + const walletState = await deps.walletClient.getStatus(); + if (walletState !== "UNLOCKED") { + return { + daemon: "running", + wallet: "error", + walletState, + error: getWalletStateMessage(walletState), + }; + } + + const balances = await deps.walletAdapter.getBalances(); + return { + daemon: "running", + wallet: "connected", + walletState, + balances, + }; + } catch (error) { + return { + daemon: "running", + wallet: "error", + walletState: "ERROR", + 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); return async function handler(req: IncomingMessage, res: ServerResponse) { @@ -51,43 +216,126 @@ 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); - res.writeHead(200, { "Content-Type": "application/json" }); - res.end( - JSON.stringify({ - output: { - daemon: "running", - wallet: "connected", - 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; } @@ -96,11 +344,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; } @@ -110,23 +356,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); @@ -137,17 +378,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( @@ -164,12 +396,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; } @@ -193,27 +422,22 @@ export function createDaemonRequestHandler(deps: { 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; } @@ -221,19 +445,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; } @@ -273,32 +493,28 @@ export function createDaemonRequestHandler(deps: { })), ]; - res.writeHead(200, { "Content-Type": "application/json" }); - res.end( - JSON.stringify({ - output: { - keys, - total: totalWallet + totalCached + totalApiKeys, - unit: "sat", - apikeysCalled: apiKeys.length, - }, - }), - ); + 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) })); + respondWithError(res, error); } return; } if (req.method === "GET" && url.pathname === "/usage") { try { - const output = usageTracker.listRecent(parseLimit(url.searchParams.get("limit"))); - res.writeHead(200, { "Content-Type": "application/json" }); - res.end(JSON.stringify({ output })); + const output = usageTracker.listRecent( + parseLimit(url.searchParams.get("limit")), + ); + sendJson(res, 200, { output }); } catch (error) { - res.writeHead(500, { "Content-Type": "application/json" }); - res.end(JSON.stringify({ error: String(error) })); + sendJson(res, 500, { error: toErrorMessage(error) }); } return; } @@ -307,12 +523,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; } @@ -320,33 +533,26 @@ export function createDaemonRequestHandler(deps: { timestamp, parseLimit(url.searchParams.get("limit")), ); - res.writeHead(200, { "Content-Type": "application/json" }); - res.end(JSON.stringify({ output })); + sendJson(res, 200, { output }); } 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") { - res.writeHead(405, { "Content-Type": "application/json" }); - res.end(JSON.stringify({ error: "Only POST is supported." })); + sendJson(res, 405, { error: "Only POST is supported." }); return; } 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; } @@ -354,8 +560,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; } @@ -450,12 +655,9 @@ export function createDaemonRequestHandler(deps: { ...nonStreamUsage, }); } - res.writeHead(response.status, { - "Content-Type": "application/json", - }); - res.end(JSON.stringify(responseBody)); + sendJson(res, response.status, responseBody as Record); } catch (error) { - const message = error instanceof Error ? error.message : String(error); + const message = toErrorMessage(error); logger.error(`[daemon] Error: ${message}`); if (error instanceof InsufficientBalanceError) { @@ -465,22 +667,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 b5dd000..fa1038e 100644 --- a/src/daemon/index.ts +++ b/src/daemon/index.ts @@ -16,11 +16,8 @@ import { saveDaemonConfig, } from "./config-store"; import { createBunSqliteDriver } from "./sqlite-driver"; -import { - createWalletAdapter, - parseBalances, - runWalletCommand, -} from "./wallet"; +import { createWalletAdapter } from "./wallet"; +import { createCocodClient } from "./wallet/cocod-client"; import { createModelService } from "./models"; import { createDaemonRequestHandler } from "./http"; @@ -37,7 +34,9 @@ async function main(): Promise { saveDaemonConfig(updatedConfig); const sqliteDriver = createBunSqliteDriver(DB_PATH); - const store = await createSdkStore({ driver: sqliteDriver }); + const sdkStore = createSdkStore({ driver: sqliteDriver }); + await sdkStore.hydrate; + const store = sdkStore.store; const discoveryAdapter = createDiscoveryAdapterFromStore(store); const providerRegistry = createProviderRegistryFromStore(store); @@ -46,7 +45,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( @@ -55,6 +58,7 @@ async function main(): Promise { provider, server, store, + walletClient, walletAdapter, storageAdapter, providerRegistry, @@ -62,8 +66,6 @@ async function main(): Promise { modelManager, ensureProvidersBootstrapped, getRoutstr21Models, - runWalletCommand, - parseBalances, }), ); 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; From 851e35c3cd0546a2bc0fc17a8800930f477464ff Mon Sep 17 00:00:00 2001 From: Evan Yang Date: Mon, 23 Mar 2026 11:02:52 +0800 Subject: [PATCH 2/2] Return 400 for invalid wallet JSON bodies --- src/daemon/http/index.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/daemon/http/index.ts b/src/daemon/http/index.ts index 9e3f67c..b885dfc 100644 --- a/src/daemon/http/index.ts +++ b/src/daemon/http/index.ts @@ -87,7 +87,15 @@ async function readBody(req: IncomingMessage): Promise { async function readJsonBody(req: IncomingMessage): Promise> { const bodyText = await readBody(req); - return bodyText ? (JSON.parse(bodyText) as Record) : {}; + 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 {