diff --git a/SKILL.md b/SKILL.md index 988a14a..a2d807a 100644 --- a/SKILL.md +++ b/SKILL.md @@ -298,8 +298,12 @@ Manage routstrd as a system service using PM2, so it survives reboots. ## Wallet Commands -New wallets automatically trust `https://mint.cubabitcoin.org` as their default -mint. The default is used when a wallet command does not include `--mint-url`. +New wallets trust two mints out of the box: `https://mint.cubabitcoin.org` and +`https://mint.minibits.cash/Bitcoin`. `https://mint.cubabitcoin.org` is the +default mint, and the default is used when a wallet command does not include +`--mint-url`. An existing wallet keeps whatever default it already has; the +shipped mints are only added as trusted, never as the default. Use +`routstrd wallet mints add ` to trust another mint. ### `routstrd send ` / `routstrd receive ` @@ -372,7 +376,7 @@ Pay a Lightning invoice. ### `routstrd wallet mints list` -List configured wallet mints. +List configured wallet mints. Includes the mints trusted by default (`https://mint.cubabitcoin.org`, `https://mint.minibits.cash/Bitcoin`) plus any added manually. ### `routstrd wallet mints add ` diff --git a/src/daemon/wallet/coco-client.test.ts b/src/daemon/wallet/coco-client.test.ts index 2a44170..5c11927 100644 --- a/src/daemon/wallet/coco-client.test.ts +++ b/src/daemon/wallet/coco-client.test.ts @@ -14,6 +14,7 @@ import { assertLegacyCocodNotRunning, claimLegacyCocodPidFile, createCocoClient, + DEFAULT_TRUSTED_MINT_URLS, isZombieProcess, settleExpiredMintQuotes, settlePendingMintQuotes, @@ -64,7 +65,7 @@ async function waitForWalletUnlocked( } describe("default mint functionality", () => { - it("automatically adds default mint when no mints exist", async () => { + it("automatically adds the shipped trusted mints when no mints exist", async () => { const walletDir = join(makeTempDir(), "wallet"); mkdirSync(walletDir, { recursive: true }); writeFileSync( @@ -90,9 +91,12 @@ describe("default mint functionality", () => { String(process.pid), ); + // Every shipped mint is trusted, so each one is usable without an + // explicit `wallet mints add`. const mints = await client.listMints(); - expect(mints).toContain("https://mint.cubabitcoin.org"); + expect(mints).toEqual(expect.arrayContaining([...DEFAULT_TRUSTED_MINT_URLS])); + // Cuba still owns the default slot even though minibits is trusted too. const defaultMint = await client.getDefaultMint(); expect(defaultMint).toBe("https://mint.cubabitcoin.org"); } finally { diff --git a/src/daemon/wallet/coco-client.ts b/src/daemon/wallet/coco-client.ts index 53c0141..c8077c8 100644 --- a/src/daemon/wallet/coco-client.ts +++ b/src/daemon/wallet/coco-client.ts @@ -58,6 +58,9 @@ import { walletDir as defaultWalletDir, walletPidPath as defaultWalletPidPath, } from "./paths"; +import { DEFAULT_MINT_URL, seedTrustedMints } from "./trusted-mints"; + +export { DEFAULT_MINT_URL, DEFAULT_TRUSTED_MINT_URLS } from "./trusted-mints"; const NPC_DEFAULT_BASE_URL = "https://npubx.cash"; @@ -117,7 +120,6 @@ interface CocodConfig { } const STARTUP_LOG_PREFIX = "[routstrd:start]"; -export const DEFAULT_MINT_URL = "https://mint.cubabitcoin.org"; function startupProgress(message: string): void { logger.info(message); @@ -1293,10 +1295,23 @@ export async function createCocoClient( configuredDefault || trustedMints[0]?.mintUrl || DEFAULT_MINT_URL, ); - if (!trustedMints.some((mint) => mint.mintUrl === defaultMintUrl)) { - startupProgress(`Adding default mint: ${defaultMintUrl}`); - await coco.mint.addMint(defaultMintUrl, { trusted: true }); - } + // Seeds the mints we ship as trusted. The default mint is strict (see + // seedTrustedMints); extra seeds only warn, so an unreachable mint that is + // not the default cannot stop the daemon from starting. + await seedTrustedMints( + { + trustedMints: trustedMints.map((mint) => mint.mintUrl), + addMint: (mintUrl) => coco!.mint.addMint(mintUrl, { trusted: true }), + }, + defaultMintUrl, + { + onProgress: startupProgress, + onError: (message, error) => + logger.warn(message, { + error: error instanceof Error ? error.message : String(error), + }), + }, + ); // Persist only after the mint was successfully fetched and trusted. A failed // network request must not leave config pointing at an unusable default. diff --git a/src/daemon/wallet/trusted-mints.test.ts b/src/daemon/wallet/trusted-mints.test.ts new file mode 100644 index 0000000..90ac4b8 --- /dev/null +++ b/src/daemon/wallet/trusted-mints.test.ts @@ -0,0 +1,139 @@ +import { describe, expect, it } from "bun:test"; +import { + DEFAULT_MINT_URL, + DEFAULT_TRUSTED_MINT_URLS, + seedTrustedMints, + type TrustedMintSeeder, +} from "./trusted-mints"; + +interface Harness { + wallet: TrustedMintSeeder; + added: string[]; + progress: string[]; + errors: { message: string; error: unknown }[]; +} + +function makeHarness( + trustedMints: string[] = [], + failing: string[] = [], +): Harness { + const added: string[] = []; + const progress: string[] = []; + const errors: { message: string; error: unknown }[] = []; + const wallet: TrustedMintSeeder = { + trustedMints, + addMint: async (mintUrl) => { + if (failing.includes(mintUrl)) { + throw new Error(`Failed to fetch mint ${mintUrl}`); + } + added.push(mintUrl); + }, + }; + return { wallet, added, progress, errors }; +} + +describe("seedTrustedMints", () => { + it("seeds every shipped mint for a wallet that trusts none", async () => { + const { wallet, added, progress } = makeHarness(); + + await seedTrustedMints(wallet, DEFAULT_MINT_URL, { + onProgress: (message) => progress.push(message), + }); + + expect(added).toEqual([...DEFAULT_TRUSTED_MINT_URLS]); + expect(progress).toEqual([ + `Adding default mint: ${DEFAULT_MINT_URL}`, + "Adding trusted mint: https://mint.minibits.cash/Bitcoin", + ]); + }); + + it("preserves the casing and path of seeded mint URLs", async () => { + const { wallet, added } = makeHarness(); + + await seedTrustedMints(wallet, DEFAULT_MINT_URL); + + // The Bitcoin path segment of the minibits mint is case sensitive; + // lowercasing it breaks the mint. + expect(added).toContain("https://mint.minibits.cash/Bitcoin"); + }); + + it("skips mints that are already trusted", async () => { + const { wallet, added } = makeHarness([ + DEFAULT_MINT_URL, + "https://mint.minibits.cash/Bitcoin", + ]); + + await seedTrustedMints(wallet, DEFAULT_MINT_URL); + + expect(added).toEqual([]); + }); + + it("treats a trailing slash on a stored mint as already trusted", async () => { + const { wallet, added } = makeHarness([ + `${DEFAULT_MINT_URL}/`, + "https://mint.minibits.cash/Bitcoin/", + ]); + + await seedTrustedMints(wallet, DEFAULT_MINT_URL); + + expect(added).toEqual([]); + }); + + it("deduplicates the default mint when it also appears in the seeds", async () => { + const { wallet, added } = makeHarness(); + + await seedTrustedMints(wallet, DEFAULT_MINT_URL, { + seeds: [DEFAULT_MINT_URL, DEFAULT_MINT_URL], + }); + + expect(added).toEqual([DEFAULT_MINT_URL]); + }); + + it("seeds the extras even when the wallet has a different default", async () => { + const { wallet, added } = makeHarness(); + const customDefault = "https://mint.example.com"; + + await seedTrustedMints(wallet, customDefault); + + // The wallet's own default stays first; the shipped seeds are added after + // it and never take the default slot. + expect(added).toEqual([ + customDefault, + ...DEFAULT_TRUSTED_MINT_URLS.filter((url) => url !== customDefault), + ]); + }); + + it("fails startup when the default mint cannot be fetched", async () => { + const { wallet, added } = makeHarness([], [DEFAULT_MINT_URL]); + + await expect( + seedTrustedMints(wallet, `${DEFAULT_MINT_URL}/`), + ).rejects.toThrow(`Failed to fetch mint ${DEFAULT_MINT_URL}`); + // The default is seeded first, so the extras were never attempted. + expect(added).toEqual([]); + }); + + it("keeps going when a non-default mint cannot be fetched", async () => { + const unavailable = "https://mint.minibits.cash/Bitcoin"; + const { wallet, added, errors } = makeHarness([], [unavailable]); + + await seedTrustedMints(wallet, DEFAULT_MINT_URL, { + onError: (message, error) => errors.push({ message, error }), + }); + + expect(added).toEqual([DEFAULT_MINT_URL]); + expect(errors).toHaveLength(1); + expect(errors[0]!.message).toBe(`Could not add trusted mint ${unavailable}`); + }); + + it("ignores stored mint URLs that cannot be normalized", async () => { + const { wallet, added, errors } = makeHarness(["not a mint url"], []); + + await seedTrustedMints(wallet, DEFAULT_MINT_URL, { + onError: (message, error) => errors.push({ message, error }), + }); + + expect(added).toEqual([...DEFAULT_TRUSTED_MINT_URLS]); + expect(errors).toEqual([]); + }); +}); \ No newline at end of file diff --git a/src/daemon/wallet/trusted-mints.ts b/src/daemon/wallet/trusted-mints.ts new file mode 100644 index 0000000..9db640c --- /dev/null +++ b/src/daemon/wallet/trusted-mints.ts @@ -0,0 +1,87 @@ +import { normalizeMintUrl } from "@cashu/coco-core"; + +/** + * Mint used as the default for wallets that have no configured default. It is + * always trusted, and a wallet is never allowed to point its default at a mint + * it could not fetch. + */ +export const DEFAULT_MINT_URL = "https://mint.cubabitcoin.org"; + +/** + * Mints routstrd trusts out of the box. Every entry is added as a trusted mint + * on startup so users can send/receive without an explicit + * `wallet mints add`. `DEFAULT_MINT_URL` is listed first because it seeds the + * default mint of a fresh wallet; extra entries never change an existing + * default. + */ +export const DEFAULT_TRUSTED_MINT_URLS: readonly string[] = [ + DEFAULT_MINT_URL, + "https://mint.minibits.cash/Bitcoin", +]; + +export interface TrustedMintSeeder { + /** Mint URLs the wallet currently trusts. */ + trustedMints: readonly string[]; + /** Trust a mint, fetching its info and keysets from the mint itself. */ + addMint: (mintUrl: string) => Promise; +} + +export interface SeedTrustedMintsOptions { + /** Mints to ensure are trusted, in order. Defaults to the shipped seeds. */ + seeds?: 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. */ + onError?: (message: string, error: unknown) => void; +} + +// Stored and configured mint URLs come from SQLite and JSON, so a malformed +// value must never crash startup. Normalization only strips the default port +// and a trailing slash, so falling back to the raw string keeps comparisons +// meaningful. +function safeNormalizeMintUrl(mintUrl: string): string { + try { + return normalizeMintUrl(mintUrl); + } catch { + return mintUrl; + } +} + +/** + * Ensure the mint seeds routstrd ships are trusted, without ever moving a + * wallet's default away from `defaultMintUrl`. + * + * The default mint is seeded strictly: if it cannot be fetched the error is + * rethrown, because persisting an unusable default is worse than failing + * startup. Every other seed is best-effort — sending the mint fetch failure to + * `onError` — so a single unreachable mint cannot keep the daemon down. + */ +export async function seedTrustedMints( + wallet: TrustedMintSeeder, + defaultMintUrl: string, + options: SeedTrustedMintsOptions = {}, +): Promise { + const seeds = options.seeds ?? DEFAULT_TRUSTED_MINT_URLS; + const target = safeNormalizeMintUrl(defaultMintUrl); + const trusted = new Set(wallet.trustedMints.map(safeNormalizeMintUrl)); + const attempted = new Set(); + + for (const seed of [defaultMintUrl, ...seeds]) { + const mintUrl = safeNormalizeMintUrl(seed); + if (attempted.has(mintUrl)) continue; + attempted.add(mintUrl); + if (trusted.has(mintUrl)) continue; + + const isDefault = mintUrl === target; + try { + options.onProgress?.( + `Adding ${isDefault ? "default" : "trusted"} mint: ${mintUrl}`, + ); + await wallet.addMint(mintUrl); + trusted.add(mintUrl); + } catch (error) { + if (isDefault) throw error; + options.onError?.(`Could not add trusted mint ${mintUrl}`, error); + } + } +} \ No newline at end of file