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..255a37e 100644 --- a/src/daemon/models.test.ts +++ b/src/daemon/models.test.ts @@ -289,3 +289,94 @@ 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(); + }); + + 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 fd52ff6..0708aab 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,8 +29,22 @@ export function createModelService( modelManager: ModelManager, providerManager: ProviderManager, store: SdkStore, + 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}/`; @@ -132,6 +147,13 @@ 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. + scheduleMintDiscovery(providers); })().catch((error) => { providerBootstrapPromise = null; logger.error("Provider bootstrap failed:", error); @@ -278,6 +300,10 @@ 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). + scheduleMintDiscovery(providers); + // Sync review events from Nostr (kind 38425) and apply disabled status const reviewedDisabled = await modelManager.syncReviewedProvidersFromNostr( providers,