Reserve send inputs and commit recovered wallet results exactly once
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user