Merge branch 'wallet-integration' of github.com-red:Routstr/routstrd

This commit is contained in:
redshift
2026-04-01 15:55:35 +02:00
7 changed files with 1577 additions and 317 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
@@ -16,7 +16,8 @@
"prepublishOnly": "bun run build"
},
"devDependencies": {
"@types/bun": "latest"
"@types/bun": "latest",
"@types/qrcode": "^1.5.6"
},
"peerDependencies": {
"typescript": "^5"
@@ -27,6 +28,7 @@
"applesauce-core": "^5.1.0",
"applesauce-relay": "^5.1.0",
"commander": "^14.0.2",
"qrcode": "^1.5.4",
"rxjs": "^7.8.1",
"zustand": "^5.0.5"
}
+198 -8
View File
@@ -20,6 +20,11 @@ import { logger } from "./utils/logger";
import { setupIntegration } from "./integrations";
import { createSdkStore } from "@routstr/sdk";
import { createBunSqliteDriver } from "@routstr/sdk/storage";
import * as QRCode from "qrcode";
import {
isCocodInstalled,
resolveCocodExecutable,
} from "./daemon/wallet/cocod-client";
type RoutstrModel = {
id: string;
@@ -44,6 +49,25 @@ type UsageEntry = {
const cliVersion = "0.1.1";
function parsePositiveIntOrExit(value: string, fieldName: string): number {
const parsed = Number.parseInt(value, 10);
if (!Number.isFinite(parsed) || parsed <= 0) {
console.error(`Invalid ${fieldName}: ${value}`);
process.exit(1);
}
return parsed;
}
async function printLightningInvoice(invoice: string): Promise<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...");
@@ -82,10 +106,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",
});
@@ -138,7 +191,6 @@ async function initDaemon(): Promise<void> {
logger.log("cocod initialized successfully.");
}
const config = await loadConfig();
await startDaemon({ port: String(config.port || 8008) });
// Create SDK store for integrations
@@ -149,7 +201,7 @@ async function initDaemon(): Promise<void> {
logger.log("\nInitialization complete!");
logger.log(
"\n use 'cocod receive cashu' or 'cocod receive bolt11 2100' to top up your local wallet!",
"\n use 'routstrd wallet receive cashu <token>' or 'routstrd wallet receive bolt11 2100' to top up your local wallet!",
);
}
@@ -188,13 +240,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,
@@ -620,6 +673,143 @@ program
await runUsageTui();
});
const walletCmd = program.command("wallet").description("Wallet operations");
walletCmd
.command("status")
.description("Check wallet status")
.action(async () => {
await handleDaemonCommand("/wallet/status");
});
walletCmd
.command("unlock <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")
+726 -162
View File
File diff suppressed because it is too large Load Diff
+7 -3
View File
@@ -16,6 +16,7 @@ import {
createBunSqliteUsageTrackingDriver,
} from "@routstr/sdk/storage";
import { createWalletAdapter, parseBalances, runWalletCommand } from "./wallet";
import { createCocodClient } from "./wallet/cocod-client";
import { createModelService } from "./models";
import { createDaemonRequestHandler } from "./http";
import { RoutstrClient } from "@routstr/sdk";
@@ -48,7 +49,11 @@ async function main(): Promise<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(
@@ -57,6 +62,7 @@ async function main(): Promise<void> {
provider,
server,
store,
walletClient,
walletAdapter,
storageAdapter,
providerRegistry,
@@ -64,8 +70,6 @@ async function main(): Promise<void> {
modelManager,
ensureProvidersBootstrapped,
getRoutstr21Models,
runWalletCommand,
parseBalances,
mode: config.mode || "apikeys",
usageTrackingDriver,
}),
+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;