Cashu token redeemed elsewhere is credited as Paid after a retry #4

Closed
opened 2026-10-09 11:20:29 +00:00 by thesimplekid · 0 comments
Owner

Summary

In automatic mode (wallet receive, cashu_settlement: None), a Cashu token that someone else already redeemed is rejected on the first submit_payment. If the same token is submitted again after recover(), it is accepted: a receipt is recorded and the intent becomes Paid, 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 with cargo run --example spent_token_repro.

token spent elsewhere
attempt 1: Err(InvalidPayment)
attempt 2: Ok(Receipt { id: "cashu:03c680…", method: Cashu, applied_sats: 100, additional: false, … })
BUG: spent token credited; status Paid
examples/spent_token_repro.rs
//! Repro: a Cashu token spent elsewhere is credited after a retry.
use std::{sync::Arc, time::Duration};

use cdk::{
    Amount,
    amount::SplitTarget,
    nuts::{CurrencyUnit, PaymentMethod},
    wallet::{ReceiveOptions, SendOptions, Wallet},
};
use satsettle::{CaptureMode, CreateIntent, Engine, IdempotencyKey, PaymentProof};

const MINT: &str = "https://testnut.cashudevkit.org";

async fn wallet() -> anyhow::Result<Wallet> {
    let mut seed = [0u8; 64];
    openssl::rand::rand_bytes(&mut seed)?;
    let store = cdk_sqlite::wallet::memory::empty().await?;
    Ok(Wallet::new(MINT, CurrencyUnit::Sat, Arc::new(store), seed, None)?)
}

#[tokio::main]
async fn main() -> anyhow::Result<()> {
    let payer = wallet().await?;
    let thief = wallet().await?;
    let quote = payer
        .mint_quote(PaymentMethod::from("bolt11"), Some(Amount::from(500)), None, None)
        .await?;
    for _ in 0..30 {
        if payer.check_mint_quote_status(&quote.id).await?.amount_paid >= Amount::from(500) {
            break;
        }
        tokio::time::sleep(Duration::from_secs(1)).await;
    }
    payer.mint(&quote.id, SplitTarget::default(), None).await?;
    let token = payer
        .prepare_send(Amount::from(120), SendOptions::default())
        .await?
        .confirm(None)
        .await?
        .to_string();
    // Someone else redeems the token first.
    thief.receive(&token, ReceiveOptions::default()).await?;
    println!("token spent elsewhere");

    let dir = tempfile::tempdir()?;
    let engine = Engine::open(dir.path().join("payments")).await?;
    let intent = engine
        .create_intent(CreateIntent {
            idempotency_key: IdempotencyKey::generate(),
            reference: "alice@xyz".into(),
            amount_sats: 100,
            expires_at: 4_000_000_000,
            cashu_mint: Some(MINT.into()),
            capture: CaptureMode::Automatic,
            cashu_settlement: None,
            lightning: None,
        })
        .await?;
    for attempt in 1..=6 {
        let result = engine
            .submit_payment(&intent.id, PaymentProof::Cashu(token.clone()))
            .await;
        println!("attempt {attempt}: {result:?}");
        if result.is_ok() {
            println!("BUG: spent token credited; status {:?}", engine.status(&intent.id).await?.status);
            return Ok(());
        }
        let _ = engine.recover().await;
        tokio::time::sleep(Duration::from_secs(5)).await;
    }
    println!("not reproduced");
    Ok(())
}

Cause

  1. Payments::collect_inner reserves the token in the ledger (Reservation::New), then calls receive_proofs.
  2. The mint answers TokenAlreadySpent. CDK does not treat this as definitive: the receive saga stays SwapRequested, and its transaction stays Pending with the memo geata-payment:{id}. classify_error maps the error to Invalid, so the engine discards its pending record. The ledger reservation stays Pending, and needs_recovery stays set.
  3. On the retry, recover() runs recover_incomplete_sagas(). For a receive saga whose inputs are spent, CDK calls /v1/restore. When no outputs are restored, CDK still marks the transaction Completed: see complete_receive_from_restore in cdk/src/wallet/receive/saga/resume.rs, branch "couldn't restore outputs, removing spent inputs".
  4. collect_inner takes the Reservation::Pending branch, finds a transaction with the memo, Completed, and enough amount, and calls claim().

A Completed CDK 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:

  • Before calling recover_incomplete_sagas(), look at each incomplete Receive saga. Its ReceiveOperationData holds the token and the blinded_messages sent to the mint.
  • Send those outputs to 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 no blinded_messages were recorded, the swap was never sent.
  • Journal that verdict against the token key, then run CDK recovery. Never infer receipt from CDK's transaction status.
  • Combine all sagas for one token before deciding. A failed retry leaves a second saga with fresh outputs the mint never signed, so one signed swap is enough to prove receipt.
  • If the mint is unreachable, report Unavailable and 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).

