Skip to content

fix(tollwallet): bound the fee precheck's keyset fetch against wedged mints - #532

Closed
Amperstrand wants to merge 2 commits into
mainfrom
fix/wedged-mint-precheck-budget
Closed

Amperstrand wants to merge 2 commits into
mainfrom
fix/wedged-mint-precheck-budget

Conversation

@Amperstrand

Copy link
Copy Markdown
Collaborator

Closes #525.

What

SwapFeeSats' keyset fetch rode the wallet library's full retry ladder — 30 s per HTTP attempt, up to five attempts, two chained endpoints — so a wedged mint (accepts nothing, never answers; PRTA #95's class) parked the payment's fee precheck for minutes before any deadline applied: goroutine evidence in #525 shows both in-flight payments parked at PurchaseSession → SwapFeeSats → GetMintActiveKeyset, minutes deep.

The fetch now runs under a 3 s budget (mintKeysetFeesBounded); on expiry SwapFeeSats errors and callers fall through to Receive's own classification — exactly the fallback the WalletPort contract already documents for this method. The abandoned fetch goroutine ends on the library's own schedule into a buffered channel; nothing waits on it.

Verification

Unit (new, red/green): a real silent TCP listener (accepts, never answers) — TestMintKeysetFeesBoundedAgainstWedgedMint returns the budget error in 0.30 s; TestMintKeysetFeesUnboundedBlocksOnWedgedMint pins the original still blocked at 2 s (the regression witness).

Integration (cloud-lab, frozen mint, this fix stacked on #511):

step result
pay with mint frozen 400 in 33.0 s — 3 s budget + 30 s receive timeout (was: 120 s+ client-side death, no response)
notice payment-processing-timeout, "may still complete. Check your usage before retrying"
mint thawed (+9 s) late Receive completed … amount=50 — granting the paid-for session; steps converted, gate opened, MAC authorized
replay of the same token 400 / kind 21023 — no second grant

This is also the first live validation of #511's late-reconciliation claim (previously unit-proven only): the two fixes compose exactly as designed — the budget gets the request to the timeout, the timeout hands off to the reconciler.

make go-battery: 16 modules green.

Follow-ups noted in #525

The strictly-better variant (serve the fee from the wallet's cached keyset — InputFeePpk is already in bolt) needs a small exported accessor in gonuts-tollgate; this PR's bound makes the lane safe now without a dependency-bump cycle.

Amperstrand added 2 commits September 22, 2026 18:33
… mints

SwapFeeSats' keyset fetch rides the lib's retry ladder (30 s per attempt,
up to five attempts, two chained endpoints), so a wedged mint parked the
payment's fee precheck for minutes before any deadline applied —
freezing the payment lane and defeating PurchaseSession's receive
timeout (#525; goroutine evidence in the issue).

The fetch now runs under a 3 s budget; on expiry SwapFeeSats errors and
callers fall through to Receive's own classification, which the
WalletPort contract already allows. The abandoned goroutine ends on the
lib's own schedule into a buffered channel — nothing waits on it.
@Amperstrand

Copy link
Copy Markdown
Collaborator Author

Verification run (worktree at head 311f2d12, as-is):

Battery. make go-battery from the repo root — 16 modules green, exit 0 (merchant 218.9 s, tollwallet 37.5 s under -race -count=1 -tags testenv). The two new tests isolated under race: TestMintKeysetFeesBoundedAgainstWedgedMint PASS in 0.30 s (returns the budget error against a real accept-but-silent TCP listener), TestMintKeysetFeesUnboundedBlocksOnWedgedMint PASS in 2.00 s (regression witness: the unwrapped mintKeysetFees is still wedged at 2 s, pinning that the lib's retry ladder — not something shorter — is what the budget must cover).

Mechanism walk. The bound is a wall-clock race, not a context cancellation: mintKeysetFeesBounded runs the unchanged mintKeysetFees in a goroutine writing to a buffered channel and selects it against time.After(keysetFeeFetchBudget) (3 s default, package var the test can shrink). SwapFeeSats is the only call site switched (gonuts_wallet.go:162); the underlying HTTP fetch is not cancelled — the abandoned goroutine rides the lib's own retry ladder into the buffered channel, so the leak is bounded and per-attempt, exactly as the code comment states. The caller-side composition holds: merchant.go:495's feeErr == nil && fee > 0 guard means a budget error skips the precheck and hands the request to Receive's 30 s timeout — the fallback the WalletPort contract documents at port.go and the same fall-through shape #447's expired-keyset fix built on. The live-integration claim (400 in ~33 s, late reconciliation granting the session after thaw) was independently exercised in the #511 cloud-lab run on this code stacked there.

One blocking nit — wrong PR link in the CHANGELOG. The new entry under [Unreleased] / Fixed (and the second commit's subject) cites ([#529](.../pull/529)), but #529 is the ngit stage-1 epoch CI PR — this change is #532. The repo rule is one entry linking its own PR, and the link persists into release notes at finalization. One-word fix: 529 → 532 in CHANGELOG.md.

Minor, non-blocking: a customer retrying against a still-wedged mint stacks one abandoned fetch goroutine per attempt for up to the ladder duration (~minutes); the payment lane unfreezes regardless, so this is a fragility note, not a defect. Test hygiene is fine — the budget-var mutation is safe because the package's tests don't run in parallel.

Everything else checks out: two commits, one logical change, no attribution footers, new paths covered by real-network-shape tests, changelog otherwise accurate (the ~33 s arithmetic matches 3 s budget + 30 s receive timeout).

Disposition: request-changes — solely for the #529 → #532 changelog link; everything else is land-quality.

@Amperstrand

Copy link
Copy Markdown
Collaborator Author

Comparison evidence pass (tested side by side with #533, both green, both fresh on a99d983a): merchant.go:495 is the only production caller of SwapFeeSats, so #532 and #533 are functionally identical today. The architectural call favors #533 (the 3 s budget is a payment-lane SLA → policy at the call site; the WalletPort stays policy-free). Recommend closing this as superseded by #533 — with one salvage suggestion: the unbounded-blocked regression witness (TestMintKeysetFeesUnboundedBlocksOnWedgedMint) is worth re-homing. (Also noting the wave-3 catch stands: this branch's CHANGELOG commit links #529, not #532.)

@Amperstrand

Amperstrand commented Sep 23, 2026 •

Copy link
Copy Markdown
Collaborator Author

Closing as superseded by #533 — agreed with the review's architectural read: the 3 s budget is a payment-lane SLA, and policy belongs at the lane's call site (the merchant.go call site — its single production SwapFeeSats caller), not inside the WalletPort adapter. #533 carries that shape with its own tests, verified green side-by-side with this branch.

Two things worth carrying forward from this PR's review:

#525 stays open until #533 merges.

Amperstrand pushed a commit that referenced this pull request Sep 23, 2026
CheckTokenSpendable's mint call rode the gonuts client's full retry
ladder, so a wedged mint (accepts the connection, never answers —
#525's class) would park wallet recover for minutes. The call now runs
under a 10s budget (goroutine-abandon idiom; the abandoned request
terminates on the client's own schedule) and budget expiry surfaces as
an error, which recover already classifies as unknown — never spent.
Self-contained twin of the closed #532's keyset-fetch bound; naming
(silentMint) matches so the helpers can unify if that idiom re-lands.
Amperstrand pushed a commit that referenced this pull request Sep 23, 2026
CheckTokenSpendable's mint call rode the gonuts client's full retry
ladder, so a wedged mint (accepts the connection, never answers —
#525's class) would park wallet recover for minutes. The call now runs
under a 10s budget (goroutine-abandon idiom; the abandoned request
terminates on the client's own schedule) and budget expiry surfaces as
an error, which recover already classifies as unknown — never spent.
Self-contained twin of the closed #532's keyset-fetch bound; naming
(silentMint) matches so the helpers can unify if that idiom re-lands.
@Amperstrand

Copy link
Copy Markdown
Collaborator Author

Technical review (evidence-gathering pass, not an approval).

What it does. Bounds the fee precheck at the wallet layer: mintKeysetFeesBounded runs the keyset fetch under a 3-second budget (buffered channel; the abandoned fetch self-terminates on the lib's retry ladder), and SwapFeeSats errors with a fall-through-friendly message on expiry — which the WalletPort contract already allows: the precheck is an optimization, not a gate, and Receive's own classification (plus #511's late-receive reconciliation) takes over.

Verification on this head (311f2d12): gofmt/go vet clean; the new TestMintKeysetFeesBoundedAgainstWedgedMint passes in 0.30 s against a genuinely wedged mint (a TCP listener that accepts and never answers — the PRTA #95 shape, better than a stubbed hang); full tollwallet suite green under -race (36.9 s).

Reconciliation with the other #525 fixes — these are layers, not duplicates:

One nit, worth a one-line fixup: the changelog entry links #529 (the stage-1 epoch PR) instead of this PR — copy-paste slip in the PR link, the prose itself is accurate (including the honest ~33 s customer-visible timeout figure).

Disposition: land (with the link fix, or the maintainer's squash-message edit covers it) — then #533 behind it.

Amperstrand pushed a commit that referenced this pull request Sep 24, 2026
CheckTokenSpendable's mint call rode the gonuts client's full retry
ladder, so a wedged mint (accepts the connection, never answers —
#525's class) would park wallet recover for minutes. The call now runs
under a 10s budget (goroutine-abandon idiom; the abandoned request
terminates on the client's own schedule) and budget expiry surfaces as
an error, which recover already classifies as unknown — never spent.
Self-contained twin of the closed #532's keyset-fetch bound; naming
(silentMint) matches so the helpers can unify if that idiom re-lands.
Amperstrand pushed a commit that referenced this pull request Sep 27, 2026
CheckTokenSpendable's mint call rode the gonuts client's full retry
ladder, so a wedged mint (accepts the connection, never answers —
#525's class) would park wallet recover for minutes. The call now runs
under a 10s budget (goroutine-abandon idiom; the abandoned request
terminates on the client's own schedule) and budget expiry surfaces as
an error, which recover already classifies as unknown — never spent.
Self-contained twin of the closed #532's keyset-fetch bound; naming
(silentMint) matches so the helpers can unify if that idiom re-lands.
Amperstrand pushed a commit that referenced this pull request Oct 5, 2026
CheckTokenSpendable's mint call rode the gonuts client's full retry
ladder, so a wedged mint (accepts the connection, never answers —
#525's class) would park wallet recover for minutes. The call now runs
under a 10s budget (goroutine-abandon idiom; the abandoned request
terminates on the client's own schedule) and budget expiry surfaces as
an error, which recover already classifies as unknown — never spent.
Self-contained twin of the closed #532's keyset-fetch bound; naming
(silentMint) matches so the helpers can unify if that idiom re-lands.
@Amperstrand
Amperstrand deleted the fix/wedged-mint-precheck-budget branch October 17, 2026 18:56
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.

merchant: fee precheck stalls minutes on a wedged mint — blocks the payment lane before any timeout applies

1 participant