mirror of
https://github.com/Routstr/routstrd.git
synced 2026-10-05 20:38:22 +00:00
Merge pull request #105 from Routstr/feat/default-trusted-mints
feat(wallet): trust minibits alongside cuba by default
This commit is contained in:
@@ -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>`
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user