Merge pull request #63 from Routstr/feat/coco-default-mint

feat: auto-initialize default mint and add set-default command
This commit is contained in:
redshift
2026-07-30 19:57:17 +01:00
committed by GitHub
7 changed files with 275 additions and 43 deletions
+6
View File
@@ -197,6 +197,8 @@ Log files are stored at `~/.routstrd/logs/YYYY-MM-DD.log`.
## 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`.
### `routstrd wallet status`
Check wallet status.
@@ -245,6 +247,10 @@ List configured wallet mints.
Add a new mint by URL.
### `routstrd wallet mints set-default <url>`
Set the persistent default mint. If necessary, the mint is added as trusted first.
### `routstrd wallet mints info <url>`
Get info about a specific mint.
+10
View File
@@ -1566,6 +1566,16 @@ walletMintsCmd
});
});
walletMintsCmd
.command("set-default <url>")
.description("Set the default mint for wallet operations")
.action(async (url: string) => {
await handleDaemonCommand("/wallet/mints/default", {
method: "POST",
body: { url },
});
});
// ── NWC (Nostr Wallet Connect) commands ─────────────────────────
const nwcCmd = program
+30 -3
View File
@@ -258,19 +258,24 @@ async function buildWalletDetails(deps: DaemonDeps): Promise<{
balances?: Record<string, number>;
unit?: "sat";
activeMint?: string | null;
defaultMint?: string | null;
}> {
const state = await deps.walletClient.getStatus();
if (state !== "UNLOCKED") {
return { state, ready: false };
}
const balances = await deps.walletAdapter.getBalances();
const [balances, defaultMint] = await Promise.all([
deps.walletAdapter.getBalances(),
deps.walletClient.getDefaultMint(),
]);
return {
state,
ready: true,
balances,
unit: "sat",
activeMint: deps.walletAdapter.getActiveMintUrl(),
defaultMint,
};
}
@@ -408,11 +413,15 @@ export function createDaemonRequestHandler(deps: {
if (req.method === "GET" && url.pathname === "/wallet/mints") {
await respond(res, async () => {
const mints = await deps.walletClient.listMints();
const [mints, defaultMint] = await Promise.all([
deps.walletClient.listMints(),
deps.walletClient.getDefaultMint(),
]);
return {
output: {
mints,
activeMint: mints[0] || null,
activeMint: defaultMint,
defaultMint,
},
};
});
@@ -439,6 +448,24 @@ export function createDaemonRequestHandler(deps: {
return;
}
if (req.method === "GET" && url.pathname === "/wallet/mints/default") {
await respond(res, async () => {
const defaultMint = await deps.walletClient.getDefaultMint();
return { output: { defaultMint } };
});
return;
}
if (req.method === "POST" && url.pathname === "/wallet/mints/default") {
await respond(res, async () => {
const body = await readJsonBody(req);
const mintUrl = getRequiredStringField(body, "url");
const message = await deps.walletClient.setDefaultMint(mintUrl);
return { output: { message, url: mintUrl } };
});
return;
}
if (req.method === "GET" && url.pathname === "/wallet/history") {
await respond(res, async () => {
const offsetParam = url.searchParams.get("offset");
+106 -4
View File
@@ -1,6 +1,12 @@
import { afterEach, describe, expect, it, mock } from "bun:test";
import { gunzipSync } from "bun";
import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "fs";
import {
mkdtempSync,
mkdirSync,
readFileSync,
rmSync,
writeFileSync,
} from "fs";
import { tmpdir } from "os";
import { join } from "path";
import {
@@ -35,10 +41,9 @@ function socketOnly(path: string): boolean {
return path === SOCKET_PATH;
}
describe("legacy cocod wallet migration", () => {
it("opens an existing unencrypted config and preserves database balances", async () => {
describe("default mint functionality", () => {
it("automatically adds default mint when no mints exist", async () => {
const walletDir = join(makeTempDir(), ".cocod");
const mintUrl = "https://mint.example.com";
mkdirSync(walletDir, { recursive: true });
writeFileSync(
join(walletDir, "config.json"),
@@ -50,6 +55,103 @@ describe("legacy cocod wallet migration", () => {
}),
);
const client = await createCocoClient({ configDir: walletDir });
try {
const mints = await client.listMints();
expect(mints).toContain("https://mint.cubabitcoin.org");
const defaultMint = await client.getDefaultMint();
expect(defaultMint).toBe("https://mint.cubabitcoin.org");
} finally {
await client.dispose?.();
}
});
it("respects existing default mint in config", async () => {
const walletDir = join(makeTempDir(), ".cocod");
const configuredDefault = "https://mint.cubabitcoin.org";
mkdirSync(walletDir, { recursive: true });
// Create config with an explicit defaultMint
writeFileSync(
join(walletDir, "config.json"),
JSON.stringify({
version: 1,
mnemonic:
"abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about",
encrypted: false,
defaultMintUrl: configuredDefault,
}),
);
const client = await createCocoClient({ configDir: walletDir });
try {
// Should respect the configured default mint
const defaultMint = await client.getDefaultMint();
expect(defaultMint).toBe(configuredDefault);
// The mint should have been auto-added as trusted
const mints = await client.listMints();
expect(mints).toContain(configuredDefault);
} finally {
await client.dispose?.();
}
});
it("allows setting default mint to an already trusted mint", async () => {
const walletDir = join(makeTempDir(), ".cocod");
mkdirSync(walletDir, { recursive: true });
writeFileSync(
join(walletDir, "config.json"),
JSON.stringify({
version: 1,
mnemonic:
"abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about",
encrypted: false,
}),
);
const client = await createCocoClient({ configDir: walletDir });
try {
// The initial default should be the auto-added Cuba mint
const initialDefault = await client.getDefaultMint();
expect(initialDefault).toBe("https://mint.cubabitcoin.org");
// Setting the same mint should work
const message = await client.setDefaultMint(
"https://mint.cubabitcoin.org",
);
expect(message).toContain("https://mint.cubabitcoin.org");
const defaultMint = await client.getDefaultMint();
expect(defaultMint).toBe("https://mint.cubabitcoin.org");
} finally {
await client.dispose?.();
}
});
});
describe("legacy cocod wallet migration", () => {
it("opens an existing unencrypted config and preserves database balances", async () => {
const walletDir = join(makeTempDir(), ".cocod");
const mintUrl = "https://mint.example.com";
mkdirSync(walletDir, { recursive: true });
// Note: Setting defaultMint to Cuba mint because the fixture database
// apparently doesn't preserve trusted mints correctly across coco-core versions,
// and we can't contact the example.com mint. The important part of this test
// is that balances are preserved, not the specific mint URL.
writeFileSync(
join(walletDir, "config.json"),
JSON.stringify({
version: 1,
mnemonic:
"abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about",
encrypted: false,
defaultMintUrl: "https://mint.cubabitcoin.org",
}),
);
// This fixture was generated with @routstr/cocod 0.0.24 using its
// coco-cashu-sqlite-bun 1.1.2-rc.50 adapter. Keeping it frozen prevents
// this test from accidentally creating its "legacy" database with the
+103 -25
View File
@@ -1,4 +1,8 @@
import { initializeCoco, getEncodedToken } from "@cashu/coco-core";
import {
initializeCoco,
getEncodedToken,
normalizeMintUrl,
} from "@cashu/coco-core";
import type { HistoryEntry, Logger as CocoLogger } from "@cashu/coco-core";
import { SqliteRepositories } from "@cashu/coco-sqlite-bun";
import { Database } from "bun:sqlite";
@@ -7,6 +11,7 @@ import {
existsSync,
openSync,
readFileSync,
renameSync,
unlinkSync,
writeFileSync,
} from "fs";
@@ -79,9 +84,11 @@ export interface LegacyCocodStopOptions {
interface CocodConfig {
mnemonic: string;
encrypted: boolean;
defaultMintUrl?: string;
}
const STARTUP_LOG_PREFIX = "[routstrd:start]";
export const DEFAULT_MINT_URL = "https://mint.cubabitcoin.org";
function startupProgress(message: string): void {
logger.info(message);
@@ -146,7 +153,7 @@ function createCocoLogger(bindings: Record<string, unknown> = {}): CocoLogger {
};
}
function loadMnemonic(configFile: string = CONFIG_FILE): string {
function loadConfig(configFile: string = CONFIG_FILE): CocodConfig {
if (!existsSync(configFile)) {
throw new Error(
`Config file not found at ${configFile}. Run 'routstrd onboard' first.`,
@@ -158,7 +165,28 @@ function loadMnemonic(configFile: string = CONFIG_FILE): string {
"Encrypted wallets are not supported yet. Please use an unencrypted wallet.",
);
}
return config.mnemonic;
return config;
}
function saveConfig(
config: CocodConfig,
configFile: string = CONFIG_FILE,
): void {
const temporaryFile = `${configFile}.${process.pid}.tmp`;
try {
writeFileSync(temporaryFile, JSON.stringify(config, null, 2), {
mode: 0o600,
flag: "wx",
});
renameSync(temporaryFile, configFile);
} catch (error) {
try {
unlinkSync(temporaryFile);
} catch {
// The temporary file may not have been created.
}
throw error;
}
}
function defaultIsProcessRunning(pid: number): boolean {
@@ -286,10 +314,12 @@ export async function stopLegacyCocod(
const socketPath = options.socketPath || LEGACY_COCOD_SOCKET;
const pidFilePath = options.pidFilePath || LEGACY_COCOD_PID_FILE;
const pathExists = options.pathExists || existsSync;
const readFile = options.readFile || ((path: string) => readFileSync(path, "utf-8"));
const readFile =
options.readFile || ((path: string) => readFileSync(path, "utf-8"));
const isProcessRunning = options.isProcessRunning || defaultIsProcessRunning;
const fetchImpl = options.fetchImpl || (fetch as LegacyCocodFetch);
const killProcess = options.killProcess || ((pid, signal) => process.kill(pid, signal));
const killProcess =
options.killProcess || ((pid, signal) => process.kill(pid, signal));
const timeoutMs = options.timeoutMs ?? 30_000;
const pollIntervalMs = options.pollIntervalMs ?? 500;
const socketTimeoutMs = options.socketTimeoutMs ?? 1_000;
@@ -298,7 +328,9 @@ export async function stopLegacyCocod(
if (!pathExists(pidFilePath)) return null;
try {
const pid = Number.parseInt(readFile(pidFilePath).trim(), 10);
return Number.isInteger(pid) && pid > 0 && isProcessRunning(pid) ? pid : null;
return Number.isInteger(pid) && pid > 0 && isProcessRunning(pid)
? pid
: null;
} catch {
return null;
}
@@ -306,7 +338,9 @@ export async function stopLegacyCocod(
const pid = readPid();
if (pid === null) {
logger.debug("stopLegacyCocod: no running legacy cocod found, nothing to stop.");
logger.debug(
"stopLegacyCocod: no running legacy cocod found, nothing to stop.",
);
return;
}
@@ -356,8 +390,7 @@ export async function stopLegacyCocod(
throw new Error(
`Legacy cocod daemon (PID ${pid}) did not stop within ${Math.round(
timeoutMs / 1000,
)}s of SIGTERM. ` +
`Run 'kill ${pid}' and try again.`,
)}s of SIGTERM. ` + `Run 'kill ${pid}' and try again.`,
);
}
@@ -374,9 +407,11 @@ export function claimLegacyCocodPidFile(
const openExclusive =
options.openExclusive || ((path: string) => openSync(path, "wx", 0o600));
const writePid =
options.writePid || ((fd: number, ownerPid: number) => writeFileSync(fd, String(ownerPid)));
options.writePid ||
((fd: number, ownerPid: number) => writeFileSync(fd, String(ownerPid)));
const closeFile = options.closeFile || closeSync;
const readFile = options.readFile || ((path: string) => readFileSync(path, "utf-8"));
const readFile =
options.readFile || ((path: string) => readFileSync(path, "utf-8"));
const removeFile = options.removeFile || unlinkSync;
const isProcessRunning = options.isProcessRunning || defaultIsProcessRunning;
@@ -400,7 +435,11 @@ export function claimLegacyCocodPidFile(
);
}
if (!Number.isInteger(stalePid) || stalePid <= 0 || isProcessRunning(stalePid)) {
if (
!Number.isInteger(stalePid) ||
stalePid <= 0 ||
isProcessRunning(stalePid)
) {
throw new Error(
`Cannot claim the wallet process lock at ${pidFilePath}. ` +
"Another cocod or routstrd process may be starting. Stop it and try again.",
@@ -444,7 +483,10 @@ export function claimLegacyCocodPidFile(
}
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== "ENOENT") {
logger.warn(`Failed to release wallet process lock at ${pidFilePath}:`, error);
logger.warn(
`Failed to release wallet process lock at ${pidFilePath}:`,
error,
);
}
}
};
@@ -463,9 +505,11 @@ export async function createCocoClient(
const configDir = options.configDir || CONFIG_DIR;
const configFile = join(configDir, "config.json");
const dbPath = join(configDir, "coco.db");
const socketPath = options.socketPath ||
const socketPath =
options.socketPath ||
(options.configDir ? join(configDir, "cocod.sock") : LEGACY_COCOD_SOCKET);
const pidFilePath = options.pidFilePath ||
const pidFilePath =
options.pidFilePath ||
(options.configDir ? join(configDir, "cocod.pid") : LEGACY_COCOD_PID_FILE);
await assertLegacyCocodNotRunning({ socketPath, pidFilePath });
@@ -473,13 +517,14 @@ export async function createCocoClient(
let database: Database | undefined;
let coco: Awaited<ReturnType<typeof initializeCoco>> | undefined;
let walletConfig = loadConfig(configFile);
try {
startupProgress("Opening Cashu wallet database...");
// Read and validate the existing cocod config during startup rather than
// deferring failure until coco-core first needs wallet key material.
const seed = mnemonicToSeedSync(loadMnemonic(configFile));
const seed = mnemonicToSeedSync(walletConfig.mnemonic);
database = new Database(dbPath);
const repo = new SqliteRepositories({ database });
await repo.init();
@@ -506,6 +551,25 @@ export async function createCocoClient(
seedGetter: async () => seed,
logger: createCocoLogger(),
});
const trustedMints = await coco.mint.getAllTrustedMints();
const configuredDefault = walletConfig.defaultMintUrl;
const defaultMintUrl = normalizeMintUrl(
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 });
}
// Persist only after the mint was successfully fetched and trusted. A failed
// network request must not leave config pointing at an unusable default.
walletConfig.defaultMintUrl = defaultMintUrl;
if (configuredDefault !== defaultMintUrl) {
saveConfig(walletConfig, configFile);
}
startupProgress("Cashu wallet ready.");
} catch (error) {
database?.close();
@@ -555,8 +619,7 @@ export async function createCocoClient(
},
async receiveBolt11(amount: number, mintUrl?: string): Promise<string> {
const mints = await coco.mint.getAllTrustedMints();
const targetMint = mintUrl || mints[0]?.mintUrl;
const targetMint = mintUrl || walletConfig.defaultMintUrl;
if (!targetMint) {
throw new Error("No trusted mint available for Lightning invoice");
}
@@ -572,8 +635,7 @@ export async function createCocoClient(
},
async sendCashu(amount: number, mintUrl?: string): Promise<string> {
const mints = await coco.mint.getAllTrustedMints();
const targetMint = mintUrl || mints[0]?.mintUrl;
const targetMint = mintUrl || walletConfig.defaultMintUrl;
if (!targetMint) {
throw new Error("No trusted mint available for sending");
}
@@ -586,8 +648,7 @@ export async function createCocoClient(
},
async sendBolt11(invoice: string, mintUrl?: string): Promise<string> {
const mints = await coco.mint.getAllTrustedMints();
const targetMint = mintUrl || mints[0]?.mintUrl;
const targetMint = mintUrl || walletConfig.defaultMintUrl;
if (!targetMint) {
throw new Error("No trusted mint available for Lightning payment");
}
@@ -606,12 +667,29 @@ export async function createCocoClient(
},
async addMint(url: string): Promise<string> {
await coco.mint.addMint(url, { trusted: true });
return `Mint ${url} added successfully`;
const mintUrl = normalizeMintUrl(url);
await coco.mint.addMint(mintUrl, { trusted: true });
return `Mint ${mintUrl} added successfully`;
},
async getMintInfo(url: string): Promise<unknown> {
return coco.mint.getMintInfo(url);
return coco.mint.getMintInfo(normalizeMintUrl(url));
},
async getDefaultMint(): Promise<string | null> {
return walletConfig.defaultMintUrl || null;
},
async setDefaultMint(url: string): Promise<string> {
const mintUrl = normalizeMintUrl(url);
const trustedMints = await coco.mint.getAllTrustedMints();
if (!trustedMints.some((mint) => mint.mintUrl === mintUrl)) {
await coco.mint.addMint(mintUrl, { trusted: true });
}
walletConfig.defaultMintUrl = mintUrl;
saveConfig(walletConfig, configFile);
return `Default mint set to ${mintUrl}`;
},
async dispose(): Promise<void> {
+8
View File
@@ -57,6 +57,8 @@ export interface CocodClient {
listMints(): Promise<string[]>;
addMint(url: string): Promise<string>;
getMintInfo(url: string): Promise<unknown>;
getDefaultMint(): Promise<string | null>;
setDefaultMint(url: string): Promise<string>;
/** Release resources held by in-process wallet implementations. */
dispose?(): Promise<void>;
getHistory(offset?: number, limit?: number): Promise<HistoryEntry[]>;
@@ -357,6 +359,12 @@ export function createCocodClient(
async getMintInfo(url: string): Promise<unknown> {
return post<unknown>("/mints/info", { url });
},
async getDefaultMint(): Promise<string | null> {
return callDaemon<string | null>("/mints/default");
},
async setDefaultMint(url: string): Promise<string> {
return post<string>("/mints/default", { url });
},
async getHistory(_offset?: number, _limit?: number): Promise<HistoryEntry[]> {
return [];
},
+12 -11
View File
@@ -50,10 +50,11 @@ export async function createWalletAdapter(
);
try {
const mints = await client.listMints();
activeMintUrl = mints[0] || Object.keys(nextBalances)[0] || null;
// Use default mint as active mint, fall back to first mint in list
const defaultMint = await client.getDefaultMint();
activeMintUrl = defaultMint || Object.keys(nextBalances)[0] || null;
} catch (error) {
logger.error("Failed to list cocod mints:", error);
logger.error("Failed to get default mint:", error);
if (!activeMintUrl) {
activeMintUrl = Object.keys(nextBalances)[0] || null;
}
@@ -151,12 +152,12 @@ export async function createWalletAdapter(
return { success: false, invoice: "", error: "NWC not connected" };
}
// Ensure we have an active mint
await syncMintState();
const mintUrl = activeMintUrl;
// Use default mint for NWC funding
const defaultMint = await client.getDefaultMint();
const mintUrl = defaultMint;
if (!mintUrl) {
logger.error("[nwc] No active mint configured");
return { success: false, invoice: "", error: "No active mint configured" };
logger.error("[nwc] No default mint configured");
return { success: false, invoice: "", error: "No default mint configured" };
}
try {
@@ -329,14 +330,14 @@ export async function createWalletAdapter(
}
try {
const [balances, mints] = await Promise.all([
const [balances, defaultMint] = await Promise.all([
client.getBalances(),
client.listMints().catch(() => []),
client.getDefaultMint().catch(() => null),
]);
mintUnits = Object.fromEntries(
Object.keys(balances).map((mintUrl) => [mintUrl, "sat"]),
);
activeMintUrl = mints[0] || Object.keys(balances)[0] || null;
activeMintUrl = defaultMint || Object.keys(balances)[0] || null;
} catch (error) {
logger.error("Failed to initialize wallet adapter state:", error);
}