From 06f7a675e521933cac20970124d322aeae3316be Mon Sep 17 00:00:00 2001 From: redshift <213178690+1ftredsh@users.noreply.github.com> Date: Sun, 4 Oct 2026 12:46:54 +0800 Subject: [PATCH] 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. --- src/daemon/index.ts | 8 ++++- src/daemon/models.test.ts | 66 +++++++++++++++++++++++++++++++++++++++ src/daemon/models.ts | 30 ++++++++++++++++++ 3 files changed, 103 insertions(+), 1 deletion(-) diff --git a/src/daemon/index.ts b/src/daemon/index.ts index afd3ae2..a0408ca 100644 --- a/src/daemon/index.ts +++ b/src/daemon/index.ts @@ -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 { }); // 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. diff --git a/src/daemon/models.test.ts b/src/daemon/models.test.ts index b372406..fedd6b5 100644 --- a/src/daemon/models.test.ts +++ b/src/daemon/models.test.ts @@ -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 = { + 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(); + }); +}); diff --git a/src/daemon/models.ts b/src/daemon/models.ts index fd52ff6..9869063 100644 --- a/src/daemon/models.ts +++ b/src/daemon/models.ts @@ -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 | 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,