fix(daemon): populate the provider mint cache during bootstrap/refresh

Finding #4, part 1: MintDiscovery.discoverMints had zero call sites in the
daemon, so the SDK's mint cache was always empty, getCachedMints() returned
{} and the SDK spent the wallet's largest mint regardless of what a node
accepts.

Construct a shared MintDiscovery (own 21-minute TTL) and run it for every
discovered provider during ensureProvidersBootstrapped and
refreshProvidersAndModels. Discovery is best-effort: a provider that fails
must not abort model bootstrap or block request handling.

Verified live with a scratch daemon (separate HOME/port): it persisted
mints_from_all_providers, with routstr.cypherpunk.today advertising
cashu.cz/21mint.me/coinos.io/cubabitcoin/mountainlake/macadamia and NO
minibits, while nostrmodels/otrta advertise minibits.

Tests: bootstrap calls discoverMints for every provider; a throwing
discovery does not fail bootstrap. Suite: 441 passing.
This commit is contained in:
redshift
2026-10-04 12:47:28 +08:00
parent 83abd7adef
commit 06f7a675e5
3 changed files with 103 additions and 1 deletions
+7 -1
View File
@@ -2,6 +2,7 @@ import { createServer } from "http";
import { existsSync, unlinkSync } from "fs";
import {
ProviderManager,
MintDiscovery,
createStorageAdapterFromStore,
createSdkStore,
} from "@routstr/sdk";
@@ -132,8 +133,13 @@ export async function runDaemon(argv: string[] = process.argv): Promise<void> {
});
// Create shared ProviderManager for consistent failure tracking across all requests
const providerManager = new ProviderManager(discoveryAdapter, store, daemonSdkLogger);
// Mint discovery owns the mint cache the wallet uses to refuse a mint a
// provider does not accept. Its own 21-minute TTL gates network refreshes.
const mintDiscovery = new MintDiscovery(discoveryAdapter, {
logger: daemonSdkLogger,
});
const { ensureProvidersBootstrapped, getRoutstr21Models, getModelProviders, refreshProvidersAndModels } =
createModelService(modelManager, providerManager, store);
createModelService(modelManager, providerManager, store, mintDiscovery);
// The daemon may be launched directly (or by an older/global CLI), so do
// not rely on the parent command having stopped the external wallet first.
+66
View File
@@ -289,3 +289,69 @@ describe("createModelService.getRoutstr21Models", () => {
expect(models).toEqual([{ id: "no-such-model", name: "no-such-model" }]);
});
});
/**
* Finding #4: the SDK refuses to spend a mint a provider does not advertise,
* so the daemon must actually populate the mint cache during bootstrap (and
* refresh). `discoverMints` was previously never called, leaving
* `getCachedMints()` empty and every provider "accepting all mints".
*/
describe("createModelService mint discovery wiring", () => {
function makeStore() {
const state: Record<string, unknown> = {
baseUrlsList: [],
setBaseUrlsList: () => {},
disabledProviders: [],
setDisabledProviders: () => {},
manuallyDisabledProviders: [],
manuallyEnabledProviders: [],
};
return { store: { getState: () => state } as unknown as SdkStore };
}
it("discovers mints for every bootstrapped provider", async () => {
const providers = ["https://a.example/", "https://b.example/"];
const modelManager = {
bootstrapProviders: async () => providers,
syncReviewedProvidersFromNostr: async () => [],
} as never;
const discoverCalls: string[][] = [];
const mintDiscovery = {
discoverMints: async (urls: string[]) => {
discoverCalls.push(urls);
return { mintsFromProviders: {}, infoFromProviders: {} };
},
} as never;
const service = createModelService(
modelManager as never,
{} as never,
makeStore().store,
mintDiscovery as never,
);
await service.ensureProvidersBootstrapped();
expect(discoverCalls).toEqual([providers]);
});
it("does not fail bootstrap when mint discovery throws", async () => {
const modelManager = {
bootstrapProviders: async () => ["https://a.example/"],
syncReviewedProvidersFromNostr: async () => [],
} as never;
const mintDiscovery = {
discoverMints: async () => {
throw new Error("mint discovery boom");
},
} as never;
const service = createModelService(
modelManager as never,
{} as never,
makeStore().store,
mintDiscovery as never,
);
await expect(service.ensureProvidersBootstrapped()).resolves.toBeUndefined();
});
});
+30
View File
@@ -3,6 +3,7 @@ import {
findModelForId,
ModelManager,
ProviderManager,
type MintDiscovery,
type Model,
type SdkStore,
} from "@routstr/sdk";
@@ -28,6 +29,7 @@ export function createModelService(
modelManager: ModelManager,
providerManager: ProviderManager,
store: SdkStore,
mintDiscovery?: MintDiscovery,
) {
let providerBootstrapPromise: Promise<void> | null = null;
@@ -132,6 +134,24 @@ export function createModelService(
}
logger.log("Provider bootstrap complete.");
// Populate the mint cache for every enabled provider. The SDK refuses
// to spend a mint a provider does not advertise, so without this the
// routing layer spends the wallet's largest mint and gets rejected
// (finding #4). MintDiscovery owns its own 21-minute TTL, so repeated
// calls within that window are cache hits and never refetch.
if (mintDiscovery) {
try {
await mintDiscovery.discoverMints(providers);
logger.log(
`Discovered mints for ${providers.length} provider(s)`,
);
} catch (error) {
// Mint discovery is best-effort: a provider that fails here must
// not abort model bootstrap or block request handling.
logger.error("Mint discovery failed:", error);
}
}
})().catch((error) => {
providerBootstrapPromise = null;
logger.error("Provider bootstrap failed:", error);
@@ -278,6 +298,16 @@ export function createModelService(
const models = await modelManager.fetchModels(providers, true);
console.log(`Fetched ${models.length} models from ${providers.length} providers`);
// Refresh each provider's advertised mint list alongside its models so
// routing always has current mint data (TTL-gated by MintDiscovery).
if (mintDiscovery) {
try {
await mintDiscovery.discoverMints(providers);
} catch (error) {
console.error("Mint discovery failed during refresh:", error);
}
}
// Sync review events from Nostr (kind 38425) and apply disabled status
const reviewedDisabled = await modelManager.syncReviewedProvidersFromNostr(
providers,