When the local wallet runs out of proofs, the fallback chain now:
1. Retries across ALL configured mints (createProviderToken patched —
previously only topUp retried on 'Not enough proofs')
2. Creates a routstr-core Lightning invoice (POST /lightning/invoice)
→ uses 'topup' purpose when an existing API key is available
→ pays via NWC (payBolt11) if connected, then retries
→ otherwise surfaces invoice for manual payment + polls until paid/expired
3. Falls back to local wallet Lightning invoice + NWC funding
Hardening (15 new tests):
- SSRF protection: rejects non-HTTPS provider URLs (except localhost)
- Amount validation: clamps to [1, 1_000_000] sats
- bolt11 validation: must start with 'lnbc'
- invoice_id validation: must be non-empty string
- Fetch timeout: AbortController (10s) on invoice creation
- NWC exception handling: caught, not propagated
- Double-install idempotency: patch markers prevent re-patching
- Concurrent calls: no shared state corruption
- API key safety: never logged in error messages
- Poller lifecycle: stops after 84 attempts (~7min) or on expired status
New wallet adapter method:
- payBolt11(bolt11): pays externally-created invoices via NWC
Verified end-to-end:
- 50-request stress test: 44/50 success, 6 fallback triggers (fugu-ultra)
- Routstr-core topup: 64,781 → 114,340 msats (balance increased)
- Daemon stable post-stress (142MB RSS, immediate recovery)
- 48/48 tests pass (33 existing + 15 hardening)
When routstr.otrta.me rejects a Cashu token with 'mint_unreachable',
the mint fallback handler calls _spendToken with a fallback mint URL
(e.g. cubabitcoin.org). However, _spendToken's internal
_selectCandidateMints puts the highest-balance mint first regardless
of the preferredMintUrl parameter, so the fallback token was still
minted from the same unreachable mint (minibits).
Pass excludeMints: [initialMintUrl] to _spendToken so the failed
mint is excluded from candidate selection, forcing the fallback to
use the requested alternative mint.
- Redirect detached daemon stdout/stderr to ~/.routstrd/debug.log
instead of ignoring them, so uncaught exception stack traces are
no longer lost
- Add process-level uncaughtException/unhandledRejection handlers
that log to the file logger before the process dies
- Wrap setInterval async callbacks (model refresh + refund jobs) in
IIFE catch chains so rejected promises can't escape and kill the
process silently
The update command previously only downloaded and installed new
binaries for routstrd and cocod without restarting the running
daemons, so updates would not take effect until a manual restart.
- Add restartDaemonsAfterUpdate() helper that gracefully stops and
restarts both daemons after a successful update
- routstrd: uses POST /stop (drains active connections), polls for
shutdown, then calls startDaemon() with configured port/provider
- cocod: runs 'cocod stop', then spawns 'cocod daemon' detached,
polls 'cocod ping' until it comes back up
- Skips restart for daemons that weren't running
- Skips routstrd daemon restart when using a remote daemon
- Collects and reports failures without rolling back the update
- Bump @routstr/sdk to 0.3.15
- Bump routstrd version to 0.3.7
Closes nostr task: update-restart
renderToday declared todayStats/recentDays/hourlyMap with let but only
assigned inside if (stats.summary); TypeScript flagged them as used
before assignment (TS2454). Initialize with sensible defaults so the
function degrades gracefully when summary is missing.
The usage-summary tz-bucketing test used hardcoded May 2026 timestamps
with a comment dated 2026-06-02. Those entries aged out of
getUsageSummary's 30-day rolling window, so days came back empty.
Recompute timestamps relative to now and assert on dynamically-derived
local-day date strings.
Calls refreshNostrEvents() after initial bootstrap and in the 21-minute
recurring job, covering provider discovery (38421) and lgtm reviews
(38425) which were previously only refreshed on manual trigger.
Split the monolithic render() into two distinct functions:
- fetchData(): async background fetch that updates state and triggers
a repaint, guarded against overlapping calls
- render(): synchronous paint that reads current state and writes to
stdout, safe to call from key handlers without blocking
Run all four daemon calls concurrently via Promise.all to cut the
blocked window. Remove redundant isDaemonRunning() checks from each
fetch function in data.ts — the single check now lives in fetchData().
Key handlers now call the sync render() directly instead of
void render(false), so scrolling and tab switching feel instant.
The SDK dropped minTotalTokens/maxTotalTokens from AggregateUsageOptions
(routstr-sdk c98de6b), so replace the five aggregate() calls with a single
list() and bucket entries in-process.
routstrd providers list --refresh now:
- Re-fetches Nostr kind 38421 provider discovery events
- Re-fetches Nostr kind 38423 routstr21 model list
- Re-fetches Nostr kind 38425 review events (applies LGTM-based disable)
- Fetches models from all discovered providers
- Syncs fresh provider list and disabled status into the store
Usage: routstrd providers list --refresh
- Import ModelManager from @routstr/sdk/bun instead of @routstr/sdk
- Import storage helpers from @routstr/sdk/storage/bun instead of @routstr/sdk/storage
- Fix createBunSqliteUsageTrackingDriver call: async and no longer needs
manual bun:sqlite import (now handled internally by the sdk entrypoint)
- Verified sharded discovery adapter setup matches SDK pattern in
scripts/routstr-daemon.ts at 6077aa7