PR #4: core/cashu /v1/info + Layer B + scheduler#30
Merged
Conversation
NUT-06 `/v1/info` fetcher in two layers:
- fetchMintInfo(url, opts?) — single one-shot fetch. https-only,
AbortController-based timeout (default 5s), discriminated
MintInfoResult so callers don't try/catch. Human-readable error
strings ("connect ETIMEDOUT", "non-2xx (502)", "invalid JSON",
"missing pubkey field") that round-trip into MintInfoRow.lastError.
- createMintInfoFetcher({ concurrency, ttlMs }) — wraps fetchMintInfo
with per-URL TTL cache, in-flight dedup, and a global semaphore.
Borrows the cashu-kym request.ts shape (audit/cashu-kym.md §6),
trimmed: kym does retry-with-backoff inside the fetcher; we keep
the fetcher pure and let the scheduler (PR #4 piece 3) decide
retry cadence at a higher level.
Tests cover happy path, every documented failure mode (404, 5xx,
network, timeout via AbortSignal, invalid JSON, missing pubkey,
http:// rejection), TTL cache hit + expiry, failure caching, in-flight
dedup, and a 10-fired-at-concurrency-2 peak-tracking test that drives
the semaphore one step at a time.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
verifySignerBinding(announcement, fetcher) — Cashu-only NUT-06 check that the announcement signer pubkey matches the live mint's /v1/info.pubkey. Closes the signer-binding gap NIP-87 leaves on the floor (audit/DIGEST.md §"Top 5 findings" #2). Algorithm: iterate every URL in announcement.u, ask the fetcher, verified=true on first ok-fetch with a matching pubkey (lowercase compare per dtag.ts hex discipline). Short-circuits on first match — multi-URL announcements typically point at the same logical mint behind different endpoints, no value in double-checking. Failure reasons distinguish the operational case: - "non-cashu" — kind:38173 (Fedimint), Layer B doesn't apply - "no-urls" — empty u array - "all-fetches-failed" — every URL returned ok:false - "pubkey-mismatch: ..." — at least one ok-fetch but no pubkey matched; includes both announcement and mint pubkey(s) so logs are self-contained Pure with respect to the fetcher — no retry, no backoff, no caching here. The scheduler shares its createMintInfoFetcher instance so the TTL cache + concurrency budget is amortized across both the "announcement arrived, verify it" path and the "user clicked refresh" path. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Wire the existing pieces into a running pipeline: - Single subscription per kind (38172, 38173, 38000, 0, 10002) with per-kind `since` filters derived from the cache on start (cold-start optimization; CAS in the cache is the correctness gate). - Per-event funnel through nip87 parsers, cache CAS upserts, Layer B enqueue for accepted Cashu announcements. - Layer B work tracked in an `inflight` set so stop() can drain. Concurrency is the fetcher's — we don't double-cap. - Per-URL backoff (2^(attempts-1) × 30s, capped 1h) skips re-enqueueing an announcement whose every URL is still in cooldown. Backoff is separate from the fetcher's TTL cache. - Profile (kind:0) parsing tolerates malformed JSON content. Relay-list (kind:10002) maps tag markers to read/write booleans (no marker → both). - start()/stop() are idempotent. start() returns a Promise so tests can deterministically await the watermark restore + subscribe wiring. - getStats() returns a defensive copy of the counters. 149 unit tests across the core package; 12 cover the scheduler explicitly: happy path, mismatch, Layer A reject, Fedimint, reviews, profiles, relay lists, defensive copy, idempotency, watermark restore, restart-replay no-op, backoff cooldown, drain on stop. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Two new tests in packages/core/src/integration.test.ts driving the
fixture corpus through createScheduler end-to-end:
1. "runs the corpus through createScheduler and converges with Layer B
applied" — replay all 16 events (5 bot-spam + 1 legacy + 2 spec-
conforming + 3 fedimint + 5 reviews) through the scheduler with a
fake pool and a curated fetcher. Asserts:
- stats: eventsReceived=16, rejectedByLayerA=5, accepted=11,
layerBVerified=2 (Alpha+Beta), layerBFailed=1 (Nostrodomo
pubkey-mismatch), layerBPending=0
- cache row counts match the existing parse→cache test (6 ann, 5 rev)
- verifiedBySignerBinding wired through correctly per row
- Fedimint row stays verifiedBySignerBinding=null (Layer B doesn't
enqueue, distinguishing "didn't try" from "tried and failed")
- mintInfo holds 2 ok rows + 1 !ok row with pubkey-mismatch reason
2. "idempotency: stop and re-start replays the corpus with no double-
fetches and no duplicate rows" — run the corpus through scheduler 1,
stop, then replay through a fresh scheduler 2 against the same DB.
Asserts:
- row counts unchanged across the second pass
- calls2.length === 0 (every replayed event lands as 'rejected-stale'
since createdAt is identical, which short-circuits the Layer B
enqueue path — exactly the design contract documented in the
scheduler's "Watermark / restart story" header comment)
- verifiedBySignerBinding=true preserved for Alpha across restart
(the PR #29 Layer-B preservation fix combined with the scheduler's
inserted/replaced-only enqueue gate)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
Layer B used to return only `info` on success, leaving the scheduler to
guess which URL had verified by writing `url: row.u[0] ?? ""` into
MintInfoRow regardless of which URL actually matched. For multi-URL Cashu
mints this picked the wrong canonical URL whenever a non-first URL was
the one that responded with the matching pubkey.
Discriminate LayerBResult into success/failure arms:
- success: { verified: true, url, info }
- failure: { verified: false, reason }
verifySignerBinding now records the URL that produced the matching
pubkey response. Tests that pattern-matched the old loose shape are
updated to narrow on `r.verified` and assert the new `url` field on
multi-URL inputs.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…rmark restore
restoreWatermarks() in scheduler/index.ts used to materialize the entire
announcements table per kind via:
db.announcements.where("kind").equals(k).reverse().sortBy("createdAt")
That's an in-memory sort proportional to the cache size. As the directory
grows past a few thousand mints, the cold-start latency degrades.
Bump the Dexie schema to v2 and add a compound index `[kind+createdAt]`
on the announcements table. The scheduler will switch to a bounded
between(...).last() lookup in a follow-up commit. Dexie auto-migrates
additive index changes; existing rows are re-indexed on first open.
fake-indexeddb supports compound indexes, so existing tests pass through
unchanged (the schema-test indexes assertion is updated for the new
compound entry).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
A single ttlMs applied to both ok and fail responses meant a flaky mint on a transient outage blocked retries for the full success TTL window (default 5 min for ok would propagate to fail). A short outage that should clear in seconds got pinned for minutes. Add INFO_TTL_OK_MS (5 min) and INFO_TTL_FAIL_MS (30s) constants and accept ttlOkMs / ttlFailMs in MintInfoFetcherOptions. Each cache entry remembers the TTL it was admitted with so an entry can't outlive its own budget if globals change mid-process. The legacy ttlMs option is still honored as a single-bucket fallback for backwards compatibility — if both are provided, the split values win. Tests cover the split behavior end-to-end (fail TTL expires before ok TTL, ok stays cached past fail TTL) and the legacy single-TTL path. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…ing, and stalls Bundles five interleaved scheduler hardening fixes that all touch runLayerB / restoreWatermarks / startup. Splitting into separate commits would require interleaving hunks within a few hundred lines that already share constants and control flow — the changes are conceptually distinct but mechanically entangled. A. Layer B vs CAS-replace race (gap #22) The persist branch used to read `existing`, then put({...existing, verifiedBySignerBinding}) outside any transaction. A newer event landing between the read and the put would get clobbered by the stale snapshot. Now the announcement update runs in a Dexie transaction that re-fetches and asserts the eventId still matches before writing — if it doesn't, the row was replaced mid-flight and we drop the verification result (the new row gets its own Layer B pass via the normal onEvent path). Writes only the field we own (`db.announcements.update`), no `...existing` spread. B. Watermark future-poisoning (gap #19) updateWatermark clamps `createdAt` to `now + WATERMARK_FUTURE_SLACK_SEC` (10 min). A junk event with year-3000 created_at no longer poisons the in-memory watermark and silently filters legitimate events. The clamp also applies inside restoreWatermarks (via the same updateWatermark path) so a poisoned row already in cache can't re-poison the watermark on restart. D. Bounded restoreWatermarks lookup Switches from .where("kind").equals(k).reverse().sortBy("createdAt") (full-table materialize) to .where("[kind+createdAt]").between([k,Dexie.minKey],[k,Dexie.maxKey]).last() (bounded compound-index range scan). Requires the v2 schema bump from the previous commit. E. Transient-failure verdict mapping verdictForPersistence maps LayerBResult to the value persisted on the announcement row: - verified=true → true (real positive) - verified=false, all-fetches-failed → null (transient — re-try later) - verified=false, pubkey-mismatch → false (real negative) - verified=false, anything else → null (defensive) Without this, a transient `all-fetches-failed` would write verifiedBySignerBinding=false, leaving a permanent negative verdict for what was actually a temporary network issue. F. Per-Layer-B-task timeout Wraps verifySignerBinding in Promise.race with a 30s deadline (LAYER_B_TASK_TIMEOUT_MS). A stuck mint (or fetcher hung in a non-respect-AbortSignal way) used to pin a worker indefinitely. On timeout we map to "all-fetches-failed" so the row stays null/ transient and gets retried. G. Re-enqueue unverified rows on restart reenqueueUnverified() runs after restoreWatermarks() and queries announcements.where("kind").anyOf([38172]).filter(verifiedBySignerBinding === null) to re-feed up to RESTART_REENQUEUE_CAP=100 rows into the Layer B queue. Without this, a row that was inserted before a Layer B failure (or before a crash) sat in the cache forever as "not yet verified". Also uses the new LayerBResult.url field (from the cashu commit) so the MintInfoRow's `url` reflects which URL actually verified, rather than always being row.u[0]. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
… poisoning, backoff cap
Pins the four hardening fixes from the prior commit with explicit
regression tests:
1. "does not clobber a newer event when Layer B finishes after a
replace" — drives the gap #22 race shape: insert event at t=100,
hold the fetcher hostage, insert newer event at t=200, release the
stale fetcher. Asserts the row in cache stays the t=200 row with
verifiedBySignerBinding=null.
2. "clamps a future-poisoned createdAt on restore" — pre-seeds the
cache with a year-3000 row, restarts the scheduler, asserts the
resulting `since` filter is at most now+600s, not year-3000.
3. "cold-start with empty cache leaves the watermark filter absent"
— asserts no `since` key in the filter object when there's
nothing to restore (vs an undefined value, which would round-trip
wrong over the wire).
4. "caps backoff at MAX_BACKOFF_MS" — drives 10 consecutive failures
for the same announcement, then asserts a re-attempt at exactly
cap-1ms is suppressed and at cap ms goes through.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…f fixed yield count The fixed 10-microtask drain in integration.test.ts raced under slower CI runners: the new transactional Layer B persistence (gap #22 fix) adds microtask hops, and one of the two spec-conforming Cashu mints hadn't finished verifying by the time stats were asserted, surfacing as `expected 2 to be 1 // layerBVerified` in CI but green locally. Switch to a polling drain that reads `sched.getStats().layerBPending` and exits as soon as it hits 0 (with a generous 200-yield ceiling). The minimum 5-yield floor preserves the original "let scheduled work start" behavior for tests that don't push a sched in. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
orveth
added a commit
that referenced
this pull request
Apr 17, 2026
Below the existing scheduler stats `<pre>` block and above the `<hr>`,
add three new blocks so the X-ray is self-documenting during demos:
(A) filters in use — one line per subscribed kind, rendered from
`getSubscribedKinds()` so the UI can never drift from
scheduler/SUBSCRIBED_KINDS. Kinds 0 and 10002 carry the explicit
"firehose — no authors" annotation flagged in PR #30 review — we
want it visible, not footnoted.
(B) relays — the SEED_RELAYS list, one per line.
(C) validation paths — fixed-width reference table laying out kind →
parser → Layer A gate → Layer B → counter path for every
subscribed kind. Inline `<pre>` string, no React table styling:
stays terse and font-mono alongside the rest of the X-ray.
Also annotate the stats `<pre>` with a one-liner that calls out
`layerBPending` as the only transient counter (enqueue↑ / complete↓).
The alchemist noticed it "going up then down" during the prior demo
and asked about it — inline note pre-empts the next ask.
No layout overhaul, no new deps, no Tailwind components.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Three concerns landing together because they're useless apart and the
dependency graph would otherwise force three round-trips of review on
the same code paths:
@bitcoinmints/core➜cashu/info— Cashu NUT-06/v1/infoHTTP client (
fetchMintInfo+createMintInfoFetcher). HTTPS-only,5s default timeout, FIFO concurrency semaphore, per-URL TTL cache
that caches both ok and !ok results, in-flight dedup map. The
discriminated
MintInfoResultunion is the contract every otherconsumer in this PR (and PR Minor spec incompatiblity #5) shares.
@bitcoinmints/core➜cashu/layerB—verifySignerBindingdoes the semantic check NIP-87 leaves on the floor: confirm the
announcement signer's pubkey matches the live mint's NUT-06 pubkey.
Pure w.r.t. the fetcher (it's passed in), so the same fetcher
instance shares cache + concurrency budget across the scheduler and
any future on-demand UI refresh path. Cashu-only — kind:38173 short-
circuits to
non-cashu.@bitcoinmints/core➜scheduler—createScheduler({db, pool, fetcher, relays})wires PR V3 #2's pool/parse + PR Support fedimints #3's cache CAS intoa running pipeline, with Layer B hung off the announcement-accepted
branch. Per-kind
sincefilters are derived from the cache onstart()(cold-start optimization; CAS is the correctness gate).Layer B work tracked via an
inflightset sostop()can drain;per-URL backoff (2^(attempts-1) × 30s, capped 1h) skips re-enqueueing
announcements whose every URL is in cooldown.
start()/stop()areidempotent and
start()returns a Promise so tests can await wiringdeterministically.
Plus: integration coverage extended to drive the full corpus through
createSchedulerend-to-end, asserting stats, mintInfo writes,verifiedBySignerBinding wiring, and start→stop→start idempotency
preserves verification status.
Open question decisions
The PR brief flagged 4 open questions; defaults applied:
level. Single subscribe with one global
sinceper kind. No newpersistent table, no pool.ts change. Documented in
scheduler/index.tsheader comment.
start()frommax(createdAt)per kind across announcements/reviews/profiles/relayLists tables. No
relayWatermarkstable.insert/replace. Fetcher's TTL cache short-circuits the actual HTTP
call within the window so this is cheap.
The scheduler only enqueues on
insertedorreplaced. This makesstart→stop→start a clean no-op against a populated cache.
Code shape
Test plan
bun run typecheck— clean across both packagescd packages/core && bun run test— 151 tests passing (11 files)cd packages/core && bunx biome check .— cleanFedimint, kind:38000/0/10002, defensive copy, idempotent start/stop,
watermark restore, restart-replay no-op, backoff cooldown + retry
after window, drain on stop
+ Layer B + mintInfo assertions; idempotency on stop/start replay
Follow-ups out of scope here
TTL cache key — deferred to PR Minor spec incompatiblity #5 review/follow-up
urls[]field(data-model §15 case D variant)
🤖 Generated with Claude Code