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 1/2] 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, From 18c280cdb192ecf1aa06fc171f660c68f5b50619 Mon Sep 17 00:00:00 2001 From: redshift <213178690+1ftredsh@users.noreply.github.com> Date: Sun, 4 Oct 2026 14:30:25 +0800 Subject: [PATCH 2/2] fix(daemon): discover provider mints without blocking bootstrap --- src/daemon/models.test.ts | 25 +++++++++++++++++++++++++ src/daemon/models.ts | 34 +++++++++++++++------------------- 2 files changed, 40 insertions(+), 19 deletions(-) diff --git a/src/daemon/models.test.ts b/src/daemon/models.test.ts index fedd6b5..255a37e 100644 --- a/src/daemon/models.test.ts +++ b/src/daemon/models.test.ts @@ -354,4 +354,29 @@ describe("createModelService mint discovery wiring", () => { await expect(service.ensureProvidersBootstrapped()).resolves.toBeUndefined(); }); + + it("does not await an outstanding mint discovery pass or duplicate it", async () => { + let resolveDiscovery!: () => void; + const pending = new Promise((resolve) => { resolveDiscovery = resolve; }); + let calls = 0; + const modelManager = { + bootstrapProviders: async () => ["https://a.example/"], + syncReviewedProvidersFromNostr: async () => [], + } as never; + const discovery = { + discoverMints: () => { calls++; return pending; }, + } as never; + const service = createModelService(modelManager, {} as never, makeStore().store, discovery); + try { + await Promise.race([ + service.ensureProvidersBootstrapped(), + new Promise((_, reject) => setTimeout(() => reject(new Error("bootstrap blocked on mint discovery")), 100)), + ]); + await service.ensureProvidersBootstrapped(); + expect(calls).toBe(1); + } finally { + resolveDiscovery(); + } + }); + }); diff --git a/src/daemon/models.ts b/src/daemon/models.ts index 9869063..0708aab 100644 --- a/src/daemon/models.ts +++ b/src/daemon/models.ts @@ -32,6 +32,19 @@ export function createModelService( mintDiscovery?: MintDiscovery, ) { let providerBootstrapPromise: Promise | null = null; + let mintDiscoveryPromise: Promise | null = null; + + const scheduleMintDiscovery = (providers: string[]): void => { + if (!mintDiscovery || mintDiscoveryPromise) return; + const discovery = mintDiscovery; + mintDiscoveryPromise = Promise.resolve() + .then(async () => { + await discovery.discoverMints(providers); + logger.log(`Discovered mints for ${providers.length} provider(s)`); + }) + .catch((error) => logger.error("Mint discovery failed:", error)) + .finally(() => { mintDiscoveryPromise = null; }); + }; const normalizeBaseUrl = (url: string): string => url.endsWith("/") ? url : `${url}/`; @@ -140,18 +153,7 @@ export function createModelService( // 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); - } - } + scheduleMintDiscovery(providers); })().catch((error) => { providerBootstrapPromise = null; logger.error("Provider bootstrap failed:", error); @@ -300,13 +302,7 @@ export function createModelService( // 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); - } - } + scheduleMintDiscovery(providers); // Sync review events from Nostr (kind 38425) and apply disabled status const reviewedDisabled = await modelManager.syncReviewedProvidersFromNostr(