Skip to content

fix(refund): durable, balance-versioned refund idempotency (closes #412)#562

Draft
shroominic wants to merge 1 commit into
mainfrom
fix/refund-idempotency-durable
Draft

fix(refund): durable, balance-versioned refund idempotency (closes #412)#562
shroominic wants to merge 1 commit into
mainfrom
fix/refund-idempotency-durable

Conversation

@shroominic

Copy link
Copy Markdown
Contributor

Problem

The refund endpoint cached the issued refund token in-process keyed by sha256(bearer) and re-served it whenever total_balance <= 0. Two gaps caused fund loss ("proofs already spent"):

  1. Lightning topup credits balance from the background invoice watcher with no bearer in scope, so a header-keyed cache invalidation cannot fire.
  2. Multi-worker/replica: an in-process cache invalidated in worker A is stale in worker B.

The sequence refund → topup → spend → refund re-served the already-spent token.

Fix (data-driven, not header-driven)

  • ApiKey.balance_version bumped atomically on every credit via one choke point credit_key_balance() — cashu topup (wallet.credit_balance), Lightning topup (lightning.topup_api_key_from_invoice), and new-key credit.
  • New refund_tokens(api_key_hash, balance_version PK, token, redeemed_at) table.
  • POST /refund re-serves a stored token only at the current balance_version and only if unredeemed; otherwise debit → mint → insert. Any credit bumps the version, so a token minted at v=k is unreachable after a topup (v=k+1).
  • The in-process refund cache is removed from the endpoint.

Migration

Alembic b5e7c9d1f3a2 → c7f1a2b3d4e5: add balance_version (default 0) + create refund_tokens. Existing rows default to 0; first credit moves them to 1. Applies and reverses cleanly.

Tests

STAGED PROTOTYPE — one documented decision for you

  • redeemed_at column + read-side check exist, but nothing writes redeemed_at yet (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.
  • Pre-existing mint-partial-failure (send_token mints then errors → _restore_balance, no RefundToken stored) is out of scope for Lost 11k sats — 'proofs already spent' on dashboard topup after refund (related to #378) #412.

Opened as draft — this is the durable replacement for the partial cache fix; needs a maintainer's eye on the migration + the redeemed_at decision.

🤖 Generated with Claude Code

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>
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