Add cocod-backed wallet integration

This commit is contained in:
Evan Yang
2026-03-21 12:40:47 +08:00
parent 384a836550
commit 76ce27d518
7 changed files with 1217 additions and 355 deletions
+322 -5
View File
@@ -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",
+3 -1
View File
@@ -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"
}
+196 -36
View File
@@ -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<void> {
const paymentUri = `lightning:${invoice}`;
const qr = await QRCode.toString(paymentUri, {
type: "terminal",
small: true,
});
console.log(`${qr}\nInvoice:\n${invoice}`);
}
async function initDaemon(): Promise<void> {
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<void> {
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<void> {
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<boolean> {
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 <token>' or 'routstrd wallet receive bolt11 2100' to top up your local wallet!",
);
}
program
@@ -168,11 +192,14 @@ program
.option("--port <port>", "Port to listen on")
.option("-p, --provider <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 <passphrase>")
.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 <token>")
.description("Receive a Cashu token")
.action(async (token: string) => {
await handleDaemonCommand("/wallet/receive/cashu", {
method: "POST",
body: { token },
});
});
walletReceiveCmd
.command("bolt11 <amount>")
.description("Create a Lightning invoice")
.option("--mint-url <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 <amount>")
.description("Create a Cashu token to send")
.option("--mint-url <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 <invoice>")
.description("Pay a Lightning invoice")
.option("--mint-url <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 <url>")
.description("Add a wallet mint")
.action(async (url: string) => {
await handleDaemonCommand("/wallet/mints", {
method: "POST",
body: { url },
});
});
walletMintsCmd
.command("info <url>")
.description("Get wallet mint info")
.action(async (url: string) => {
await handleDaemonCommand("/wallet/mints/info", {
method: "POST",
body: { url },
});
});
// Stop
program
.command("stop")
+364 -166
View File
@@ -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<string, number>;
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<void>;
getRoutstr21Models: (forceRefresh?: boolean) => Promise<any[]>;
};
async function readBody(req: IncomingMessage): Promise<string> {
return new Promise((resolve, reject) => {
@@ -23,6 +51,11 @@ async function readBody(req: IncomingMessage): Promise<string> {
});
}
async function readJsonBody(req: IncomingMessage): Promise<Record<string, unknown>> {
const bodyText = await readBody(req);
return bodyText ? (JSON.parse(bodyText) as Record<string, unknown>) : {};
}
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<void>;
getRoutstr21Models: (forceRefresh?: boolean) => Promise<any[]>;
runWalletCommand: (args: string[]) => Promise<string>;
parseBalances: (output: string) => Record<string, number>;
}) {
function sendJson(
res: ServerResponse,
status: number,
payload: Record<string, unknown>,
): 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<Record<string, unknown>>,
): Promise<void> {
try {
sendJson(res, 200, await getPayload());
} catch (error) {
respondWithError(res, error);
}
}
function requireStringField(
body: Record<string, unknown>,
field: string,
): string | null {
const value = body[field];
return typeof value === "string" && value.trim() ? value.trim() : null;
}
function getRequiredStringField(
body: Record<string, unknown>,
field: string,
): string {
const value = requireStringField(body, field);
if (!value) {
throw new CocodHttpError(400, `Missing required '${field}' field.`);
}
return value;
}
function getRequiredPositiveNumberField(
body: Record<string, unknown>,
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<string, unknown>,
field: string,
): string | undefined {
const value = body[field];
return typeof value === "string" && value.trim() ? value.trim() : undefined;
}
async function buildStatusOutput(deps: DaemonDeps): Promise<WalletStatusOutput> {
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<string, number>;
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<string, unknown>);
} 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);
}
};
}
+11 -9
View File
@@ -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<void> {
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<void> {
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<void> {
provider,
server,
store,
walletClient,
walletAdapter,
storageAdapter,
providerRegistry,
@@ -62,8 +66,6 @@ async function main(): Promise<void> {
modelManager,
ensureProvidersBootstrapped,
getRoutstr21Models,
runWalletCommand,
parseBalances,
}),
);
+267
View File
@@ -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<T> = {
output?: T;
error?: string;
};
type CocodFetch = (
input: string | URL | Request,
init?: UnixRequestInit,
) => Promise<Response>;
type SpawnedProcess = {
exited: Promise<number>;
unref?: () => void;
};
type SpawnDaemon = (args: string[], env: Record<string, string>) => SpawnedProcess;
export type CocodState = "UNINITIALIZED" | "LOCKED" | "UNLOCKED" | "ERROR";
export type CocodBalanceOutput = Record<string, { sats?: number } | number>;
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<boolean>;
getStatus(): Promise<CocodState>;
unlock(passphrase: string): Promise<string>;
getBalances(): Promise<Record<string, number>>;
receiveCashu(token: string): Promise<string>;
receiveBolt11(amount: number, mintUrl?: string): Promise<string>;
sendCashu(amount: number, mintUrl?: string): Promise<string>;
sendBolt11(invoice: string, mintUrl?: string): Promise<string>;
listMints(): Promise<string[]>;
addMint(url: string): Promise<string>;
getMintInfo(url: string): Promise<unknown>;
}
export function resolveCocodExecutable(
cocodPath?: string | null,
): string {
const trimmed = cocodPath?.trim();
return trimmed || "cocod";
}
export async function isCocodInstalled(
cocodPath?: string | null,
): Promise<boolean> {
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<string, number> {
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<void> {
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<void> | null = null;
async function fetchJson<T>(
path: string,
init: Omit<UnixRequestInit, "unix"> = {},
): Promise<CommandResponse<T>> {
const requestInit: UnixRequestInit = {
...init,
unix: socketPath,
};
const response = await fetchImpl(`http://localhost${path}`, requestInit);
const data = (await response.json()) as CommandResponse<T>;
if (!response.ok) {
throw new CocodHttpError(
response.status,
data.error || response.statusText || `HTTP ${response.status}`,
);
}
return data;
}
async function pingInternal(): Promise<boolean> {
try {
await fetchJson<string>("/ping");
return true;
} catch {
return false;
}
}
async function startDaemon(): Promise<void> {
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<void> {
if (await pingInternal()) {
return;
}
if (!startPromise) {
logger.log(`Starting cocod daemon via ${executable}...`);
startPromise = startDaemon().finally(() => {
startPromise = null;
});
}
await startPromise;
}
async function callDaemon<T>(
path: string,
init: Omit<UnixRequestInit, "unix"> = {},
): Promise<T> {
await ensureDaemonRunning();
const response = await fetchJson<T>(path, init);
return response.output as T;
}
function post<T>(path: string, body: Record<string, unknown>): Promise<T> {
return callDaemon<T>(path, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
}
return {
async ping(): Promise<boolean> {
return pingInternal();
},
async getStatus(): Promise<CocodState> {
return callDaemon<CocodState>("/status");
},
async unlock(passphrase: string): Promise<string> {
return post<string>("/unlock", { passphrase });
},
async getBalances(): Promise<Record<string, number>> {
const output = await callDaemon<CocodBalanceOutput>("/balance");
return normalizeBalances(output);
},
async receiveCashu(token: string): Promise<string> {
return post<string>("/receive/cashu", { token });
},
async receiveBolt11(amount: number, mintUrl?: string): Promise<string> {
return post<string>("/receive/bolt11", { amount, mintUrl });
},
async sendCashu(amount: number, mintUrl?: string): Promise<string> {
return post<string>("/send/cashu", { amount, mintUrl });
},
async sendBolt11(invoice: string, mintUrl?: string): Promise<string> {
return post<string>("/send/bolt11", { invoice, mintUrl });
},
async listMints(): Promise<string[]> {
const output = await callDaemon<string>("/mints/list");
return parseMintList(output);
},
async addMint(url: string): Promise<string> {
return post<string>("/mints/add", { url });
},
async getMintInfo(url: string): Promise<unknown> {
return post<unknown>("/mints/info", { url });
},
};
}
+54 -138
View File
@@ -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<string> {
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<string, number> {
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<string, number> = {};
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<string, "sat" | "msat"> = {};
async function syncMintState(
balances?: Record<string, number>,
): Promise<Record<string, number>> {
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<Record<string, number>> {
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<string, "sat" | "msat"> {
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;