ADR: Deterministic Mint-Keyset Resolution in Settlement Validation

ADR-XXX: Deterministic Mint-Keyset Resolution in Settlement Validation (Root-Cause Fix for the settlementDescriptor Flake)

Status

Proposed

Date

2026-09-10

Related

  • src/lib/auction/settlementDescriptor.tsgetSettlementDescriptor calls await fetchMintKeysets(winnerBid.mint) at line 512
  • src/lib/auction/validation.tsfetchMintKeysets (lines 49-66) performs real network I/O via CashuMint.getKeySets(); module-global keysetCache at line 50
  • src/lib/__tests__/settlementDescriptor.test.ts — the flaky test file
  • src/components/AuctionSettlement.tsx — the only production caller of getSettlementDescriptor (line 239)
  • ADR-0005: No External Service Dependencies in Tests
  • ADR-0006: Local Cashu Mint for e2e Tests
  • ADR-0004: Auction Settlement Descriptor

Context

The flake

settlementDescriptor.test.ts intermittently fails with a 5-second timeout on
getSettlementDescriptor. The failure is pre-existing — it is unrelated to
any specific PR (it has blocked pushes on the ADR-0011 DLEQ branch, the NDK
seam migration, and others) and passes on re-run. It erodes trust in the CI
signal and wastes developer time debugging a failure that is not caused by the
code under test.

Root cause (verified by two independent cross-family consultant reviews, 2026-09-10)

getSettlementDescriptor (settlementDescriptor.ts:512) performs a real
network call
to the winning bid’s mint:

const mintKeysets = winnerBid ? await fetchMintKeysets(winnerBid.mint) : undefined

fetchMintKeysets (validation.ts:49-66) constructs a CashuMint and awaits
mint.getKeySets() — an HTTP request to the mint URL. In unit tests, the mint
URL is the inert string https://mint.example.com (per ADR-0005, mint URLs in
fixtures are data, not services). But fetchMintKeysets actually tries to
fetch it
. When the network is slow, unreachable, or rate-limited, this call
hangs past the test’s 5-second timeout → flaky failure.

Verified facts:

  • The call is unconditional — no try/catch, no timeout wrapper around it in
    getSettlementDescriptor.
  • fetchMintKeysets has no timeout / AbortSignal. The cashu-ts
    getKeySets() internally calls fetch(url + "/v1/keysets") with no signal
    option. The catch { return [] } only guards against rejected promises
    (DNS failure, connection refused) — it does not guard against a hanging
    promise (slow/unreachable network where the TCP handshake never completes).
  • The test file calls getSettlementDescriptor in ~50 places with zero
    mocking
    of fetchMintKeysets or CashuMint. Any test with a truthy
    canonicalWinner triggers the real network call.
  • The keysetCache (validation.ts:50) is module-global, shared across all
    tests in a run. A slow mint in one test can poison subsequent tests.

This violates ADR-0005’s core rule: “Tests must not make network calls to
external services.”
The fetchMintKeysets call inside the settlement
descriptor’s validation path is a network dependency that unit tests cannot
control.

Why it is intermittent

  • When the runner has fast, reliable network, mint.example.com resolves and
    the request fails fast (DNS/connection refused) → test passes.
  • When the runner is slow or the network is flaky, the request hangs → 5s
    timeout → test fails.
  • The keysetCache is module-global and shared across tests, so the first test
    to hit a slow mint poisons subsequent tests in the same run.

Decision

Make mint-keyset resolution a deterministic, injectable seam in the
settlement validation path, and remove real network I/O from unit tests.

1. Expose mintKeysets as the injectable seam (data, not a fetcher)

Add an optional mintKeysets?: MintKeyset[] field to
GetSettlementDescriptorInput. This matches the existing pattern in the
validation architecture — ValidatePathReleaseInput and
ValidateSettlementCompletenessInput already accept mintKeysets?: MintKeyset[],
and deriveState already threads it through. The seam is data-driven, not
function-driven: the caller decides whether to pre-fetch (production) or inject
mocks (tests).

Backward compatibility: if mintKeysets is undefined, getSettlementDescriptor
falls back to the internal fetchMintKeysets (now with a bounded timeout). This
avoids breaking the existing production caller.

2. Bound the network call (defense in depth)

Even in production, fetchMintKeysets must not hang indefinitely. Add a bounded
timeout (AbortSignal.timeout(2000)) so a slow mint degrades to [] (empty
keysets) rather than blocking the descriptor. An empty keyset result already
routes to the pending/invalid path — it must never hang.

