fix(refund): durable, balance-versioned refund idempotency (closes #412)#562
Draft
shroominic wants to merge 1 commit into
Draft
fix(refund): durable, balance-versioned refund idempotency (closes #412)#562shroominic wants to merge 1 commit into
shroominic wants to merge 1 commit into
Conversation
DB-persisted, balance-versioned refund idempotency replacing the in-process refund-token cache. Every credit (cashu topup, Lightning topup, new-key) bumps ApiKey.balance_version atomically; /refund re-serves a stored token only at the current version and only if unredeemed. Covers the Lightning-topup path and multi-worker, which the in-process cache missed. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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.
Problem
The refund endpoint cached the issued refund token in-process keyed by
sha256(bearer)and re-served it whenevertotal_balance <= 0. Two gaps caused fund loss ("proofs already spent"):The sequence refund → topup → spend → refund re-served the already-spent token.
Fix (data-driven, not header-driven)
ApiKey.balance_versionbumped atomically on every credit via one choke pointcredit_key_balance()— cashu topup (wallet.credit_balance), Lightning topup (lightning.topup_api_key_from_invoice), and new-key credit.refund_tokens(api_key_hash, balance_version PK, token, redeemed_at)table.POST /refundre-serves a stored token only at the currentbalance_versionand only if unredeemed; otherwise debit → mint → insert. Any credit bumps the version, so a token minted atv=kis unreachable after a topup (v=k+1).Migration
Alembic
b5e7c9d1f3a2 → c7f1a2b3d4e5: addbalance_version(default 0) + createrefund_tokens. Existing rows default to 0; first credit moves them to 1. Applies and reverses cleanly.Tests
tests/unit/test_refund_idempotency_durable.py: reproduces Lost 11k sats — 'proofs already spent' on dashboard topup after refund (related to #378) #412 across cashu AND Lightning topup (fails without fix), plus in-flight idempotency. Concurrent double-refund verified: one mint, loser 409s, retry re-serves the same token, onerefund_tokensrow, balance debited once.ruff+mypyclean; full unit suite 353 passed; integration 232 passed (mocked).STAGED PROTOTYPE — one documented decision for you
redeemed_atcolumn + read-side check exist, but nothing writesredeemed_atyet (the sweep/gateway ack of spent proofs). Impact is bounded — any topup already invalidates the token; this only adds protection for a redeemed-then-retried token at the same version with no intervening credit. Left as a documented TODO rather than guessing the money-side sweep logic. Decision: wire it in this PR or as a fast-follow.send_tokenmints then errors →_restore_balance, noRefundTokenstored) is out of scope for Lost 11k sats — 'proofs already spent' on dashboard topup after refund (related to #378) #412.🤖 Generated with Claude Code