mirror of
https://github.com/Routstr/routstrd.git
synced 2026-10-05 12:28:23 +00:00
feat(wallet): add wallet mints remove with asset warnings
Add `routstrd wallet mints remove <url-or-index>` plus the daemon plumbing behind it: - GET /wallet/mints/removal-info reads local state (held sats including reserved proofs, pending mint quotes, in-flight melts, default flag) so the check works even when the mint is offline. - DELETE /wallet/mints removes a mint, refusing to remove the last one. - The CLI warns and asks for confirmation only when the mint still holds funds or pending quotes; an empty mint is removed without a prompt. - Removed mints are recorded in the wallet config and skipped by trusted-mint seeding, so a removed shipped mint does not reappear on restart. Adding it back clears the marker. - `wallet mints list` now renders a numbered list (with `--json` for the raw response). Proofs are intentionally left in place: deleting a mint never burns sats, and re-adding the mint restores access to them.
This commit is contained in:
@@ -402,12 +402,35 @@ Pay a Lightning invoice.
|
|||||||
|
|
||||||
### `routstrd wallet mints list`
|
### `routstrd wallet mints list`
|
||||||
|
|
||||||
List configured wallet mints. Includes the mints trusted by default (`https://mint.minibits.cash/Bitcoin`, `https://mint.cubabitcoin.org`) plus any added manually.
|
List configured wallet mints as a numbered list, marking the default mint.
|
||||||
|
Includes the mints trusted by default (`https://mint.minibits.cash/Bitcoin`,
|
||||||
|
`https://mint.cubabitcoin.org`) plus any added manually. Pass `--json` for the
|
||||||
|
raw response.
|
||||||
|
|
||||||
### `routstrd wallet mints add <url>`
|
### `routstrd wallet mints add <url>`
|
||||||
|
|
||||||
Add a new mint by URL.
|
Add a new mint by URL.
|
||||||
|
|
||||||
|
### `routstrd wallet mints remove <mint>`
|
||||||
|
|
||||||
|
Remove a mint from the wallet. `<mint>` is a mint URL or the index shown by
|
||||||
|
`routstrd wallet mints list`.
|
||||||
|
|
||||||
|
Before removing, the daemon checks the mint's local state. If the mint still
|
||||||
|
holds sats, has pending top-up (mint) quotes, or has in-flight outbound (melt)
|
||||||
|
payments, the command prints a warning and asks for confirmation. Removing a
|
||||||
|
mint keeps those sats in the wallet database but makes them unspendable until
|
||||||
|
the mint is added back. A mint with none of these is removed without a prompt.
|
||||||
|
|
||||||
|
| Option | Default | Description |
|
||||||
|
|--------|---------|-------------|
|
||||||
|
| `-y, --yes` | false | Skip the confirmation prompt when the mint still has funds or pending operations |
|
||||||
|
|
||||||
|
Removing the default mint moves the default to another trusted mint. Removed
|
||||||
|
mints stay removed across daemon restarts and are only re-added with
|
||||||
|
`routstrd wallet mints add <url>`. The last mint in the wallet cannot be
|
||||||
|
removed.
|
||||||
|
|
||||||
### `routstrd wallet mints set-default <url>`
|
### `routstrd wallet mints set-default <url>`
|
||||||
|
|
||||||
Set the persistent default mint. If necessary, the mint is added as trusted first.
|
Set the persistent default mint. If necessary, the mint is added as trusted first.
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { tmpdir } from "os";
|
|||||||
import { join } from "path";
|
import { join } from "path";
|
||||||
import {
|
import {
|
||||||
collectRecentRequestsFromLines,
|
collectRecentRequestsFromLines,
|
||||||
|
describeMintRemovalRisks,
|
||||||
getLivePidFileOwner,
|
getLivePidFileOwner,
|
||||||
initializeWallet,
|
initializeWallet,
|
||||||
parseStructuredLogLine,
|
parseStructuredLogLine,
|
||||||
@@ -232,3 +233,51 @@ describe("initializeWallet", () => {
|
|||||||
expect(permissions(walletConfig)).toBe(0o600);
|
expect(permissions(walletConfig)).toBe(0o600);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("describeMintRemovalRisks", () => {
|
||||||
|
test("says nothing for a plain mint with no funds or pending work", () => {
|
||||||
|
expect(
|
||||||
|
describeMintRemovalRisks({
|
||||||
|
total: 0,
|
||||||
|
spendable: 0,
|
||||||
|
reserved: 0,
|
||||||
|
pendingMintQuotes: 0,
|
||||||
|
pendingMeltQuotes: 0,
|
||||||
|
isDefault: false,
|
||||||
|
}),
|
||||||
|
).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("breaks down held sats, including reserved ones", () => {
|
||||||
|
const lines = describeMintRemovalRisks({
|
||||||
|
total: 1500,
|
||||||
|
spendable: 1000,
|
||||||
|
reserved: 500,
|
||||||
|
pendingMintQuotes: 0,
|
||||||
|
pendingMeltQuotes: 0,
|
||||||
|
isDefault: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(lines).toHaveLength(1);
|
||||||
|
expect(lines[0]).toContain("1500 sats");
|
||||||
|
expect(lines[0]).toContain("1000 spendable");
|
||||||
|
expect(lines[0]).toContain("500 reserved");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("lists pending quotes, in-flight melts, and the default marker", () => {
|
||||||
|
const lines = describeMintRemovalRisks({
|
||||||
|
total: 0,
|
||||||
|
spendable: 0,
|
||||||
|
reserved: 0,
|
||||||
|
pendingMintQuotes: 2,
|
||||||
|
pendingMeltQuotes: 1,
|
||||||
|
isDefault: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(lines.some((line) => line.includes("2 pending top-up"))).toBe(true);
|
||||||
|
expect(lines.some((line) => line.includes("1 in-flight outbound"))).toBe(
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
expect(lines.some((line) => line.includes("default mint"))).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
+161
-2
@@ -49,6 +49,7 @@ import {
|
|||||||
summarizeWalletDirectory,
|
summarizeWalletDirectory,
|
||||||
WalletMigrationConflictError,
|
WalletMigrationConflictError,
|
||||||
} from "./daemon/wallet/diagnostics";
|
} from "./daemon/wallet/diagnostics";
|
||||||
|
import type { MintRemovalInfo } from "./daemon/wallet/wallet-client";
|
||||||
import {
|
import {
|
||||||
legacyCocodDir,
|
legacyCocodDir,
|
||||||
legacyCocodPidPath,
|
legacyCocodPidPath,
|
||||||
@@ -2416,15 +2417,87 @@ walletSendCmd
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/** Call the daemon for a mint command, printing and exiting on transport errors. */
|
||||||
|
async function callMintDaemon(
|
||||||
|
path: string,
|
||||||
|
options?: { method?: "GET" | "POST" | "PATCH" | "DELETE"; body?: object },
|
||||||
|
): Promise<CommandResponse> {
|
||||||
|
try {
|
||||||
|
return await callDaemon(path, options);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error instanceof Error ? error.message : String(error));
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const walletMintsCmd = walletCmd
|
const walletMintsCmd = walletCmd
|
||||||
.command("mints")
|
.command("mints")
|
||||||
.description("Wallet mint operations");
|
.description("Wallet mint operations");
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Human-readable reasons a mint is risky to remove. Only risks that actually
|
||||||
|
* apply to `info` are returned, so a mint holding nothing produces no lines.
|
||||||
|
*/
|
||||||
|
export function describeMintRemovalRisks(
|
||||||
|
info: Pick<
|
||||||
|
MintRemovalInfo,
|
||||||
|
| "total"
|
||||||
|
| "spendable"
|
||||||
|
| "reserved"
|
||||||
|
| "pendingMintQuotes"
|
||||||
|
| "pendingMeltQuotes"
|
||||||
|
| "isDefault"
|
||||||
|
>,
|
||||||
|
): string[] {
|
||||||
|
const lines: string[] = [];
|
||||||
|
if (info.total > 0) {
|
||||||
|
const breakdown =
|
||||||
|
info.reserved > 0
|
||||||
|
? ` (${info.spendable} spendable, ${info.reserved} reserved for in-flight operations)`
|
||||||
|
: "";
|
||||||
|
lines.push(` - ${info.total} sats are held at this mint${breakdown}`);
|
||||||
|
}
|
||||||
|
if (info.pendingMintQuotes > 0) {
|
||||||
|
lines.push(
|
||||||
|
` - ${info.pendingMintQuotes} pending top-up (mint) quote(s) at this mint`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (info.pendingMeltQuotes > 0) {
|
||||||
|
lines.push(
|
||||||
|
` - ${info.pendingMeltQuotes} in-flight outbound (melt) payment(s) for this mint`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (info.isDefault) {
|
||||||
|
lines.push(" - this is the wallet's default mint");
|
||||||
|
}
|
||||||
|
return lines;
|
||||||
|
}
|
||||||
|
|
||||||
walletMintsCmd
|
walletMintsCmd
|
||||||
.command("list")
|
.command("list")
|
||||||
.description("List configured wallet mints")
|
.description("List configured wallet mints")
|
||||||
.action(async () => {
|
.option("--json", "Print the raw JSON response", false)
|
||||||
await handleDaemonCommand("/wallet/mints");
|
.action(async (options: { json: boolean }) => {
|
||||||
|
if (options.json) {
|
||||||
|
await handleDaemonCommand("/wallet/mints");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await ensureDaemonRunning();
|
||||||
|
const result = await callMintDaemon("/wallet/mints");
|
||||||
|
const output = result.output as
|
||||||
|
| { mints?: string[]; defaultMint?: string | null; activeMint?: string | null }
|
||||||
|
| undefined;
|
||||||
|
const mints = output?.mints ?? [];
|
||||||
|
if (mints.length === 0) {
|
||||||
|
console.log("No wallet mints configured.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const defaultMint = output?.defaultMint ?? output?.activeMint ?? null;
|
||||||
|
console.log(`Wallet mints (${mints.length}):`);
|
||||||
|
mints.forEach((mint, index) => {
|
||||||
|
const marker = mint === defaultMint ? " (default)" : "";
|
||||||
|
console.log(` ${index + 1}. ${mint}${marker}`);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
walletMintsCmd
|
walletMintsCmd
|
||||||
@@ -2437,6 +2510,92 @@ walletMintsCmd
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
walletMintsCmd
|
||||||
|
.command("remove <mint>")
|
||||||
|
.description(
|
||||||
|
"Remove a wallet mint (accepts a mint URL or the index from 'wallet mints list')",
|
||||||
|
)
|
||||||
|
.option("-y, --yes", "Skip the confirmation prompt", false)
|
||||||
|
.action(async (mint: string, options: { yes: boolean }) => {
|
||||||
|
await ensureDaemonRunning();
|
||||||
|
|
||||||
|
// Allow the numbered entry from `wallet mints list` as a shortcut.
|
||||||
|
let mintUrl = mint.trim();
|
||||||
|
if (/^\d+$/.test(mintUrl)) {
|
||||||
|
const listResult = await callMintDaemon("/wallet/mints");
|
||||||
|
const mints =
|
||||||
|
(listResult.output as { mints?: string[] } | undefined)?.mints ?? [];
|
||||||
|
const index = Number.parseInt(mintUrl, 10);
|
||||||
|
const selected = index >= 1 ? mints[index - 1] : undefined;
|
||||||
|
if (!selected) {
|
||||||
|
console.error(
|
||||||
|
`No mint at index ${index}. Run 'routstrd wallet mints list' first.`,
|
||||||
|
);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
mintUrl = selected;
|
||||||
|
}
|
||||||
|
|
||||||
|
const infoResult = await callMintDaemon(
|
||||||
|
`/wallet/mints/removal-info?url=${encodeURIComponent(mintUrl)}`,
|
||||||
|
);
|
||||||
|
const info = infoResult.output as MintRemovalInfo | undefined;
|
||||||
|
if (!info) {
|
||||||
|
console.error("Could not read mint details from the daemon.");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
if (info.mintCount <= 1) {
|
||||||
|
console.error(
|
||||||
|
"Cannot remove the last mint in the wallet; add another mint first.",
|
||||||
|
);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only hold back a mint that still has funds or pending quotes. A mint the
|
||||||
|
// user never funded is removed without a prompt.
|
||||||
|
if (info.hasAssets) {
|
||||||
|
console.log(`Warning: ${info.url} still has funds or pending operations:`);
|
||||||
|
for (const line of describeMintRemovalRisks(info)) {
|
||||||
|
console.log(line);
|
||||||
|
}
|
||||||
|
console.log("");
|
||||||
|
console.log(
|
||||||
|
"Removing the mint keeps those sats in the wallet database, but they are",
|
||||||
|
);
|
||||||
|
console.log("not spendable until the mint is added back.");
|
||||||
|
|
||||||
|
if (!options.yes) {
|
||||||
|
const rl = require("readline").createInterface({
|
||||||
|
input: process.stdin,
|
||||||
|
output: process.stdout,
|
||||||
|
});
|
||||||
|
const answer = await new Promise<string>((resolve) => {
|
||||||
|
rl.question(`Remove ${info.url} anyway? [y/N] `, (value: string) => {
|
||||||
|
rl.close();
|
||||||
|
resolve(value.trim().toLowerCase());
|
||||||
|
});
|
||||||
|
});
|
||||||
|
if (answer !== "y" && answer !== "yes") {
|
||||||
|
console.log("Aborted.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (info.isDefault) {
|
||||||
|
console.log(
|
||||||
|
`Note: ${info.url} is the wallet's default mint; the default will move to another mint.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await callMintDaemon("/wallet/mints", {
|
||||||
|
method: "DELETE",
|
||||||
|
body: { url: info.url },
|
||||||
|
});
|
||||||
|
const output = result.output as { message?: string } | undefined;
|
||||||
|
console.log(
|
||||||
|
output?.message ?? `Mint ${info.url} removed from the wallet`,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
walletMintsCmd
|
walletMintsCmd
|
||||||
.command("info <url>")
|
.command("info <url>")
|
||||||
.description("Get wallet mint info")
|
.description("Get wallet mint info")
|
||||||
|
|||||||
@@ -684,6 +684,31 @@ export function createDaemonRequestHandler(deps: {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (req.method === "GET" && url.pathname === "/wallet/mints/removal-info") {
|
||||||
|
await respond(res, async () => {
|
||||||
|
const mintUrl = url.searchParams.get("url")?.trim();
|
||||||
|
if (!mintUrl) {
|
||||||
|
throw new WalletHttpError(
|
||||||
|
400,
|
||||||
|
"Missing required 'url' query parameter.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const info = await deps.walletClient.getMintRemovalInfo(mintUrl);
|
||||||
|
return { output: info };
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (req.method === "DELETE" && url.pathname === "/wallet/mints") {
|
||||||
|
await respond(res, async () => {
|
||||||
|
const body = await readJsonBody(req);
|
||||||
|
const mintUrl = getRequiredStringField(body, "url");
|
||||||
|
const message = await deps.walletClient.removeMint(mintUrl);
|
||||||
|
return { output: { message, url: mintUrl } };
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (req.method === "POST" && url.pathname === "/wallet/mints/info") {
|
if (req.method === "POST" && url.pathname === "/wallet/mints/info") {
|
||||||
await respond(res, async () => {
|
await respond(res, async () => {
|
||||||
const body = await readJsonBody(req);
|
const body = await readJsonBody(req);
|
||||||
|
|||||||
@@ -0,0 +1,138 @@
|
|||||||
|
import { describe, expect, it } from "bun:test";
|
||||||
|
import { Readable } from "stream";
|
||||||
|
import { createDaemonRequestHandler } from "./index";
|
||||||
|
import type { MintRemovalInfo } from "../wallet/wallet-client";
|
||||||
|
|
||||||
|
const MINT_URL = "https://mint.example/";
|
||||||
|
|
||||||
|
function makeInfo(overrides: Partial<MintRemovalInfo> = {}): MintRemovalInfo {
|
||||||
|
return {
|
||||||
|
url: MINT_URL,
|
||||||
|
spendable: 0,
|
||||||
|
reserved: 0,
|
||||||
|
total: 0,
|
||||||
|
pendingMintQuotes: 0,
|
||||||
|
pendingMeltQuotes: 0,
|
||||||
|
isDefault: false,
|
||||||
|
mintCount: 2,
|
||||||
|
hasAssets: false,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeWalletClient(overrides: Record<string, unknown> = {}) {
|
||||||
|
const calls: { removed: string[] } = { removed: [] };
|
||||||
|
return {
|
||||||
|
calls,
|
||||||
|
client: {
|
||||||
|
listMints: async () => [MINT_URL, "https://mint.other/"],
|
||||||
|
getDefaultMint: async () => MINT_URL,
|
||||||
|
addMint: async (url: string) => `Mint ${url} added successfully`,
|
||||||
|
getMintRemovalInfo: async (url: string) => makeInfo({ url }),
|
||||||
|
removeMint: async (url: string) => {
|
||||||
|
calls.removed.push(url);
|
||||||
|
return `Mint ${url} removed from the wallet`;
|
||||||
|
},
|
||||||
|
...overrides,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeReq(method: string, path: string, body?: unknown) {
|
||||||
|
const payload = body === undefined ? undefined : JSON.stringify(body);
|
||||||
|
const req = payload
|
||||||
|
? (Readable.from([payload]) as any)
|
||||||
|
: (Readable.from([]) as any);
|
||||||
|
req.method = method;
|
||||||
|
req.url = path;
|
||||||
|
req.headers = { host: "localhost", "content-type": "application/json" };
|
||||||
|
return req;
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeRes() {
|
||||||
|
const res: any = {
|
||||||
|
status: 0,
|
||||||
|
body: "",
|
||||||
|
writeHead(status: number) {
|
||||||
|
res.status = status;
|
||||||
|
return res;
|
||||||
|
},
|
||||||
|
end(chunk?: string) {
|
||||||
|
if (chunk) res.body += chunk;
|
||||||
|
return res;
|
||||||
|
},
|
||||||
|
json() {
|
||||||
|
return JSON.parse(res.body);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function call(
|
||||||
|
method: string,
|
||||||
|
path: string,
|
||||||
|
client: unknown,
|
||||||
|
body?: unknown,
|
||||||
|
) {
|
||||||
|
const handler = createDaemonRequestHandler({
|
||||||
|
walletClient: client,
|
||||||
|
} as any);
|
||||||
|
const res = makeRes();
|
||||||
|
await handler(makeReq(method, path, body), res);
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("GET /wallet/mints/removal-info", () => {
|
||||||
|
it("returns the removal info for the requested mint", async () => {
|
||||||
|
const { client } = makeWalletClient();
|
||||||
|
const res = await call(
|
||||||
|
"GET",
|
||||||
|
`/wallet/mints/removal-info?url=${encodeURIComponent(MINT_URL)}`,
|
||||||
|
client,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const output = res.json().output as MintRemovalInfo;
|
||||||
|
expect(output.url).toBe(MINT_URL);
|
||||||
|
expect(output.hasAssets).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a request with no url", async () => {
|
||||||
|
const { client } = makeWalletClient();
|
||||||
|
const res = await call("GET", "/wallet/mints/removal-info", client);
|
||||||
|
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
expect(String(res.json().error)).toContain("url");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("DELETE /wallet/mints", () => {
|
||||||
|
it("removes the mint and returns the daemon message", async () => {
|
||||||
|
const { client, calls } = makeWalletClient();
|
||||||
|
const res = await call("DELETE", "/wallet/mints", client, { url: MINT_URL });
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(calls.removed).toEqual([MINT_URL]);
|
||||||
|
expect(res.json().output.message).toContain("removed");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a request with no url", async () => {
|
||||||
|
const { client, calls } = makeWalletClient();
|
||||||
|
const res = await call("DELETE", "/wallet/mints", client, {});
|
||||||
|
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
expect(calls.removed).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("surfaces a backend refusal as an error response", async () => {
|
||||||
|
const { client } = makeWalletClient({
|
||||||
|
removeMint: async () => {
|
||||||
|
throw new Error("Cannot remove the last mint in the wallet");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const res = await call("DELETE", "/wallet/mints", client, { url: MINT_URL });
|
||||||
|
|
||||||
|
expect(res.status).toBe(500);
|
||||||
|
expect(String(res.json().error)).toContain("last mint");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -37,6 +37,7 @@ import type {
|
|||||||
WalletCleanupOptions,
|
WalletCleanupOptions,
|
||||||
WalletCleanupResult,
|
WalletCleanupResult,
|
||||||
WalletRecoveryProgress,
|
WalletRecoveryProgress,
|
||||||
|
MintRemovalInfo,
|
||||||
} from "./wallet-client";
|
} from "./wallet-client";
|
||||||
import { selectCleanupOperations, summarizeMintCleanup } from "./cleanup";
|
import { selectCleanupOperations, summarizeMintCleanup } from "./cleanup";
|
||||||
import {
|
import {
|
||||||
@@ -131,6 +132,12 @@ interface CocodConfig {
|
|||||||
mnemonic: string;
|
mnemonic: string;
|
||||||
encrypted: boolean;
|
encrypted: boolean;
|
||||||
defaultMintUrl?: string;
|
defaultMintUrl?: string;
|
||||||
|
/**
|
||||||
|
* Mint URLs the user removed from the wallet. Trusted-mint seeding skips
|
||||||
|
* these so a removed shipped mint does not reappear on the next restart.
|
||||||
|
* Adding a mint again clears its entry.
|
||||||
|
*/
|
||||||
|
removedMintUrls?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
const STARTUP_LOG_PREFIX = "[routstrd:start]";
|
const STARTUP_LOG_PREFIX = "[routstrd:start]";
|
||||||
@@ -262,6 +269,95 @@ function saveConfig(config: CocodConfig, configFile: string): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Config mint URLs are written by routstrd, but a hand-edited file must not
|
||||||
|
// crash startup. Normalization only strips a default port and trailing slash.
|
||||||
|
function configMintUrl(mintUrl: string): string {
|
||||||
|
try {
|
||||||
|
return normalizeMintUrl(mintUrl);
|
||||||
|
} catch {
|
||||||
|
return mintUrl.trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Record a removed mint so trusted-mint seeding will not re-add it. */
|
||||||
|
function markMintRemoved(config: CocodConfig, mintUrl: string): boolean {
|
||||||
|
const url = configMintUrl(mintUrl);
|
||||||
|
const removed = new Set((config.removedMintUrls ?? []).map(configMintUrl));
|
||||||
|
if (removed.has(url)) return false;
|
||||||
|
removed.add(url);
|
||||||
|
config.removedMintUrls = [...removed];
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Clear a mint's removed marker because the user added it back. */
|
||||||
|
function clearMintRemoved(config: CocodConfig, mintUrl: string): boolean {
|
||||||
|
const url = configMintUrl(mintUrl);
|
||||||
|
const current = config.removedMintUrls ?? [];
|
||||||
|
const remaining = current.filter((entry) => configMintUrl(entry) !== url);
|
||||||
|
if (remaining.length === current.length) return false;
|
||||||
|
config.removedMintUrls = remaining;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove a mint's record and keysets from the wallet database. The public
|
||||||
|
* `MintApi` only exposes trust changes, so reach the underlying `MintService`
|
||||||
|
* structurally, as this module already does for other coco internals. Stored
|
||||||
|
* proofs are intentionally left untouched: deleting them would burn sats, and
|
||||||
|
* re-adding the mint restores access to them.
|
||||||
|
*/
|
||||||
|
async function deleteMintFromWallet(coco: Manager, mintUrl: string): Promise<void> {
|
||||||
|
const service = (
|
||||||
|
coco as unknown as {
|
||||||
|
mintService?: { deleteMint?: (url: string) => Promise<void> };
|
||||||
|
}
|
||||||
|
).mintService;
|
||||||
|
if (!service?.deleteMint) {
|
||||||
|
throw new Error("Wallet backend does not support removing mints");
|
||||||
|
}
|
||||||
|
await service.deleteMint(mintUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Pending top-up (mint) quotes for one mint, read from local state. */
|
||||||
|
async function countPendingMintQuotes(
|
||||||
|
coco: Manager,
|
||||||
|
mintUrl: string,
|
||||||
|
): Promise<number> {
|
||||||
|
try {
|
||||||
|
const pending = await coco.ops.mint.listPending();
|
||||||
|
return pending.filter((op) => configMintUrl(op.mintUrl) === mintUrl).length;
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn("Could not read pending mint quotes while inspecting a mint", {
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
});
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Prepared or in-flight outbound (melt) payments for one mint. */
|
||||||
|
async function countPendingMeltQuotes(
|
||||||
|
coco: Manager,
|
||||||
|
mintUrl: string,
|
||||||
|
): Promise<number> {
|
||||||
|
try {
|
||||||
|
const [prepared, inFlight] = await Promise.all([
|
||||||
|
coco.ops.melt.listPrepared(),
|
||||||
|
coco.ops.melt.listInFlight(),
|
||||||
|
]);
|
||||||
|
const seen = new Set<string>();
|
||||||
|
for (const op of [...prepared, ...inFlight]) {
|
||||||
|
if (configMintUrl(op.mintUrl) !== mintUrl) continue;
|
||||||
|
seen.add(op.id);
|
||||||
|
}
|
||||||
|
return seen.size;
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn("Could not read pending melt quotes while inspecting a mint", {
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
});
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function isZombieProcess(
|
export function isZombieProcess(
|
||||||
pid: number,
|
pid: number,
|
||||||
readFile: (path: string) => string = (path) =>
|
readFile: (path: string) => string = (path) =>
|
||||||
@@ -2016,6 +2112,7 @@ export async function createCocoClient(
|
|||||||
},
|
},
|
||||||
defaultMintUrl,
|
defaultMintUrl,
|
||||||
{
|
{
|
||||||
|
skipMints: walletConfig.removedMintUrls,
|
||||||
onProgress: startupProgress,
|
onProgress: startupProgress,
|
||||||
onError: (message, error) =>
|
onError: (message, error) =>
|
||||||
logger.warn(message, {
|
logger.warn(message, {
|
||||||
@@ -2389,9 +2486,83 @@ export async function createCocoClient(
|
|||||||
await waitForRecovery();
|
await waitForRecovery();
|
||||||
const mintUrl = normalizeMintUrl(url);
|
const mintUrl = normalizeMintUrl(url);
|
||||||
await coco.mint.addMint(mintUrl, { trusted: true });
|
await coco.mint.addMint(mintUrl, { trusted: true });
|
||||||
|
// A mint the user adds back is wanted again, so forget any removal
|
||||||
|
// marker that would make trusted-mint seeding skip it on restart.
|
||||||
|
if (clearMintRemoved(walletConfig, mintUrl)) {
|
||||||
|
saveConfig(walletConfig, configFile);
|
||||||
|
}
|
||||||
return `Mint ${mintUrl} added successfully`;
|
return `Mint ${mintUrl} added successfully`;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async removeMint(url: string): Promise<string> {
|
||||||
|
await waitForRecovery();
|
||||||
|
const mintUrl = normalizeMintUrl(url);
|
||||||
|
const trustedMints = await coco.mint.getAllTrustedMints();
|
||||||
|
if (!trustedMints.some((mint) => mint.mintUrl === mintUrl)) {
|
||||||
|
throw new Error(`Mint ${mintUrl} is not in the wallet mint list`);
|
||||||
|
}
|
||||||
|
if (trustedMints.length <= 1) {
|
||||||
|
throw new Error(
|
||||||
|
"Cannot remove the last mint in the wallet; add another mint first",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const remaining = trustedMints.filter((mint) => mint.mintUrl !== mintUrl);
|
||||||
|
await deleteMintFromWallet(coco, mintUrl);
|
||||||
|
|
||||||
|
let message = `Mint ${mintUrl} removed from the wallet`;
|
||||||
|
const wasDefault =
|
||||||
|
!!walletConfig.defaultMintUrl &&
|
||||||
|
configMintUrl(walletConfig.defaultMintUrl) === mintUrl;
|
||||||
|
if (wasDefault) {
|
||||||
|
const nextDefault = normalizeMintUrl(remaining[0]!.mintUrl);
|
||||||
|
walletConfig.defaultMintUrl = nextDefault;
|
||||||
|
message += `; default mint switched to ${nextDefault}`;
|
||||||
|
}
|
||||||
|
markMintRemoved(walletConfig, mintUrl);
|
||||||
|
saveConfig(walletConfig, configFile);
|
||||||
|
return message;
|
||||||
|
},
|
||||||
|
|
||||||
|
async getMintRemovalInfo(url: string): Promise<MintRemovalInfo> {
|
||||||
|
const mintUrl = normalizeMintUrl(url);
|
||||||
|
const trustedMints = await coco.mint.getAllTrustedMints();
|
||||||
|
if (!trustedMints.some((mint) => mint.mintUrl === mintUrl)) {
|
||||||
|
throw new Error(`Mint ${mintUrl} is not in the wallet mint list`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Balances come from stored proofs, so this stays local even when the
|
||||||
|
// mint is offline. `reserved` covers sats locked in in-flight sends.
|
||||||
|
const balances = await coco.wallet.balances.byMint({
|
||||||
|
mintUrls: [mintUrl],
|
||||||
|
});
|
||||||
|
const snapshot = balances[mintUrl] ?? {
|
||||||
|
spendable: 0,
|
||||||
|
reserved: 0,
|
||||||
|
total: 0,
|
||||||
|
};
|
||||||
|
const [pendingMintQuotes, pendingMeltQuotes] = await Promise.all([
|
||||||
|
countPendingMintQuotes(coco, mintUrl),
|
||||||
|
countPendingMeltQuotes(coco, mintUrl),
|
||||||
|
]);
|
||||||
|
const isDefault =
|
||||||
|
!!walletConfig.defaultMintUrl &&
|
||||||
|
configMintUrl(walletConfig.defaultMintUrl) === mintUrl;
|
||||||
|
|
||||||
|
return {
|
||||||
|
url: mintUrl,
|
||||||
|
spendable: snapshot.spendable,
|
||||||
|
reserved: snapshot.reserved,
|
||||||
|
total: snapshot.total,
|
||||||
|
pendingMintQuotes,
|
||||||
|
pendingMeltQuotes,
|
||||||
|
isDefault,
|
||||||
|
mintCount: trustedMints.length,
|
||||||
|
hasAssets:
|
||||||
|
snapshot.total > 0 || pendingMintQuotes > 0 || pendingMeltQuotes > 0,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
async getMintInfo(url: string): Promise<unknown> {
|
async getMintInfo(url: string): Promise<unknown> {
|
||||||
return coco.mint.getMintInfo(normalizeMintUrl(url));
|
return coco.mint.getMintInfo(normalizeMintUrl(url));
|
||||||
},
|
},
|
||||||
@@ -2406,6 +2577,9 @@ export async function createCocoClient(
|
|||||||
const trustedMints = await coco.mint.getAllTrustedMints();
|
const trustedMints = await coco.mint.getAllTrustedMints();
|
||||||
if (!trustedMints.some((mint) => mint.mintUrl === mintUrl)) {
|
if (!trustedMints.some((mint) => mint.mintUrl === mintUrl)) {
|
||||||
await coco.mint.addMint(mintUrl, { trusted: true });
|
await coco.mint.addMint(mintUrl, { trusted: true });
|
||||||
|
// A mint the user adds back is wanted again, so forget any removal
|
||||||
|
// marker that would make trusted-mint seeding skip it on restart.
|
||||||
|
clearMintRemoved(walletConfig, mintUrl);
|
||||||
}
|
}
|
||||||
|
|
||||||
walletConfig.defaultMintUrl = mintUrl;
|
walletConfig.defaultMintUrl = mintUrl;
|
||||||
|
|||||||
@@ -136,4 +136,35 @@ describe("seedTrustedMints", () => {
|
|||||||
expect(added).toEqual([...DEFAULT_TRUSTED_MINT_URLS]);
|
expect(added).toEqual([...DEFAULT_TRUSTED_MINT_URLS]);
|
||||||
expect(errors).toEqual([]);
|
expect(errors).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("never re-seeds mints the user explicitly removed", async () => {
|
||||||
|
const { wallet, added } = makeHarness(["https://mint.cubabitcoin.org"]);
|
||||||
|
|
||||||
|
await seedTrustedMints(wallet, DEFAULT_MINT_URL, {
|
||||||
|
skipMints: [DEFAULT_MINT_URL, "https://mint.cubabitcoin.org"],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(added).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("skips a removed mint even when it is the configured default", async () => {
|
||||||
|
const { wallet, added } = makeHarness();
|
||||||
|
|
||||||
|
await seedTrustedMints(wallet, DEFAULT_MINT_URL, {
|
||||||
|
skipMints: [DEFAULT_MINT_URL],
|
||||||
|
});
|
||||||
|
|
||||||
|
// The removed default is skipped; the still-shipped extra is seeded.
|
||||||
|
expect(added).toEqual(["https://mint.cubabitcoin.org"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("matches removed mints after normalization", async () => {
|
||||||
|
const { wallet, added } = makeHarness();
|
||||||
|
|
||||||
|
await seedTrustedMints(wallet, DEFAULT_MINT_URL, {
|
||||||
|
skipMints: [`${DEFAULT_MINT_URL}/`],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(added).not.toContain(DEFAULT_MINT_URL);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -29,6 +29,12 @@ export interface TrustedMintSeeder {
|
|||||||
export interface SeedTrustedMintsOptions {
|
export interface SeedTrustedMintsOptions {
|
||||||
/** Mints to ensure are trusted, in order. Defaults to the shipped seeds. */
|
/** Mints to ensure are trusted, in order. Defaults to the shipped seeds. */
|
||||||
seeds?: readonly string[];
|
seeds?: readonly string[];
|
||||||
|
/**
|
||||||
|
* Mints the user explicitly removed. They are never re-seeded, so a removed
|
||||||
|
* shipped mint does not silently reappear on the next daemon start. Adding a
|
||||||
|
* mint again clears it from this list at the call site.
|
||||||
|
*/
|
||||||
|
skipMints?: readonly string[];
|
||||||
/** Called before each mint fetch with a user-facing progress message. */
|
/** Called before each mint fetch with a user-facing progress message. */
|
||||||
onProgress?: (message: string) => void;
|
onProgress?: (message: string) => void;
|
||||||
/** Called when a non-default seed could not be added. */
|
/** Called when a non-default seed could not be added. */
|
||||||
@@ -64,11 +70,12 @@ export async function seedTrustedMints(
|
|||||||
const seeds = options.seeds ?? DEFAULT_TRUSTED_MINT_URLS;
|
const seeds = options.seeds ?? DEFAULT_TRUSTED_MINT_URLS;
|
||||||
const target = safeNormalizeMintUrl(defaultMintUrl);
|
const target = safeNormalizeMintUrl(defaultMintUrl);
|
||||||
const trusted = new Set(wallet.trustedMints.map(safeNormalizeMintUrl));
|
const trusted = new Set(wallet.trustedMints.map(safeNormalizeMintUrl));
|
||||||
|
const skipped = new Set((options.skipMints ?? []).map(safeNormalizeMintUrl));
|
||||||
const attempted = new Set<string>();
|
const attempted = new Set<string>();
|
||||||
|
|
||||||
for (const seed of [defaultMintUrl, ...seeds]) {
|
for (const seed of [defaultMintUrl, ...seeds]) {
|
||||||
const mintUrl = safeNormalizeMintUrl(seed);
|
const mintUrl = safeNormalizeMintUrl(seed);
|
||||||
if (attempted.has(mintUrl)) continue;
|
if (attempted.has(mintUrl) || skipped.has(mintUrl)) continue;
|
||||||
attempted.add(mintUrl);
|
attempted.add(mintUrl);
|
||||||
if (trusted.has(mintUrl)) continue;
|
if (trusted.has(mintUrl)) continue;
|
||||||
|
|
||||||
|
|||||||
@@ -153,6 +153,31 @@ export interface WalletStuckOperationRecoveryResult {
|
|||||||
skippedMints: Record<string, number>;
|
skippedMints: Record<string, number>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Everything a caller needs to warn about before removing a mint. All values
|
||||||
|
* are read from local state, so this works even when the mint is unreachable.
|
||||||
|
*/
|
||||||
|
export interface MintRemovalInfo {
|
||||||
|
/** Normalized mint URL. */
|
||||||
|
url: string;
|
||||||
|
/** Spendable sats the wallet holds at this mint. */
|
||||||
|
spendable: number;
|
||||||
|
/** Sats locked in in-flight operations at this mint. */
|
||||||
|
reserved: number;
|
||||||
|
/** `spendable + reserved`. */
|
||||||
|
total: number;
|
||||||
|
/** Top-up (mint) quotes still waiting on payment or redemption. */
|
||||||
|
pendingMintQuotes: number;
|
||||||
|
/** Prepared or in-flight outbound (melt) payments for this mint. */
|
||||||
|
pendingMeltQuotes: number;
|
||||||
|
/** Whether this mint is the wallet's default. */
|
||||||
|
isDefault: boolean;
|
||||||
|
/** Total number of trusted mints in the wallet. */
|
||||||
|
mintCount: number;
|
||||||
|
/** True when funds or quotes at this mint deserve a confirmation prompt. */
|
||||||
|
hasAssets: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export interface WalletClient {
|
export interface WalletClient {
|
||||||
ping(): Promise<boolean>;
|
ping(): Promise<boolean>;
|
||||||
getStatus(): Promise<WalletRuntimeState>;
|
getStatus(): Promise<WalletRuntimeState>;
|
||||||
@@ -169,6 +194,9 @@ export interface WalletClient {
|
|||||||
sendBolt11(invoice: string, mintUrl?: string): Promise<string>;
|
sendBolt11(invoice: string, mintUrl?: string): Promise<string>;
|
||||||
listMints(): Promise<string[]>;
|
listMints(): Promise<string[]>;
|
||||||
addMint(url: string): Promise<string>;
|
addMint(url: string): Promise<string>;
|
||||||
|
removeMint(url: string): Promise<string>;
|
||||||
|
/** Local reminder of what removing a mint would strand, for the CLI prompt. */
|
||||||
|
getMintRemovalInfo(url: string): Promise<MintRemovalInfo>;
|
||||||
getMintInfo(url: string): Promise<unknown>;
|
getMintInfo(url: string): Promise<unknown>;
|
||||||
getDefaultMint(): Promise<string | null>;
|
getDefaultMint(): Promise<string | null>;
|
||||||
setDefaultMint(url: string): Promise<string>;
|
setDefaultMint(url: string): Promise<string>;
|
||||||
|
|||||||
Reference in New Issue
Block a user