Skip to content

PR #4: core/cashu /v1/info + Layer B + scheduler#30

Merged
orveth merged 10 commits intov2from
feat/core-cashu
Apr 17, 2026
Merged

PR #4: core/cashu /v1/info + Layer B + scheduler#30
orveth merged 10 commits intov2from
feat/core-cashu

Conversation

@orveth
Copy link
Copy Markdown

@orveth orveth commented Apr 17, 2026

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:

  1. @bitcoinmints/corecashu/info — Cashu NUT-06 /v1/info
    HTTP 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 MintInfoResult union is the contract every other
    consumer in this PR (and PR Minor spec incompatiblity  #5) shares.

  2. @bitcoinmints/corecashu/layerBverifySignerBinding
    does 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.

  3. @bitcoinmints/coreschedulercreateScheduler({db, pool, fetcher, relays}) wires PR V3 #2's pool/parse + PR Support fedimints #3's cache CAS into
    a running pipeline, with Layer B hung off the announcement-accepted
    branch. Per-kind since filters are derived from the cache on
    start() (cold-start optimization; CAS is the correctness gate).
    Layer B work tracked via an inflight set so stop() can drain;
    per-URL backoff (2^(attempts-1) × 30s, capped 1h) skips re-enqueueing
    announcements whose every URL is in cooldown. start()/stop() are
    idempotent and start() returns a Promise so tests can await wiring
    deterministically.

Plus: integration coverage extended to drive the full corpus through
createScheduler end-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:

  • Q1 (per-relay vs aggregate watermarks): aggregated at scheduler
    level. Single subscribe with one global since per kind. No new
    persistent table, no pool.ts change. Documented in scheduler/index.ts
    header comment.
  • Q2 (watermark restoration source): derive on each start() from
    max(createdAt) per kind across announcements/reviews/profiles/
    relayLists tables. No relayWatermarks table.
  • Q3 (Layer B re-verification on replace): re-enqueue every accepted
    insert/replace. Fetcher's TTL cache short-circuits the actual HTTP
    call within the window so this is cheap.
  • Q4 (CAS-rejected-stale Layer B re-enqueue): do not re-enqueue.
    The scheduler only enqueues on inserted or replaced. This makes
    start→stop→start a clean no-op against a populated cache.

Code shape

packages/core/src/
├── cashu/
│   ├── index.ts          (re-exports)
│   ├── info.ts           ← Piece 1: /v1/info HTTP client
│   ├── info.test.ts      (19 tests)
│   ├── layerB.ts         ← Piece 2: signer-binding verifier
│   └── layerB.test.ts    (10 tests)
├── scheduler/
│   ├── index.ts          ← Piece 3: orchestrator
│   └── index.test.ts     (12 tests)
└── integration.test.ts   ← Piece 4: +2 scheduler-pipeline tests

Test plan

  • bun run typecheck — clean across both packages
  • cd packages/core && bun run test — 151 tests passing (11 files)
  • cd packages/core && bunx biome check . — clean
  • Scheduler unit tests cover: happy path, mismatch, Layer A reject,
    Fedimint, kind:38000/0/10002, defensive copy, idempotent start/stop,
    watermark restore, restart-replay no-op, backoff cooldown + retry
    after window, drain on stop
  • Integration tests cover: full corpus through scheduler with stats
    + Layer B + mintInfo assertions; idempotency on stop/start replay

Follow-ups out of scope here

  • URL canonicalization (trailing slash, /v1 path stripping) before the
    TTL cache key — deferred to PR Minor spec incompatiblity  #5 review/follow-up
  • Cross-checking the URL itself against the mint's urls[] field
    (data-model §15 case D variant)
  • Persistent backoff state across process restart (in-memory for v1)

🤖 Generated with Claude Code

orveth and others added 4 commits April 16, 2026 20:03
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>
@vercel
Copy link
Copy Markdown

vercel Bot commented Apr 17, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
bitcoinmints Error Error Apr 17, 2026 3:44am

Request Review

orveth and others added 5 commits April 16, 2026 20:39
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 orveth merged commit d7fb5ef into v2 Apr 17, 2026
2 of 3 checks passed
@orveth orveth deleted the feat/core-cashu branch April 17, 2026 03:47
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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant