Cashu token redeemed elsewhere is credited as Paid after a retry #4
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Summary
In automatic mode (wallet receive,
cashu_settlement: None), a Cashu token that someone else already redeemed is rejected on the firstsubmit_payment. If the same token is submitted again afterrecover(), it is accepted: a receipt is recorded and the intent becomesPaid, although the wallet never received the funds.So a buyer can pay with a token, spend it elsewhere at the same moment, then retry and get the order for free.
Reproduction
Against
https://testnut.cashudevkit.org(CDK 0.18.0, fake funds). The full example is below; run it withcargo run --example spent_token_repro.examples/spent_token_repro.rs
Cause
Payments::collect_innerreserves the token in the ledger (Reservation::New), then callsreceive_proofs.TokenAlreadySpent. CDK does not treat this as definitive: the receive saga staysSwapRequested, and its transaction staysPendingwith the memogeata-payment:{id}.classify_errormaps the error toInvalid, so the engine discards its pending record. The ledger reservation staysPending, andneeds_recoverystays set.recover()runsrecover_incomplete_sagas(). For a receive saga whose inputs are spent, CDK calls/v1/restore. When no outputs are restored, CDK still marks the transactionCompleted: seecomplete_receive_from_restoreincdk/src/wallet/receive/saga/resume.rs, branch "couldn't restore outputs, removing spent inputs".collect_innertakes theReservation::Pendingbranch, finds a transaction with the memo,Completed, and enough amount, and callsclaim().A
CompletedCDK receive is therefore not proof of receipt. The only proof is that the mint signed this wallet's swap outputs.Suggested fix
Decide from the mint's record, before CDK recovery can erase the evidence:
recover_incomplete_sagas(), look at each incompleteReceivesaga. ItsReceiveOperationDataholds the token and theblinded_messagessent to the mint.POST /v1/restore(NUT-09). If the mint returns signatures, the swap executed for this wallet: the token was received. If it returns none, the token was not received, even when the inputs are spent. If noblinded_messageswere recorded, the swap was never sent.Unavailableand do not run CDK recovery for that saga yet.npub.cash implemented this and checked it against the CDK test mint with real interrupted receives. It distinguishes "swap executed, then the response was lost" (credited) from "spent elsewhere" (rejected).