chore(wallet): remove the dead legacy cocod client

The external `cocod` binary was replaced by the in-process Coco wallet
long ago, but its HTTP/unix-socket client kept shipping ~280 lines of
unreachable code next to the wallet contract.

Remove it:

- delete `createCocodClient` (spawns `cocod init`, talks over a unix
  socket, takes a cross-process startup lock) and its helpers
  `resolveCocodExecutable`, `isCocodInstalled`, `normalizeBalances`
  and `CocodBalanceOutput`. None of them are reachable from production
  or from tests; `isCocodInstalled` had no callers at all.
- drop the `options.walletClient || createCocodClient(...)` fallback in
  `createWalletAdapter`. The daemon always passes the in-process client
  built by `createCocoClient()`, so `walletClient` is now required and
  the adapter can no longer silently spawn an external binary.
- drop the vestigial `cocodPath` config key, which only ever fed that
  fallback.
- delete the "legacy cocod client NPC passthroughs" test block, which
  covered only the deleted client.

The shared wallet contract moves from `cocod-client.ts` to
`wallet-client.ts` and loses its `Cocod` prefixes (`WalletClient`,
`WalletRuntimeState`, `WalletHttpError`) — none of it describes cocod
any more. `WalletRuntimeState` avoids colliding with the existing
`WalletState` in `wallet-state.ts`.

Legacy cocod *migration* code is deliberately untouched: `stopLegacyCocod`,
`assertLegacyCocodNotRunning`, `claimLegacyCocodPidFile`, `migration.ts`
and the `legacyCocod*` paths still migrate an existing `~/.cocod` wallet
and fence off a running external daemon.

Net -456 lines. Existing configs that still contain `cocodPath` load
unchanged (unknown keys are ignored) — the key is just no longer written.
This commit is contained in:
redshift
2026-10-02 16:59:42 +08:00
parent 632cdb6e86
commit 99f55d3bae
15 changed files with 255 additions and 718 deletions
-1
View File
@@ -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 }
}
-1
View File
@@ -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`) |
+1 -4
View File
@@ -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
View File
@@ -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";
@@ -50,7 +50,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;
@@ -61,7 +61,7 @@ type DaemonDeps = {
server: { close(cb?: () => void): void };
shutdown?: () => void;
store: any;
walletClient: CocodClient;
walletClient: WalletClient;
walletAdapter: any;
storageAdapter: any;
discoveryAdapter: any;
@@ -132,7 +132,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.");
}
}
@@ -200,7 +200,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.";
@@ -220,7 +220,7 @@ function respondWithError(
error: unknown,
fallbackStatus = 500,
): void {
if (error instanceof CocodHttpError) {
if (error instanceof WalletHttpError) {
sendJson(res, error.status, { error: error.message });
return;
}
@@ -253,7 +253,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;
}
@@ -272,7 +272,7 @@ function getRequiredPositiveNumberField(
return parsed;
}
}
throw new CocodHttpError(400, `Missing required '${field}' field.`);
throw new WalletHttpError(400, `Missing required '${field}' field.`);
}
function optionalStringField(
@@ -293,7 +293,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());
}
@@ -349,7 +349,7 @@ async function buildStatusOutput(
}
async function buildWalletDetails(deps: DaemonDeps): Promise<{
state: CocodState;
state: WalletRuntimeState;
ready: boolean;
balances?: Record<string, number>;
unit?: "sat";
@@ -411,7 +411,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,
@@ -435,7 +435,7 @@ export function createDaemonRequestHandler(deps: {
server: { close(cb?: () => void): void };
shutdown?: () => void;
store: any;
walletClient: CocodClient;
walletClient: WalletClient;
walletAdapter: any;
storageAdapter: any;
discoveryAdapter: any;
@@ -514,7 +514,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.",
);
@@ -538,7 +538,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.",
);
@@ -547,7 +547,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'.",
);
@@ -557,7 +557,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.",
);
@@ -575,7 +575,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.",
);
@@ -615,7 +615,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;
@@ -759,12 +759,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`,
-1
View File
@@ -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,
+10 -7
View File
@@ -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,7 +33,7 @@ function isFatalError(message: string): boolean {
}
export function startAutoRefillLoop(
cocod: CocodClient,
walletClient: WalletClient,
getWallet: () => WalletConnect | undefined,
getConfig: () => AutoRefillConfig | undefined,
intervalMs: number = 5000,
@@ -88,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,
@@ -103,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...`);
@@ -125,7 +128,7 @@ export function startAutoRefillLoop(
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) {
-82
View File
@@ -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");
});
});
+5 -5
View File
@@ -30,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,
@@ -1812,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");
@@ -2148,7 +2148,7 @@ export async function createCocoClient(
}
},
async getStatus(): Promise<CocodState> {
async getStatus(): Promise<WalletRuntimeState> {
if (recoveryError) return "ERROR";
if (!recoveryDone) return "RECOVERING";
try {
-579
View File
@@ -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.",
);
},
};
}
+6 -7
View File
@@ -5,7 +5,7 @@ import { WalletBaseError } from "applesauce-wallet-connect/helpers/error";
import { RelayPool } from "applesauce-relay";
import { logger } from "../../utils/logger";
import { withTimeout } from "../../utils/with-timeout";
import { createCocodClient, type CocodClient } from "./cocod-client";
import type { WalletClient } from "./wallet-client";
import { startAutoRefillLoop, type AutoRefillConfig } from "./auto-refill";
/**
@@ -34,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
@@ -46,8 +46,8 @@ 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). */
@@ -65,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"> = {};
+206
View File
@@ -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>;
}
-2
View File
@@ -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,
-1
View File
@@ -24,7 +24,6 @@ const baseConfig = {
port: 8008,
host: "127.0.0.1",
provider: null,
cocodPath: null,
};
function assert(cond: unknown, msg: string): void {
-1
View File
@@ -11,7 +11,6 @@ function config(host: string): RoutstrdConfig {
port: 8008,
host,
provider: null,
cocodPath: null,
};
}
+4 -4
View File
@@ -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", () => {