Skip to content

Commit 35d4fa1

Browse files
committed
fix(wallet): recover failed melt confirms via saga state
1 parent eaa43d2 commit 35d4fa1

2 files changed

Lines changed: 394 additions & 25 deletions

File tree

crates/cdk-integration-tests/tests/fake_wallet.rs

Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1899,6 +1899,138 @@ async fn test_wallet_proof_recovery_after_failed_melt() {
18991899
);
19001900
}
19011901

1902+
/// Regression test for cashubtc/cdk#1891.
1903+
///
1904+
/// Exercises the chained `prepare_melt(...).await?.confirm().await?` pattern
1905+
/// where `PreparedMelt` is consumed on failure. The wallet should recover via
1906+
/// the persisted melt saga without requiring `check_all_pending_proofs()`.
1907+
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
1908+
async fn test_chained_confirm_auto_releases_proofs_on_failure() {
1909+
let wallet = Wallet::new(
1910+
MINT_URL,
1911+
CurrencyUnit::Sat,
1912+
Arc::new(memory::empty().await.unwrap()),
1913+
Mnemonic::generate(12).unwrap().to_seed_normalized(""),
1914+
None,
1915+
)
1916+
.expect("failed to create new wallet");
1917+
1918+
let mint_quote = wallet
1919+
.mint_quote(PaymentMethod::BOLT11, Some(100.into()), None, None)
1920+
.await
1921+
.unwrap();
1922+
let mut proof_streams = wallet.proof_stream(mint_quote.clone(), SplitTarget::default(), None);
1923+
proof_streams
1924+
.next()
1925+
.await
1926+
.expect("payment")
1927+
.expect("no error");
1928+
1929+
assert_eq!(wallet.total_balance().await.unwrap(), Amount::from(100));
1930+
1931+
let fake_description = FakeInvoiceDescription {
1932+
pay_invoice_state: MeltQuoteState::Failed,
1933+
check_payment_state: MeltQuoteState::Failed,
1934+
pay_err: false,
1935+
check_err: false,
1936+
};
1937+
let invoice = create_fake_invoice(1000, serde_json::to_string(&fake_description).unwrap());
1938+
let melt_quote = wallet
1939+
.melt_quote(PaymentMethod::BOLT11, invoice.to_string(), None, None)
1940+
.await
1941+
.unwrap();
1942+
1943+
let melt_result = wallet
1944+
.prepare_melt(&melt_quote.id, std::collections::HashMap::new())
1945+
.await
1946+
.unwrap()
1947+
.confirm()
1948+
.await;
1949+
assert!(melt_result.is_err(), "Melt should fail on Failed state");
1950+
1951+
let pending = wallet
1952+
.localstore
1953+
.get_proofs(None, None, Some(vec![State::Pending]), None)
1954+
.await
1955+
.unwrap();
1956+
assert!(
1957+
pending.is_empty(),
1958+
"no proofs should remain Pending after automatic saga recovery"
1959+
);
1960+
1961+
assert_eq!(
1962+
wallet.total_balance().await.unwrap(),
1963+
Amount::from(100),
1964+
"balance should be fully recovered without explicit recovery calls"
1965+
);
1966+
}
1967+
1968+
/// Regression test for the recovered-paid confirm path.
1969+
///
1970+
/// If the initial melt request errors locally but recovery sees the quote as
1971+
/// `Paid`, chained `prepare_melt(...).confirm()` should still return success.
1972+
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
1973+
async fn test_chained_confirm_recovers_paid_melt() {
1974+
let wallet = Wallet::new(
1975+
MINT_URL,
1976+
CurrencyUnit::Sat,
1977+
Arc::new(memory::empty().await.unwrap()),
1978+
Mnemonic::generate(12).unwrap().to_seed_normalized(""),
1979+
None,
1980+
)
1981+
.expect("Failed to create new wallet");
1982+
1983+
let mint_quote = wallet
1984+
.mint_quote(PaymentMethod::BOLT11, Some(100.into()), None, None)
1985+
.await
1986+
.unwrap();
1987+
let mut proof_streams = wallet.proof_stream(mint_quote.clone(), SplitTarget::default(), None);
1988+
proof_streams
1989+
.next()
1990+
.await
1991+
.expect("payment")
1992+
.expect("no error");
1993+
1994+
let old_balance = wallet.total_balance().await.expect("balance");
1995+
1996+
let fake_description = FakeInvoiceDescription {
1997+
pay_invoice_state: MeltQuoteState::Failed,
1998+
check_payment_state: MeltQuoteState::Paid,
1999+
pay_err: true,
2000+
check_err: false,
2001+
};
2002+
let invoice = create_fake_invoice(7000, serde_json::to_string(&fake_description).unwrap());
2003+
let melt_quote = wallet
2004+
.melt_quote(PaymentMethod::BOLT11, invoice.to_string(), None, None)
2005+
.await
2006+
.unwrap();
2007+
2008+
let melt = wallet
2009+
.prepare_melt(&melt_quote.id, std::collections::HashMap::new())
2010+
.await
2011+
.unwrap()
2012+
.confirm()
2013+
.await
2014+
.expect("confirm should recover to Paid");
2015+
2016+
assert_eq!(melt.fee_paid(), Amount::ZERO);
2017+
assert_eq!(melt.amount(), Amount::from(7));
2018+
assert_eq!(
2019+
old_balance - melt.amount(),
2020+
wallet.total_balance().await.expect("new balance")
2021+
);
2022+
2023+
let pending = wallet
2024+
.localstore
2025+
.get_proofs(None, None, Some(vec![State::Pending]), None)
2026+
.await
2027+
.unwrap();
2028+
assert!(
2029+
pending.is_empty(),
2030+
"paid recovery should not leave pending proofs"
2031+
);
2032+
}
2033+
19022034
/// Tests that concurrent melt attempts for the same invoice result in exactly one success
19032035
///
19042036
/// This test verifies the race condition protection: when multiple melt quotes exist for the

0 commit comments

Comments
 (0)