Reserve send inputs and commit recovered wallet results exactly once

This commit is contained in:
archipelago
2026-10-06 16:42:24 -04:00
parent 048007ea2d
commit 3211852acd
5 changed files with 420 additions and 0 deletions
+9
View File
@@ -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<String>,
/// 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();
@@ -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,
@@ -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;
+295
View File
@@ -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<Record> {
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<Vec<usize>> {
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<String> {
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<PathBuf> {
let id = uuid::Uuid::parse_str(id).context("Invalid payment operation identifier")?;
Ok(self