Merge pull request #125 from Routstr/fix/provider-mint-awareness

fix(daemon): populate the provider mint cache during bootstrap/refresh (finding #4)
This commit is contained in:
redshift
2026-10-04 07:57:13 +00:00
committed by GitHub
3 changed files with 124 additions and 1 deletions
+7 -1
View File
@@ -2,6 +2,7 @@ import { createServer } from "http";
import { existsSync, unlinkSync } from "fs"; import { existsSync, unlinkSync } from "fs";
import { import {
ProviderManager, ProviderManager,
MintDiscovery,
createStorageAdapterFromStore, createStorageAdapterFromStore,
createSdkStore, createSdkStore,
} from "@routstr/sdk"; } 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 // Create shared ProviderManager for consistent failure tracking across all requests
const providerManager = new ProviderManager(discoveryAdapter, store, daemonSdkLogger); 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 } = 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 // 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. // not rely on the parent command having stopped the external wallet first.
+91
View File
@@ -289,3 +289,94 @@ describe("createModelService.getRoutstr21Models", () => {
expect(models).toEqual([{ id: "no-such-model", name: "no-such-model" }]); 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();
});
it("does not await an outstanding mint discovery pass or duplicate it", async () => {
let resolveDiscovery!: () => void;
const pending = new Promise<void>((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();
}
});
});
+26
View File
@@ -3,6 +3,7 @@ import {
findModelForId, findModelForId,
ModelManager, ModelManager,
ProviderManager, ProviderManager,
type MintDiscovery,
type Model, type Model,
type SdkStore, type SdkStore,
} from "@routstr/sdk"; } from "@routstr/sdk";
@@ -28,8 +29,22 @@ export function createModelService(
modelManager: ModelManager, modelManager: ModelManager,
providerManager: ProviderManager, providerManager: ProviderManager,
store: SdkStore, store: SdkStore,
mintDiscovery?: MintDiscovery,
) { ) {
let providerBootstrapPromise: Promise<void> | null = null; let providerBootstrapPromise: Promise<void> | null = null;
let mintDiscoveryPromise: Promise<void> | 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 => const normalizeBaseUrl = (url: string): string =>
url.endsWith("/") ? url : `${url}/`; url.endsWith("/") ? url : `${url}/`;
@@ -132,6 +147,13 @@ export function createModelService(
} }
logger.log("Provider bootstrap complete."); 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) => { })().catch((error) => {
providerBootstrapPromise = null; providerBootstrapPromise = null;
logger.error("Provider bootstrap failed:", error); logger.error("Provider bootstrap failed:", error);
@@ -278,6 +300,10 @@ export function createModelService(
const models = await modelManager.fetchModels(providers, true); const models = await modelManager.fetchModels(providers, true);
console.log(`Fetched ${models.length} models from ${providers.length} providers`); 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 // Sync review events from Nostr (kind 38425) and apply disabled status
const reviewedDisabled = await modelManager.syncReviewedProvidersFromNostr( const reviewedDisabled = await modelManager.syncReviewedProvidersFromNostr(
providers, providers,