Pārlūkot izejas kodu

feat: wallet async melt (#1600)

* feat: wallet async melt

* fix: update quote state

* feat: wallet ws handle bolt12

This is really a hack but works for now

* feat: box to avoid large enum

* fix: workaround for nutshell mints that do not include change in ws

* feat: move box to inner type to avoid public deref

* feat: add doc comments for confirm melt

* chore: fix docs and comments
tsk 1 mēnesi atpakaļ
vecāks
revīzija
5af581d121

+ 2 - 1
crates/cdk-integration-tests/src/init_pure_tests.rs

@@ -139,10 +139,11 @@ impl MintConnector for DirectMintConnection {
             .map(Into::into)
     }
 
-    async fn post_melt(
+    async fn post_melt_with_options(
         &self,
         _method: &PaymentMethod,
         request: MeltRequest<String>,
+        _options: cdk::wallet::MeltOptions,
     ) -> Result<MeltQuoteBolt11Response<String>, Error> {
         let request_uuid = request.try_into().unwrap();
         self.mint.melt(&request_uuid).await.map(Into::into)

+ 751 - 10
crates/cdk-integration-tests/tests/async_melt.rs

@@ -8,13 +8,14 @@
 //! - Background task completion
 //! - Quote polling pattern
 
+use std::collections::HashSet;
 use std::sync::Arc;
 
 use bip39::Mnemonic;
 use cashu::PaymentMethod;
 use cdk::amount::SplitTarget;
-use cdk::nuts::{CurrencyUnit, MeltQuoteState};
-use cdk::wallet::Wallet;
+use cdk::nuts::{CurrencyUnit, MeltQuoteState, State};
+use cdk::wallet::{MeltOutcome, Wallet};
 use cdk::StreamExt;
 use cdk_fake_wallet::{create_fake_invoice, FakeInvoiceDescription};
 use cdk_sqlite::wallet::memory;
@@ -43,12 +44,18 @@ async fn test_async_melt_returns_pending() {
         .unwrap();
     let mut proof_streams = wallet.proof_stream(mint_quote.clone(), SplitTarget::default(), None);
 
-    let _proofs = proof_streams
+    let proofs_before = proof_streams
         .next()
         .await
         .expect("payment")
         .expect("no error");
 
+    // Collect Y values of proofs before melt
+    let ys_before: HashSet<_> = proofs_before
+        .iter()
+        .map(|p| p.y().expect("Invalid proof Y value").clone())
+        .collect();
+
     let balance = wallet.total_balance().await.unwrap();
     assert_eq!(balance, 100.into());
 
@@ -71,19 +78,21 @@ async fn test_async_melt_returns_pending() {
         .unwrap();
 
     // Step 3: Call melt (wallet handles proof selection internally)
-    let start_time = std::time::Instant::now();
-
     // This should complete and return the final state
     let prepared = wallet
         .prepare_melt(&melt_quote.id, std::collections::HashMap::new())
         .await
         .unwrap();
-    let confirmed = prepared.confirm().await.unwrap();
 
-    let elapsed = start_time.elapsed();
+    // Collect Y values of proofs that will be used in the melt
+    let proofs_to_use: HashSet<_> = prepared
+        .proofs()
+        .iter()
+        .chain(prepared.proofs_to_swap().iter())
+        .map(|p| p.y().expect("Invalid proof Y value").clone())
+        .collect();
 
-    // For now, this is synchronous, so it will take longer
-    println!("Melt took {:?}", elapsed);
+    let confirmed = prepared.confirm().await.unwrap();
 
     // Step 4: Verify the melt completed successfully
     assert_eq!(
@@ -91,6 +100,58 @@ async fn test_async_melt_returns_pending() {
         MeltQuoteState::Paid,
         "Melt should complete with PAID state"
     );
+
+    // Step 5: Verify balance reduced (100 - 50 - fees)
+    let final_balance = wallet.total_balance().await.unwrap();
+    assert!(
+        final_balance < 100.into(),
+        "Balance should be reduced after melt. Initial: 100, Final: {}",
+        final_balance
+    );
+
+    // Step 6: Verify no proofs are pending
+    let pending_proofs = wallet
+        .get_proofs_with(Some(vec![State::Pending]), None)
+        .await
+        .unwrap();
+    assert!(
+        pending_proofs.is_empty(),
+        "No proofs should be in pending state after melt completes"
+    );
+
+    // Step 7: Verify proofs used in melt are marked as Spent
+    let proofs_after = wallet.get_proofs_with(None, None).await.unwrap();
+    let ys_after: HashSet<_> = proofs_after
+        .iter()
+        .map(|p| p.y().expect("Invalid proof Y value").clone())
+        .collect();
+
+    // All original proofs should still exist (not deleted)
+    for y in &ys_before {
+        assert!(
+            ys_after.contains(y),
+            "Original proof with Y={} should still exist after melt",
+            y
+        );
+    }
+
+    // Verify the specific proofs used are in Spent state
+    let spent_proofs = wallet
+        .get_proofs_with(Some(vec![State::Spent]), None)
+        .await
+        .unwrap();
+    let spent_ys: HashSet<_> = spent_proofs
+        .iter()
+        .map(|p| p.y().expect("Invalid proof Y value").clone())
+        .collect();
+
+    for y in &proofs_to_use {
+        assert!(
+            spent_ys.contains(y),
+            "Proof with Y={} that was used in melt should be marked as Spent",
+            y
+        );
+    }
 }
 
 /// Test: Synchronous melt still works correctly
@@ -115,12 +176,18 @@ async fn test_sync_melt_completes_fully() {
         .unwrap();
     let mut proof_streams = wallet.proof_stream(mint_quote.clone(), SplitTarget::default(), None);
 
-    let _proofs = proof_streams
+    let proofs_before = proof_streams
         .next()
         .await
         .expect("payment")
         .expect("no error");
 
+    // Collect Y values of proofs before melt
+    let ys_before: HashSet<_> = proofs_before
+        .iter()
+        .map(|p| p.y().expect("Invalid proof Y value").clone())
+        .collect();
+
     let balance = wallet.total_balance().await.unwrap();
     assert_eq!(balance, 100.into());
 
@@ -147,6 +214,15 @@ async fn test_sync_melt_completes_fully() {
         .prepare_melt(&melt_quote.id, std::collections::HashMap::new())
         .await
         .unwrap();
+
+    // Collect Y values of proofs that will be used in the melt
+    let proofs_to_use: HashSet<_> = prepared
+        .proofs()
+        .iter()
+        .chain(prepared.proofs_to_swap().iter())
+        .map(|p| p.y().expect("Invalid proof Y value").clone())
+        .collect();
+
     let confirmed = prepared.confirm().await.unwrap();
 
     // Step 5: Verify response shows payment completed
@@ -166,4 +242,669 @@ async fn test_sync_melt_completes_fully() {
         MeltQuoteState::Paid,
         "Quote should be PAID"
     );
+
+    // Step 7: Verify balance reduced after melt
+    let final_balance = wallet.total_balance().await.unwrap();
+    assert!(
+        final_balance < 100.into(),
+        "Balance should be reduced after melt. Initial: 100, Final: {}",
+        final_balance
+    );
+
+    // Step 8: Verify no proofs are pending
+    let pending_proofs = wallet
+        .get_proofs_with(Some(vec![State::Pending]), None)
+        .await
+        .unwrap();
+    assert!(
+        pending_proofs.is_empty(),
+        "No proofs should be in pending state after melt completes"
+    );
+
+    // Step 9: Verify proofs used in melt are marked as Spent
+    let proofs_after = wallet.get_proofs_with(None, None).await.unwrap();
+    let ys_after: HashSet<_> = proofs_after
+        .iter()
+        .map(|p| p.y().expect("Invalid proof Y value").clone())
+        .collect();
+
+    // All original proofs should still exist (not deleted)
+    for y in &ys_before {
+        assert!(
+            ys_after.contains(y),
+            "Original proof with Y={} should still exist after melt",
+            y
+        );
+    }
+
+    // Verify the specific proofs used are in Spent state
+    let spent_proofs = wallet
+        .get_proofs_with(Some(vec![State::Spent]), None)
+        .await
+        .unwrap();
+    let spent_ys: HashSet<_> = spent_proofs
+        .iter()
+        .map(|p| p.y().expect("Invalid proof Y value").clone())
+        .collect();
+
+    for y in &proofs_to_use {
+        assert!(
+            spent_ys.contains(y),
+            "Proof with Y={} that was used in melt should be marked as Spent",
+            y
+        );
+    }
+}
+
+/// Test: confirm_prefer_async returns Pending when mint supports async
+///
+/// This test validates that confirm_prefer_async() returns MeltOutcome::Pending
+/// when the mint accepts the async request and returns PENDING state.
+#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
+async fn test_confirm_prefer_async_returns_pending_immediately() {
+    let wallet = Wallet::new(
+        MINT_URL,
+        CurrencyUnit::Sat,
+        Arc::new(memory::empty().await.unwrap()),
+        Mnemonic::generate(12).unwrap().to_seed_normalized(""),
+        None,
+    )
+    .expect("failed to create new wallet");
+
+    // Step 1: Mint some tokens
+    let mint_quote = wallet
+        .mint_quote(PaymentMethod::BOLT11, Some(100.into()), None, None)
+        .await
+        .unwrap();
+    let mut proof_streams = wallet.proof_stream(mint_quote.clone(), SplitTarget::default(), None);
+
+    let _proofs = proof_streams
+        .next()
+        .await
+        .expect("payment")
+        .expect("no error");
+
+    let balance = wallet.total_balance().await.unwrap();
+    assert_eq!(balance, 100.into());
+
+    // Step 2: Create a melt quote with Pending state
+    let fake_invoice_description = FakeInvoiceDescription {
+        pay_invoice_state: MeltQuoteState::Pending,
+        check_payment_state: MeltQuoteState::Pending,
+        pay_err: false,
+        check_err: false,
+    };
+
+    let invoice = create_fake_invoice(
+        50_000, // 50 sats in millisats
+        serde_json::to_string(&fake_invoice_description).unwrap(),
+    );
+
+    let melt_quote = wallet
+        .melt_quote(PaymentMethod::BOLT11, invoice.to_string(), None, None)
+        .await
+        .unwrap();
+
+    // Step 3: Call confirm_prefer_async
+    let prepared = wallet
+        .prepare_melt(&melt_quote.id, std::collections::HashMap::new())
+        .await
+        .unwrap();
+
+    let result = prepared.confirm_prefer_async().await.unwrap();
+
+    // Step 4: Verify we got Pending result
+    assert!(
+        matches!(result, MeltOutcome::Pending(_)),
+        "confirm_prefer_async should return MeltOutcome::Pending when mint supports async"
+    );
+
+    // Step 5: Verify proofs are in pending state
+    let pending_proofs = wallet
+        .get_proofs_with(Some(vec![State::Pending]), None)
+        .await
+        .unwrap();
+    assert!(
+        !pending_proofs.is_empty(),
+        "Proofs should be in pending state"
+    );
+
+    // Note: Fake wallet may complete immediately even with Pending state configured.
+    // The key assertion is that confirm_prefer_async returns MeltOutcome::Pending,
+    // which proves the API is working correctly.
+}
+
+/// Test: Pending melt from confirm_prefer_async can be awaited
+///
+/// This test validates that when confirm_prefer_async() returns MeltOutcome::Pending,
+/// the pending melt can be awaited to completion.
+#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
+async fn test_confirm_prefer_async_pending_can_be_awaited() {
+    let wallet = Wallet::new(
+        MINT_URL,
+        CurrencyUnit::Sat,
+        Arc::new(memory::empty().await.unwrap()),
+        Mnemonic::generate(12).unwrap().to_seed_normalized(""),
+        None,
+    )
+    .expect("failed to create new wallet");
+
+    // Step 1: Mint some tokens
+    let mint_quote = wallet
+        .mint_quote(PaymentMethod::BOLT11, Some(100.into()), None, None)
+        .await
+        .unwrap();
+    let mut proof_streams = wallet.proof_stream(mint_quote.clone(), SplitTarget::default(), None);
+
+    let proofs_before = proof_streams
+        .next()
+        .await
+        .expect("payment")
+        .expect("no error");
+
+    // Collect Y values of proofs before melt
+    let ys_before: HashSet<_> = proofs_before
+        .iter()
+        .map(|p| p.y().expect("Invalid proof Y value").clone())
+        .collect();
+
+    let fake_invoice_description = FakeInvoiceDescription {
+        pay_invoice_state: MeltQuoteState::Paid,
+        check_payment_state: MeltQuoteState::Paid,
+        pay_err: false,
+        check_err: false,
+    };
+
+    let invoice = create_fake_invoice(
+        50_000,
+        serde_json::to_string(&fake_invoice_description).unwrap(),
+    );
+
+    let melt_quote = wallet
+        .melt_quote(PaymentMethod::BOLT11, invoice.to_string(), None, None)
+        .await
+        .unwrap();
+
+    // Step 3: Call confirm_prefer_async
+    let prepared = wallet
+        .prepare_melt(&melt_quote.id, std::collections::HashMap::new())
+        .await
+        .unwrap();
+
+    // Collect Y values of proofs that will be used in the melt
+    let proofs_to_use: HashSet<_> = prepared
+        .proofs()
+        .iter()
+        .chain(prepared.proofs_to_swap().iter())
+        .map(|p| p.y().expect("Invalid proof Y value").clone())
+        .collect();
+
+    let result = prepared.confirm_prefer_async().await.unwrap();
+
+    // Step 4: If we got Pending, await it
+    let finalized = match result {
+        MeltOutcome::Paid(_melt) => panic!("We expect it to be pending"),
+        MeltOutcome::Pending(pending) => {
+            // This is the key test - awaiting the pending melt
+            let melt = pending.await.unwrap();
+            melt
+        }
+    };
+
+    // Step 5: Verify final state
+    assert_eq!(
+        finalized.state(),
+        MeltQuoteState::Paid,
+        "Awaited melt should complete to PAID state"
+    );
+
+    // Step 6: Verify balance reduced after awaiting
+    let final_balance = wallet.total_balance().await.unwrap();
+    assert!(
+        final_balance < 100.into(),
+        "Balance should be reduced after melt completes. Initial: 100, Final: {}",
+        final_balance
+    );
+
+    // Step 7: Verify no proofs are pending
+    let pending_proofs = wallet
+        .get_proofs_with(Some(vec![State::Pending]), None)
+        .await
+        .unwrap();
+    assert!(
+        pending_proofs.is_empty(),
+        "No proofs should be in pending state after melt completes"
+    );
+
+    // Step 8: Verify proofs used in melt are marked as Spent after awaiting
+    let proofs_after = wallet.get_proofs_with(None, None).await.unwrap();
+    let ys_after: HashSet<_> = proofs_after
+        .iter()
+        .map(|p| p.y().expect("Invalid proof Y value").clone())
+        .collect();
+
+    // All original proofs should still exist (not deleted)
+    for y in &ys_before {
+        assert!(
+            ys_after.contains(y),
+            "Original proof with Y={} should still exist after awaiting",
+            y
+        );
+    }
+
+    // Verify the specific proofs used are in Spent state
+    let spent_proofs = wallet
+        .get_proofs_with(Some(vec![State::Spent]), None)
+        .await
+        .unwrap();
+    let spent_ys: HashSet<_> = spent_proofs
+        .iter()
+        .map(|p| p.y().expect("Invalid proof Y value").clone())
+        .collect();
+
+    for y in &proofs_to_use {
+        assert!(
+            spent_ys.contains(y),
+            "Proof with Y={} that was used in melt should be marked as Spent after awaiting",
+            y
+        );
+    }
+}
+
+/// Test: Pending melt can be dropped and polled elsewhere
+///
+/// This test validates that when confirm_prefer_async() returns MeltOutcome::Pending,
+/// the caller can drop the pending handle and poll the status via check_melt_quote_status().
+#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
+async fn test_confirm_prefer_async_pending_can_be_dropped_and_polled() {
+    let wallet = Wallet::new(
+        MINT_URL,
+        CurrencyUnit::Sat,
+        Arc::new(memory::empty().await.unwrap()),
+        Mnemonic::generate(12).unwrap().to_seed_normalized(""),
+        None,
+    )
+    .expect("failed to create new wallet");
+
+    // Step 1: Mint some tokens
+    let mint_quote = wallet
+        .mint_quote(PaymentMethod::BOLT11, Some(100.into()), None, None)
+        .await
+        .unwrap();
+    let mut proof_streams = wallet.proof_stream(mint_quote.clone(), SplitTarget::default(), None);
+
+    let proofs_before = proof_streams
+        .next()
+        .await
+        .expect("payment")
+        .expect("no error");
+
+    // Collect Y values of proofs before melt
+    let ys_before: HashSet<_> = proofs_before
+        .iter()
+        .map(|p| p.y().expect("Invalid proof Y value").clone())
+        .collect();
+
+    // Step 2: Create a melt quote
+    let fake_invoice_description = FakeInvoiceDescription {
+        pay_invoice_state: MeltQuoteState::Paid,
+        check_payment_state: MeltQuoteState::Paid,
+        pay_err: false,
+        check_err: false,
+    };
+
+    let invoice = create_fake_invoice(
+        50_000,
+        serde_json::to_string(&fake_invoice_description).unwrap(),
+    );
+
+    let melt_quote = wallet
+        .melt_quote(PaymentMethod::BOLT11, invoice.to_string(), None, None)
+        .await
+        .unwrap();
+
+    let quote_id = melt_quote.id.clone();
+
+    // Step 3: Call confirm_prefer_async
+    let prepared = wallet
+        .prepare_melt(&melt_quote.id, std::collections::HashMap::new())
+        .await
+        .unwrap();
+
+    // Collect Y values of proofs that will be used in the melt
+    let proofs_to_use: HashSet<_> = prepared
+        .proofs()
+        .iter()
+        .chain(prepared.proofs_to_swap().iter())
+        .map(|p| p.y().expect("Invalid proof Y value").clone())
+        .collect();
+
+    let result = prepared.confirm_prefer_async().await.unwrap();
+
+    // Step 4: Drop the pending handle (simulating caller not awaiting)
+    match result {
+        MeltOutcome::Paid(_) => {
+            panic!("We expect it to be pending");
+        }
+        MeltOutcome::Pending(_) => {
+            // Drop the pending handle - don't await
+        }
+    }
+
+    // Step 5: Poll the quote status
+    let mut attempts = 0;
+    let max_attempts = 10;
+    let mut final_state = MeltQuoteState::Unknown;
+
+    while attempts < max_attempts {
+        let quote = wallet.check_melt_quote_status(&quote_id).await.unwrap();
+        final_state = quote.state;
+
+        if matches!(final_state, MeltQuoteState::Paid | MeltQuoteState::Failed) {
+            break;
+        }
+
+        tokio::time::sleep(std::time::Duration::from_millis(100)).await;
+        attempts += 1;
+    }
+
+    // Step 6: Verify final state
+    assert_eq!(
+        final_state,
+        MeltQuoteState::Paid,
+        "Quote should reach PAID state after polling"
+    );
+
+    // Step 7: Verify balance reduced after polling shows Paid
+    let final_balance = wallet.total_balance().await.unwrap();
+    assert!(
+        final_balance < 100.into(),
+        "Balance should be reduced after melt completes via polling. Initial: 100, Final: {}",
+        final_balance
+    );
+
+    // Step 8: Verify no proofs are pending
+    let pending_proofs = wallet
+        .get_proofs_with(Some(vec![State::Pending]), None)
+        .await
+        .unwrap();
+    assert!(
+        pending_proofs.is_empty(),
+        "No proofs should be in pending state after polling shows Paid"
+    );
+
+    // Step 9: Verify proofs used in melt are marked as Spent after polling
+    let proofs_after = wallet.get_proofs_with(None, None).await.unwrap();
+    let ys_after: HashSet<_> = proofs_after
+        .iter()
+        .map(|p| p.y().expect("Invalid proof Y value").clone())
+        .collect();
+
+    // All original proofs should still exist (not deleted)
+    for y in &ys_before {
+        assert!(
+            ys_after.contains(y),
+            "Original proof with Y={} should still exist after polling",
+            y
+        );
+    }
+
+    // Verify the specific proofs used are in Spent state
+    let spent_proofs = wallet
+        .get_proofs_with(Some(vec![State::Spent]), None)
+        .await
+        .unwrap();
+    let spent_ys: HashSet<_> = spent_proofs
+        .iter()
+        .map(|p| p.y().expect("Invalid proof Y value").clone())
+        .collect();
+
+    for y in &proofs_to_use {
+        assert!(
+            spent_ys.contains(y),
+            "Proof with Y={} that was used in melt should be marked as Spent after polling",
+            y
+        );
+    }
+}
+
+/// Test: Compare confirm() vs confirm_prefer_async() behavior
+///
+/// This test validates the difference between blocking confirm() and
+/// non-blocking confirm_prefer_async() methods.
+#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
+async fn test_confirm_vs_confirm_prefer_async_behavior() {
+    // Create two wallets for the comparison
+    let wallet_a = Wallet::new(
+        MINT_URL,
+        CurrencyUnit::Sat,
+        Arc::new(memory::empty().await.unwrap()),
+        Mnemonic::generate(12).unwrap().to_seed_normalized(""),
+        None,
+    )
+    .expect("failed to create wallet A");
+
+    let wallet_b = Wallet::new(
+        MINT_URL,
+        CurrencyUnit::Sat,
+        Arc::new(memory::empty().await.unwrap()),
+        Mnemonic::generate(12).unwrap().to_seed_normalized(""),
+        None,
+    )
+    .expect("failed to create wallet B");
+
+    // Step 1: Fund both wallets and collect their proof Y values
+    let mint_quote_a = wallet_a
+        .mint_quote(PaymentMethod::BOLT11, Some(100.into()), None, None)
+        .await
+        .unwrap();
+    let mut proof_streams_a =
+        wallet_a.proof_stream(mint_quote_a.clone(), SplitTarget::default(), None);
+
+    let proofs_before_a = proof_streams_a
+        .next()
+        .await
+        .expect("payment")
+        .expect("no error");
+
+    // Collect Y values of proofs before melt for wallet A
+    let ys_before_a: HashSet<_> = proofs_before_a
+        .iter()
+        .map(|p| p.y().expect("Invalid proof Y value").clone())
+        .collect();
+
+    let mint_quote_b = wallet_b
+        .mint_quote(PaymentMethod::BOLT11, Some(100.into()), None, None)
+        .await
+        .unwrap();
+    let mut proof_streams_b =
+        wallet_b.proof_stream(mint_quote_b.clone(), SplitTarget::default(), None);
+
+    let proofs_before_b = proof_streams_b
+        .next()
+        .await
+        .expect("payment")
+        .expect("no error");
+
+    // Collect Y values of proofs before melt for wallet B
+    let ys_before_b: HashSet<_> = proofs_before_b
+        .iter()
+        .map(|p| p.y().expect("Invalid proof Y value").clone())
+        .collect();
+
+    // Step 2: Create melt quotes for both wallets (separate invoices with unique payment hashes)
+    let fake_invoice_description = FakeInvoiceDescription {
+        pay_invoice_state: MeltQuoteState::Paid,
+        check_payment_state: MeltQuoteState::Paid,
+        pay_err: false,
+        check_err: false,
+    };
+
+    let invoice_a = create_fake_invoice(
+        50_000,
+        serde_json::to_string(&fake_invoice_description).unwrap(),
+    );
+
+    let melt_quote_a = wallet_a
+        .melt_quote(PaymentMethod::BOLT11, invoice_a.to_string(), None, None)
+        .await
+        .unwrap();
+
+    // Create separate invoice for wallet B (different payment hash)
+    let invoice_b = create_fake_invoice(
+        50_000,
+        serde_json::to_string(&fake_invoice_description).unwrap(),
+    );
+
+    let melt_quote_b = wallet_b
+        .melt_quote(PaymentMethod::BOLT11, invoice_b.to_string(), None, None)
+        .await
+        .unwrap();
+
+    // Step 3: Wallet A uses confirm() - blocks until completion
+    let prepared_a = wallet_a
+        .prepare_melt(&melt_quote_a.id, std::collections::HashMap::new())
+        .await
+        .unwrap();
+
+    // Collect Y values of proofs that will be used in the melt for wallet A
+    let proofs_to_use_a: HashSet<_> = prepared_a
+        .proofs()
+        .iter()
+        .chain(prepared_a.proofs_to_swap().iter())
+        .map(|p| p.y().expect("Invalid proof Y value").clone())
+        .collect();
+
+    let finalized_a = prepared_a.confirm().await.unwrap();
+
+    // Step 4: Wallet B uses confirm_prefer_async() - returns immediately
+    let prepared_b = wallet_b
+        .prepare_melt(&melt_quote_b.id, std::collections::HashMap::new())
+        .await
+        .unwrap();
+
+    // Collect Y values of proofs that will be used in the melt for wallet B
+    let proofs_to_use_b: HashSet<_> = prepared_b
+        .proofs()
+        .iter()
+        .chain(prepared_b.proofs_to_swap().iter())
+        .map(|p| p.y().expect("Invalid proof Y value").clone())
+        .collect();
+
+    let result_b = prepared_b.confirm_prefer_async().await.unwrap();
+
+    // Step 5: Both should complete successfully
+    assert_eq!(
+        finalized_a.state(),
+        MeltQuoteState::Paid,
+        "Wallet A (confirm) should complete successfully"
+    );
+
+    let finalized_b = match result_b {
+        MeltOutcome::Paid(melt) => melt,
+        MeltOutcome::Pending(pending) => pending.await.unwrap(),
+    };
+
+    assert_eq!(
+        finalized_b.state(),
+        MeltQuoteState::Paid,
+        "Wallet B (confirm_prefer_async) should complete successfully"
+    );
+
+    // Step 6: Verify both wallets have reduced balances
+    let balance_a = wallet_a.total_balance().await.unwrap();
+    let balance_b = wallet_b.total_balance().await.unwrap();
+    assert!(
+        balance_a < 100.into(),
+        "Wallet A balance should be reduced. Initial: 100, Final: {}",
+        balance_a
+    );
+    assert!(
+        balance_b < 100.into(),
+        "Wallet B balance should be reduced. Initial: 100, Final: {}",
+        balance_b
+    );
+
+    // Step 7: Verify no proofs are pending in either wallet
+    let pending_a = wallet_a
+        .get_proofs_with(Some(vec![State::Pending]), None)
+        .await
+        .unwrap();
+    let pending_b = wallet_b
+        .get_proofs_with(Some(vec![State::Pending]), None)
+        .await
+        .unwrap();
+    assert!(
+        pending_a.is_empty(),
+        "Wallet A should have no pending proofs"
+    );
+    assert!(
+        pending_b.is_empty(),
+        "Wallet B should have no pending proofs"
+    );
+
+    // Step 8: Verify original proofs are marked as Spent in both wallets
+    let proofs_after_a = wallet_a.get_proofs_with(None, None).await.unwrap();
+    let proofs_after_b = wallet_b.get_proofs_with(None, None).await.unwrap();
+
+    let ys_after_a: HashSet<_> = proofs_after_a
+        .iter()
+        .map(|p| p.y().expect("Invalid proof Y value").clone())
+        .collect();
+    let ys_after_b: HashSet<_> = proofs_after_b
+        .iter()
+        .map(|p| p.y().expect("Invalid proof Y value").clone())
+        .collect();
+
+    // All original proofs should still exist (not deleted)
+    for y in &ys_before_a {
+        assert!(
+            ys_after_a.contains(y),
+            "Wallet A original proof with Y={} should still exist after melt",
+            y
+        );
+    }
+
+    for y in &ys_before_b {
+        assert!(
+            ys_after_b.contains(y),
+            "Wallet B original proof with Y={} should still exist after melt",
+            y
+        );
+    }
+
+    // Verify the specific proofs used are in Spent state
+    let spent_a = wallet_a
+        .get_proofs_with(Some(vec![State::Spent]), None)
+        .await
+        .unwrap();
+    let spent_b = wallet_b
+        .get_proofs_with(Some(vec![State::Spent]), None)
+        .await
+        .unwrap();
+
+    let spent_ys_a: HashSet<_> = spent_a
+        .iter()
+        .map(|p| p.y().expect("Invalid proof Y value").clone())
+        .collect();
+    let spent_ys_b: HashSet<_> = spent_b
+        .iter()
+        .map(|p| p.y().expect("Invalid proof Y value").clone())
+        .collect();
+
+    for y in &proofs_to_use_a {
+        assert!(
+            spent_ys_a.contains(y),
+            "Wallet A proof with Y={} that was used in melt should be marked as Spent",
+            y
+        );
+    }
+
+    for y in &proofs_to_use_b {
+        assert!(
+            spent_ys_b.contains(y),
+            "Wallet B proof with Y={} that was used in melt should be marked as Spent",
+            y
+        );
+    }
 }

+ 11 - 16
crates/cdk-integration-tests/tests/fake_wallet.rs

@@ -72,15 +72,13 @@ async fn test_fake_tokens_pending() {
         .await
         .unwrap();
 
-    let melt = async {
-        let prepared = wallet
-            .prepare_melt(&melt_quote.id, std::collections::HashMap::new())
-            .await?;
-        prepared.confirm().await
-    }
-    .await;
+    let prepared = wallet
+        .prepare_melt(&melt_quote.id, std::collections::HashMap::new())
+        .await
+        .unwrap();
+    let _res = prepared.confirm_prefer_async().await.unwrap();
 
-    assert!(melt.is_err());
+    // matches!(_res, MeltOutcome::Pending);
 
     // melt failed, but there is new code to reclaim unspent proofs
     assert!(!wallet
@@ -210,14 +208,11 @@ async fn test_fake_melt_payment_fail_and_check() {
         .unwrap();
 
     // The melt should error at the payment invoice command
-    let melt = async {
-        let prepared = wallet
-            .prepare_melt(&melt_quote.id, std::collections::HashMap::new())
-            .await?;
-        prepared.confirm().await
-    }
-    .await;
-    assert!(melt.is_err());
+    let prepared = wallet
+        .prepare_melt(&melt_quote.id, std::collections::HashMap::new())
+        .await
+        .unwrap();
+    prepared.confirm_prefer_async().await.unwrap();
 
     assert!(!wallet
         .localstore

+ 113 - 42
crates/cdk/examples/melt-token.rs

@@ -7,9 +7,8 @@ use bitcoin::hashes::{sha256, Hash};
 use bitcoin::hex::prelude::FromHex;
 use bitcoin::secp256k1::Secp256k1;
 use cdk::error::Error;
-use cdk::nuts::nut00::ProofsMethods;
 use cdk::nuts::{CurrencyUnit, PaymentMethod, SecretKey};
-use cdk::wallet::Wallet;
+use cdk::wallet::{MeltOutcome, Wallet};
 use cdk::Amount;
 use cdk_sqlite::wallet::memory;
 use lightning_invoice::{Currency, InvoiceBuilder, PaymentSecret};
@@ -26,15 +25,16 @@ async fn main() -> Result<(), Error> {
     // Define the mint URL and currency unit
     let mint_url = "https://fake.thesimplekid.dev";
     let unit = CurrencyUnit::Sat;
-    let amount = Amount::from(10);
+    let amount = Amount::from(20);
 
     // Create a new wallet
     let wallet = Wallet::new(mint_url, unit, Arc::new(localstore), seed, None)?;
 
+    // Mint enough tokens for both examples
     let quote = wallet
         .mint_quote(PaymentMethod::BOLT11, Some(amount), None, None)
         .await?;
-    let proofs = wallet
+    let _proofs = wallet
         .wait_and_mint_quote(
             quote,
             Default::default(),
@@ -43,57 +43,128 @@ async fn main() -> Result<(), Error> {
         )
         .await?;
 
-    let receive_amount = proofs.total_amount()?;
-    println!("Received {} from mint {}", receive_amount, mint_url);
+    let balance = wallet.total_balance().await?;
+    println!("Minted {} sats from {}", balance, mint_url);
 
-    // Now melt what we have
-    // We need to prepare a lightning invoice
-    let private_key = SecretKey::from_slice(
-        &<[u8; 32]>::from_hex("e126f68f7eafcc8b74f54d269fe206be715000f94dac067d1c04a8ca3b2db734")
+    // Helper to create a test invoice
+    let create_test_invoice = |amount_msats: u64, description: &str| {
+        let private_key = SecretKey::from_slice(
+            &<[u8; 32]>::from_hex(
+                "e126f68f7eafcc8b74f54d269fe206be715000f94dac067d1c04a8ca3b2db734",
+            )
             .unwrap(),
-    )
-    .unwrap();
-    let random_bytes = rand::rng().random::<[u8; 32]>();
-    let payment_hash = sha256::Hash::from_slice(&random_bytes).unwrap();
-    let payment_secret = PaymentSecret([42u8; 32]);
-    let invoice_to_be_paid = InvoiceBuilder::new(Currency::Bitcoin)
-        .amount_milli_satoshis(5 * 1000)
-        .description("Pay me".into())
-        .payment_hash(payment_hash)
-        .payment_secret(payment_secret)
-        .current_timestamp()
-        .min_final_cltv_expiry_delta(144)
-        .build_signed(|hash| Secp256k1::new().sign_ecdsa_recoverable(hash, &private_key))
-        .unwrap()
-        .to_string();
-    println!("Invoice to be paid: {}", invoice_to_be_paid);
-
-    let melt_quote = wallet
-        .melt_quote(PaymentMethod::BOLT11, invoice_to_be_paid, None, None)
+        )
+        .unwrap();
+        let random_bytes = rand::rng().random::<[u8; 32]>();
+        let payment_hash = sha256::Hash::from_slice(&random_bytes).unwrap();
+        let payment_secret = PaymentSecret([42u8; 32]);
+        InvoiceBuilder::new(Currency::Bitcoin)
+            .amount_milli_satoshis(amount_msats)
+            .description(description.into())
+            .payment_hash(payment_hash)
+            .payment_secret(payment_secret)
+            .current_timestamp()
+            .min_final_cltv_expiry_delta(144)
+            .build_signed(|hash| Secp256k1::new().sign_ecdsa_recoverable(hash, &private_key))
+            .unwrap()
+            .to_string()
+    };
+
+    println!("\n=== Example 1: Synchronous Confirm ===");
+    println!("This approach blocks until the payment completes.");
+    println!("Use this when you need to wait for completion before continuing.");
+
+    // Create first melt quote
+    let invoice1 = create_test_invoice(5 * 1000, "Sync melt example");
+    let melt_quote1 = wallet
+        .melt_quote(PaymentMethod::BOLT11, invoice1, None, None)
         .await?;
     println!(
-        "Melt quote: {} {} {:?}",
-        melt_quote.amount, melt_quote.state, melt_quote,
+        "Melt quote 1: {} sats, fee reserve: {:?}",
+        melt_quote1.amount, melt_quote1.fee_reserve
     );
 
-    // Prepare the melt - this shows fees before confirming
-    let prepared = wallet
-        .prepare_melt(&melt_quote.id, std::collections::HashMap::new())
+    // Prepare and confirm synchronously
+    let prepared1 = wallet
+        .prepare_melt(&melt_quote1.id, std::collections::HashMap::new())
         .await?;
     println!(
         "Prepared melt - Amount: {}, Total Fee: {}",
-        prepared.amount(),
-        prepared.total_fee()
+        prepared1.amount(),
+        prepared1.total_fee()
+    );
+
+    let confirmed1 = prepared1.confirm().await?;
+    println!(
+        "Sync melt completed: state={:?}, amount={}, fee_paid={}",
+        confirmed1.state(),
+        confirmed1.amount(),
+        confirmed1.fee_paid()
+    );
+
+    println!("\n=== Example 2: Async Confirm ===");
+    println!(
+        "This approach sends the request with async preference and waits for the mint's response."
+    );
+    println!(
+        "If the mint supports async payments, it may return Pending quickly without waiting for"
     );
+    println!("the payment to complete. If not, it may block until the payment completes.");
 
-    // Confirm the melt to execute the payment
-    let confirmed = prepared.confirm().await?;
+    // Create second melt quote
+    let invoice2 = create_test_invoice(5 * 1000, "Async melt example");
+    let melt_quote2 = wallet
+        .melt_quote(PaymentMethod::BOLT11, invoice2, None, None)
+        .await?;
+    println!(
+        "Melt quote 2: {} sats, fee reserve: {:?}",
+        melt_quote2.amount, melt_quote2.fee_reserve
+    );
+
+    // Prepare and confirm asynchronously
+    let prepared2 = wallet
+        .prepare_melt(&melt_quote2.id, std::collections::HashMap::new())
+        .await?;
     println!(
-        "Melted: state={:?}, amount={}, fee={}",
-        confirmed.state(),
-        confirmed.amount(),
-        confirmed.fee_paid()
+        "Prepared melt - Amount: {}, Total Fee: {}",
+        prepared2.amount(),
+        prepared2.total_fee()
     );
 
+    // confirm_prefer_async waits for the mint's response, which may be quick if async is supported
+    let result = prepared2.confirm_prefer_async().await?;
+
+    match result {
+        MeltOutcome::Paid(finalized) => {
+            println!(
+                "Async melt completed immediately: state={:?}, amount={}, fee_paid={}",
+                finalized.state(),
+                finalized.amount(),
+                finalized.fee_paid()
+            );
+        }
+        MeltOutcome::Pending(pending) => {
+            println!("Melt is pending, waiting for completion via WebSocket...");
+            // You can either await the pending melt directly:
+
+            let finalized = pending.await?;
+            println!(
+                "Async melt completed after waiting: state={:?}, amount={}, fee_paid={}",
+                finalized.state(),
+                finalized.amount(),
+                finalized.fee_paid()
+            );
+
+            // Alternative: Instead of awaiting, you could:
+            // 1. Store the quote ID and check status later with:
+            //    wallet.check_melt_quote_status(&melt_quote2.id).await?
+            // 2. Let the wallet's background task handle it via:
+            //    wallet.finalize_pending_melts().await?
+        }
+    }
+
+    let final_balance = wallet.total_balance().await?;
+    println!("\nFinal balance: {} sats", final_balance);
+
     Ok(())
 }

+ 23 - 9
crates/cdk/src/event.rs

@@ -111,26 +111,40 @@ where
     type Topic = NotificationId<T>;
 
     fn get_topics(&self) -> Vec<Self::Topic> {
-        vec![match &self.0 {
+        match &self.0 {
             NotificationPayload::MeltQuoteBolt11Response(r) => {
-                NotificationId::MeltQuoteBolt11(r.quote.to_owned())
+                // TODO: MeltQuoteBolt12Response is a type alias for MeltQuoteBolt11Response.
+                // Since NotificationPayload uses untagged serde, all melt responses are
+                // deserialized as Bolt11. We broadcast to both topics to ensure Bolt12
+                // subscribers receive the event. This workaround should be addressed by
+                // properly distinguishing the response types in the protocol.
+                vec![
+                    NotificationId::MeltQuoteBolt11(r.quote.to_owned()),
+                    NotificationId::MeltQuoteBolt12(r.quote.to_owned()),
+                ]
             }
             NotificationPayload::MintQuoteBolt11Response(r) => {
-                NotificationId::MintQuoteBolt11(r.quote.to_owned())
+                vec![NotificationId::MintQuoteBolt11(r.quote.to_owned())]
             }
             NotificationPayload::MintQuoteBolt12Response(r) => {
-                NotificationId::MintQuoteBolt12(r.quote.to_owned())
+                vec![NotificationId::MintQuoteBolt12(r.quote.to_owned())]
             }
             NotificationPayload::MeltQuoteBolt12Response(r) => {
-                NotificationId::MeltQuoteBolt12(r.quote.to_owned())
+                vec![NotificationId::MeltQuoteBolt12(r.quote.to_owned())]
             }
             NotificationPayload::CustomMintQuoteResponse(method, r) => {
-                NotificationId::MintQuoteCustom(method.clone(), r.quote.to_owned())
+                vec![NotificationId::MintQuoteCustom(
+                    method.clone(),
+                    r.quote.to_owned(),
+                )]
             }
             NotificationPayload::CustomMeltQuoteResponse(method, r) => {
-                NotificationId::MeltQuoteCustom(method.clone(), r.quote.to_owned())
+                vec![NotificationId::MeltQuoteCustom(
+                    method.clone(),
+                    r.quote.to_owned(),
+                )]
             }
-            NotificationPayload::ProofState(p) => NotificationId::ProofState(p.y.to_owned()),
-        }]
+            NotificationPayload::ProofState(p) => vec![NotificationId::ProofState(p.y.to_owned())],
+        }
     }
 }

+ 2 - 2
crates/cdk/src/mint/melt/melt_saga/mod.rs

@@ -406,7 +406,7 @@ impl MeltSaga<Initial> {
 
         // Publish melt quote status change AFTER transaction commits
         self.pubsub
-            .melt_quote_status(&*quote, None, None, MeltQuoteState::Pending);
+            .melt_quote_status(&quote, None, None, MeltQuoteState::Pending);
 
         // Store blinded messages for state
         let blinded_messages_vec = melt_request.outputs().clone().unwrap_or_default();
@@ -1014,7 +1014,7 @@ impl MeltSaga<PaymentConfirmed> {
         tx.commit().await?;
 
         self.pubsub.melt_quote_status(
-            &self.state_data.quote,
+            &quote,
             payment_preimage.clone(),
             change.clone(),
             MeltQuoteState::Paid,

+ 4 - 2
crates/cdk/src/mint/melt/shared.rs

@@ -158,7 +158,7 @@ pub async fn rollback_melt_quote(
     }
 
     if let Some(quote) = quote_option {
-        pubsub.melt_quote_status(&*quote, None, None, MeltQuoteState::Unpaid);
+        pubsub.melt_quote_status(&quote, None, None, MeltQuoteState::Unpaid);
     }
 
     tracing::info!(
@@ -453,6 +453,8 @@ pub async fn finalize_melt_core(
     tx.update_melt_quote_state(quote, MeltQuoteState::Paid, payment_preimage.clone())
         .await?;
 
+    quote.state = MeltQuoteState::Paid;
+
     // Update payment lookup ID if changed
     if quote.request_lookup_id.as_ref() != Some(payment_lookup_id) {
         tracing::info!(
@@ -579,7 +581,7 @@ pub async fn finalize_melt_quote(
 
     // Publish quote status change
     pubsub.melt_quote_status(
-        quote,
+        &locked_quote,
         payment_preimage,
         change_sigs.clone(),
         MeltQuoteState::Paid,

+ 60 - 12
crates/cdk/src/mint/subscription.rs

@@ -6,15 +6,15 @@ use std::sync::Arc;
 
 use cdk_common::common::PaymentProcessorKey;
 use cdk_common::database::DynMintDatabase;
-use cdk_common::mint::MintQuote;
+use cdk_common::mint::{MeltQuote, MintQuote};
 use cdk_common::nut17::NotificationId;
 use cdk_common::payment::DynMintPayment;
 use cdk_common::pub_sub::{Pubsub, Spec, Subscriber};
 use cdk_common::subscription::SubId;
 use cdk_common::{
-    Amount, BlindSignature, CurrencyUnit, MeltQuoteBolt11Response, MeltQuoteState,
-    MintQuoteBolt11Response, MintQuoteBolt12Response, MintQuoteCustomResponse, MintQuoteState,
-    NotificationPayload, ProofState, PublicKey, QuoteId,
+    Amount, BlindSignature, CurrencyUnit, MeltQuoteBolt11Response, MeltQuoteBolt12Response,
+    MeltQuoteState, MintQuoteBolt11Response, MintQuoteBolt12Response, MintQuoteCustomResponse,
+    MintQuoteState, NotificationPayload, ProofState, PublicKey, QuoteId,
 };
 
 use super::Mint;
@@ -61,7 +61,7 @@ impl MintPubSubSpec {
         for idx in request.iter() {
             match idx {
                 NotificationId::ProofState(pk) => public_keys.push(*pk),
-                NotificationId::MeltQuoteBolt11(uuid) | NotificationId::MeltQuoteBolt12(uuid) => {
+                NotificationId::MeltQuoteBolt11(uuid) => {
                     // TODO: In the HTTP handler, we check with the LN backend if a payment is in a pending quote state to resolve stuck payments.
                     // Implement similar logic here for WebSocket-only wallets.
                     if let Some(melt_quote) = self
@@ -74,6 +74,17 @@ impl MintPubSubSpec {
                         to_return.push(melt_quote.into());
                     }
                 }
+                NotificationId::MeltQuoteBolt12(uuid) => {
+                    if let Some(melt_quote) = self
+                        .db
+                        .get_melt_quote(uuid)
+                        .await
+                        .map_err(|e| e.to_string())?
+                    {
+                        let melt_quote: MeltQuoteBolt12Response<_> = melt_quote.into();
+                        to_return.push(melt_quote.into());
+                    }
+                }
                 NotificationId::MintQuoteBolt11(uuid) | NotificationId::MintQuoteBolt12(uuid) => {
                     if let Some(mint_quote) =
                         self.get_mint_quote(uuid).await.map_err(|e| e.to_string())?
@@ -249,18 +260,55 @@ impl PubSubManager {
     }
 
     /// Helper function to emit a MeltQuoteBolt11Response status
-    pub fn melt_quote_status<E: Into<MeltQuoteBolt11Response<QuoteId>>>(
+    pub fn melt_quote_status(
         &self,
-        quote: E,
+        quote: &MeltQuote,
         payment_preimage: Option<String>,
         change: Option<Vec<BlindSignature>>,
         new_state: MeltQuoteState,
     ) {
-        let mut quote = quote.into();
-        quote.state = new_state;
-        quote.payment_preimage = payment_preimage;
-        quote.change = change;
-        self.publish(quote);
+        match quote.payment_method {
+            cdk_common::PaymentMethod::Known(cdk_common::nut00::KnownMethod::Bolt11) => {
+                let mut event: MeltQuoteBolt11Response<QuoteId> = quote.clone().into();
+                event.state = new_state;
+                event.payment_preimage = payment_preimage;
+                event.change = change;
+                self.publish(NotificationPayload::MeltQuoteBolt11Response(event));
+            }
+            cdk_common::PaymentMethod::Known(cdk_common::nut00::KnownMethod::Bolt12) => {
+                let mut event: MeltQuoteBolt12Response<QuoteId> = quote.clone().into();
+                event.state = new_state;
+                event.payment_preimage = payment_preimage;
+                event.change = change;
+                self.publish(NotificationPayload::MeltQuoteBolt12Response(event));
+            }
+            cdk_common::PaymentMethod::Custom(ref method) => {
+                let request_str = match &quote.request {
+                    cdk_common::mint::MeltPaymentRequest::Custom { request, .. } => {
+                        Some(request.clone())
+                    }
+                    _ => None,
+                };
+
+                let response = cdk_common::nuts::MeltQuoteCustomResponse {
+                    quote: quote.id.clone(),
+                    amount: quote.amount().into(),
+                    fee_reserve: quote.fee_reserve().into(),
+                    state: new_state,
+                    expiry: quote.expiry,
+                    payment_preimage,
+                    change,
+                    request: request_str,
+                    unit: Some(quote.unit.clone()),
+                    extra: serde_json::Value::Null,
+                };
+
+                self.publish(NotificationPayload::CustomMeltQuoteResponse(
+                    method.clone(),
+                    response,
+                ));
+            }
+        }
     }
 }
 

+ 314 - 21
crates/cdk/src/wallet/melt/mod.rs

@@ -36,6 +36,8 @@
 
 use std::collections::HashMap;
 use std::fmt::Debug;
+use std::future::{Future, IntoFuture};
+use std::pin::Pin;
 
 use cdk_common::util::unix_time;
 use cdk_common::wallet::{MeltQuote, Transaction, TransactionDirection};
@@ -46,6 +48,8 @@ use uuid::Uuid;
 use crate::nuts::nut00::KnownMethod;
 use crate::nuts::{MeltOptions, Proofs};
 use crate::types::FinalizedMelt;
+use crate::wallet::subscription::NotificationPayload;
+use crate::wallet::WalletSubscription;
 use crate::{Amount, Wallet};
 
 mod bolt11;
@@ -58,7 +62,165 @@ mod melt_lightning_address;
 pub(crate) mod saga;
 
 use saga::state::Prepared;
-use saga::MeltSaga;
+use saga::{MeltSaga, MeltSagaResult};
+
+/// Outcome of a melt operation using async support (NUT-05).
+#[derive(Debug)]
+pub enum MeltOutcome<'a> {
+    /// Melt completed immediately
+    Paid(FinalizedMelt),
+    /// Melt is pending - can be awaited or dropped to poll elsewhere
+    Pending(PendingMelt<'a>),
+}
+
+/// A pending melt operation that can be awaited.
+#[derive(Debug)]
+pub struct PendingMelt<'a> {
+    saga: Box<MeltSaga<'a, saga::state::PaymentPending>>,
+    metadata: HashMap<String, String>,
+}
+
+impl<'a> PendingMelt<'a> {
+    /// Wait for the melt to complete by polling the mint.
+    async fn wait(self) -> Result<FinalizedMelt, Error> {
+        let quote_id = self.saga.quote().id.clone();
+        let wallet = self.saga.wallet;
+
+        let mut subscription = match self.saga.quote().payment_method {
+            PaymentMethod::Known(KnownMethod::Bolt11) => {
+                wallet
+                    .subscribe(WalletSubscription::Bolt11MeltQuoteState(vec![
+                        quote_id.clone()
+                    ]))
+                    .await?
+            }
+            PaymentMethod::Known(KnownMethod::Bolt12) => {
+                wallet
+                    .subscribe(WalletSubscription::Bolt12MeltQuoteState(vec![
+                        quote_id.clone()
+                    ]))
+                    .await?
+            }
+            PaymentMethod::Custom(ref method) => {
+                wallet
+                    .subscribe(WalletSubscription::MeltQuoteCustom(
+                        method.to_string(),
+                        vec![quote_id.clone()],
+                    ))
+                    .await?
+            }
+        };
+
+        loop {
+            match subscription.recv().await {
+                Some(event) => {
+                    let notification = event.into_inner();
+
+                    let (response_quote_id, state, payment_preimage, change) = match notification {
+                        NotificationPayload::MeltQuoteBolt11Response(response) => (
+                            response.quote,
+                            response.state,
+                            response.payment_preimage,
+                            response.change,
+                        ),
+                        NotificationPayload::MeltQuoteBolt12Response(response) => (
+                            response.quote,
+                            response.state,
+                            response.payment_preimage,
+                            response.change,
+                        ),
+                        NotificationPayload::CustomMeltQuoteResponse(_, response) => (
+                            response.quote,
+                            response.state,
+                            response.payment_preimage,
+                            response.change,
+                        ),
+                        _ => continue,
+                    };
+
+                    if response_quote_id != quote_id {
+                        continue;
+                    }
+
+                    match state {
+                        MeltQuoteState::Paid => {
+                            // TODO: Remove this workaround once Nutshell 0.18.3+ is widely deployed
+                            //
+                            // Per NUT-05, mints SHOULD include change in WebSocket notifications when
+                            // available. However, Nutshell 0.18.2 and below have a bug where change
+                            // is omitted from WS notifications even when proofs were provided.
+                            //
+                            // Workaround: When WS shows Paid but has no change, we make an extra HTTP
+                            // request to get the full response with change. This adds latency and
+                            // unnecessary network traffic for the common case where change exists.
+                            //
+                            // Impact: One extra HTTP request per melt until Nutshell versions < 0.18.3
+                            // are no longer widely used.
+                            let change = if change.is_none() {
+                                tracing::debug!("Received WS with no change checking with HTTP");
+
+                                match self.saga.wallet.internal_check_melt_status(&quote_id).await {
+                                    Ok(response) => match response {
+                                        MeltQuoteStatusResponse::Standard(r) => r.change,
+                                        MeltQuoteStatusResponse::Bolt12(r) => r.change,
+                                        MeltQuoteStatusResponse::Custom(r) => r.change,
+                                    },
+                                    Err(e) => {
+                                        tracing::warn!(
+                                            "Failed to check melt status via HTTP: {}",
+                                            e
+                                        );
+                                        None
+                                    }
+                                }
+                            } else {
+                                change
+                            };
+
+                            let finalized = self
+                                .saga
+                                .finalize(state, payment_preimage, change, self.metadata)
+                                .await?;
+
+                            return Ok(FinalizedMelt::new(
+                                finalized.quote_id().to_string(),
+                                finalized.state(),
+                                finalized.payment_proof().map(|s| s.to_string()),
+                                finalized.amount(),
+                                finalized.fee_paid(),
+                                finalized.into_change(),
+                            ));
+                        }
+                        MeltQuoteState::Failed
+                        | MeltQuoteState::Unpaid
+                        | MeltQuoteState::Unknown => {
+                            self.saga.handle_failure().await;
+                            return Err(Error::PaymentFailed);
+                        }
+                        MeltQuoteState::Pending => continue,
+                    }
+                }
+                None => {
+                    return Err(Error::Custom("Subscription closed".to_string()));
+                }
+            }
+        }
+    }
+}
+
+impl<'a> IntoFuture for PendingMelt<'a> {
+    type Output = Result<FinalizedMelt, Error>;
+
+    #[cfg(not(target_arch = "wasm32"))]
+    type IntoFuture = Pin<Box<dyn Future<Output = Self::Output> + Send + 'a>>;
+
+    #[cfg(target_arch = "wasm32")]
+    type IntoFuture = Pin<Box<dyn Future<Output = Self::Output> + 'a>>;
+
+    fn into_future(self) -> Self::IntoFuture {
+        Box::pin(self.wait())
+    }
+}
 
 /// Internal response type for melt quote status checking.
 ///
@@ -210,28 +372,150 @@ impl<'a> PreparedMelt<'a> {
     }
 
     /// Confirm the prepared melt and execute the payment.
+    ///
+    /// This method waits for the payment to complete and returns the finalized melt.
+    /// If the mint supports async payments (NUT-05), this may complete faster by
+    /// not blocking on the payment processing.
+    ///
+    /// # Example
+    ///
+    /// ```rust,no_run
+    /// # use std::collections::HashMap;
+    /// # async fn example(wallet: &cdk::wallet::Wallet) -> anyhow::Result<()> {
+    /// use cdk::nuts::PaymentMethod;
+    ///
+    /// let quote = wallet
+    ///     .melt_quote(PaymentMethod::BOLT11, "lnbc...", None, None)
+    ///     .await?;
+    ///
+    /// // Prepare the melt
+    /// let prepared = wallet.prepare_melt(&quote.id, HashMap::new()).await?;
+    ///
+    /// // Confirm and wait for completion
+    /// let finalized = prepared.confirm().await?;
+    ///
+    /// println!(
+    ///     "Melt completed: state={:?}, amount={}, fee_paid={}",
+    ///     finalized.state(),
+    ///     finalized.amount(),
+    ///     finalized.fee_paid()
+    /// );
+    /// # Ok(())
+    /// # }
+    /// ```
     pub async fn confirm(self) -> Result<FinalizedMelt, Error> {
         self.confirm_with_options(MeltConfirmOptions::default())
             .await
     }
 
     /// Confirm the prepared melt with custom options.
+    ///
+    /// This method waits for the payment to complete and returns the finalized melt.
+    /// If the mint supports async payments (NUT-05), this may complete faster by
+    /// not blocking on the payment processing.
     pub async fn confirm_with_options(
         self,
         options: MeltConfirmOptions,
     ) -> Result<FinalizedMelt, Error> {
-        let melt_requested = self.saga.request_melt_with_options(options).await?;
+        match self.confirm_prefer_async_with_options(options).await? {
+            MeltOutcome::Paid(finalized) => Ok(finalized),
+            MeltOutcome::Pending(pending) => pending.await,
+        }
+    }
+
+    /// Confirm the prepared melt using async support (NUT-05).
+    ///
+    /// Sends the melt request with a `Prefer: respond-async` header and waits for the
+    /// mint's response. Returns `Paid` if the payment completed immediately, or
+    /// `Pending` if the mint accepted the async request and will process it in the
+    /// background.
+    ///
+    /// Note: This waits for the mint's initial response, which may block if the mint
+    /// does not support async payments. Only returns `Pending` if the mint explicitly
+    /// supports and accepts async melt requests.
+    ///
+    /// # Example
+    ///
+    /// ```rust,no_run
+    /// # async fn example(wallet: &cdk::wallet::Wallet) -> anyhow::Result<()> {
+    /// use std::collections::HashMap;
+    /// use cdk::nuts::PaymentMethod;
+    /// use cdk::wallet::MeltOutcome;
+    ///
+    /// let quote = wallet
+    ///     .melt_quote(PaymentMethod::BOLT11, "lnbc...", None, None)
+    ///     .await?;
+    ///
+    /// // Prepare the melt
+    /// let prepared = wallet.prepare_melt(&quote.id, HashMap::new()).await?;
+    ///
+    /// // Confirm with async preference
+    /// match prepared.confirm_prefer_async().await? {
+    ///     MeltOutcome::Paid(finalized) => {
+    ///         println!(
+    ///             "Melt completed immediately: state={:?}, amount={}, fee_paid={}",
+    ///             finalized.state(),
+    ///             finalized.amount(),
+    ///             finalized.fee_paid()
+    ///         );
+    ///     }
+    ///     MeltOutcome::Pending(pending) => {
+    ///         // You can await the pending melt directly
+    ///         let finalized = pending.await?;
+    ///         println!(
+    ///             "Melt completed after waiting: state={:?}, amount={}, fee_paid={}",
+    ///             finalized.state(),
+    ///             finalized.amount(),
+    ///             finalized.fee_paid()
+    ///         );
+    ///
+    ///         // Alternative: Instead of awaiting, you could:
+    ///         // 1. Store the quote ID and check status later with:
+    ///         //    wallet.check_melt_quote_status(&quote.id).await?
+    ///         // 2. Let the wallet's background task handle it via:
+    ///         //    wallet.finalize_pending_melts().await?
+    ///     }
+    /// }
+    /// # Ok(())
+    /// # }
+    /// ```
+    pub async fn confirm_prefer_async(self) -> Result<MeltOutcome<'a>, Error> {
+        self.confirm_prefer_async_with_options(MeltConfirmOptions::default())
+            .await
+    }
 
-        let finalized = melt_requested.execute(self.metadata).await?;
+    /// Confirm with async support and custom options.
+    ///
+    /// Sends the melt request with a `Prefer: respond-async` header and waits for the
+    /// mint's response. Returns `Paid` if the payment completed immediately, or
+    /// `Pending` if the mint accepted the async request and will process it in the
+    /// background.
+    ///
+    /// Note: This waits for the mint's initial response, which may block if the mint
+    /// does not support async payments. Only returns `Pending` if the mint explicitly
+    /// supports and accepts async melt requests.
+    pub async fn confirm_prefer_async_with_options(
+        self,
+        options: MeltConfirmOptions,
+    ) -> Result<MeltOutcome<'a>, Error> {
+        let melt_requested = self.saga.request_melt_with_options(options).await?;
 
-        Ok(FinalizedMelt::new(
-            finalized.quote_id().to_string(),
-            finalized.state(),
-            finalized.payment_proof().map(|s| s.to_string()),
-            finalized.amount(),
-            finalized.fee_paid(),
-            finalized.into_change(),
-        ))
+        let result = melt_requested.execute_async(self.metadata.clone()).await?;
+
+        match result {
+            MeltSagaResult::Finalized(finalized) => Ok(MeltOutcome::Paid(FinalizedMelt::new(
+                finalized.quote_id().to_string(),
+                finalized.state(),
+                finalized.payment_proof().map(|s| s.to_string()),
+                finalized.amount(),
+                finalized.fee_paid(),
+                finalized.into_change(),
+            ))),
+            MeltSagaResult::Pending(pending_saga) => Ok(MeltOutcome::Pending(PendingMelt {
+                saga: Box::new(pending_saga),
+                metadata: self.metadata,
+            })),
+        }
     }
 
     /// Cancel the prepared melt and release reserved proofs
@@ -402,16 +686,25 @@ impl Wallet {
         );
 
         let melt_requested = saga.request_melt_with_options(options).await?;
-        let finalized = melt_requested.execute(metadata).await?;
-
-        Ok(FinalizedMelt::new(
-            finalized.quote_id().to_string(),
-            finalized.state(),
-            finalized.payment_proof().map(|s| s.to_string()),
-            finalized.amount(),
-            finalized.fee_paid(),
-            finalized.into_change(),
-        ))
+        let result = melt_requested.execute_async(metadata.clone()).await?;
+
+        match result {
+            MeltSagaResult::Finalized(finalized) => Ok(FinalizedMelt::new(
+                finalized.quote_id().to_string(),
+                finalized.state(),
+                finalized.payment_proof().map(|s| s.to_string()),
+                finalized.amount(),
+                finalized.fee_paid(),
+                finalized.into_change(),
+            )),
+            MeltSagaResult::Pending(pending_saga) => {
+                let pending = PendingMelt {
+                    saga: Box::new(pending_saga),
+                    metadata,
+                };
+                pending.wait().await
+            }
+        }
     }
 
     /// Cancel a prepared melt and release reserved proofs.

+ 351 - 227
crates/cdk/src/wallet/melt/saga/mod.rs

@@ -42,13 +42,15 @@ use cdk_common::wallet::{
 };
 use cdk_common::MeltQuoteState;
 use tracing::instrument;
+use uuid::Uuid;
 
 use self::compensation::{ReleaseMeltQuote, RevertProofReservation};
-use self::state::{Finalized, Initial, MeltRequested, Prepared};
+use self::state::{Finalized, Initial, MeltRequested, PaymentPending, Prepared};
 use super::MeltConfirmOptions;
 use crate::nuts::nut00::ProofsMethods;
 use crate::nuts::{MeltRequest, PreMintSecrets, Proofs, State};
 use crate::util::unix_time;
+use crate::wallet::mint_connector::MeltOptions;
 use crate::wallet::saga::{add_compensation, new_compensations, Compensations};
 use crate::{ensure_cdk, Amount, Error, Wallet};
 
@@ -56,6 +58,14 @@ pub(crate) mod compensation;
 pub(crate) mod resume;
 pub(crate) mod state;
 
+/// Result of an async melt execution
+pub enum MeltSagaResult<'a> {
+    /// Melt finalized (paid)
+    Finalized(MeltSaga<'a, Finalized>),
+    /// Melt pending
+    Pending(MeltSaga<'a, PaymentPending>),
+}
+
 /// Saga pattern implementation for melt operations.
 ///
 /// Uses the typestate pattern to enforce valid state transitions at compile-time.
@@ -63,11 +73,144 @@ pub(crate) mod state;
 /// are only available on the appropriate type.
 pub(crate) struct MeltSaga<'a, S> {
     /// Wallet reference
-    wallet: &'a Wallet,
+    pub(crate) wallet: &'a Wallet,
     /// Compensating actions in LIFO order (most recent first)
-    compensations: Compensations,
+    pub(crate) compensations: Compensations,
     /// State-specific data
-    state_data: S,
+    pub(crate) state_data: S,
+}
+
+/// Shared helper function to perform the actual melt finalization.
+/// Used by `execute_async` and `PaymentPending::finalize`.
+#[allow(clippy::too_many_arguments)]
+async fn finalize_melt_common<'a>(
+    wallet: &'a Wallet,
+    compensations: Compensations,
+    operation_id: Uuid,
+    quote_info: &MeltQuote,
+    final_proofs: &Proofs,
+    premint_secrets: &PreMintSecrets,
+    state: MeltQuoteState,
+    payment_preimage: Option<String>,
+    change: Option<Vec<crate::nuts::BlindSignature>>,
+    metadata: HashMap<String, String>,
+) -> Result<MeltSaga<'a, Finalized>, Error> {
+    let active_keyset_id = wallet.fetch_active_keyset().await?.id;
+    let active_keys = wallet.load_keyset_keys(active_keyset_id).await?;
+
+    let change_proofs = match change {
+        Some(change) => {
+            let num_change_proof = change.len();
+
+            let num_change_proof = match (
+                premint_secrets.len() < num_change_proof,
+                premint_secrets.secrets().len() < num_change_proof,
+            ) {
+                (true, _) | (_, true) => {
+                    tracing::error!("Mismatch in change promises to change");
+                    premint_secrets.len()
+                }
+                _ => num_change_proof,
+            };
+
+            Some(construct_proofs(
+                change,
+                premint_secrets.rs()[..num_change_proof].to_vec(),
+                premint_secrets.secrets()[..num_change_proof].to_vec(),
+                &active_keys,
+            )?)
+        }
+        None => None,
+    };
+
+    let proofs_total = final_proofs.total_amount()?;
+    let change_total = change_proofs
+        .as_ref()
+        .map(|p| p.total_amount())
+        .transpose()?
+        .unwrap_or(Amount::ZERO);
+    let fee = proofs_total - quote_info.amount - change_total;
+
+    let mut updated_quote = quote_info.clone();
+    updated_quote.state = state;
+    wallet.localstore.add_melt_quote(updated_quote).await?;
+
+    let change_proof_infos = match change_proofs.clone() {
+        Some(change_proofs) => change_proofs
+            .into_iter()
+            .map(|proof| {
+                ProofInfo::new(
+                    proof,
+                    wallet.mint_url.clone(),
+                    State::Unspent,
+                    quote_info.unit.clone(),
+                )
+            })
+            .collect::<Result<Vec<ProofInfo>, _>>()?,
+        None => Vec::new(),
+    };
+
+    // Add new (change) proofs to the database
+    wallet
+        .localstore
+        .update_proofs(change_proof_infos, vec![])
+        .await?;
+
+    // Mark input proofs as Spent instead of deleting them
+    let spent_ys = final_proofs.ys()?;
+    wallet
+        .localstore
+        .update_proofs_state(spent_ys, State::Spent)
+        .await?;
+
+    wallet
+        .localstore
+        .add_transaction(Transaction {
+            mint_url: wallet.mint_url.clone(),
+            direction: TransactionDirection::Outgoing,
+            amount: quote_info.amount,
+            fee,
+            unit: wallet.unit.clone(),
+            ys: final_proofs.ys()?,
+            timestamp: unix_time(),
+            memo: None,
+            metadata,
+            quote_id: Some(quote_info.id.clone()),
+            payment_request: Some(quote_info.request.clone()),
+            payment_proof: payment_preimage.clone(),
+            payment_method: Some(quote_info.payment_method.clone()),
+            saga_id: Some(operation_id),
+        })
+        .await?;
+
+    if let Err(e) = wallet.localstore.release_melt_quote(&operation_id).await {
+        tracing::warn!(
+            "Failed to release melt quote for operation {}: {}",
+            operation_id,
+            e
+        );
+    }
+
+    if let Err(e) = wallet.localstore.delete_saga(&operation_id).await {
+        tracing::warn!(
+            "Failed to delete melt saga {}: {}. Will be cleaned up on recovery.",
+            operation_id,
+            e
+        );
+    }
+
+    Ok(MeltSaga {
+        wallet,
+        compensations,
+        state_data: Finalized {
+            quote_id: quote_info.id.clone(),
+            state,
+            amount: quote_info.amount,
+            fee,
+            payment_proof: payment_preimage,
+            change: change_proofs,
+        },
+    })
 }
 
 impl<'a> MeltSaga<'a, Initial> {
@@ -647,7 +790,6 @@ impl<'a> MeltSaga<'a, Prepared> {
                 quote: quote_info,
                 final_proofs,
                 premint_secrets,
-                saga,
             },
         })
     }
@@ -700,26 +842,28 @@ impl std::fmt::Debug for MeltSaga<'_, Prepared> {
     }
 }
 
+impl std::fmt::Debug for MeltSaga<'_, PaymentPending> {
+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+        f.debug_struct("MeltSaga<PaymentPending>")
+            .field("operation_id", &self.state_data.operation_id)
+            .field("quote_id", &self.state_data.quote.id)
+            .field("amount", &self.state_data.quote.amount)
+            .finish()
+    }
+}
+
 impl<'a> MeltSaga<'a, MeltRequested> {
-    /// Execute the melt request.
-    ///
-    /// Sends the melt request to the mint and handles the response.
-    ///
-    /// # Returns
-    ///
-    /// - `Ok(MeltSaga<Finalized>)` on successful payment
-    /// - `Err(Error::PaymentPending)` when payment is in flight
-    /// - `Err(Error::PaymentFailed)` when payment fails
+    /// Execute the melt request with async support.
     #[instrument(skip_all)]
-    pub async fn execute(
+    pub async fn execute_async(
         self,
         metadata: HashMap<String, String>,
-    ) -> Result<MeltSaga<'a, Finalized>, Error> {
+    ) -> Result<MeltSagaResult<'a>, Error> {
         let operation_id = self.state_data.operation_id;
         let quote_info = &self.state_data.quote;
 
         tracing::info!(
-            "Executing melt request for quote {} with operation {}",
+            "Executing async melt request for quote {} with operation {}",
             quote_info.id,
             operation_id
         );
@@ -733,21 +877,132 @@ impl<'a> MeltSaga<'a, MeltRequested> {
         let melt_result = self
             .wallet
             .client
-            .post_melt(&quote_info.payment_method, request)
+            .post_melt_with_options(
+                &quote_info.payment_method,
+                request,
+                MeltOptions { async_melt: true },
+            )
             .await;
 
         let melt_response = match melt_result {
             Ok(response) => response,
-            Err(e) => {
-                return self.handle_melt_error(e, metadata).await;
+            Err(error) => {
+                // Check for known terminal errors first
+                if matches!(error, Error::RequestAlreadyPaid) {
+                    tracing::info!("Invoice already paid by another wallet - releasing proofs");
+                    self.handle_failure().await;
+                    return Err(error);
+                }
+
+                // On HTTP error, check quote status to determine if payment failed
+                tracing::warn!(
+                    "Melt request failed with error: {}. Checking quote status...",
+                    error
+                );
+
+                match self.wallet.internal_check_melt_status(&quote_info.id).await {
+                    Ok(response) => match response.state() {
+                        MeltQuoteState::Failed
+                        | MeltQuoteState::Unknown
+                        | MeltQuoteState::Unpaid => {
+                            tracing::info!(
+                                "Quote {} status is {:?} - releasing proofs",
+                                quote_info.id,
+                                response.state()
+                            );
+                            self.handle_failure().await;
+                            return Err(Error::PaymentFailed);
+                        }
+                        MeltQuoteState::Paid => {
+                            tracing::info!(
+                                "Quote {} confirmed paid - finalizing with change",
+                                quote_info.id
+                            );
+                            let standard_response = response.into_standard()?;
+                            let finalized = finalize_melt_common(
+                                self.wallet,
+                                self.compensations,
+                                self.state_data.operation_id,
+                                &self.state_data.quote,
+                                &self.state_data.final_proofs,
+                                &self.state_data.premint_secrets,
+                                standard_response.state,
+                                standard_response.payment_preimage,
+                                standard_response.change,
+                                metadata,
+                            )
+                            .await?;
+                            return Ok(MeltSagaResult::Finalized(finalized));
+                        }
+                        MeltQuoteState::Pending => {
+                            tracing::info!(
+                                "Quote {} status is Pending - keeping proofs pending",
+                                quote_info.id
+                            );
+                            self.handle_pending().await;
+                            return Ok(MeltSagaResult::Pending(MeltSaga {
+                                wallet: self.wallet,
+                                compensations: self.compensations,
+                                state_data: PaymentPending {
+                                    operation_id: self.state_data.operation_id,
+                                    quote: self.state_data.quote,
+                                    final_proofs: self.state_data.final_proofs.clone(),
+                                    premint_secrets: self.state_data.premint_secrets.clone(),
+                                },
+                            }));
+                        }
+                    },
+                    Err(check_err) => {
+                        tracing::warn!(
+                            "Failed to check quote {} status: {}. Keeping proofs pending.",
+                            quote_info.id,
+                            check_err
+                        );
+                        self.handle_pending().await;
+                        return Ok(MeltSagaResult::Pending(MeltSaga {
+                            wallet: self.wallet,
+                            compensations: self.compensations,
+                            state_data: PaymentPending {
+                                operation_id: self.state_data.operation_id,
+                                quote: self.state_data.quote,
+                                final_proofs: self.state_data.final_proofs.clone(),
+                                premint_secrets: self.state_data.premint_secrets.clone(),
+                            },
+                        }));
+                    }
+                }
             }
         };
 
         match melt_response.state {
-            MeltQuoteState::Paid => self.finalize_success(melt_response, metadata).await,
+            MeltQuoteState::Paid => {
+                let finalized = finalize_melt_common(
+                    self.wallet,
+                    self.compensations,
+                    self.state_data.operation_id,
+                    &self.state_data.quote,
+                    &self.state_data.final_proofs,
+                    &self.state_data.premint_secrets,
+                    melt_response.state,
+                    melt_response.payment_preimage,
+                    melt_response.change,
+                    metadata,
+                )
+                .await?;
+                Ok(MeltSagaResult::Finalized(finalized))
+            }
             MeltQuoteState::Pending => {
                 self.handle_pending().await;
-                Err(Error::PaymentPending)
+                Ok(MeltSagaResult::Pending(MeltSaga {
+                    wallet: self.wallet,
+                    compensations: self.compensations,
+                    state_data: PaymentPending {
+                        operation_id: self.state_data.operation_id,
+                        quote: self.state_data.quote,
+                        final_proofs: self.state_data.final_proofs.clone(),
+                        premint_secrets: self.state_data.premint_secrets.clone(),
+                    },
+                }))
             }
             MeltQuoteState::Failed => {
                 self.handle_failure().await;
@@ -759,237 +1014,106 @@ impl<'a> MeltSaga<'a, MeltRequested> {
                     quote_info.id,
                     melt_response.state
                 );
-                self.finalize_success(melt_response, metadata).await
+                let finalized = finalize_melt_common(
+                    self.wallet,
+                    self.compensations,
+                    self.state_data.operation_id,
+                    &self.state_data.quote,
+                    &self.state_data.final_proofs,
+                    &self.state_data.premint_secrets,
+                    melt_response.state,
+                    melt_response.payment_preimage,
+                    melt_response.change,
+                    metadata,
+                )
+                .await?;
+                Ok(MeltSagaResult::Finalized(finalized))
             }
         }
     }
 
-    /// Handle a successful melt response.
-    async fn finalize_success(
-        self,
-        response: cdk_common::MeltQuoteBolt11Response<String>,
-        metadata: HashMap<String, String>,
-    ) -> Result<MeltSaga<'a, Finalized>, Error> {
-        let operation_id = self.state_data.operation_id;
-        let quote_info = self.state_data.quote.clone();
-        let final_proofs = &self.state_data.final_proofs;
-        let premint_secrets = &self.state_data.premint_secrets;
-
-        let active_keyset_id = self.wallet.fetch_active_keyset().await?.id;
-        let active_keys = self.wallet.load_keyset_keys(active_keyset_id).await?;
-
-        let change_proofs = match response.change {
-            Some(change) => {
-                let num_change_proof = change.len();
-
-                let num_change_proof = match (
-                    premint_secrets.len() < num_change_proof,
-                    premint_secrets.secrets().len() < num_change_proof,
-                ) {
-                    (true, _) | (_, true) => {
-                        tracing::error!("Mismatch in change promises to change");
-                        premint_secrets.len()
-                    }
-                    _ => num_change_proof,
-                };
-
-                Some(construct_proofs(
-                    change,
-                    premint_secrets.rs()[..num_change_proof].to_vec(),
-                    premint_secrets.secrets()[..num_change_proof].to_vec(),
-                    &active_keys,
-                )?)
-            }
-            None => None,
-        };
-
-        let payment_preimage = response.payment_preimage.clone();
-
-        let proofs_total = final_proofs.total_amount()?;
-        let change_total = change_proofs
-            .as_ref()
-            .map(|p| p.total_amount())
-            .transpose()?
-            .unwrap_or(Amount::ZERO);
-        let fee = proofs_total - quote_info.amount - change_total;
-
-        let mut updated_quote = quote_info.clone();
-        updated_quote.state = response.state;
-        self.wallet.localstore.add_melt_quote(updated_quote).await?;
-
-        let change_proof_infos = match change_proofs.clone() {
-            Some(change_proofs) => change_proofs
-                .into_iter()
-                .map(|proof| {
-                    ProofInfo::new(
-                        proof,
-                        self.wallet.mint_url.clone(),
-                        State::Unspent,
-                        quote_info.unit.clone(),
-                    )
-                })
-                .collect::<Result<Vec<ProofInfo>, _>>()?,
-            None => Vec::new(),
-        };
-
-        let deleted_ys = final_proofs.ys()?;
+    /// Handle pending payment state.
+    async fn handle_pending(&self) {
+        let quote_info = &self.state_data.quote;
 
-        self.wallet
-            .localstore
-            .update_proofs(change_proof_infos, deleted_ys)
-            .await?;
+        tracing::info!(
+            "Melt quote {} is pending - proofs kept in pending state",
+            quote_info.id
+        );
+    }
 
-        self.wallet
-            .localstore
-            .add_transaction(Transaction {
-                mint_url: self.wallet.mint_url.clone(),
-                direction: TransactionDirection::Outgoing,
-                amount: quote_info.amount,
-                fee,
-                unit: self.wallet.unit.clone(),
-                ys: final_proofs.ys()?,
-                timestamp: unix_time(),
-                memo: None,
-                metadata,
-                quote_id: Some(quote_info.id.clone()),
-                payment_request: Some(quote_info.request.clone()),
-                payment_proof: payment_preimage.clone(),
-                payment_method: Some(quote_info.payment_method.clone()),
-                saga_id: Some(operation_id),
-            })
-            .await?;
+    /// Handle failed payment - release proofs and clean up.
+    async fn handle_failure(&self) {
+        let operation_id = self.state_data.operation_id;
+        let final_proofs = &self.state_data.final_proofs;
 
-        if let Err(e) = self
+        if let Ok(all_ys) = final_proofs.ys() {
+            let _ = self
+                .wallet
+                .localstore
+                .update_proofs_state(all_ys, State::Unspent)
+                .await;
+        }
+        let _ = self
             .wallet
             .localstore
             .release_melt_quote(&operation_id)
-            .await
-        {
-            tracing::warn!(
-                "Failed to release melt quote for operation {}: {}",
-                operation_id,
-                e
-            );
-        }
-
-        if let Err(e) = self.wallet.localstore.delete_saga(&operation_id).await {
-            tracing::warn!(
-                "Failed to delete melt saga {}: {}. Will be cleaned up on recovery.",
-                operation_id,
-                e
-            );
-        }
+            .await;
+        let _ = self.wallet.localstore.delete_saga(&operation_id).await;
+    }
+}
 
-        Ok(MeltSaga {
-            wallet: self.wallet,
-            compensations: self.compensations,
-            state_data: Finalized {
-                quote_id: quote_info.id.clone(),
-                state: response.state,
-                amount: quote_info.amount,
-                fee,
-                payment_proof: payment_preimage,
-                change: change_proofs,
-            },
-        })
+impl<'a> MeltSaga<'a, PaymentPending> {
+    /// Get the quote
+    pub fn quote(&self) -> &MeltQuote {
+        &self.state_data.quote
     }
 
-    /// Handle melt error by checking quote status.
-    async fn handle_melt_error(
+    /// Finalize the melt with the response from subscription
+    pub async fn finalize(
         self,
-        error: Error,
+        state: MeltQuoteState,
+        payment_preimage: Option<String>,
+        change: Option<Vec<crate::nuts::BlindSignature>>,
         metadata: HashMap<String, String>,
     ) -> Result<MeltSaga<'a, Finalized>, Error> {
-        let quote_info = &self.state_data.quote;
-
-        // Check for known terminal errors first
-        if matches!(error, Error::RequestAlreadyPaid) {
-            tracing::info!("Invoice already paid by another wallet - releasing proofs");
-            self.handle_failure().await;
-            return Err(error);
-        }
-
-        // On HTTP error, check quote status to determine if payment failed
-        tracing::warn!(
-            "Melt request failed with error: {}. Checking quote status...",
-            error
-        );
-
-        match self.wallet.internal_check_melt_status(&quote_info.id).await {
-            Ok(response) => match response.state() {
-                MeltQuoteState::Failed | MeltQuoteState::Unknown | MeltQuoteState::Unpaid => {
-                    tracing::info!(
-                        "Quote {} status is {:?} - releasing proofs",
-                        quote_info.id,
-                        response.state()
-                    );
-                    self.handle_failure().await;
-                    Err(Error::PaymentFailed)
-                }
-                MeltQuoteState::Paid => {
-                    tracing::info!(
-                        "Quote {} confirmed paid - finalizing with change",
-                        quote_info.id
-                    );
-                    // Convert to standard response for finalize_success
-                    // Custom payment methods will error here (not supported in current saga)
-                    let standard_response = response.into_standard()?;
-                    self.finalize_success(standard_response, metadata).await
-                }
-                MeltQuoteState::Pending => {
-                    tracing::info!(
-                        "Quote {} status is Pending - keeping proofs pending",
-                        quote_info.id
-                    );
-                    self.handle_pending().await;
-                    Err(Error::PaymentPending)
-                }
-            },
-            Err(check_err) => {
-                tracing::warn!(
-                    "Failed to check quote {} status: {}. Keeping proofs pending.",
-                    quote_info.id,
-                    check_err
-                );
-                self.handle_pending().await;
-                Err(Error::PaymentPending)
-            }
-        }
+        finalize_melt_common(
+            self.wallet,
+            self.compensations,
+            self.state_data.operation_id,
+            &self.state_data.quote,
+            &self.state_data.final_proofs,
+            &self.state_data.premint_secrets,
+            state,
+            payment_preimage,
+            change,
+            metadata,
+        )
+        .await
     }
-
-    /// Handle pending payment state.
-    async fn handle_pending(&self) {
+    /// Handle failed payment - release proofs and clean up.
+    pub async fn handle_failure(&self) {
         let operation_id = self.state_data.operation_id;
-        let quote_info = &self.state_data.quote;
+        let final_proofs = &self.state_data.final_proofs;
 
         tracing::info!(
-            "Melt quote {} is pending - proofs kept in pending state",
-            quote_info.id
+            "Handling failure for melt operation {}. Restoring {} proofs. Total amount: {}",
+            operation_id,
+            final_proofs.len(),
+            final_proofs.total_amount().unwrap_or(Amount::ZERO)
         );
 
-        let mut pending_saga = self.state_data.saga.clone();
-        pending_saga.update_state(WalletSagaState::Melt(MeltSagaState::PaymentPending));
-
-        if let Err(e) = self.wallet.localstore.update_saga(pending_saga).await {
-            tracing::warn!(
-                "Failed to update saga {} to PaymentPending state: {}",
-                operation_id,
-                e
-            );
-        }
-    }
-
-    /// Handle failed payment - release proofs and clean up.
-    async fn handle_failure(&self) {
-        let operation_id = self.state_data.operation_id;
-        let final_proofs = &self.state_data.final_proofs;
-
         if let Ok(all_ys) = final_proofs.ys() {
-            let _ = self
+            if let Err(e) = self
                 .wallet
                 .localstore
                 .update_proofs_state(all_ys, State::Unspent)
-                .await;
+                .await
+            {
+                tracing::error!("Failed to restore proofs for failed melt: {}", e);
+            } else {
+                tracing::info!("Successfully restored proofs to Unspent");
+            }
         }
         let _ = self
             .wallet

+ 12 - 2
crates/cdk/src/wallet/melt/saga/state.rs

@@ -62,8 +62,6 @@ pub struct MeltRequested {
     pub final_proofs: Proofs,
     /// Pre-mint secrets for change
     pub premint_secrets: PreMintSecrets,
-    /// The persisted saga for optimistic locking (contains recovery data)
-    pub saga: WalletSaga,
 }
 
 /// Finalized state - melt completed successfully.
@@ -81,3 +79,15 @@ pub struct Finalized {
     /// Change proofs returned from the melt
     pub change: Option<Proofs>,
 }
+
+/// PaymentPending state - melt is asynchronous and pending at the mint.
+pub struct PaymentPending {
+    /// Unique operation identifier
+    pub operation_id: Uuid,
+    /// The melt quote
+    pub quote: MeltQuote,
+    /// The proofs used for payment
+    pub final_proofs: Proofs,
+    /// Pre-mint secrets for change
+    pub premint_secrets: PreMintSecrets,
+}

+ 17 - 4
crates/cdk/src/wallet/mint_connector/http_client.rs

@@ -15,7 +15,7 @@ use url::Url;
 use web_time::{Duration, Instant};
 
 use super::transport::Transport;
-use super::{Error, MintConnector};
+use super::{Error, MeltOptions, MintConnector};
 use crate::mint_url::MintUrl;
 use crate::nuts::nut00::{KnownMethod, PaymentMethod};
 use crate::nuts::nut22::MintAuthRequest;
@@ -365,10 +365,11 @@ where
     /// Melt [NUT-05]
     /// [Nut-08] Lightning fee return if outputs defined
     #[instrument(skip(self, request), fields(mint_url = %self.mint_url))]
-    async fn post_melt(
+    async fn post_melt_with_options(
         &self,
         method: &PaymentMethod,
         request: MeltRequest<String>,
+        options: MeltOptions,
     ) -> Result<MeltQuoteBolt11Response<String>, Error> {
         let auth_token = self
             .get_auth_token(Method::Post, RoutePath::Melt(method.to_string()))
@@ -384,8 +385,20 @@ where
             PaymentMethod::Custom(m) => nut19::Path::custom_melt(m),
         };
 
-        self.retriable_http_request(nut19::Method::Post, path, auth_token, &[], &request)
-            .await
+        let custom_headers = if options.async_melt {
+            vec![("Prefer", "respond-async")]
+        } else {
+            vec![]
+        };
+
+        self.retriable_http_request(
+            nut19::Method::Post,
+            path,
+            auth_token,
+            &custom_headers,
+            &request,
+        )
+        .await
     }
 
     /// Swap Token [NUT-03]

+ 18 - 0
crates/cdk/src/wallet/mint_connector/mod.rs

@@ -22,6 +22,13 @@ use crate::wallet::AuthWallet;
 pub mod http_client;
 pub mod transport;
 
+/// Melt Options
+#[derive(Debug, Clone, Default)]
+pub struct MeltOptions {
+    /// Prefer respond-async
+    pub async_melt: bool,
+}
+
 /// Auth HTTP Client with async transport
 pub type AuthHttpClient = http_client::AuthHttpClient<transport::Async>;
 /// Default Http Client with async transport (non-Tor)
@@ -91,6 +98,17 @@ pub trait MintConnector: Debug {
         &self,
         method: &PaymentMethod,
         request: MeltRequest<String>,
+    ) -> Result<MeltQuoteBolt11Response<String>, Error> {
+        self.post_melt_with_options(method, request, MeltOptions::default())
+            .await
+    }
+
+    /// Melt [NUT-05] with options (e.g. async)
+    async fn post_melt_with_options(
+        &self,
+        method: &PaymentMethod,
+        request: MeltRequest<String>,
+        options: MeltOptions,
     ) -> Result<MeltQuoteBolt11Response<String>, Error>;
 
     /// Split Token [NUT-06]

+ 20 - 5
crates/cdk/src/wallet/mod.rs

@@ -12,6 +12,9 @@ use cdk_common::parking_lot::RwLock;
 use cdk_common::subscription::WalletParams;
 use cdk_common::wallet::ProofInfo;
 use getrandom::getrandom;
+pub use mint_connector::http_client::{
+    AuthHttpClient as BaseAuthHttpClient, HttpClient as BaseHttpClient,
+};
 use subscription::{ActiveSubscription, SubscriptionManager};
 use tokio::sync::RwLock as TokioRwLock;
 use tracing::instrument;
@@ -65,13 +68,11 @@ pub mod util;
 pub use auth::{AuthMintConnector, AuthWallet};
 pub use builder::WalletBuilder;
 pub use cdk_common::wallet as types;
-pub use melt::{MeltConfirmOptions, PreparedMelt};
-pub use mint_connector::http_client::{
-    AuthHttpClient as BaseAuthHttpClient, HttpClient as BaseHttpClient,
-};
+pub use melt::{MeltConfirmOptions, MeltOutcome, PendingMelt, PreparedMelt};
 pub use mint_connector::transport::Transport as HttpTransport;
 pub use mint_connector::{
-    AuthHttpClient, HttpClient, LnurlPayInvoiceResponse, LnurlPayResponse, MintConnector,
+    AuthHttpClient, HttpClient, LnurlPayInvoiceResponse, LnurlPayResponse, MeltOptions,
+    MintConnector,
 };
 pub use multi_mint_wallet::{MultiMintReceiveOptions, MultiMintSendOptions, MultiMintWallet};
 #[cfg(feature = "nostr")]
@@ -123,8 +124,12 @@ pub enum WalletSubscription {
     Bolt11MintQuoteState(Vec<String>),
     /// Melt quote subscription
     Bolt11MeltQuoteState(Vec<String>),
+    /// Melt bolt12 quote subscription
+    Bolt12MeltQuoteState(Vec<String>),
     /// Mint bolt12 quote subscription
     Bolt12MintQuoteState(Vec<String>),
+    /// Custom melt quote subscription
+    MeltQuoteCustom(String, Vec<String>),
 }
 
 impl From<WalletSubscription> for WalletParams {
@@ -164,6 +169,16 @@ impl From<WalletSubscription> for WalletParams {
                 kind: Kind::Bolt12MintQuote,
                 id,
             },
+            WalletSubscription::Bolt12MeltQuoteState(filters) => WalletParams {
+                filters,
+                kind: Kind::Bolt12MeltQuote,
+                id,
+            },
+            WalletSubscription::MeltQuoteCustom(method, filters) => WalletParams {
+                filters,
+                kind: Kind::Custom(format!("{}_melt_quote", method)),
+                id,
+            },
         }
     }
 }

+ 4 - 0
crates/cdk/src/wallet/streams/mod.rs

@@ -18,8 +18,12 @@ mod wait;
 pub mod npubcash;
 
 /// Shared type
+#[cfg(not(target_arch = "wasm32"))]
 type RecvFuture<'a, Ret> = Pin<Box<dyn Future<Output = Ret> + Send + 'a>>;
 
+#[cfg(target_arch = "wasm32")]
+type RecvFuture<'a, Ret> = Pin<Box<dyn Future<Output = Ret> + 'a>>;
+
 #[allow(private_bounds)]
 #[allow(clippy::enum_variant_names)]
 enum WaitableEvent {

+ 6 - 1
crates/cdk/src/wallet/swap/saga/mod.rs

@@ -297,9 +297,14 @@ impl<'a> SwapSaga<'a, Prepared> {
             .collect::<Result<Vec<ProofInfo>, _>>()?;
         added_proofs.extend(keep_proofs);
 
+        // Add new proofs and mark input proofs as Spent (don't delete them)
         self.wallet
             .localstore
-            .update_proofs(added_proofs, self.state_data.input_ys.clone())
+            .update_proofs(added_proofs, vec![])
+            .await?;
+        self.wallet
+            .localstore
+            .update_proofs_state(self.state_data.input_ys.clone(), State::Spent)
             .await?;
 
         clear_compensations(&mut self.compensations).await;

+ 18 - 14
crates/cdk/src/wallet/test_utils.rs

@@ -9,19 +9,22 @@ use cdk_common::database::WalletDatabase;
 use cdk_common::mint_url::MintUrl;
 use cdk_common::nut00::KnownMethod;
 use cdk_common::nuts::{
-    CheckStateResponse, CurrencyUnit, Id, KeysetResponse, MeltQuoteBolt11Response,
-    MeltQuoteCustomRequest, MeltQuoteCustomResponse, MintQuoteBolt11Request,
-    MintQuoteBolt11Response, MintQuoteCustomRequest, MintQuoteCustomResponse, MintRequest,
-    MintResponse, Proof, RestoreResponse, SwapRequest, SwapResponse,
+    CheckStateResponse, CurrencyUnit, Id, KeysetResponse, MeltQuoteCustomRequest,
+    MeltQuoteCustomResponse, MintQuoteBolt11Request, MintQuoteBolt11Response,
+    MintQuoteCustomRequest, MintQuoteCustomResponse, MintRequest, MintResponse, Proof,
+    RestoreResponse, SwapRequest, SwapResponse,
 };
 use cdk_common::secret::Secret;
 use cdk_common::wallet::{MeltQuote, MintQuote};
 use cdk_common::{
     Amount, MeltQuoteBolt12Request, MeltQuoteState, MintQuoteBolt12Request,
-    MintQuoteBolt12Response, PaymentMethod, SecretKey, State,
+    MintQuoteBolt12Response, SecretKey, State,
 };
 
-use crate::nuts::{CheckStateRequest, MeltQuoteBolt11Request, MeltRequest, RestoreRequest};
+use crate::nuts::{
+    CheckStateRequest, MeltQuoteBolt11Request, MeltQuoteBolt11Response, MeltRequest, PaymentMethod,
+    RestoreRequest,
+};
 use crate::wallet::{MintConnector, Wallet};
 use crate::Error;
 
@@ -250,14 +253,6 @@ impl MintConnector for MockMintConnector {
             .expect("MockMintConnector: get_melt_quote_status called without configured response")
     }
 
-    async fn post_melt(
-        &self,
-        _method: &PaymentMethod,
-        _request: MeltRequest<String>,
-    ) -> Result<MeltQuoteBolt11Response<String>, Error> {
-        unimplemented!()
-    }
-
     async fn post_swap(&self, _request: SwapRequest) -> Result<SwapResponse, Error> {
         self.post_swap_response
             .lock()
@@ -353,4 +348,13 @@ impl MintConnector for MockMintConnector {
     ) -> Result<MeltQuoteCustomResponse<String>, Error> {
         unimplemented!()
     }
+
+    async fn post_melt_with_options(
+        &self,
+        _method: &PaymentMethod,
+        _request: MeltRequest<String>,
+        _options: crate::wallet::MeltOptions,
+    ) -> Result<MeltQuoteBolt11Response<String>, Error> {
+        unimplemented!()
+    }
 }