mirror of
https://github.com/Routstr/routstrd.git
synced 2026-10-05 12:28:23 +00:00
Merge remote-tracking branch 'origin/main'
This commit is contained in:
@@ -275,7 +275,6 @@ Configuration is stored in `~/.routstrd/config.json`:
|
||||
"port": 8008,
|
||||
"host": "127.0.0.1",
|
||||
"provider": null,
|
||||
"cocodPath": null,
|
||||
"autoModelPath": false,
|
||||
"autoRefresh": { "enabled": true }
|
||||
}
|
||||
|
||||
@@ -488,7 +488,6 @@ Config file: `~/.routstrd/config.json`
|
||||
| `port` | number | 8008 | Daemon HTTP port |
|
||||
| `host` | string | `"127.0.0.1"` | Bind address |
|
||||
| `provider` | string\|null | null | Default provider URL |
|
||||
| `cocodPath` | string\|null | null | Custom path to a legacy cocod executable |
|
||||
| `mode` | string | `"apikeys"` | Client mode (`apikeys` or `xcashu`) |
|
||||
| `maxTokens` | number | 64000 | Completion budget applied when a client sets no output-token limit |
|
||||
| `daemonUrl` | string | — | Remote daemon URL (set by `routstrd remote`) |
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
# NWC request timeouts
|
||||
|
||||
In `applesauce-wallet-connect@6.2.0`, encryption negotiation waits for a wallet-info event (kind 13194) before starting the response timeout. If a relay subscription stalls before that event arrives, the library request can remain pending indefinitely.
|
||||
|
||||
The wallet adapter adds an overall deadline: 15 seconds per read attempt and 45 seconds per payment. Reads can rebuild the relay connection and retry once. Payments are never automatically retried. The library still applies its own 30-second response timeout after negotiation; the 45-second deadline does not extend it.
|
||||
|
||||
Normal NIP-47 wallet errors do not rebuild the shared relay connection: a wallet error proves a response arrived, and rebuilding could interrupt unrelated payments. Transport failures and timeouts, including the library's own timeout, still trigger recovery.
|
||||
|
||||
Every CLI daemon request has a deadline covering headers and response-body consumption: 120 seconds by default, and 600 seconds for value-moving wallet routes (`/wallet/send/*`, `/wallet/receive/*`), whose mint operations can legitimately run longer. Aborting the CLI request never cancels the daemon-side operation; the longer bound only delays how soon the CLI reports the stall.
|
||||
|
||||
A payment timeout is an **unknown outcome**, not proof that no payment occurred. Promise deadlines do not cancel the underlying operation. Check the mint quote, wallet transactions, and Cashu balance before creating and paying another invoice.
|
||||
|
||||
The auto-refill loop starts when static configuration or a dynamic configuration getter is supplied, even without an NWC connection at startup. It reads the current wallet and configuration each cycle, so a later connection can activate refills without restarting the daemon.
|
||||
+1
-4
@@ -305,10 +305,7 @@ async function initDaemon(integrationKey?: IntegrationKey): Promise<void> {
|
||||
|
||||
// Create initial config (0600, atomic write)
|
||||
if (!existsSync(CONFIG_FILE)) {
|
||||
const config: RoutstrdConfig = {
|
||||
...DEFAULT_CONFIG,
|
||||
cocodPath: null,
|
||||
};
|
||||
const config: RoutstrdConfig = { ...DEFAULT_CONFIG };
|
||||
saveDaemonConfig(config);
|
||||
console.log(`Created config file: ${CONFIG_FILE}`);
|
||||
}
|
||||
|
||||
+23
-23
@@ -13,11 +13,11 @@ import type { HistoryEntry } from "@cashu/coco-core";
|
||||
import { logger } from "../../utils/logger";
|
||||
import { loadDaemonConfig, saveDaemonConfig } from "../config-store";
|
||||
import {
|
||||
CocodHttpError,
|
||||
type CocodClient,
|
||||
type CocodState,
|
||||
WalletHttpError,
|
||||
type WalletClient,
|
||||
type WalletRuntimeState,
|
||||
type WalletRecoveryProgress,
|
||||
} from "../wallet/cocod-client";
|
||||
} from "../wallet/wallet-client";
|
||||
import { receiveCashuToken } from "../wallet";
|
||||
import { getClientsFromStore } from "../../utils/clients";
|
||||
import { getUsageSummary } from "./usage-summary";
|
||||
@@ -51,7 +51,7 @@ type ClientMode = "xcashu" | "lazyrefund" | "apikeys";
|
||||
type WalletStatusOutput = {
|
||||
daemon: "running";
|
||||
wallet: "connected" | "recovering" | "error";
|
||||
walletState: CocodState;
|
||||
walletState: WalletRuntimeState;
|
||||
balances?: Record<string, number>;
|
||||
mode: ClientMode;
|
||||
error?: string;
|
||||
@@ -62,7 +62,7 @@ type DaemonDeps = {
|
||||
server: { close(cb?: () => void): void };
|
||||
shutdown?: () => void;
|
||||
store: any;
|
||||
walletClient: CocodClient;
|
||||
walletClient: WalletClient;
|
||||
walletAdapter: any;
|
||||
storageAdapter: any;
|
||||
discoveryAdapter: any;
|
||||
@@ -133,7 +133,7 @@ async function readJsonBody(
|
||||
try {
|
||||
return JSON.parse(bodyText) as Record<string, unknown>;
|
||||
} catch {
|
||||
throw new CocodHttpError(400, "Invalid JSON body.");
|
||||
throw new WalletHttpError(400, "Invalid JSON body.");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -201,7 +201,7 @@ function toErrorMessage(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
|
||||
function getWalletStateMessage(state: CocodState): string {
|
||||
function getWalletStateMessage(state: WalletRuntimeState): string {
|
||||
switch (state) {
|
||||
case "LOCKED":
|
||||
return "Wallet is locked. Unlock it before performing wallet operations.";
|
||||
@@ -221,7 +221,7 @@ function respondWithError(
|
||||
error: unknown,
|
||||
fallbackStatus = 500,
|
||||
): void {
|
||||
if (error instanceof CocodHttpError) {
|
||||
if (error instanceof WalletHttpError) {
|
||||
sendJson(res, error.status, { error: error.message });
|
||||
return;
|
||||
}
|
||||
@@ -254,7 +254,7 @@ function getRequiredStringField(
|
||||
): string {
|
||||
const value = requireStringField(body, field);
|
||||
if (!value) {
|
||||
throw new CocodHttpError(400, `Missing required '${field}' field.`);
|
||||
throw new WalletHttpError(400, `Missing required '${field}' field.`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
@@ -273,7 +273,7 @@ function getRequiredPositiveNumberField(
|
||||
return parsed;
|
||||
}
|
||||
}
|
||||
throw new CocodHttpError(400, `Missing required '${field}' field.`);
|
||||
throw new WalletHttpError(400, `Missing required '${field}' field.`);
|
||||
}
|
||||
|
||||
function optionalStringField(
|
||||
@@ -294,7 +294,7 @@ function optionalStringArrayField(
|
||||
!Array.isArray(value) ||
|
||||
value.some((item) => typeof item !== "string" || !item.trim())
|
||||
) {
|
||||
throw new CocodHttpError(400, `'${field}' must be an array of non-empty strings.`);
|
||||
throw new WalletHttpError(400, `'${field}' must be an array of non-empty strings.`);
|
||||
}
|
||||
return value.map((item: string) => item.trim());
|
||||
}
|
||||
@@ -350,7 +350,7 @@ async function buildStatusOutput(
|
||||
}
|
||||
|
||||
async function buildWalletDetails(deps: DaemonDeps): Promise<{
|
||||
state: CocodState;
|
||||
state: WalletRuntimeState;
|
||||
ready: boolean;
|
||||
balances?: Record<string, number>;
|
||||
unit?: "sat";
|
||||
@@ -412,7 +412,7 @@ const HISTORY_TYPE_SCAN_PAGE_SIZE = 200;
|
||||
* scan pages until enough matches accumulate (or history is exhausted).
|
||||
*/
|
||||
export async function getHistoryByTypes(
|
||||
client: Pick<CocodClient, "getHistory">,
|
||||
client: Pick<WalletClient, "getHistory">,
|
||||
types: string[],
|
||||
offset: number,
|
||||
limit: number,
|
||||
@@ -436,7 +436,7 @@ export function createDaemonRequestHandler(deps: {
|
||||
server: { close(cb?: () => void): void };
|
||||
shutdown?: () => void;
|
||||
store: any;
|
||||
walletClient: CocodClient;
|
||||
walletClient: WalletClient;
|
||||
walletAdapter: any;
|
||||
storageAdapter: any;
|
||||
discoveryAdapter: any;
|
||||
@@ -528,7 +528,7 @@ export function createDaemonRequestHandler(deps: {
|
||||
if (req.method === "POST" && url.pathname === "/wallet/cleanup") {
|
||||
await respond(res, async () => {
|
||||
if (!deps.walletClient.cleanupStuckOperations) {
|
||||
throw new CocodHttpError(
|
||||
throw new WalletHttpError(
|
||||
501,
|
||||
"Wallet cleanup is not supported by this wallet client.",
|
||||
);
|
||||
@@ -552,7 +552,7 @@ export function createDaemonRequestHandler(deps: {
|
||||
if (req.method === "POST" && url.pathname === "/wallet/recover") {
|
||||
await respond(res, async () => {
|
||||
if (!deps.walletClient.recoverMintQuotes) {
|
||||
throw new CocodHttpError(
|
||||
throw new WalletHttpError(
|
||||
501,
|
||||
"Mint quote recovery is not supported by this wallet client.",
|
||||
);
|
||||
@@ -561,7 +561,7 @@ export function createDaemonRequestHandler(deps: {
|
||||
const body = await readJsonBody(req);
|
||||
const operationIds = optionalStringArrayField(body, "operationIds");
|
||||
if (body.includeFailed === true && !operationIds?.length) {
|
||||
throw new CocodHttpError(
|
||||
throw new WalletHttpError(
|
||||
400,
|
||||
"'includeFailed' requires non-empty 'operationIds'.",
|
||||
);
|
||||
@@ -571,7 +571,7 @@ export function createDaemonRequestHandler(deps: {
|
||||
(typeof body.timeoutMs !== "number" ||
|
||||
!Number.isFinite(body.timeoutMs) || body.timeoutMs <= 0)
|
||||
) {
|
||||
throw new CocodHttpError(
|
||||
throw new WalletHttpError(
|
||||
400,
|
||||
"'timeoutMs' must be a positive finite number.",
|
||||
);
|
||||
@@ -589,7 +589,7 @@ export function createDaemonRequestHandler(deps: {
|
||||
if (req.method === "POST" && url.pathname === "/wallet/recover/operations") {
|
||||
await respond(res, async () => {
|
||||
if (!deps.walletClient.recoverStuckOperations) {
|
||||
throw new CocodHttpError(
|
||||
throw new WalletHttpError(
|
||||
501,
|
||||
"Stuck operation recovery is not supported by this wallet client.",
|
||||
);
|
||||
@@ -629,7 +629,7 @@ export function createDaemonRequestHandler(deps: {
|
||||
const operationId = decodeURIComponent(mintQuoteMatch[1]);
|
||||
await respond(res, async () => {
|
||||
const quote = await deps.walletClient.getMintQuote?.(operationId);
|
||||
if (!quote) throw new CocodHttpError(404, "Mint quote not found");
|
||||
if (!quote) throw new WalletHttpError(404, "Mint quote not found");
|
||||
return { output: quote };
|
||||
});
|
||||
return;
|
||||
@@ -773,12 +773,12 @@ export function createDaemonRequestHandler(deps: {
|
||||
const amount = typeof pr.amount === "number" ? pr.amount : 0;
|
||||
const mints = Array.isArray(pr.mints) ? pr.mints.join(", ") : "";
|
||||
if (confirm) {
|
||||
throw new CocodHttpError(
|
||||
throw new WalletHttpError(
|
||||
402,
|
||||
`Failed to set username. Required amount: ${amount} SATS. Required mints: ${mints}`,
|
||||
);
|
||||
}
|
||||
throw new CocodHttpError(
|
||||
throw new WalletHttpError(
|
||||
402,
|
||||
`Payment required to set username: ${amount} SATS. ` +
|
||||
`Use 'routstrd wallet npc username ${username} --confirm' to proceed`,
|
||||
|
||||
@@ -269,3 +269,14 @@ describe("POST /providers/nostr-sync", () => {
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
});
|
||||
|
||||
describe("wallet errors", () => {
|
||||
it("maps a WalletHttpError onto its HTTP status", async () => {
|
||||
const handler = createDaemonRequestHandler({
|
||||
walletClient: { getMintQuote: async () => null },
|
||||
} as any);
|
||||
const res = await call(handler, "GET", "/wallet/receive/bolt11/missing");
|
||||
expect(res.status).toBe(404);
|
||||
expect(res.json()).toEqual({ error: "Mint quote not found" });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -181,7 +181,6 @@ export async function runDaemon(argv: string[] = process.argv): Promise<void> {
|
||||
};
|
||||
|
||||
const walletAdapter = await createWalletAdapter({
|
||||
cocodPath: config.cocodPath,
|
||||
walletClient,
|
||||
getAutoRefillConfig,
|
||||
nwcConnectionString: config.nwc?.connectionString,
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
//
|
||||
// Uses applesauce-wallet-connect (same approach as nwc_integration/pay_invoice.mts).
|
||||
|
||||
import type { CocodClient } from "./cocod-client";
|
||||
import type { WalletClient } from "./wallet-client";
|
||||
import type { WalletConnect } from "applesauce-wallet-connect";
|
||||
import { logger } from "../../utils/logger";
|
||||
|
||||
@@ -33,10 +33,19 @@ function isFatalError(message: string): boolean {
|
||||
}
|
||||
|
||||
export function startAutoRefillLoop(
|
||||
cocod: CocodClient,
|
||||
walletClient: WalletClient,
|
||||
getWallet: () => WalletConnect | undefined,
|
||||
getConfig: () => AutoRefillConfig | undefined,
|
||||
intervalMs: number = 5000,
|
||||
payInvoice: (
|
||||
invoice: string,
|
||||
) => Promise<{ preimage?: string; fees_paid?: number }> = (invoice) => {
|
||||
const wallet = getWallet();
|
||||
if (!wallet?.service) {
|
||||
return Promise.reject(new Error("NWC not connected"));
|
||||
}
|
||||
return wallet.payInvoice(invoice);
|
||||
},
|
||||
): () => void {
|
||||
let lastRefillAt = 0;
|
||||
let lastAttemptAt = 0; // tracks last attempt (success or failure) for backoff
|
||||
@@ -79,7 +88,7 @@ export function startAutoRefillLoop(
|
||||
checkInProgress = true;
|
||||
|
||||
try {
|
||||
const balances = await cocod.getBalances();
|
||||
const balances = await walletClient.getBalances();
|
||||
const totalBalance = Object.values(balances).reduce<number>(
|
||||
(sum, b) => sum + (typeof b === "number" ? b : 0),
|
||||
0,
|
||||
@@ -94,17 +103,20 @@ export function startAutoRefillLoop(
|
||||
`[auto-refill] Balance ${totalBalance} sats < threshold ${config.threshold}. Refilling ${config.amount} sats...`,
|
||||
);
|
||||
|
||||
const mintUrl = await cocod.getDefaultMint();
|
||||
const mintUrl = await walletClient.getDefaultMint();
|
||||
if (!mintUrl) {
|
||||
logger.error("[auto-refill] No default mint configured");
|
||||
return;
|
||||
}
|
||||
|
||||
// Step 1: Create a BOLT-11 invoice via cocod to fund the Cashu wallet
|
||||
// Step 1: Create a BOLT-11 invoice via the wallet to fund the Cashu wallet
|
||||
logger.log(
|
||||
`[auto-refill] Creating BOLT-11 invoice for ${config.amount} sats via ${mintUrl}...`,
|
||||
);
|
||||
const { invoice } = await cocod.receiveBolt11(config.amount, mintUrl);
|
||||
const { invoice } = await walletClient.receiveBolt11(
|
||||
config.amount,
|
||||
mintUrl,
|
||||
);
|
||||
|
||||
// Step 2: Pay the invoice via NWC (applesauce)
|
||||
logger.log(`[auto-refill] Paying invoice via NWC...`);
|
||||
@@ -113,10 +125,10 @@ export function startAutoRefillLoop(
|
||||
logger.log("[auto-refill] Wallet disconnected during refill check");
|
||||
return;
|
||||
}
|
||||
const payment = await currentWallet.payInvoice(invoice);
|
||||
const payment = await payInvoice(invoice);
|
||||
|
||||
// Step 3: The Cashu mint should automatically detect the paid invoice
|
||||
// and issue tokens. We don't need to explicitly mint here; cocod
|
||||
// and issue tokens. We don't need to explicitly mint here; the wallet
|
||||
// handles this on its end when the mint sees the payment.
|
||||
const preimage = payment.preimage;
|
||||
if (preimage) {
|
||||
|
||||
@@ -39,7 +39,6 @@ mock.module("coco-cashu-plugin-npc", () => ({
|
||||
}));
|
||||
|
||||
const { createCocoClient } = await import("./coco-client");
|
||||
const { createCocodClient, CocodHttpError } = await import("./cocod-client");
|
||||
|
||||
const TEST_MNEMONIC =
|
||||
"abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about";
|
||||
@@ -169,84 +168,3 @@ describe("in-process coco client NPC integration", () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("legacy cocod client NPC passthroughs", () => {
|
||||
type FetchImpl = (
|
||||
input: string | URL | Request,
|
||||
init?: RequestInit & { unix: string },
|
||||
) => Promise<Response>;
|
||||
|
||||
function legacyClient(handler: (path: string, body?: unknown) => Response) {
|
||||
const requests: Array<{ path: string; body?: unknown }> = [];
|
||||
const fetchImpl: FetchImpl = async (input, init) => {
|
||||
const path = String(input).replace("http://localhost", "");
|
||||
const body =
|
||||
typeof init?.body === "string" ? JSON.parse(init.body) : undefined;
|
||||
if (path !== "/ping") requests.push({ path, body });
|
||||
if (path === "/ping") {
|
||||
return new Response(JSON.stringify({ output: "pong" }));
|
||||
}
|
||||
return handler(path, body);
|
||||
};
|
||||
const client = createCocodClient({
|
||||
socketPath: "/tmp/routstrd-test-legacy-npc/cocod.sock",
|
||||
fetchImpl,
|
||||
});
|
||||
return { client, requests };
|
||||
}
|
||||
|
||||
it("fetches and parses a username NPC address", async () => {
|
||||
const { client, requests } = legacyClient(
|
||||
() => new Response(JSON.stringify({ output: "alice@npubx.cash" })),
|
||||
);
|
||||
const info = await client.getNpcAddress();
|
||||
expect(info).toEqual({ address: "alice@npubx.cash", name: "alice" });
|
||||
expect(requests[0]?.path).toBe("/npc/address");
|
||||
});
|
||||
|
||||
it("omits the name for npub fallback addresses", async () => {
|
||||
const { client } = legacyClient(
|
||||
() =>
|
||||
new Response(JSON.stringify({ output: "npub1abc123@npubx.cash" })),
|
||||
);
|
||||
const info = await client.getNpcAddress();
|
||||
expect(info).toEqual({ address: "npub1abc123@npubx.cash" });
|
||||
});
|
||||
|
||||
it("posts username claims with the confirm flag", async () => {
|
||||
const { client, requests } = legacyClient(
|
||||
() => new Response(JSON.stringify({ output: { success: true } })),
|
||||
);
|
||||
const result = await client.setNpcUsername("bob", true);
|
||||
expect(result).toEqual({ success: true });
|
||||
expect(requests[0]).toEqual({
|
||||
path: "/npc/username",
|
||||
body: { username: "bob", confirm: true },
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves the 402 payment-required error from cocod", async () => {
|
||||
const { client } = legacyClient(
|
||||
() =>
|
||||
new Response(
|
||||
JSON.stringify({ error: "Payment required to set username" }),
|
||||
{ status: 402 },
|
||||
),
|
||||
);
|
||||
try {
|
||||
await client.setNpcUsername("bob");
|
||||
expect.unreachable("should have thrown");
|
||||
} catch (error) {
|
||||
expect(error).toBeInstanceOf(CocodHttpError);
|
||||
expect((error as InstanceType<typeof CocodHttpError>).status).toBe(402);
|
||||
expect((error as Error).message).toContain("Payment required");
|
||||
}
|
||||
});
|
||||
|
||||
it("reports manual sync as unsupported", async () => {
|
||||
const { client } = legacyClient(
|
||||
() => new Response(JSON.stringify({ output: {} })),
|
||||
);
|
||||
await expect(client.syncNpc()).rejects.toThrow("not supported");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { withTimeout as withRequestTimeout } from "../../utils/with-timeout";
|
||||
import { recoveryKey, trackRecovery, drainRecoveryWork, waitForRecoveryWork, createRecoveryDisposer, type RecoveryWork } from "./recovery-work";
|
||||
import {
|
||||
Manager,
|
||||
@@ -29,14 +30,14 @@ import {
|
||||
import { dirname, join } from "path";
|
||||
import { mnemonicToSeedSync } from "@scure/bip39";
|
||||
import type {
|
||||
CocodClient,
|
||||
CocodState,
|
||||
WalletClient,
|
||||
WalletRuntimeState,
|
||||
NpcAddress,
|
||||
NpcUsernameResult,
|
||||
WalletCleanupOptions,
|
||||
WalletCleanupResult,
|
||||
WalletRecoveryProgress,
|
||||
} from "./cocod-client";
|
||||
} from "./wallet-client";
|
||||
import { selectCleanupOperations, summarizeMintCleanup } from "./cleanup";
|
||||
import {
|
||||
classifyMintQuoteObservation,
|
||||
@@ -733,16 +734,7 @@ const EXPIRED_MINT_OBSERVATION_DEADLINE_MS = 15_000;
|
||||
/** Rejects when `timeoutMs` elapses before `promise` settles. */
|
||||
function withTimeout<T>(promise: Promise<T>, timeoutMs: number): Promise<T> {
|
||||
if (timeoutMs === Infinity) return promise;
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
const timeout = new Promise<never>((_resolve, reject) => {
|
||||
timer = setTimeout(
|
||||
() => reject(new Error("Timed out contacting mint")),
|
||||
timeoutMs,
|
||||
);
|
||||
});
|
||||
return Promise.race([promise, timeout]).finally(() => {
|
||||
if (timer !== undefined) clearTimeout(timer);
|
||||
});
|
||||
return withRequestTimeout(promise, timeoutMs, "Timed out contacting mint");
|
||||
}
|
||||
|
||||
/** Structural subset of coco's Manager used by expired-quote settlement. */
|
||||
@@ -1820,7 +1812,7 @@ export async function runWalletRecovery(
|
||||
|
||||
export async function createCocoClient(
|
||||
options: CreateCocoClientOptions = {},
|
||||
): Promise<CocodClient> {
|
||||
): Promise<WalletClient> {
|
||||
const configDir = options.walletDir || options.configDir || defaultWalletDir();
|
||||
const configFile = join(configDir, "config.json");
|
||||
const dbPath = join(configDir, "coco.db");
|
||||
@@ -2156,7 +2148,7 @@ export async function createCocoClient(
|
||||
}
|
||||
},
|
||||
|
||||
async getStatus(): Promise<CocodState> {
|
||||
async getStatus(): Promise<WalletRuntimeState> {
|
||||
if (recoveryError) return "ERROR";
|
||||
if (!recoveryDone) return "RECOVERING";
|
||||
try {
|
||||
|
||||
@@ -1,579 +0,0 @@
|
||||
import { existsSync } from "fs";
|
||||
import { createHash } from "crypto";
|
||||
import { isAbsolute } from "path";
|
||||
import { logger } from "../../utils/logger";
|
||||
import { withCrossProcessLock } from "../../utils/process-lock";
|
||||
import type { HistoryEntry } from "@cashu/coco-core";
|
||||
|
||||
const DEFAULT_CONFIG_DIR =
|
||||
process.env.COCOD_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"
|
||||
| "RECOVERING"
|
||||
| "ERROR";
|
||||
|
||||
/** Live progress for background wallet recovery started at daemon startup. */
|
||||
export interface WalletRecoveryProgress {
|
||||
state: "RECOVERING" | "UNLOCKED" | "ERROR";
|
||||
/** Current recovery phase, e.g. "Mint recovery" or "done". */
|
||||
phase: string;
|
||||
pendingSends: number;
|
||||
inflightProofs: number;
|
||||
pendingMints: number;
|
||||
/** Expired unpaid mint quotes failed locally without a mint round-trip. */
|
||||
failedMintQuotes: number;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export type CocodBalanceOutput = Record<string, { sats?: number } | number>;
|
||||
|
||||
/** NPC (npubx.cash) Lightning address details for this wallet. */
|
||||
export interface NpcAddress {
|
||||
/** Full Lightning address, e.g. "alice@npubx.cash" (npub fallback when no username is set). */
|
||||
address: string;
|
||||
/** NPC username, when one has been claimed. */
|
||||
name?: string;
|
||||
/** Nostr hex pubkey of the NPC account (only available from the in-process wallet). */
|
||||
pubkey?: string;
|
||||
}
|
||||
|
||||
/** Result of an NPC username claim attempt. */
|
||||
export interface NpcUsernameResult {
|
||||
success: boolean;
|
||||
/** Present when NPC requires payment to claim the username. */
|
||||
paymentRequest?: {
|
||||
amount?: number;
|
||||
mints?: string[];
|
||||
[key: string]: unknown;
|
||||
};
|
||||
}
|
||||
|
||||
/** Options for the wallet cleanup command. */
|
||||
export interface WalletCleanupOptions {
|
||||
/** Only clean up operations for this mint URL. */
|
||||
mintUrl?: string;
|
||||
/** Minimum operation age in milliseconds (defaults to 7 days / 1 week). */
|
||||
minAgeMs?: number;
|
||||
/** Report what would be cleaned without applying changes. */
|
||||
dryRun?: boolean;
|
||||
/**
|
||||
* Fail expired mint quotes without confirming UNPAID with the mint. Only for
|
||||
* operators who accept the risk of stranding a quote that was paid before
|
||||
* its invoice expired; recovery is the safe default.
|
||||
*/
|
||||
force?: boolean;
|
||||
}
|
||||
|
||||
/** Summary of a wallet cleanup run. */
|
||||
export interface WalletCleanupResult {
|
||||
dryRun: boolean;
|
||||
/** Expired quotes selected for checking; dry runs do not contact the mint. */
|
||||
mintQuoteCandidates: number;
|
||||
/** Number actually marked failed (always zero in a dry run). */
|
||||
failedMintQuotes: number;
|
||||
/** Expired quotes kept pending because they are paid/issued or unverified. */
|
||||
leftForRecovery: number;
|
||||
/** Number of stale pending send operations reclaimed. */
|
||||
reclaimedSends: number;
|
||||
/** Number of stale prepared melt operations cancelled. */
|
||||
cancelledMelts: number;
|
||||
/** Number of in-flight operations that were left untouched. */
|
||||
skipped: number;
|
||||
errors: Array<{ operationId: string; error: string }>;
|
||||
}
|
||||
|
||||
export class CocodHttpError extends Error {
|
||||
status: number;
|
||||
|
||||
constructor(status: number, message: string) {
|
||||
super(message);
|
||||
this.name = "CocodHttpError";
|
||||
this.status = status;
|
||||
}
|
||||
}
|
||||
|
||||
/** Progress of a Lightning top-up created with `receiveBolt11`. */
|
||||
export interface MintQuoteStatus {
|
||||
operationId: string;
|
||||
state: "pending" | "executing" | "finalized" | "failed";
|
||||
/** Last quote state reported by the mint (UNPAID, PAID, ISSUED). */
|
||||
mintState?: string;
|
||||
amount: number;
|
||||
mintUrl: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/** Options for explicit PAID mint-quote recovery. */
|
||||
export interface WalletMintQuoteRecoveryOptions {
|
||||
/** Target only these operation ids (may include failed operations). */
|
||||
operationIds?: string[];
|
||||
/** Re-open failed operations instead of skipping them. */
|
||||
includeFailed?: boolean;
|
||||
/** Per-quote mint timeout in milliseconds. */
|
||||
timeoutMs?: number;
|
||||
}
|
||||
|
||||
/** Summary of a PAID mint-quote recovery run. */
|
||||
export interface WalletMintQuoteRecoveryResult {
|
||||
/** Operations whose quote state was checked with the mint. */
|
||||
checked: number;
|
||||
/** Operations whose paid sats were minted or restored. */
|
||||
recovered: number;
|
||||
/** Quotes the mint still reports UNPAID; left pending. */
|
||||
waiting: number;
|
||||
/** Quotes the mint can no longer issue. */
|
||||
terminal: number;
|
||||
/** Failed operations moved back to pending before checking. */
|
||||
reopened: number;
|
||||
/** Operations left to a later run (mint unreachable, budget spent, non-terminal). */
|
||||
retryable: number;
|
||||
/** Operations skipped because an earlier recovery of them is still running. */
|
||||
busy: number;
|
||||
errors: Array<{ operationId: string; error: string }>;
|
||||
}
|
||||
|
||||
/** Summary of a stuck-operation (send/melt/mint) recovery run. */
|
||||
export interface WalletStuckOperationRecoveryResult {
|
||||
/** Timed-out waits; the underlying operation remains tracked. */
|
||||
timedOut: number;
|
||||
/** Operations for which recovery was attempted (not necessarily completed). */
|
||||
attempted: number;
|
||||
/** Locked operations or unfinished work from another pass; retry later. */
|
||||
busy: number;
|
||||
/** Operations skipped for unreachable mints, shutdown, or pass budget exhaustion. */
|
||||
skipped: number;
|
||||
/** Operations at reachable mints whose recovery still failed. */
|
||||
failed: number;
|
||||
/** Unreachable mint URL -> number of operations skipped there. */
|
||||
skippedMints: Record<string, number>;
|
||||
}
|
||||
|
||||
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<{ invoice: string; operationId?: string }>;
|
||||
/** Progress of a top-up, when the wallet tracks mint operations. */
|
||||
getMintQuote?(operationId: string): Promise<MintQuoteStatus | null>;
|
||||
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>;
|
||||
getDefaultMint(): Promise<string | null>;
|
||||
setDefaultMint(url: string): Promise<string>;
|
||||
/** Release resources held by in-process wallet implementations. */
|
||||
dispose?(): Promise<void>;
|
||||
getHistory(offset?: number, limit?: number): Promise<HistoryEntry[]>;
|
||||
/** Look up a single transaction by its history entry ID. */
|
||||
getHistoryEntryById(id: string): Promise<HistoryEntry | null>;
|
||||
/** NPC (npubx.cash) Lightning address for this wallet. */
|
||||
getNpcAddress(): Promise<NpcAddress>;
|
||||
/** Claim an NPC username; pass confirm=true to pay the claim fee from the wallet. */
|
||||
setNpcUsername(username: string, confirm?: boolean): Promise<NpcUsernameResult>;
|
||||
/** Manually trigger an NPC quote sync into the wallet. */
|
||||
syncNpc(): Promise<void>;
|
||||
/** Clear stuck pending/in-flight wallet operations that are safe to resolve. */
|
||||
cleanupStuckOperations?(
|
||||
options?: WalletCleanupOptions,
|
||||
): Promise<WalletCleanupResult>;
|
||||
/**
|
||||
* Re-issue PAID mint quotes whose sats were never claimed, optionally
|
||||
* targeting specific operations (including ones coco already failed).
|
||||
*/
|
||||
recoverMintQuotes?(
|
||||
options?: WalletMintQuoteRecoveryOptions,
|
||||
onProgress?: (message: string) => void,
|
||||
): Promise<WalletMintQuoteRecoveryResult>;
|
||||
/**
|
||||
* Recover stuck send/melt/mint operations whose mints answer a
|
||||
* reachability probe. Operations a live execute holds are reported busy,
|
||||
* never driven. Receive stays startup-only (receive dedup classification).
|
||||
*/
|
||||
recoverStuckOperations?(): Promise<WalletStuckOperationRecoveryResult>;
|
||||
/** Report background wallet recovery progress, when the wallet supports it. */
|
||||
getRecoveryProgress?(): Promise<WalletRecoveryProgress>;
|
||||
}
|
||||
|
||||
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 (
|
||||
isAbsolute(executable) ||
|
||||
executable.includes("/") ||
|
||||
executable.includes("\\")
|
||||
) {
|
||||
return existsSync(executable);
|
||||
}
|
||||
|
||||
try {
|
||||
const command = process.platform === "win32" ? "where.exe" : "which";
|
||||
const proc = Bun.spawn({
|
||||
cmd: [command, 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));
|
||||
}
|
||||
|
||||
function toErrorText(value: unknown): string {
|
||||
if (typeof value === "string") {
|
||||
return value.trim();
|
||||
}
|
||||
if (value === null || value === undefined) {
|
||||
return "";
|
||||
}
|
||||
try {
|
||||
return JSON.stringify(value);
|
||||
} catch {
|
||||
return String(value);
|
||||
}
|
||||
}
|
||||
|
||||
function tokenFingerprint(token: string): string {
|
||||
return createHash("sha256").update(token).digest("hex").slice(0, 12);
|
||||
}
|
||||
|
||||
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 startupLockPath = `${socketPath}.startup.lock`;
|
||||
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 method = init.method || "GET";
|
||||
const requestInit: UnixRequestInit = {
|
||||
...init,
|
||||
unix: socketPath,
|
||||
};
|
||||
|
||||
const response = await fetchImpl(`http://localhost${path}`, requestInit);
|
||||
const rawText = await response.text();
|
||||
// logger.debug(
|
||||
// `[fetchJson] ${method} ${path} status=${response.status} body=${rawText}`,
|
||||
// );
|
||||
|
||||
if (!rawText.trim()) {
|
||||
throw new CocodHttpError(
|
||||
response.ok ? 502 : response.status,
|
||||
`Empty response from cocod for ${method} ${path}`,
|
||||
);
|
||||
}
|
||||
|
||||
let data: CommandResponse<T>;
|
||||
try {
|
||||
data = JSON.parse(rawText) as CommandResponse<T>;
|
||||
} catch {
|
||||
throw new CocodHttpError(
|
||||
response.ok ? 502 : response.status,
|
||||
`Invalid JSON response from cocod for ${method} ${path}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (!data || typeof data !== "object") {
|
||||
throw new CocodHttpError(
|
||||
response.ok ? 502 : response.status,
|
||||
`Unexpected response shape from cocod for ${method} ${path}`,
|
||||
);
|
||||
}
|
||||
|
||||
const errorMessage = toErrorText((data as CommandResponse<T>).error);
|
||||
if (errorMessage) {
|
||||
throw new CocodHttpError(
|
||||
response.ok ? 400 : response.status,
|
||||
errorMessage,
|
||||
);
|
||||
}
|
||||
|
||||
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, "init"], 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 && exitCode !== 0) {
|
||||
throw new Error(`cocod init exited early with code ${exitCode}`);
|
||||
}
|
||||
|
||||
if (await pingInternal()) {
|
||||
logger.debug(`Connected to cocod daemon on ${socketPath}`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`cocod failed to start within ${Math.round(startupTimeoutMs / 1000)} seconds`,
|
||||
);
|
||||
}
|
||||
|
||||
async function ensureDaemonRunning(): Promise<void> {
|
||||
if (await pingInternal()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!startPromise) {
|
||||
startPromise = withCrossProcessLock(
|
||||
startupLockPath,
|
||||
async () => {
|
||||
if (await pingInternal()) {
|
||||
return;
|
||||
}
|
||||
|
||||
logger.debug(`Starting cocod daemon via ${executable} init...`);
|
||||
await startDaemon();
|
||||
},
|
||||
{
|
||||
acquireTimeoutMs: startupTimeoutMs + 30_000,
|
||||
staleAfterMs: startupTimeoutMs + 30_000,
|
||||
log: (message) => logger.debug(message),
|
||||
},
|
||||
).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> {
|
||||
logger.debug(
|
||||
`[receiveCashu] Receiving Cashu token ${tokenFingerprint(token)}`,
|
||||
);
|
||||
const message = await callDaemon<string>("/receive/cashu", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ token }),
|
||||
});
|
||||
if (typeof message !== "string" || !message.trim()) {
|
||||
throw new CocodHttpError(
|
||||
502,
|
||||
"Unexpected response from cocod while receiving Cashu token.",
|
||||
);
|
||||
}
|
||||
logger.debug(`[receiveCashu] Full response.output:`, message);
|
||||
return message;
|
||||
},
|
||||
async receiveBolt11(amount: number, mintUrl?: string) {
|
||||
return { invoice: await 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 });
|
||||
},
|
||||
async getDefaultMint(): Promise<string | null> {
|
||||
return callDaemon<string | null>("/mints/default");
|
||||
},
|
||||
async setDefaultMint(url: string): Promise<string> {
|
||||
return post<string>("/mints/default", { url });
|
||||
},
|
||||
async getHistory(_offset?: number, _limit?: number): Promise<HistoryEntry[]> {
|
||||
return [];
|
||||
},
|
||||
async getHistoryEntryById(_id: string): Promise<HistoryEntry | null> {
|
||||
return null;
|
||||
},
|
||||
async getNpcAddress(): Promise<NpcAddress> {
|
||||
const address = await callDaemon<string>("/npc/address");
|
||||
if (typeof address !== "string" || !address.trim()) {
|
||||
throw new CocodHttpError(
|
||||
502,
|
||||
"Unexpected response from cocod while fetching NPC address.",
|
||||
);
|
||||
}
|
||||
const trimmed = address.trim();
|
||||
const localPart = trimmed.split("@")[0];
|
||||
return {
|
||||
address: trimmed,
|
||||
...(localPart && !localPart.startsWith("npub1")
|
||||
? { name: localPart }
|
||||
: {}),
|
||||
};
|
||||
},
|
||||
async setNpcUsername(
|
||||
username: string,
|
||||
confirm?: boolean,
|
||||
): Promise<NpcUsernameResult> {
|
||||
// cocod answers 402 with a payment-required message when the claim fee
|
||||
// has not been confirmed; fetchJson preserves that status on the thrown
|
||||
// CocodHttpError, so callers can surface it unchanged.
|
||||
const result = await post<{ success?: boolean }>("/npc/username", {
|
||||
username,
|
||||
confirm: confirm === true,
|
||||
});
|
||||
return { success: result?.success !== false };
|
||||
},
|
||||
async syncNpc(): Promise<void> {
|
||||
throw new CocodHttpError(
|
||||
501,
|
||||
"Manual NPC sync is not supported by the legacy cocod client.",
|
||||
);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
import { afterAll, beforeEach, describe, expect, it, mock } from "bun:test";
|
||||
import { RestrictedError } from "applesauce-wallet-connect/helpers/error";
|
||||
import type { WalletAdapterOptions } from "./index";
|
||||
|
||||
/**
|
||||
* Regression tests for the NWC hang fixed in this change.
|
||||
*
|
||||
* `applesauce-wallet-connect` applies its request timeout only after it has
|
||||
* negotiated encryption from the wallet's `kind:13194` info event. On a stale
|
||||
* relay subscription that negotiation never completes, so `getInfo()` /
|
||||
* `getBalance()` wait forever. The adapter must bound the wait, rebuild the
|
||||
* relay connection, and retry once instead of hanging `routstrd nwc status`.
|
||||
*/
|
||||
|
||||
type Behavior = "resolve" | "hang" | "restricted" | "library-timeout" | "deferred";
|
||||
|
||||
const state = {
|
||||
infoQueue: [] as Behavior[],
|
||||
balanceQueue: [] as Behavior[],
|
||||
payQueue: [] as Behavior[],
|
||||
createdInstances: 0,
|
||||
pendingPayments: [] as (() => void)[],
|
||||
paymentStarted: undefined as (() => void) | undefined,
|
||||
closedPools: 0,
|
||||
};
|
||||
|
||||
function next(queue: Behavior[]): Behavior {
|
||||
return queue.shift() ?? "resolve";
|
||||
}
|
||||
|
||||
class MockRelayPool {
|
||||
relays = new Map<string, unknown>([["wss://relay.example", {}]]);
|
||||
remove(url: string, _close?: boolean): void {
|
||||
state.closedPools++;
|
||||
this.relays.delete(url);
|
||||
}
|
||||
}
|
||||
|
||||
class MockWalletConnect {
|
||||
service = "ab".repeat(32);
|
||||
relays = ["wss://relay.example"];
|
||||
|
||||
constructor() {
|
||||
state.createdInstances += 1;
|
||||
}
|
||||
|
||||
static fromConnectURI(_uri: string): MockWalletConnect {
|
||||
return new MockWalletConnect();
|
||||
}
|
||||
|
||||
waitForService(): Promise<string> {
|
||||
return Promise.resolve(this.service);
|
||||
}
|
||||
|
||||
getInfo(): Promise<{
|
||||
alias: string;
|
||||
pubkey: string;
|
||||
network: string;
|
||||
methods: string[];
|
||||
}> {
|
||||
const behavior = next(state.infoQueue);
|
||||
if (behavior === "hang") return new Promise<never>(() => {});
|
||||
if (behavior === "restricted") return Promise.reject(new RestrictedError("restricted"));
|
||||
if (behavior === "library-timeout") return Promise.reject(new Error("Timeout"));
|
||||
return Promise.resolve({
|
||||
alias: "Test Wallet",
|
||||
pubkey: "cd".repeat(32),
|
||||
network: "mainnet",
|
||||
methods: ["get_balance", "get_info"],
|
||||
});
|
||||
}
|
||||
|
||||
getBalance(): Promise<{ balance: number }> {
|
||||
const behavior = next(state.balanceQueue);
|
||||
if (behavior === "hang") return new Promise<never>(() => {});
|
||||
if (behavior === "restricted") return Promise.reject(new RestrictedError("restricted"));
|
||||
return Promise.resolve({ balance: 123_000 });
|
||||
}
|
||||
|
||||
payInvoice(
|
||||
_invoice: string,
|
||||
): Promise<{ preimage: string; fees_paid: number }> {
|
||||
const behavior = next(state.payQueue);
|
||||
if (behavior === "hang") return new Promise<never>(() => {});
|
||||
if (behavior === "restricted") return Promise.reject(new RestrictedError("restricted"));
|
||||
if (behavior === "library-timeout") return Promise.reject(new Error("Timeout"));
|
||||
if (behavior === "deferred") {
|
||||
return new Promise((resolve) => {
|
||||
const closedAtStart = state.closedPools;
|
||||
state.pendingPayments.push(() => {
|
||||
// Closing the shared pool loses the original payment response.
|
||||
if (state.closedPools === closedAtStart) {
|
||||
resolve({ preimage: "00".repeat(32), fees_paid: 1000 });
|
||||
}
|
||||
});
|
||||
state.paymentStarted?.();
|
||||
});
|
||||
}
|
||||
return Promise.resolve({ preimage: "00".repeat(32), fees_paid: 1000 });
|
||||
}
|
||||
}
|
||||
|
||||
mock.module("applesauce-wallet-connect", () => ({
|
||||
WalletConnect: MockWalletConnect,
|
||||
}));
|
||||
mock.module("applesauce-relay", () => ({ RelayPool: MockRelayPool }));
|
||||
|
||||
const { createWalletAdapter } = await import("./index");
|
||||
|
||||
/**
|
||||
* Type of the injected wallet client, derived from the adapter options so this
|
||||
* test keeps compiling when the legacy `CocodClient` is replaced (see #118).
|
||||
*/
|
||||
type WalletClientOption = NonNullable<WalletAdapterOptions["walletClient"]>;
|
||||
|
||||
function makeClient(): WalletClientOption {
|
||||
return {
|
||||
getBalances: async () => ({ "https://mint.example": 0 }),
|
||||
getDefaultMint: async () => "https://mint.example",
|
||||
receiveBolt11: async () => ({ invoice: "lnbc-test-invoice" }),
|
||||
receiveCashu: async () => "ok",
|
||||
} as unknown as WalletClientOption;
|
||||
}
|
||||
|
||||
function makeAdapter(timeoutMs = 25) {
|
||||
return createWalletAdapter({
|
||||
walletClient: makeClient(),
|
||||
nwcConnectionString:
|
||||
"nostr+walletconnect://" +
|
||||
"ab".repeat(32) +
|
||||
"?relay=wss%3A%2F%2Frelay.example&secret=" +
|
||||
"11".repeat(32),
|
||||
nwcReadTimeoutMs: timeoutMs,
|
||||
nwcPayTimeoutMs: timeoutMs,
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
state.infoQueue = [];
|
||||
state.balanceQueue = [];
|
||||
state.payQueue = [];
|
||||
state.createdInstances = 0;
|
||||
state.closedPools = 0;
|
||||
state.pendingPayments = [];
|
||||
state.paymentStarted = undefined;
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
mock.restore();
|
||||
});
|
||||
|
||||
describe("NWC status resilience", () => {
|
||||
it("rebuilds a stale relay connection and still reports status", async () => {
|
||||
state.infoQueue = ["hang"]; // first attempt stalls, retry succeeds
|
||||
|
||||
const adapter = await makeAdapter();
|
||||
const status = await adapter.getNwcStatus();
|
||||
|
||||
expect(status.connected).toBe(true);
|
||||
expect(status.alias).toBe("Test Wallet");
|
||||
expect(status.balance).toBe(123);
|
||||
// initial connection + one rebuild
|
||||
expect(state.createdInstances).toBe(2);
|
||||
});
|
||||
|
||||
it("returns a bounded error instead of hanging forever", async () => {
|
||||
state.infoQueue = ["hang", "hang"]; // every attempt stalls
|
||||
|
||||
const adapter = await makeAdapter();
|
||||
const startedAt = Date.now();
|
||||
const status = await adapter.getNwcStatus();
|
||||
|
||||
expect(status.connected).toBe(false);
|
||||
expect(status.error).toContain("timed out");
|
||||
expect(Date.now() - startedAt).toBeLessThan(2_000);
|
||||
});
|
||||
|
||||
it("bounds a hung invoice payment and surfaces the error", async () => {
|
||||
state.payQueue = ["hang"];
|
||||
|
||||
const adapter = await makeAdapter();
|
||||
const startedAt = Date.now();
|
||||
const result = await adapter.fundFromNWC(2100);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain("timed out");
|
||||
expect(Date.now() - startedAt).toBeLessThan(2_000);
|
||||
});
|
||||
});
|
||||
|
||||
describe("NWC wallet errors versus transport stalls", () => {
|
||||
it("does not retry or rebuild on a wallet read error", async () => {
|
||||
state.infoQueue = ["restricted"];
|
||||
const adapter = await makeAdapter();
|
||||
expect((await adapter.getNwcStatus()).error).toBe("restricted");
|
||||
expect(state.createdInstances).toBe(1);
|
||||
expect(state.closedPools).toBe(0);
|
||||
});
|
||||
|
||||
it("keeps an in-flight payment alive during a pay-only status check", async () => {
|
||||
state.payQueue = ["deferred"];
|
||||
state.balanceQueue = ["restricted"];
|
||||
const adapter = await makeAdapter(500);
|
||||
const started = new Promise<void>((resolve) => { state.paymentStarted = resolve; });
|
||||
const payment = adapter.fundFromNWC(2100);
|
||||
await started;
|
||||
expect((await adapter.getNwcStatus()).connected).toBe(true);
|
||||
expect(state.createdInstances).toBe(1);
|
||||
state.pendingPayments[0]!();
|
||||
expect((await payment).success).toBe(true);
|
||||
expect(state.closedPools).toBe(0);
|
||||
});
|
||||
|
||||
it("keeps a slow payment alive when an overlapping payment gets a wallet error", async () => {
|
||||
state.payQueue = ["deferred", "restricted"];
|
||||
const adapter = await makeAdapter(500);
|
||||
const started = new Promise<void>((resolve) => { state.paymentStarted = resolve; });
|
||||
const slow = adapter.fundFromNWC(2100);
|
||||
await started;
|
||||
expect((await adapter.fundFromNWC(2100)).error).toBe("restricted");
|
||||
state.pendingPayments[0]!();
|
||||
expect((await slow).success).toBe(true);
|
||||
expect(state.createdInstances).toBe(1);
|
||||
});
|
||||
|
||||
it("rebuilds and retries reads on the library's own timeout", async () => {
|
||||
state.infoQueue = ["library-timeout"];
|
||||
const adapter = await makeAdapter();
|
||||
expect((await adapter.getNwcStatus()).connected).toBe(true);
|
||||
expect(state.createdInstances).toBe(2);
|
||||
});
|
||||
|
||||
it("rebuilds after the library's payment timeout without retrying payment", async () => {
|
||||
state.payQueue = ["library-timeout", "restricted"];
|
||||
const adapter = await makeAdapter();
|
||||
expect((await adapter.fundFromNWC(2100)).error).toBe("Timeout");
|
||||
expect(state.createdInstances).toBe(2);
|
||||
expect(state.payQueue).toEqual(["restricted"]);
|
||||
});
|
||||
});
|
||||
+168
-50
@@ -1,11 +1,24 @@
|
||||
import { getTokenMetadata } from "@cashu/cashu-ts";
|
||||
import { InsufficientBalanceError } from "@routstr/sdk";
|
||||
import { WalletConnect } from "applesauce-wallet-connect";
|
||||
import { WalletBaseError } from "applesauce-wallet-connect/helpers/error";
|
||||
import { RelayPool } from "applesauce-relay";
|
||||
import { logger } from "../../utils/logger";
|
||||
import { createCocodClient, type CocodClient } from "./cocod-client";
|
||||
import { withTimeout } from "../../utils/with-timeout";
|
||||
import type { WalletClient } from "./wallet-client";
|
||||
import { startAutoRefillLoop, type AutoRefillConfig } from "./auto-refill";
|
||||
|
||||
/**
|
||||
* NWC reads (get_info/get_balance) should answer in a couple of seconds. If
|
||||
* they don't, the long-lived relay subscription is presumed stale and the
|
||||
* connection is rebuilt before one retry.
|
||||
*/
|
||||
const NWC_READ_TIMEOUT_MS = 15_000;
|
||||
/** Overall bound including encryption negotiation; replies have a library 30s timeout. */
|
||||
const NWC_PAY_TIMEOUT_MS = 45_000;
|
||||
|
||||
type NwcPayment = { preimage?: string; fees_paid?: number };
|
||||
|
||||
export function decodeCashuTokenAmount(token: string): {
|
||||
amount: number;
|
||||
unit: "sat" | "msat";
|
||||
@@ -21,7 +34,7 @@ export function decodeCashuTokenAmount(token: string): {
|
||||
}
|
||||
|
||||
export async function receiveCashuToken(
|
||||
client: Pick<CocodClient, "receiveCashu">,
|
||||
client: Pick<WalletClient, "receiveCashu">,
|
||||
token: string,
|
||||
): Promise<{ message: string; amount: number; unit: "sat" | "msat" }> {
|
||||
// Validate the token before handing it to a state-changing wallet call. This
|
||||
@@ -33,10 +46,14 @@ export async function receiveCashuToken(
|
||||
}
|
||||
|
||||
export interface WalletAdapterOptions {
|
||||
cocodPath?: string | null;
|
||||
walletClient?: CocodClient;
|
||||
/** The in-process wallet engine. Required — construct it with `createCocoClient()`. */
|
||||
walletClient: WalletClient;
|
||||
/** NWC connection string for Lightning funding (uses applesauce-wallet-connect) */
|
||||
nwcConnectionString?: string;
|
||||
/** Override the NWC read timeout in milliseconds (test hook). */
|
||||
nwcReadTimeoutMs?: number;
|
||||
/** Override the NWC payment timeout in milliseconds (test hook). */
|
||||
nwcPayTimeoutMs?: number;
|
||||
/** Auto-refill configuration (static, for startup only) */
|
||||
autoRefill?: AutoRefillConfig;
|
||||
/**
|
||||
@@ -48,10 +65,9 @@ export interface WalletAdapterOptions {
|
||||
}
|
||||
|
||||
export async function createWalletAdapter(
|
||||
options: WalletAdapterOptions = {},
|
||||
options: WalletAdapterOptions,
|
||||
) {
|
||||
const client =
|
||||
options.walletClient || createCocodClient({ cocodPath: options.cocodPath });
|
||||
const client = options.walletClient;
|
||||
let activeMintUrl: string | null = null;
|
||||
let mintUnits: Record<string, "sat" | "msat"> = {};
|
||||
|
||||
@@ -82,19 +98,44 @@ export async function createWalletAdapter(
|
||||
|
||||
let wallet: WalletConnect | undefined;
|
||||
let pool: RelayPool | undefined;
|
||||
let nwcConnectionString = options.nwcConnectionString;
|
||||
const nwcReadTimeoutMs = options.nwcReadTimeoutMs ?? NWC_READ_TIMEOUT_MS;
|
||||
const nwcPayTimeoutMs = options.nwcPayTimeoutMs ?? NWC_PAY_TIMEOUT_MS;
|
||||
|
||||
// Getter for the current wallet instance (used by auto-refill loop)
|
||||
const getWallet = (): WalletConnect | undefined => wallet;
|
||||
|
||||
if (options.nwcConnectionString) {
|
||||
pool = new RelayPool();
|
||||
wallet = WalletConnect.fromConnectURI(options.nwcConnectionString, { pool });
|
||||
/** Close the active relay pool, if any. */
|
||||
function closeNwcPool(): void {
|
||||
if (pool) {
|
||||
for (const [url] of pool.relays) {
|
||||
pool.remove(url, true);
|
||||
}
|
||||
}
|
||||
pool = undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* (Re)create the relay pool + WalletConnect client for a connection string.
|
||||
* Shared by interactive connects and self-healing recovery so both paths use
|
||||
* identical setup.
|
||||
*/
|
||||
function connectNwc(connectionString: string, reason: string): void {
|
||||
const nextPool = new RelayPool();
|
||||
const nextWallet = WalletConnect.fromConnectURI(connectionString, {
|
||||
pool: nextPool,
|
||||
});
|
||||
|
||||
pool = nextPool;
|
||||
wallet = nextWallet;
|
||||
nwcConnectionString = connectionString;
|
||||
|
||||
// Connect in background (non-blocking)
|
||||
wallet.waitForService()
|
||||
nextWallet
|
||||
.waitForService()
|
||||
.then(() => {
|
||||
logger.log(
|
||||
`[nwc] NWC wallet connected. Relay: ${wallet!.relays[0]}, Service: ${wallet!.service}`,
|
||||
`[nwc] NWC wallet ${reason}. Relay: ${nextWallet.relays[0]}, Service: ${nextWallet.service}`,
|
||||
);
|
||||
})
|
||||
.catch((err) => {
|
||||
@@ -102,39 +143,97 @@ export async function createWalletAdapter(
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Rebuild the NWC connection in place after a request stalled. applesauce's
|
||||
* request timeout only covers the response stream, so a stale relay
|
||||
* subscription can leave `getInfo`/`getBalance` pending forever while it
|
||||
* negotiates encryption. Recreating the pool gives the next call a fresh
|
||||
* subscription.
|
||||
*/
|
||||
function rebuildNwcConnection(reason: string): void {
|
||||
if (!nwcConnectionString) return;
|
||||
closeNwcPool();
|
||||
wallet = undefined;
|
||||
connectNwc(nwcConnectionString, reason);
|
||||
}
|
||||
|
||||
/**
|
||||
* Run an idempotent NWC read, bounding the wait and rebuilding the connection
|
||||
* once if it stalls.
|
||||
*/
|
||||
async function nwcRead<T>(
|
||||
label: string,
|
||||
operation: (w: WalletConnect) => Promise<T>,
|
||||
): Promise<T> {
|
||||
const first = wallet;
|
||||
if (!first?.service) {
|
||||
throw new Error("NWC not connected");
|
||||
}
|
||||
try {
|
||||
return await withTimeout(
|
||||
operation(first),
|
||||
nwcReadTimeoutMs,
|
||||
`${label} timed out`,
|
||||
);
|
||||
} catch (error) {
|
||||
// A normal NIP-47 error proves the wallet answered. Keep other calls alive.
|
||||
if (error instanceof WalletBaseError || !nwcConnectionString) throw error;
|
||||
logger.warn(
|
||||
`[nwc] ${label} failed (${(error as Error).message}); rebuilding NWC connection and retrying`,
|
||||
);
|
||||
rebuildNwcConnection("reconnected after stall");
|
||||
const retry = wallet;
|
||||
if (!retry?.service) throw error;
|
||||
return await withTimeout(
|
||||
operation(retry),
|
||||
nwcReadTimeoutMs,
|
||||
`${label} timed out after reconnect`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Pay a BOLT-11 invoice over NWC with a bounded wait. A timeout rebuilds the
|
||||
* relay connection so later calls recover without a daemon restart. The
|
||||
* payment is not retried here. A timeout is an unknown payment outcome,
|
||||
* not proof of failure; callers must reconcile before trying a fresh invoice.
|
||||
*/
|
||||
async function payNwcInvoice(invoice: string): Promise<NwcPayment> {
|
||||
const payer = wallet;
|
||||
if (!payer?.service) throw new Error("NWC not connected");
|
||||
try {
|
||||
return await withTimeout(
|
||||
payer.payInvoice(invoice),
|
||||
nwcPayTimeoutMs,
|
||||
"NWC payment timed out",
|
||||
);
|
||||
} catch (error) {
|
||||
// Include the library's own timeout, but not normal wallet error replies.
|
||||
if (!(error instanceof WalletBaseError)) {
|
||||
rebuildNwcConnection("reconnected after payment stall");
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
if (options.nwcConnectionString) {
|
||||
connectNwc(options.nwcConnectionString, "connected");
|
||||
}
|
||||
|
||||
const walletAdapter = {
|
||||
async reconnect(connectionString?: string): Promise<void> {
|
||||
logger.log(
|
||||
`[nwc] Reconnecting NWC wallet... ${connectionString ? "new connection string provided" : "disconnecting"}`,
|
||||
);
|
||||
|
||||
// 1. Close existing relay pool connections
|
||||
if (pool) {
|
||||
for (const [url] of pool.relays) {
|
||||
pool.remove(url, true);
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Update wallet reference
|
||||
// Close existing relay pool connections and update the wallet reference
|
||||
closeNwcPool();
|
||||
wallet = undefined;
|
||||
pool = undefined;
|
||||
|
||||
// 3. Create new wallet if connection string provided
|
||||
if (connectionString) {
|
||||
pool = new RelayPool();
|
||||
wallet = WalletConnect.fromConnectURI(connectionString, { pool });
|
||||
|
||||
// Connect in background (non-blocking)
|
||||
wallet.waitForService()
|
||||
.then(() => {
|
||||
logger.log(
|
||||
`[nwc] NWC wallet reconnected. Relay: ${wallet!.relays[0]}, Service: ${wallet!.service}`,
|
||||
);
|
||||
})
|
||||
.catch((err) => {
|
||||
logger.error(`[nwc] NWC reconnection failed: ${err.message}`);
|
||||
});
|
||||
connectNwc(connectionString, "reconnected");
|
||||
} else {
|
||||
nwcConnectionString = undefined;
|
||||
logger.log("[nwc] NWC wallet disconnected.");
|
||||
}
|
||||
},
|
||||
@@ -192,9 +291,9 @@ export async function createWalletAdapter(
|
||||
const { invoice } = await client.receiveBolt11(amount, mintUrl);
|
||||
logger.log(`[nwc] Invoice: ${invoice}`);
|
||||
|
||||
// Step 3: Pay it via NWC
|
||||
// Step 3: Pay it via NWC (bounded — a stale relay must not hang the CLI)
|
||||
logger.log("[nwc] Paying invoice via NWC...");
|
||||
const { preimage, fees_paid } = await wallet.payInvoice(invoice);
|
||||
const { preimage, fees_paid } = await payNwcInvoice(invoice);
|
||||
logger.log(`[nwc] ✅ Payment successful!`);
|
||||
logger.log(`[nwc] Preimage: ${preimage}`);
|
||||
if (fees_paid !== undefined) {
|
||||
@@ -244,10 +343,10 @@ export async function createWalletAdapter(
|
||||
}
|
||||
|
||||
try {
|
||||
const info = await wallet.getInfo();
|
||||
const info = await nwcRead("get_info", (w) => w.getInfo());
|
||||
let balance: number | undefined;
|
||||
try {
|
||||
const bal = await wallet.getBalance();
|
||||
const bal = await nwcRead("get_balance", (w) => w.getBalance());
|
||||
balance = Math.floor(bal.balance / 1000); // msats → sats
|
||||
} catch {
|
||||
// Balance might not be available
|
||||
@@ -326,21 +425,40 @@ export async function createWalletAdapter(
|
||||
|
||||
let stopAutoRefill: (() => void) | undefined;
|
||||
|
||||
/**
|
||||
* Start the auto-refill loop if it is not already running. Safe to call more
|
||||
* than once; it becomes a no-op after the first call.
|
||||
*/
|
||||
function ensureAutoRefillLoop(): void {
|
||||
if (stopAutoRefill) return;
|
||||
const getConfig = options.getAutoRefillConfig ?? (() => options.autoRefill);
|
||||
stopAutoRefill = startAutoRefillLoop(
|
||||
client,
|
||||
getWallet,
|
||||
getConfig,
|
||||
5000,
|
||||
payNwcInvoice,
|
||||
);
|
||||
}
|
||||
|
||||
const autoRefillConfig = options.getAutoRefillConfig
|
||||
? options.getAutoRefillConfig()
|
||||
: options.autoRefill;
|
||||
|
||||
if (autoRefillConfig && wallet) {
|
||||
const getConfig = options.getAutoRefillConfig ?? (() => options.autoRefill);
|
||||
stopAutoRefill = startAutoRefillLoop(client, getWallet, getConfig);
|
||||
logger.log(
|
||||
`[wallet] Auto-refill enabled: threshold=${autoRefillConfig.threshold} sats, amount=${autoRefillConfig.amount} sats, cooldown=${autoRefillConfig.cooldownMs / 60000} minutes`,
|
||||
);
|
||||
} else if (wallet && options.getAutoRefillConfig) {
|
||||
// Wallet exists but auto-refill is not currently enabled.
|
||||
// Start the loop anyway so it can pick up changes without a restart.
|
||||
stopAutoRefill = startAutoRefillLoop(client, getWallet, options.getAutoRefillConfig);
|
||||
logger.log("[wallet] Auto-refill loop started (currently disabled — enable via CLI to activate)");
|
||||
if (options.getAutoRefillConfig || options.autoRefill) {
|
||||
// Start the loop even when no wallet is connected yet: it reads the wallet
|
||||
// and config fresh each cycle, so a later `nwc connect` activates refills
|
||||
// without a daemon restart.
|
||||
ensureAutoRefillLoop();
|
||||
if (autoRefillConfig) {
|
||||
logger.log(
|
||||
`[wallet] Auto-refill enabled: threshold=${autoRefillConfig.threshold} sats, amount=${autoRefillConfig.amount} sats, cooldown=${autoRefillConfig.cooldownMs / 60000} minutes`,
|
||||
);
|
||||
} else {
|
||||
logger.log(
|
||||
"[wallet] Auto-refill loop started (currently disabled — enable via CLI to activate)",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
|
||||
@@ -0,0 +1,206 @@
|
||||
import type { HistoryEntry } from "@cashu/coco-core";
|
||||
|
||||
/**
|
||||
* Contract shared by every wallet implementation.
|
||||
*
|
||||
* The wallet runs in-process (`createCocoClient` in `./coco-client`); this
|
||||
* module only describes what the rest of routstrd is allowed to ask of it.
|
||||
* Historically this lived in a `cocod-client` module that also shipped an
|
||||
* HTTP/unix-socket client for the external `cocod` binary. That client was
|
||||
* removed once the in-process wallet replaced it — see `./migration` and the
|
||||
* `legacyCocod*` path helpers in `./paths` for the migration-only code that
|
||||
* still knows about external cocod installs.
|
||||
*/
|
||||
|
||||
export type WalletRuntimeState =
|
||||
| "UNINITIALIZED"
|
||||
| "LOCKED"
|
||||
| "UNLOCKED"
|
||||
| "RECOVERING"
|
||||
| "ERROR";
|
||||
|
||||
/** Live progress for background wallet recovery started at daemon startup. */
|
||||
export interface WalletRecoveryProgress {
|
||||
state: "RECOVERING" | "UNLOCKED" | "ERROR";
|
||||
/** Current recovery phase, e.g. "Mint recovery" or "done". */
|
||||
phase: string;
|
||||
pendingSends: number;
|
||||
inflightProofs: number;
|
||||
pendingMints: number;
|
||||
/** Expired unpaid mint quotes failed locally without a mint round-trip. */
|
||||
failedMintQuotes: number;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/** NPC (npubx.cash) Lightning address details for this wallet. */
|
||||
export interface NpcAddress {
|
||||
/** Full Lightning address, e.g. "alice@npubx.cash" (npub fallback when no username is set). */
|
||||
address: string;
|
||||
/** NPC username, when one has been claimed. */
|
||||
name?: string;
|
||||
/** Nostr hex pubkey of the NPC account (only available from the in-process wallet). */
|
||||
pubkey?: string;
|
||||
}
|
||||
|
||||
/** Result of an NPC username claim attempt. */
|
||||
export interface NpcUsernameResult {
|
||||
success: boolean;
|
||||
/** Present when NPC requires payment to claim the username. */
|
||||
paymentRequest?: {
|
||||
amount?: number;
|
||||
mints?: string[];
|
||||
[key: string]: unknown;
|
||||
};
|
||||
}
|
||||
|
||||
/** Options for the wallet cleanup command. */
|
||||
export interface WalletCleanupOptions {
|
||||
/** Only clean up operations for this mint URL. */
|
||||
mintUrl?: string;
|
||||
/** Minimum operation age in milliseconds (defaults to 7 days / 1 week). */
|
||||
minAgeMs?: number;
|
||||
/** Report what would be cleaned without applying changes. */
|
||||
dryRun?: boolean;
|
||||
/**
|
||||
* Fail expired mint quotes without confirming UNPAID with the mint. Only for
|
||||
* operators who accept the risk of stranding a quote that was paid before
|
||||
* its invoice expired; recovery is the safe default.
|
||||
*/
|
||||
force?: boolean;
|
||||
}
|
||||
|
||||
/** Summary of a wallet cleanup run. */
|
||||
export interface WalletCleanupResult {
|
||||
dryRun: boolean;
|
||||
/** Expired quotes selected for checking; dry runs do not contact the mint. */
|
||||
mintQuoteCandidates: number;
|
||||
/** Number actually marked failed (always zero in a dry run). */
|
||||
failedMintQuotes: number;
|
||||
/** Expired quotes kept pending because they are paid/issued or unverified. */
|
||||
leftForRecovery: number;
|
||||
/** Number of stale pending send operations reclaimed. */
|
||||
reclaimedSends: number;
|
||||
/** Number of stale prepared melt operations cancelled. */
|
||||
cancelledMelts: number;
|
||||
/** Number of in-flight operations that were left untouched. */
|
||||
skipped: number;
|
||||
errors: Array<{ operationId: string; error: string }>;
|
||||
}
|
||||
|
||||
export class WalletHttpError extends Error {
|
||||
status: number;
|
||||
|
||||
constructor(status: number, message: string) {
|
||||
super(message);
|
||||
this.name = "WalletHttpError";
|
||||
this.status = status;
|
||||
}
|
||||
}
|
||||
|
||||
/** Progress of a Lightning top-up created with `receiveBolt11`. */
|
||||
export interface MintQuoteStatus {
|
||||
operationId: string;
|
||||
state: "pending" | "executing" | "finalized" | "failed";
|
||||
/** Last quote state reported by the mint (UNPAID, PAID, ISSUED). */
|
||||
mintState?: string;
|
||||
amount: number;
|
||||
mintUrl: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/** Options for explicit PAID mint-quote recovery. */
|
||||
export interface WalletMintQuoteRecoveryOptions {
|
||||
/** Target only these operation ids (may include failed operations). */
|
||||
operationIds?: string[];
|
||||
/** Re-open failed operations instead of skipping them. */
|
||||
includeFailed?: boolean;
|
||||
/** Per-quote mint timeout in milliseconds. */
|
||||
timeoutMs?: number;
|
||||
}
|
||||
|
||||
/** Summary of a PAID mint-quote recovery run. */
|
||||
export interface WalletMintQuoteRecoveryResult {
|
||||
/** Operations whose quote state was checked with the mint. */
|
||||
checked: number;
|
||||
/** Operations whose paid sats were minted or restored. */
|
||||
recovered: number;
|
||||
/** Quotes the mint still reports UNPAID; left pending. */
|
||||
waiting: number;
|
||||
/** Quotes the mint can no longer issue. */
|
||||
terminal: number;
|
||||
/** Failed operations moved back to pending before checking. */
|
||||
reopened: number;
|
||||
/** Operations left to a later run (mint unreachable, budget spent, non-terminal). */
|
||||
retryable: number;
|
||||
/** Operations skipped because an earlier recovery of them is still running. */
|
||||
busy: number;
|
||||
errors: Array<{ operationId: string; error: string }>;
|
||||
}
|
||||
|
||||
/** Summary of a stuck-operation (send/melt/mint) recovery run. */
|
||||
export interface WalletStuckOperationRecoveryResult {
|
||||
/** Timed-out waits; the underlying operation remains tracked. */
|
||||
timedOut: number;
|
||||
/** Operations for which recovery was attempted (not necessarily completed). */
|
||||
attempted: number;
|
||||
/** Locked operations or unfinished work from another pass; retry later. */
|
||||
busy: number;
|
||||
/** Operations skipped for unreachable mints, shutdown, or pass budget exhaustion. */
|
||||
skipped: number;
|
||||
/** Operations at reachable mints whose recovery still failed. */
|
||||
failed: number;
|
||||
/** Unreachable mint URL -> number of operations skipped there. */
|
||||
skippedMints: Record<string, number>;
|
||||
}
|
||||
|
||||
export interface WalletClient {
|
||||
ping(): Promise<boolean>;
|
||||
getStatus(): Promise<WalletRuntimeState>;
|
||||
unlock(passphrase: string): Promise<string>;
|
||||
getBalances(): Promise<Record<string, number>>;
|
||||
receiveCashu(token: string): Promise<string>;
|
||||
receiveBolt11(
|
||||
amount: number,
|
||||
mintUrl?: string,
|
||||
): Promise<{ invoice: string; operationId?: string }>;
|
||||
/** Progress of a top-up, when the wallet tracks mint operations. */
|
||||
getMintQuote?(operationId: string): Promise<MintQuoteStatus | null>;
|
||||
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>;
|
||||
getDefaultMint(): Promise<string | null>;
|
||||
setDefaultMint(url: string): Promise<string>;
|
||||
/** Release resources held by in-process wallet implementations. */
|
||||
dispose?(): Promise<void>;
|
||||
getHistory(offset?: number, limit?: number): Promise<HistoryEntry[]>;
|
||||
/** Look up a single transaction by its history entry ID. */
|
||||
getHistoryEntryById(id: string): Promise<HistoryEntry | null>;
|
||||
/** NPC (npubx.cash) Lightning address for this wallet. */
|
||||
getNpcAddress(): Promise<NpcAddress>;
|
||||
/** Claim an NPC username; pass confirm=true to pay the claim fee from the wallet. */
|
||||
setNpcUsername(username: string, confirm?: boolean): Promise<NpcUsernameResult>;
|
||||
/** Manually trigger an NPC quote sync into the wallet. */
|
||||
syncNpc(): Promise<void>;
|
||||
/** Clear stuck pending/in-flight wallet operations that are safe to resolve. */
|
||||
cleanupStuckOperations?(
|
||||
options?: WalletCleanupOptions,
|
||||
): Promise<WalletCleanupResult>;
|
||||
/**
|
||||
* Re-issue PAID mint quotes whose sats were never claimed, optionally
|
||||
* targeting specific operations (including ones coco already failed).
|
||||
*/
|
||||
recoverMintQuotes?(
|
||||
options?: WalletMintQuoteRecoveryOptions,
|
||||
onProgress?: (message: string) => void,
|
||||
): Promise<WalletMintQuoteRecoveryResult>;
|
||||
/**
|
||||
* Recover stuck send/melt/mint operations whose mints answer a
|
||||
* reachability probe. Operations a live execute holds are reported busy,
|
||||
* never driven. Receive stays startup-only (receive dedup classification).
|
||||
*/
|
||||
recoverStuckOperations?(): Promise<WalletStuckOperationRecoveryResult>;
|
||||
/** Report background wallet recovery progress, when the wallet supports it. */
|
||||
getRecoveryProgress?(): Promise<WalletRecoveryProgress>;
|
||||
}
|
||||
@@ -46,7 +46,6 @@ export interface RoutstrdConfig {
|
||||
port: number;
|
||||
host: string;
|
||||
provider: string | null;
|
||||
cocodPath: string | null;
|
||||
mode?: "xcashu" | "apikeys";
|
||||
/** Opt into SDK automatic model-path selection for DeepSeek V4.1 Flash. Disabled by default; requires daemon restart after changing. */
|
||||
autoModelPath?: boolean;
|
||||
@@ -87,7 +86,6 @@ export const DEFAULT_CONFIG: RoutstrdConfig = {
|
||||
port: 8008,
|
||||
host: "127.0.0.1",
|
||||
provider: null,
|
||||
cocodPath: null,
|
||||
mode: "apikeys",
|
||||
autoModelPath: false,
|
||||
maxTokens: 64000,
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
import { afterEach, describe, expect, test } from "bun:test";
|
||||
import {
|
||||
callDaemonUrl,
|
||||
DAEMON_LONG_REQUEST_TIMEOUT_MS,
|
||||
DAEMON_REQUEST_TIMEOUT_MS,
|
||||
} from "./daemon-client";
|
||||
import { DEFAULT_CONFIG } from "./config";
|
||||
|
||||
const originalFetch = globalThis.fetch;
|
||||
const originalAbortTimeout = AbortSignal.timeout;
|
||||
|
||||
/** Deadline (ms) passed to AbortSignal.timeout by the last request. */
|
||||
let requestedDeadlines: number[] = [];
|
||||
|
||||
afterEach(() => {
|
||||
globalThis.fetch = originalFetch;
|
||||
AbortSignal.timeout = originalAbortTimeout;
|
||||
requestedDeadlines = [];
|
||||
});
|
||||
|
||||
/**
|
||||
* Record the requested deadline and arm a fast one instead, so tests do not
|
||||
* wait out the real 120s/600s bounds.
|
||||
*/
|
||||
function shortenDeadline(): void {
|
||||
AbortSignal.timeout = ((ms: number) => {
|
||||
requestedDeadlines.push(ms);
|
||||
return originalAbortTimeout(20);
|
||||
}) as typeof AbortSignal.timeout;
|
||||
}
|
||||
|
||||
function stalledResponse(status = 200): void {
|
||||
globalThis.fetch = (async (_input: string | URL | Request, init?: RequestInit) => {
|
||||
const signal = init?.signal;
|
||||
return new Response(new ReadableStream({
|
||||
start(controller) {
|
||||
controller.enqueue(new TextEncoder().encode('{"output":'));
|
||||
signal?.addEventListener("abort", () => controller.error(signal.reason), { once: true });
|
||||
},
|
||||
}), { status });
|
||||
}) as unknown as typeof fetch;
|
||||
}
|
||||
|
||||
const request = (path = "/nwc/status") =>
|
||||
callDaemonUrl("http://daemon.example", path, {}, DEFAULT_CONFIG);
|
||||
|
||||
describe("daemon request deadline", () => {
|
||||
test("bounds the wait for response headers", async () => {
|
||||
shortenDeadline();
|
||||
globalThis.fetch = ((_input: string | URL | Request, init?: RequestInit) => new Promise((_resolve, reject) => {
|
||||
init?.signal?.addEventListener("abort", () => reject(init.signal!.reason), { once: true });
|
||||
})) as unknown as typeof fetch;
|
||||
await expect(request()).rejects.toThrow("Daemon request timed out");
|
||||
});
|
||||
|
||||
for (const status of [200, 500]) {
|
||||
test(`bounds a stalled ${status} response body`, async () => {
|
||||
shortenDeadline();
|
||||
stalledResponse(status);
|
||||
await expect(request()).rejects.toThrow("payment outcome is unknown");
|
||||
});
|
||||
}
|
||||
|
||||
test("applies the default deadline to ordinary routes", async () => {
|
||||
shortenDeadline();
|
||||
globalThis.fetch = (async () => Response.json({ output: "ok" })) as unknown as typeof fetch;
|
||||
expect(await request("/nwc/status")).toEqual({ output: "ok" });
|
||||
expect(requestedDeadlines).toEqual([DAEMON_REQUEST_TIMEOUT_MS]);
|
||||
});
|
||||
|
||||
for (const path of ["/wallet/send/bolt11", "/wallet/receive/cashu"]) {
|
||||
test(`uses the long deadline for value-moving route ${path}`, async () => {
|
||||
shortenDeadline();
|
||||
globalThis.fetch = (async () => Response.json({ output: "paid" })) as unknown as typeof fetch;
|
||||
expect(await request(path)).toEqual({ output: "paid" });
|
||||
expect(requestedDeadlines).toEqual([DAEMON_LONG_REQUEST_TIMEOUT_MS]);
|
||||
});
|
||||
}
|
||||
|
||||
test("bounds a stalled body on a long-running route too", async () => {
|
||||
shortenDeadline();
|
||||
stalledResponse();
|
||||
await expect(request("/wallet/send/bolt11")).rejects.toThrow("payment outcome is unknown");
|
||||
expect(requestedDeadlines).toEqual([DAEMON_LONG_REQUEST_TIMEOUT_MS]);
|
||||
});
|
||||
|
||||
test("preserves HTTP errors rather than labeling them connection failures", async () => {
|
||||
globalThis.fetch = (async () => Response.json({ error: "restricted" }, { status: 403 })) as unknown as typeof fetch;
|
||||
await expect(request()).rejects.toThrow("restricted");
|
||||
});
|
||||
});
|
||||
@@ -56,6 +56,32 @@ class DaemonConnectionError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Upper bound for a single daemon request, including response-body
|
||||
* consumption. The daemon bounds its own NWC operations, so this only guards
|
||||
* against a wedged server; without it a hung request would block the CLI
|
||||
* forever.
|
||||
*/
|
||||
export const DAEMON_REQUEST_TIMEOUT_MS = 120_000;
|
||||
|
||||
/**
|
||||
* Upper bound for value-moving wallet routes. A cashu melt/swap can
|
||||
* legitimately run longer than {@link DAEMON_REQUEST_TIMEOUT_MS} (the mint has
|
||||
* no request timeout in routstrd), so these get a more generous bound that
|
||||
* still prevents an indefinite CLI hang.
|
||||
*/
|
||||
export const DAEMON_LONG_REQUEST_TIMEOUT_MS = 600_000;
|
||||
|
||||
/** Routes that may legitimately outlive the default request timeout. */
|
||||
const LONG_RUNNING_ROUTES = ["/wallet/send/", "/wallet/receive/"];
|
||||
|
||||
function requestTimeoutMs(path: string): number {
|
||||
const pathname = path.split("?")[0] ?? path;
|
||||
return LONG_RUNNING_ROUTES.some((route) => pathname.startsWith(route))
|
||||
? DAEMON_LONG_REQUEST_TIMEOUT_MS
|
||||
: DAEMON_REQUEST_TIMEOUT_MS;
|
||||
}
|
||||
|
||||
export function getDaemonBaseUrl(config: RoutstrdConfig): string {
|
||||
if (config.daemonUrl) {
|
||||
return config.daemonUrl.replace(/\/$/, "");
|
||||
@@ -70,7 +96,7 @@ export function getAuthBaseUrl(config: RoutstrdConfig): string {
|
||||
return getDaemonBaseUrl(config);
|
||||
}
|
||||
|
||||
async function _callUrl(
|
||||
export async function callDaemonUrl(
|
||||
baseUrl: string,
|
||||
path: string,
|
||||
options: { method?: "GET" | "POST" | "PATCH" | "DELETE"; body?: object },
|
||||
@@ -99,23 +125,41 @@ async function _callUrl(
|
||||
if (authorization) headers.set("Authorization", authorization);
|
||||
if (bodyString) headers.set("Content-Type", "application/json");
|
||||
|
||||
const timeoutMs = requestTimeoutMs(path);
|
||||
const timeoutError = () =>
|
||||
new Error(
|
||||
`Daemon request timed out after ${timeoutMs / 1000}s; ` +
|
||||
"any payment outcome is unknown — check before retrying",
|
||||
);
|
||||
// The signal stays armed while the body is read, so a daemon that sends
|
||||
// headers and then stalls the body cannot hang the CLI either. Aborting
|
||||
// here never cancels the daemon's operation — see timeoutError's warning.
|
||||
const signal = AbortSignal.timeout(timeoutMs);
|
||||
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch(url, {
|
||||
method,
|
||||
headers,
|
||||
body: bodyString,
|
||||
signal,
|
||||
});
|
||||
} catch (error) {
|
||||
if (signal.aborted) throw timeoutError();
|
||||
// Only connection failures qualify for alternate-host retries.
|
||||
throw new DaemonConnectionError(error);
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = (await response.json()) as { error?: string };
|
||||
throw new Error(errorData.error || `HTTP ${response.status}`);
|
||||
try {
|
||||
if (!response.ok) {
|
||||
const errorData = (await response.json()) as { error?: string };
|
||||
throw new Error(errorData.error || `HTTP ${response.status}`);
|
||||
}
|
||||
return (await response.json()) as CommandResponse;
|
||||
} catch (error) {
|
||||
if (signal.aborted) throw timeoutError();
|
||||
throw error;
|
||||
}
|
||||
|
||||
return response.json() as Promise<CommandResponse>;
|
||||
}
|
||||
|
||||
async function callLocalDaemon(
|
||||
@@ -127,7 +171,7 @@ async function callLocalDaemon(
|
||||
let connectionError: DaemonConnectionError | undefined;
|
||||
for (const baseUrl of localDaemonBaseUrls(config)) {
|
||||
try {
|
||||
return await _callUrl(baseUrl, path, options, config);
|
||||
return await callDaemonUrl(baseUrl, path, options, config);
|
||||
} catch (error) {
|
||||
if (!(error instanceof DaemonConnectionError)) throw error;
|
||||
connectionError = error;
|
||||
@@ -142,7 +186,7 @@ export async function callDaemon(
|
||||
): Promise<CommandResponse> {
|
||||
const config = await loadConfig();
|
||||
if (config.daemonUrl) {
|
||||
return _callUrl(getDaemonBaseUrl(config), path, options, config);
|
||||
return callDaemonUrl(getDaemonBaseUrl(config), path, options, config);
|
||||
}
|
||||
return callLocalDaemon(path, options, config);
|
||||
}
|
||||
@@ -157,7 +201,7 @@ export async function callAuth(
|
||||
if (!config.authUrl && !config.daemonUrl) {
|
||||
return callLocalDaemon(path, options, config);
|
||||
}
|
||||
return _callUrl(getAuthBaseUrl(config), path, options, config);
|
||||
return callDaemonUrl(getAuthBaseUrl(config), path, options, config);
|
||||
}
|
||||
|
||||
export async function isDaemonRunning(): Promise<boolean> {
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { withTimeout } from "./with-timeout";
|
||||
|
||||
describe("withTimeout", () => {
|
||||
test("passes through a settled value", async () => {
|
||||
await expect(withTimeout(Promise.resolve(42), 1000, "nope")).resolves.toBe(
|
||||
42,
|
||||
);
|
||||
});
|
||||
|
||||
test("rejects with the provided message once the deadline elapses", async () => {
|
||||
const never = new Promise<never>(() => {});
|
||||
await expect(withTimeout(never, 20, "operation timed out")).rejects.toThrow(
|
||||
"operation timed out",
|
||||
);
|
||||
});
|
||||
|
||||
test("propagates the original rejection", async () => {
|
||||
await expect(
|
||||
withTimeout(Promise.reject(new Error("boom")), 1000, "unused"),
|
||||
).rejects.toThrow("boom");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
/**
|
||||
* Rejects when `timeoutMs` elapses before `promise` settles.
|
||||
* This bounds the caller's wait; it does not cancel the underlying operation.
|
||||
*
|
||||
* Used to bound requests that may otherwise wait forever — notably NWC calls
|
||||
* whose underlying library applies its own timeout only after a support/encryption
|
||||
* handshake that can itself hang on a stale relay subscription.
|
||||
*/
|
||||
export function withTimeout<T>(
|
||||
promise: Promise<T>,
|
||||
timeoutMs: number,
|
||||
message = "Operation timed out",
|
||||
): Promise<T> {
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
const timeout = new Promise<never>((_resolve, reject) => {
|
||||
timer = setTimeout(() => reject(new Error(message)), timeoutMs);
|
||||
});
|
||||
return Promise.race([promise, timeout]).finally(() => {
|
||||
if (timer !== undefined) clearTimeout(timer);
|
||||
});
|
||||
}
|
||||
@@ -24,7 +24,6 @@ const baseConfig = {
|
||||
port: 8008,
|
||||
host: "127.0.0.1",
|
||||
provider: null,
|
||||
cocodPath: null,
|
||||
};
|
||||
|
||||
function assert(cond: unknown, msg: string): void {
|
||||
|
||||
@@ -11,7 +11,6 @@ function config(host: string): RoutstrdConfig {
|
||||
port: 8008,
|
||||
host,
|
||||
provider: null,
|
||||
cocodPath: null,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
createWalletAdapter,
|
||||
decodeCashuTokenAmount,
|
||||
} from "../../src/daemon/wallet";
|
||||
import type { CocodClient } from "../../src/daemon/wallet/cocod-client";
|
||||
import type { WalletClient } from "../../src/daemon/wallet/wallet-client";
|
||||
|
||||
// A full modern keyset ID with the same format as Minibits' post-migration
|
||||
// active keyset. getEncodedToken stores only its first eight bytes in TokenV4.
|
||||
@@ -38,11 +38,11 @@ function makeToken({
|
||||
}
|
||||
|
||||
function makeWalletClient(
|
||||
receiveCashu: CocodClient["receiveCashu"],
|
||||
): CocodClient {
|
||||
receiveCashu: WalletClient["receiveCashu"],
|
||||
): WalletClient {
|
||||
// createWalletAdapter is intentionally lazy; this receive-path test only
|
||||
// needs the one capability exercised by receiveToken.
|
||||
return { receiveCashu } as CocodClient;
|
||||
return { receiveCashu } as WalletClient;
|
||||
}
|
||||
|
||||
describe("short keyset TokenV4 compatibility", () => {
|
||||
|
||||
Reference in New Issue
Block a user