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`
|
||||
|
||||
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>`
|
||||
|
||||
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>`
|
||||
|
||||
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 {
|
||||
collectRecentRequestsFromLines,
|
||||
describeMintRemovalRisks,
|
||||
getLivePidFileOwner,
|
||||
initializeWallet,
|
||||
parseStructuredLogLine,
|
||||
@@ -232,3 +233,51 @@ describe("initializeWallet", () => {
|
||||
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,
|
||||
WalletMigrationConflictError,
|
||||
} from "./daemon/wallet/diagnostics";
|
||||
import type { MintRemovalInfo } from "./daemon/wallet/wallet-client";
|
||||
import {
|
||||
legacyCocodDir,
|
||||
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
|
||||
.command("mints")
|
||||
.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
|
||||
.command("list")
|
||||
.description("List configured wallet mints")
|
||||
.action(async () => {
|
||||
await handleDaemonCommand("/wallet/mints");
|
||||
.option("--json", "Print the raw JSON response", false)
|
||||
.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
|
||||
@@ -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
|
||||
.command("info <url>")
|
||||
.description("Get wallet mint info")
|
||||
|
||||
@@ -684,6 +684,31 @@ export function createDaemonRequestHandler(deps: {
|
||||
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") {
|
||||
await respond(res, async () => {
|
||||
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,
|
||||
WalletCleanupResult,
|
||||
WalletRecoveryProgress,
|
||||
MintRemovalInfo,
|
||||
} from "./wallet-client";
|
||||
import { selectCleanupOperations, summarizeMintCleanup } from "./cleanup";
|
||||
import {
|
||||
@@ -131,6 +132,12 @@ interface CocodConfig {
|
||||
mnemonic: string;
|
||||
encrypted: boolean;
|
||||
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]";
|
||||
@@ -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(
|
||||
pid: number,
|
||||
readFile: (path: string) => string = (path) =>
|
||||
@@ -2016,6 +2112,7 @@ export async function createCocoClient(
|
||||
},
|
||||
defaultMintUrl,
|
||||
{
|
||||
skipMints: walletConfig.removedMintUrls,
|
||||
onProgress: startupProgress,
|
||||
onError: (message, error) =>
|
||||
logger.warn(message, {
|
||||
@@ -2389,9 +2486,83 @@ export async function createCocoClient(
|
||||
await waitForRecovery();
|
||||
const mintUrl = normalizeMintUrl(url);
|
||||
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`;
|
||||
},
|
||||
|
||||
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> {
|
||||
return coco.mint.getMintInfo(normalizeMintUrl(url));
|
||||
},
|
||||
@@ -2406,6 +2577,9 @@ export async function createCocoClient(
|
||||
const trustedMints = await coco.mint.getAllTrustedMints();
|
||||
if (!trustedMints.some((mint) => mint.mintUrl === mintUrl)) {
|
||||
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;
|
||||
|
||||
@@ -136,4 +136,35 @@ describe("seedTrustedMints", () => {
|
||||
expect(added).toEqual([...DEFAULT_TRUSTED_MINT_URLS]);
|
||||
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 {
|
||||
/** Mints to ensure are trusted, in order. Defaults to the shipped seeds. */
|
||||
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. */
|
||||
onProgress?: (message: string) => void;
|
||||
/** 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 target = safeNormalizeMintUrl(defaultMintUrl);
|
||||
const trusted = new Set(wallet.trustedMints.map(safeNormalizeMintUrl));
|
||||
const skipped = new Set((options.skipMints ?? []).map(safeNormalizeMintUrl));
|
||||
const attempted = new Set<string>();
|
||||
|
||||
for (const seed of [defaultMintUrl, ...seeds]) {
|
||||
const mintUrl = safeNormalizeMintUrl(seed);
|
||||
if (attempted.has(mintUrl)) continue;
|
||||
if (attempted.has(mintUrl) || skipped.has(mintUrl)) continue;
|
||||
attempted.add(mintUrl);
|
||||
if (trusted.has(mintUrl)) continue;
|
||||
|
||||
|
||||
@@ -153,6 +153,31 @@ export interface WalletStuckOperationRecoveryResult {
|
||||
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 {
|
||||
ping(): Promise<boolean>;
|
||||
getStatus(): Promise<WalletRuntimeState>;
|
||||
@@ -169,6 +194,9 @@ export interface WalletClient {
|
||||
sendBolt11(invoice: string, mintUrl?: string): Promise<string>;
|
||||
listMints(): 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>;
|
||||
getDefaultMint(): Promise<string | null>;
|
||||
setDefaultMint(url: string): Promise<string>;
|
||||
|
||||
Reference in New Issue
Block a user