3. Negative caching + failure logging

  • Negative caching: a failed fetch should populate a short-TTL negative
    cache entry (cache the [] result) so a dead mint is not hammered on every
    settlement UI render.
  • Failure logging: the catch { return [] } silently swallows errors. Add a
    console.warn / structured log when fetchMintKeysets fails, so operators
    can distinguish “mint is down” from “settlement is invalid.”

4. Test isolation

  • Export a clearKeysetCache() helper from validation.ts for test cleanup.
  • Add a mockMintKeysets() test utility to settlementDescriptor.test.ts
    returning a minimal valid MintKeyset[] fixture.
  • Tests inject keysets via the seam; they never trigger the real fetch or rely
    on the module-global cache.

Invariants

  • getSettlementDescriptor never performs an unbounded network call.
  • Unit tests never make a real HTTP request to a mint URL.
  • A slow or unreachable mint degrades to pending/invalid deterministically
    — never a hang, never a flaky pass/fail.
  • The settlement descriptor’s behavior for a given set of inputs is
    deterministic given the same injected keysets.
  • Tests never rely on the module-global keysetCache.

Consequences

Positive

  • Root-cause fix — the flake is eliminated at its source (real network I/O
    in a pure validation path), not patched per-test.
  • Deterministic CIsettlementDescriptor.test.ts passes or fails based
    on code, not network conditions (ADR-0005’s stated goal).
  • Faster tests — no test waits on a network timeout.
  • Production robustness — a slow mint no longer blocks the settlement UI
    indefinitely; it degrades gracefully with a bounded timeout and negative
    caching.

Costs

  • GetSettlementDescriptorInput gains a mintKeysets field (small API surface
    change).
  • AuctionSettlement.tsx (the only production caller) must be updated to
    fetch keysets externally (with timeout) or rely on the default fallback.
  • The module-global keysetCache is documented as test-hostile; tests must
    inject keysets to avoid it.

Rollout / PR sequence

PR 1 — Add the mintKeysets seam + bounded timeout + negative cache + logging

  • Add mintKeysets?: MintKeyset[] to GetSettlementDescriptorInput.
  • getSettlementDescriptor uses the seam; falls back to fetchMintKeysets
    (with AbortSignal.timeout(2000)) when mintKeysets is undefined.
  • Add negative caching + console.warn on fetch failure in validation.ts.
  • Export clearKeysetCache().
  • Update AuctionSettlement.tsx to fetch keysets externally (with timeout) or
    pass undefined and rely on the default.

PR 2 — Make settlementDescriptor.test.ts network-free

  • Rewrite settlementDescriptor.test.ts to inject deterministic keysets via
    the seam.
  • Add mockMintKeysets() test utility.
  • Add beforeEach cache clearing.
  • Remove any reliance on the inert mint URL being fetchable.
  • Verify the full suite is green and stable across repeated runs.

Ordering vs ADR-0011 (PR #1280)

PR 1 (the seam) should land first — it makes the validation path
controllable. ADR-0011’s DLEQ verification (which also touches the validation
path) should then use the seam, passing mintKeysets through rather than
hard-coding the fetch. If ADR-0011 lands first without the seam, it may add more
network I/O that also needs to be made injectable.

Notes

  • This is a pre-existing flake, not introduced by ADR-0011. It has blocked
    pushes on multiple unrelated branches (observed 2026-09-09 on the ADR-0011
    DLEQ branch and the NDK seam migration). The fix is independent of any
    feature work and can land on its own.
  • The keysetCache global (validation.ts:50) is a secondary concern — it is
    module-global and shared across tests, so a slow mint in one test can poison
    others. The seam makes this moot for unit tests (they inject keysets), but
    the cache should be reviewed for test isolation.
  • Consultant verification (2026-09-10): two independent cross-family
    reviews (kimi-consultant, worker-reviewer-qwen) both CONFIRMED the root cause
    (real network I/O in fetchMintKeysets, no timeout, unconditional call,
    zero test mocking) and both APPROVED-WITH-CHANGES, independently converging
    on: simplify the seam to mintKeysets?: MintKeyset[] (data, not fetcher),
    add AbortSignal.timeout(2000), export clearKeysetCache(), add
    mockMintKeysets(), update AuctionSettlement.tsx, and clarify ADR-0011
    ordering. All incorporated above.
Write a comment