feat(wallet): trust cuba and minibits by default

New wallets only trusted https://mint.cubabitcoin.org, so receiving from
any other mint failed with "Mint ... is not trusted" until the user ran
`wallet mints add`. Seed a second shipped mint so a fresh wallet can
receive from minibits out of the box:

- `DEFAULT_TRUSTED_MINT_URLS` lists the mints routstrd trusts on startup,
  with `DEFAULT_MINT_URL` (cuba) first so it keeps the default slot.
- `seedTrustedMints` owns the startup seeding. The default mint stays
  strict — a failed fetch still aborts startup so config can never point
  at an unusable mint — while extra seeds are best-effort and only log a
  warning, so one unreachable mint cannot keep the daemon down.

Existing wallets keep their configured default and only gain the extra
trusted mint. The seeding logic is extracted into its own module so the
strict-default/best-effort-extra rules are testable without a network.
This commit is contained in:
redshift
2026-09-14 16:46:04 +02:00
parent cb18799d7e
commit f9dc60a9b8
5 changed files with 254 additions and 9 deletions
+2 -2
View File
@@ -216,7 +216,7 @@ Log files are stored at `~/.routstrd/logs/YYYY-MM-DD.log`. Wallet-engine (Cashu/
## Wallet Commands ## 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 <url>` to trust another mint.
### `routstrd wallet status` ### `routstrd wallet status`
@@ -260,7 +260,7 @@ Pay a Lightning invoice.
### `routstrd wallet mints list` ### `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 <url>` ### `routstrd wallet mints add <url>`
+6 -2
View File
@@ -14,6 +14,7 @@ import {
assertLegacyCocodNotRunning, assertLegacyCocodNotRunning,
claimLegacyCocodPidFile, claimLegacyCocodPidFile,
createCocoClient, createCocoClient,
DEFAULT_TRUSTED_MINT_URLS,
isZombieProcess, isZombieProcess,
settleExpiredMintQuotes, settleExpiredMintQuotes,
settlePendingMintQuotes, settlePendingMintQuotes,
@@ -64,7 +65,7 @@ async function waitForWalletUnlocked(
} }
describe("default mint functionality", () => { 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"); const walletDir = join(makeTempDir(), "wallet");
mkdirSync(walletDir, { recursive: true }); mkdirSync(walletDir, { recursive: true });
writeFileSync( writeFileSync(
@@ -90,9 +91,12 @@ describe("default mint functionality", () => {
String(process.pid), String(process.pid),
); );
// Every shipped mint is trusted, so each one is usable without an
// explicit `wallet mints add`.
const mints = await client.listMints(); 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(); const defaultMint = await client.getDefaultMint();
expect(defaultMint).toBe("https://mint.cubabitcoin.org"); expect(defaultMint).toBe("https://mint.cubabitcoin.org");
} finally { } finally {
+20 -5
View File
@@ -58,6 +58,9 @@ import {
walletDir as defaultWalletDir, walletDir as defaultWalletDir,
walletPidPath as defaultWalletPidPath, walletPidPath as defaultWalletPidPath,
} from "./paths"; } 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"; const NPC_DEFAULT_BASE_URL = "https://npubx.cash";
@@ -117,7 +120,6 @@ interface CocodConfig {
} }
const STARTUP_LOG_PREFIX = "[routstrd:start]"; const STARTUP_LOG_PREFIX = "[routstrd:start]";
export const DEFAULT_MINT_URL = "https://mint.cubabitcoin.org";
function startupProgress(message: string): void { function startupProgress(message: string): void {
logger.info(message); logger.info(message);
@@ -1293,10 +1295,23 @@ export async function createCocoClient(
configuredDefault || trustedMints[0]?.mintUrl || DEFAULT_MINT_URL, configuredDefault || trustedMints[0]?.mintUrl || DEFAULT_MINT_URL,
); );
if (!trustedMints.some((mint) => mint.mintUrl === defaultMintUrl)) { // Seeds the mints we ship as trusted. The default mint is strict (see
startupProgress(`Adding default mint: ${defaultMintUrl}`); // seedTrustedMints); extra seeds only warn, so an unreachable mint that is
await coco.mint.addMint(defaultMintUrl, { trusted: true }); // 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 // Persist only after the mint was successfully fetched and trusted. A failed
// network request must not leave config pointing at an unusable default. // network request must not leave config pointing at an unusable default.
+139
View File
@@ -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([]);
});
});
+87
View File
@@ -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<unknown>;
}
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<void> {
const seeds = options.seeds ?? DEFAULT_TRUSTED_MINT_URLS;
const target = safeNormalizeMintUrl(defaultMintUrl);
const trusted = new Set(wallet.trustedMints.map(safeNormalizeMintUrl));
const attempted = new Set<string>();
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);
}
}
}