Merge pull request #105 from Routstr/feat/default-trusted-mints

feat(wallet): trust minibits alongside cuba by default
This commit is contained in:
redshift
2026-09-20 16:56:20 +02:00
committed by GitHub
5 changed files with 259 additions and 10 deletions
+7 -3
View File
@@ -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 <url>` to trust another mint.
### `routstrd send <target>` / `routstrd receive <value>`
@@ -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 <url>`
+6 -2
View File
@@ -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 {
+20 -5
View File
@@ -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.
+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);
}
}
}