mirror of
https://github.com/Routstr/routstrd.git
synced 2026-10-05 12:28:23 +00:00
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:
@@ -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`) |
|
||||
|
||||
+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";
|
||||
@@ -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`,
|
||||
|
||||
@@ -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,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) {
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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.",
|
||||
);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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"> = {};
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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