diff --git a/core/archipelago/src/wallet/ecash.rs b/core/archipelago/src/wallet/ecash.rs index 22788f22..9c26308a 100644 --- a/core/archipelago/src/wallet/ecash.rs +++ b/core/archipelago/src/wallet/ecash.rs @@ -70,6 +70,9 @@ pub struct StoredProof { /// Whether this proof is reserved (allocated to an in-progress operation). #[serde(default)] pub reserved: bool, + /// Owner of a recoverable send reservation; absent on legacy proofs. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub reserved_by: Option, /// Timestamp when received. pub created_at: String, } @@ -180,6 +183,7 @@ impl WalletState { mint_url: mint_url.to_string(), spent: false, reserved: false, + reserved_by: None, created_at: now.clone(), }); } @@ -1958,6 +1962,7 @@ mod tests { mint_url: "http://mint".into(), spent: true, reserved: false, + reserved_by: None, created_at: "2020-01-01T00:00:00Z".into(), }); // Add a recent unspent proof @@ -1971,6 +1976,7 @@ mod tests { mint_url: "http://mint".into(), spent: false, reserved: false, + reserved_by: None, created_at: chrono::Utc::now().to_rfc3339(), }); @@ -2371,6 +2377,7 @@ mod tests { mint_url: default_mint_url(), spent: false, reserved: false, + reserved_by: None, created_at: "2026-01-01T00:00:00Z".into(), }); save_wallet(dir, &real).await.unwrap(); @@ -2400,6 +2407,7 @@ mod tests { mint_url: EcashNetwork::Testnet.default_mint(), spent: false, reserved: false, + reserved_by: None, created_at: "2026-01-01T00:00:00Z".into(), }); save_wallet(dir, &t).await.unwrap(); @@ -2522,6 +2530,7 @@ mod tests { mint_url: default_mint_url(), spent: false, reserved: false, + reserved_by: None, created_at: "2026-01-01T00:00:00Z".into(), }); save_wallet(dir, &w).await.unwrap(); diff --git a/core/archipelago/src/wallet/mint_client.rs b/core/archipelago/src/wallet/mint_client.rs index a065bfd4..70951ac8 100644 --- a/core/archipelago/src/wallet/mint_client.rs +++ b/core/archipelago/src/wallet/mint_client.rs @@ -83,6 +83,10 @@ impl std::fmt::Debug for PreparedSwap { } impl PreparedSwap { + pub(super) fn inputs(&self) -> &[Proof] { + &self.inputs + } + pub(super) fn validate_for_mint(&self, mint_url: &str) -> Result<()> { anyhow::ensure!( self.mint_url == mint_url, diff --git a/core/archipelago/src/wallet/payment_tests.rs b/core/archipelago/src/wallet/payment_tests.rs index e0b5c21e..87b55376 100644 --- a/core/archipelago/src/wallet/payment_tests.rs +++ b/core/archipelago/src/wallet/payment_tests.rs @@ -556,6 +556,97 @@ async fn prepared_swap_recovers_a_lost_reply_without_a_second_spend() { assert!(!format!("{:?}", reconstructed).contains(&restored.new_proofs[0].secret)); } +#[tokio::test] +async fn journal_recovers_lost_swap_reply_and_commits_change_once() { + use crate::wallet::{ + mutation, + send_journal::{Binding, Journal, Outcome, Request as SendRequest}, + }; + let mint = Mint::start(0, None).await; + let root = tempfile::tempdir().unwrap(); + let client = MintClient::new(&mint.url).unwrap(); + let input = proof(ACTIVE, 8); + let binding = Binding { + id: uuid::Uuid::new_v4().to_string(), + network: EcashNetwork::Mainnet, + mint_url: mint.url.clone(), + amount_sats: 4, + context_hash: "ab".repeat(32), + }; + { + let held = mutation::guard(root.path()).await.unwrap(); + let journal = Journal::new(&held); + let mut wallet = WalletState::default(); + wallet.mint_url = mint.url.clone(); + wallet.add_proofs(&mint.url, vec![input.clone()]); + save_wallet(root.path(), &wallet).await.unwrap(); + let prepared = client + .prepare_swap_at_least(&[input], &[4, 4], 4) + .await + .unwrap(); + journal + .prepare(binding.clone(), SendRequest::Swap(prepared.clone())) + .await + .unwrap(); + journal.reserve_wallet(&binding).await.unwrap(); + mint.lose_swap_reply + .store(true, std::sync::atomic::Ordering::SeqCst); + assert!(client.execute_prepared_swap(&prepared).await.is_err()); + assert_eq!(load_wallet(root.path()).await.unwrap().balance(), 0); + } + let held = mutation::guard(root.path()).await.unwrap(); + let journal = Journal::new(&held); + let record = journal.load(&binding.id).await.unwrap().unwrap(); + let SendRequest::Swap(prepared) = record.request else { + panic!("Lost prepared swap") + }; + let mut restored = MintClient::new(&mint.url) + .unwrap() + .restore_prepared_swap(&prepared) + .await + .unwrap() + .unwrap() + .new_proofs; + let send = restored.remove(0); + let token = CashuToken::new(&mint.url, vec![send.clone()]) + .serialize() + .unwrap(); + journal + .record_result( + &binding, + Outcome { + token: token.clone(), + change: restored, + }, + ) + .await + .unwrap(); + assert_eq!(journal.commit_wallet(&binding).await.unwrap(), token); + assert_eq!(journal.commit_wallet(&binding).await.unwrap(), token); + let wallet = load_wallet(root.path()).await.unwrap(); + assert_eq!(wallet.balance(), 4); + assert_eq!(wallet.transactions.len(), 1); + assert_eq!(wallet.transactions[0].id, binding.id); + assert_eq!(wallet.proofs.len(), 3); + assert!( + wallet + .proofs + .iter() + .find(|stored| stored.proof.secret == send.secret) + .unwrap() + .spent + ); + assert!(wallet + .proofs + .iter() + .all(|stored| !stored.reserved && stored.reserved_by.is_none())); + assert_eq!( + mint.requests.lock().unwrap().len(), + 1, + "Recovery must not send another swap" + ); +} + #[tokio::test] async fn damaged_or_wrong_mint_preparation_fails_before_spending() { let mint = Mint::start(0, None).await; diff --git a/core/archipelago/src/wallet/send_journal.rs b/core/archipelago/src/wallet/send_journal.rs index 441448e2..ee3c5815 100644 --- a/core/archipelago/src/wallet/send_journal.rs +++ b/core/archipelago/src/wallet/send_journal.rs @@ -55,6 +55,137 @@ mod tests { (binding, Request::Exact { proofs }, outcome) } + async fn fund_fixture(path: &std::path::Path, binding: &Binding, request: &Request) { + let mut wallet = super::super::ecash::WalletState::default(); + wallet.mint_url = binding.mint_url.clone(); + wallet.add_proofs(&binding.mint_url, Journal::inputs(request).to_vec()); + super::super::ecash::save_wallet(path, &wallet) + .await + .unwrap(); + } + + #[tokio::test] + async fn reservation_and_wallet_commit_survive_each_local_boundary() { + let root = tempfile::tempdir().unwrap(); + let (binding, request, outcome) = fixture(); + { + let held = mutation::guard(root.path()).await.unwrap(); + let journal = Journal::new(&held); + fund_fixture(root.path(), &binding, &request).await; + journal + .prepare(binding.clone(), request.clone()) + .await + .unwrap(); + assert!(journal.commit_wallet(&binding).await.is_err()); + journal.reserve_wallet(&binding).await.unwrap(); + journal.reserve_wallet(&binding).await.unwrap(); + let wallet = super::super::ecash::load_wallet(root.path()).await.unwrap(); + assert_eq!(wallet.balance(), 0); + assert_eq!( + wallet.proofs[0].reserved_by.as_deref(), + Some(binding.id.as_str()) + ); + journal + .record_result(&binding, outcome.clone()) + .await + .unwrap(); + } + let held = mutation::guard(root.path()).await.unwrap(); + let journal = Journal::new(&held); + let before_commit = journal.load(&binding.id).await.unwrap().unwrap(); + assert_eq!( + journal.commit_wallet(&binding).await.unwrap(), + outcome.token + ); + let after = fs::read(root.path().join("wallet/ecash.json")) + .await + .unwrap(); + // Emulate interruption between atomic purse save and journal phase save. + journal.write(&before_commit).await.unwrap(); + assert_eq!( + journal.commit_wallet(&binding).await.unwrap(), + outcome.token + ); + assert_eq!( + journal.commit_wallet(&binding).await.unwrap(), + outcome.token + ); + assert_eq!( + fs::read(root.path().join("wallet/ecash.json")) + .await + .unwrap(), + after + ); + let wallet = super::super::ecash::load_wallet(root.path()).await.unwrap(); + assert_eq!(wallet.transactions.len(), 1); + assert_eq!(wallet.transactions[0].id, binding.id); + assert!(wallet.proofs[0].spent && !wallet.proofs[0].reserved); + assert!(wallet.proofs[0].reserved_by.is_none()); + } + + #[tokio::test] + async fn competing_operation_cannot_take_another_reservation() { + let root = tempfile::tempdir().unwrap(); + let held = mutation::guard(root.path()).await.unwrap(); + let journal = Journal::new(&held); + let (binding, request, outcome) = fixture(); + fund_fixture(root.path(), &binding, &request).await; + journal + .prepare(binding.clone(), request.clone()) + .await + .unwrap(); + journal.reserve_wallet(&binding).await.unwrap(); + let mut other = binding.clone(); + other.id = uuid::Uuid::new_v4().to_string(); + journal.prepare(other.clone(), request).await.unwrap(); + let before = fs::read(root.path().join("wallet/ecash.json")) + .await + .unwrap(); + assert!(journal.reserve_wallet(&other).await.is_err()); + journal.record_result(&other, outcome).await.unwrap(); + assert!(journal.commit_wallet(&other).await.is_err()); + assert_eq!( + fs::read(root.path().join("wallet/ecash.json")) + .await + .unwrap(), + before + ); + } + + #[tokio::test] + async fn network_switch_cannot_redirect_a_pending_payment_commit() { + let root = tempfile::tempdir().unwrap(); + let (binding, request, outcome) = fixture(); + { + let held = mutation::guard(root.path()).await.unwrap(); + let journal = Journal::new(&held); + fund_fixture(root.path(), &binding, &request).await; + journal.prepare(binding.clone(), request).await.unwrap(); + journal.reserve_wallet(&binding).await.unwrap(); + journal + .record_result(&binding, outcome.clone()) + .await + .unwrap(); + } + super::super::ecash::save_network(root.path(), EcashNetwork::Testnet) + .await + .unwrap(); + { + let held = mutation::guard(root.path()).await.unwrap(); + let journal = Journal::new(&held); + assert!(journal.commit_wallet(&binding).await.is_err()); + assert!(!root.path().join("wallet/ecash.testnet.json").exists()); + } + super::super::ecash::save_network(root.path(), EcashNetwork::Mainnet) + .await + .unwrap(); + let held = mutation::guard(root.path()).await.unwrap(); + assert_eq!( + Journal::new(&held).commit_wallet(&binding).await.unwrap(), + outcome.token + ); + } + #[tokio::test] async fn restart_preserves_original_request_and_private_files() { use std::os::unix::fs::PermissionsExt; @@ -296,6 +427,170 @@ impl<'a> Journal<'a> { Self { guard } } + async fn bound_record(&self, binding: &Binding) -> Result { + let record = self + .load(&binding.id) + .await? + .context("Payment recovery record is missing")?; + anyhow::ensure!( + &record.binding == binding, + "Payment operation terms changed" + ); + anyhow::ensure!( + super::ecash::load_network(&self.guard.data_dir).await? == binding.network, + "Switch back to the payment's original network before recovering it" + ); + Ok(record) + } + + fn inputs(request: &Request) -> &[Proof] { + match request { + Request::Exact { proofs } => proofs, + Request::Swap(prepared) => prepared.inputs(), + } + } + + fn input_indices( + record: &Record, + wallet: &super::ecash::WalletState, + require_reserved: bool, + ) -> Result> { + Self::inputs(&record.request) + .iter() + .map(|proof| { + let matching: Vec<_> = wallet + .proofs + .iter() + .enumerate() + .filter(|(_, stored)| { + stored.mint_url == record.binding.mint_url + && stored.proof.secret == proof.secret + }) + .collect(); + anyhow::ensure!( + matching.len() == 1, + "Payment input is missing or duplicated in the wallet" + ); + let (index, stored) = matching[0]; + anyhow::ensure!( + serde_json::to_value(&stored.proof)? == serde_json::to_value(proof)?, + "Payment input changed in the wallet" + ); + anyhow::ensure!(!stored.spent, "Payment input was already spent"); + let owned = stored.reserved + && stored.reserved_by.as_deref() == Some(record.binding.id.as_str()); + let available = !stored.reserved && stored.reserved_by.is_none(); + anyhow::ensure!( + owned || (!require_reserved && available), + "Payment input belongs to another operation" + ); + Ok(index) + }) + .collect() + } + + /// The immutable journal must already exist. Return only after the purse + /// reservation is durable, before a caller may send a mint request. + pub async fn reserve_wallet(&self, binding: &Binding) -> Result<()> { + let record = self.bound_record(binding).await?; + anyhow::ensure!( + matches!(record.phase, Phase::Prepared), + "Payment already has a saved result" + ); + let mut wallet = super::ecash::load_wallet(&self.guard.data_dir).await?; + anyhow::ensure!( + !wallet.transactions.iter().any(|tx| tx.id == binding.id), + "Payment history already contains this operation" + ); + let indices = Self::input_indices(&record, &wallet, false)?; + for index in indices { + wallet.proofs[index].reserved = true; + wallet.proofs[index].reserved_by = Some(binding.id.clone()); + } + super::ecash::save_wallet(&self.guard.data_dir, &wallet).await + } + + /// Commit a previously saved result exactly once. A crash after the purse + /// save but before the phase save is recognized by its stable history ID. + pub async fn commit_wallet(&self, binding: &Binding) -> Result { + use super::ecash::TransactionType; + let record = self.bound_record(binding).await?; + let (outcome, committed) = match &record.phase { + Phase::Prepared => anyhow::bail!("Payment result is not durable yet"), + Phase::Result(outcome) => (outcome, false), + Phase::Committed(outcome) => (outcome, true), + }; + let mut wallet = super::ecash::load_wallet(&self.guard.data_dir).await?; + let history: Vec<_> = wallet + .transactions + .iter() + .filter(|tx| tx.id == binding.id) + .collect(); + if !history.is_empty() { + anyhow::ensure!( + history.len() == 1 + && matches!(history[0].tx_type, TransactionType::Send) + && history[0].amount_sats == binding.amount_sats + && history[0].mint_url == binding.mint_url + && history[0].kind == "cashu", + "Payment history does not match its recovery record" + ); + if !committed { + self.mark_committed(binding).await?; + } + return Ok(outcome.token.clone()); + } + anyhow::ensure!( + !committed, + "Committed payment is missing from the wallet; recovery required" + ); + let indices = Self::input_indices(&record, &wallet, true)?; + let token = super::cashu::CashuToken::deserialize(&outcome.token)?; + // Outgoing swap proofs are retained as spent locally so a seed scan + // cannot re-credit the still-unredeemed recipient's token. + let outgoing = if matches!(record.request, Request::Swap(_)) { + token.token[0].proofs.clone() + } else { + vec![] + }; + for proof in outgoing.iter().chain(&outcome.change) { + anyhow::ensure!( + !wallet + .proofs + .iter() + .any(|stored| stored.mint_url == binding.mint_url + && stored.proof.secret == proof.secret), + "Payment output already exists without its transaction record" + ); + } + for index in indices { + wallet.proofs[index].spent = true; + wallet.proofs[index].reserved = false; + wallet.proofs[index].reserved_by = None; + } + let start = wallet.proofs.len(); + wallet.add_proofs(&binding.mint_url, outgoing); + for stored in &mut wallet.proofs[start..] { + stored.spent = true; + } + wallet.add_proofs(&binding.mint_url, outcome.change.clone()); + wallet.record_tx( + TransactionType::Send, + binding.amount_sats, + "Sent ecash", + &binding.mint_url, + "", + ); + wallet + .transactions + .last_mut() + .context("Payment history could not be recorded")? + .id = binding.id.clone(); + super::ecash::save_wallet(&self.guard.data_dir, &wallet).await?; + self.mark_committed(binding).await?; + Ok(outcome.token.clone()) + } + fn path(&self, id: &str) -> Result { let id = uuid::Uuid::parse_str(id).context("Invalid payment operation identifier")?; Ok(self diff --git a/docs/paid-content-recovery-followup.md b/docs/paid-content-recovery-followup.md index b4c55151..b1840386 100644 --- a/docs/paid-content-recovery-followup.md +++ b/docs/paid-content-recovery-followup.md @@ -233,3 +233,24 @@ requested curve point and reject unknown state values before crediting proofs. Do not treat a same-length response as sufficient. Also prevent a seed scan from making reserved outgoing operation outputs available before that operation's result/commit is recovered. These are source findings; no live restore was run. + +### Wallet reservation/commit boundaries qualified + +The journal can now durably reserve its exact inputs with an operation owner, +refuse another operation's reservation, and commit its saved token/change/history +once. Recovery after purse-save but before journal-phase-save uses the same stable +transaction ID. Changing the selected network cannot redirect that commit into +the other purse. Outgoing swap proofs are retained as locally spent, alongside +spendable change, so they are not immediately rediscovered as wallet funds. + +Four additional regressions cover local restart boundaries, competing operations, +network switching and a real HTTP/curve-signature lost-response scenario. The +latter reconstructs the journal, restores the original swap outputs, commits twice, +and verifies one mint swap, one history entry,4sats sent and4sats change from8. +Full isolated qualification:1,777passed, zero failures, five existing skips, +`/tmp/archy-journal-wallet-commit-tests.log`. No real sats or live wallet data used. + +These methods are not yet wired into the purchase RPC or a remote-operation +executor. Seller receipts, delivery capabilities, ambiguous refunds, melt/change +and seed-restore interaction remain open. A passing commit primitive is not full +paid-content recovery acceptance.