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:
redshift
2026-10-04 20:49:43 +08:00
parent 5eb2ee68c7
commit 2cdd8d745b
9 changed files with 638 additions and 4 deletions
+24 -1
View File
@@ -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.
+49
View File
@@ -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
View File
@@ -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")
+25
View File
@@ -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);
+138
View File
@@ -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");
});
});
+174
View File
@@ -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;
+31
View File
@@ -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);
});
}); });
+8 -1
View File
@@ -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;
+28
View File
@@ -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>;