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
+21
View File
@@ -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.