## Summary In automatic mode (wallet receive, `cashu_settlement: None`), a Cashu token that **someone else already redeemed** is rejected on the first `submit_payment`. If the same token is submitted again after `recover()`, it is accepted: a receipt is recorded and the intent becomes `Paid`, 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 with `cargo run --example spent_token_repro`. ``` token spent elsewhere attempt 1: Err(InvalidPayment) attempt 2: Ok(Receipt { id: "cashu:03c680…", method: Cashu, applied_sats: 100, additional: false, … }) BUG: spent token credited; status Paid ``` <details><summary>examples/spent_token_repro.rs</summary> ```rust //! Repro: a Cashu token spent elsewhere is credited after a retry. use std::{sync::Arc, time::Duration}; use cdk::{ Amount, amount::SplitTarget, nuts::{CurrencyUnit, PaymentMethod}, wallet::{ReceiveOptions, SendOptions, Wallet}, }; use satsettle::{CaptureMode, CreateIntent, Engine, IdempotencyKey, PaymentProof}; const MINT: &str = "https://testnut.cashudevkit.org"; async fn wallet() -> anyhow::Result<Wallet> { let mut seed = [0u8; 64]; openssl::rand::rand_bytes(&mut seed)?; let store = cdk_sqlite::wallet::memory::empty().await?; Ok(Wallet::new(MINT, CurrencyUnit::Sat, Arc::new(store), seed, None)?) } #[tokio::main] async fn main() -> anyhow::Result<()> { let payer = wallet().await?; let thief = wallet().await?; let quote = payer .mint_quote(PaymentMethod::from("bolt11"), Some(Amount::from(500)), None, None) .await?; for _ in 0..30 { if payer.check_mint_quote_status(&quote.id).await?.amount_paid >= Amount::from(500) { break; } tokio::time::sleep(Duration::from_secs(1)).await; } payer.mint(&quote.id, SplitTarget::default(), None).await?; let token = payer .prepare_send(Amount::from(120), SendOptions::default()) .await? .confirm(None) .await? .to_string(); // Someone else redeems the token first. thief.receive(&token, ReceiveOptions::default()).await?; println!("token spent elsewhere"); let dir = tempfile::tempdir()?; let engine = Engine::open(dir.path().join("payments")).await?; let intent = engine .create_intent(CreateIntent { idempotency_key: IdempotencyKey::generate(), reference: "alice@xyz".into(), amount_sats: 100, expires_at: 4_000_000_000, cashu_mint: Some(MINT.into()), capture: CaptureMode::Automatic, cashu_settlement: None, lightning: None, }) .await?; for attempt in 1..=6 { let result = engine .submit_payment(&intent.id, PaymentProof::Cashu(token.clone())) .await; println!("attempt {attempt}: {result:?}"); if result.is_ok() { println!("BUG: spent token credited; status {:?}", engine.status(&intent.id).await?.status); return Ok(()); } let _ = engine.recover().await; tokio::time::sleep(Duration::from_secs(5)).await; } println!("not reproduced"); Ok(()) } ``` </details> ## Cause 1. `Payments::collect_inner` reserves the token in the ledger (`Reservation::New`), then calls `receive_proofs`. 2. The mint answers `TokenAlreadySpent`. CDK does **not** treat this as definitive: the receive saga stays `SwapRequested`, and its transaction stays `Pending` with the memo `geata-payment:{id}`. `classify_error` maps the error to `Invalid`, so the engine discards its pending record. The ledger reservation stays `Pending`, and `needs_recovery` stays set. 3. On the retry, `recover()` runs `recover_incomplete_sagas()`. For a receive saga whose inputs are spent, CDK calls `/v1/restore`. When no outputs are restored, CDK still marks the transaction **`Completed`**: see `complete_receive_from_restore` in `cdk/src/wallet/receive/saga/resume.rs`, branch "couldn't restore outputs, removing spent inputs". 4. `collect_inner` takes the `Reservation::Pending` branch, finds a transaction with the memo, `Completed`, and enough amount, and calls `claim()`. A `Completed` CDK 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: - Before calling `recover_incomplete_sagas()`, look at each incomplete `Receive` saga. Its `ReceiveOperationData` holds the token and the `blinded_messages` sent to the mint. - Send those outputs to `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 no `blinded_messages` were recorded, the swap was never sent. - Journal that verdict against the token key, then run CDK recovery. Never infer receipt from CDK's transaction status. - Combine all sagas for one token before deciding. A failed retry leaves a second saga with fresh outputs the mint never signed, so one signed swap is enough to prove receipt. - If the mint is unreachable, report `Unavailable` and 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).
Sign in to join this conversation.
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
thesimplekid/satsettle#4
No description provided.