Recover incoming settlement and persist immutable purchase journals

This commit is contained in:
archipelago
2026-10-06 19:22:10 -04:00
parent 1c6daab92a
commit cae0099d6f
7 changed files with 2262 additions and 14 deletions
+167 -8
View File
@@ -88,6 +88,10 @@ pub struct WalletState {
#[serde(default)]
pub mint_url: String,
/// Durable receive commit ownership; never prune with transaction history.
#[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
pub(super) receive_commits: std::collections::BTreeMap<String, String>,
// ── Legacy compatibility ──
// Old wallet format had a `tokens` field. If present during deserialization,
// we migrate to proofs. This field is never written.
@@ -239,7 +243,7 @@ pub enum EcashNetwork {
}
impl EcashNetwork {
fn wallet_file(&self) -> &'static str {
pub(super) fn wallet_file(&self) -> &'static str {
match self {
Self::Mainnet => WALLET_FILE,
Self::Testnet => "wallet/ecash.testnet.json",
@@ -367,13 +371,11 @@ async fn write_file_atomically(path: &Path, content: &str) -> Result<()> {
.await
.context("Failed to flush wallet file")?;
drop(file);
fs::rename(&tmp.0, path)
.await
.context("Failed to replace wallet file")?;
fs::File::open(parent)
.await?
// Complete the commit without yielding: async filesystem work must not
// rename an old purse after cancellation releases the mutation guard.
std::fs::rename(&tmp.0, path).context("Failed to replace wallet file")?;
std::fs::File::open(parent)?
.sync_all()
.await
.context("Failed to flush wallet directory")?;
Ok(())
}
@@ -856,7 +858,9 @@ pub async fn send_token_recoverable(
// Establish recovery support before reserving or spending inputs.
// Newly derived outputs must not already exist at the mint.
let existing = client.restore_prepared_swap(&prepared).await.map_err(|_| {
anyhow::anyhow!("The mint could not verify payment recovery support; no funds spent")
anyhow::anyhow!(
"The mint could not verify payment recovery support; no funds spent"
)
})?;
anyhow::ensure!(
existing.is_none(),
@@ -1366,6 +1370,150 @@ fn target_liquidity_score(liq: &SwapLiquidity, to_mint: &str) -> i64 {
.sum()
}
/// Settle one caller-owned incoming token without losing an ambiguous mint
/// response. Persist and reuse operation_id/context_hash for the same purchase.
/// This is not a delivery receipt, refund authorization, or purchase protocol.
pub async fn receive_token_recoverable(
data_dir: &Path,
operation_id: &str,
network: EcashNetwork,
mint_url: &str,
token_str: &str,
minimum_sats: u64,
context_hash: &str,
) -> Result<u64> {
use super::receive_journal::{canonical_mint, Binding, Journal, Phase};
use sha2::{Digest, Sha256};
let held = super::mutation::guard(data_dir).await?;
anyhow::ensure!(
load_network(data_dir).await? == network,
"Switch back to the settlement's original network"
);
anyhow::ensure!(
token_str.len() <= 512 * 1024,
"Incoming token exceeds settlement size limit"
);
let binding = Binding {
id: operation_id.into(),
network,
mint_url: canonical_mint(mint_url)?,
token_hash: hex::encode(Sha256::digest(token_str.as_bytes())),
context_hash: context_hash.into(),
minimum_sats,
};
binding.validate()?;
let journal = Journal::new(&held);
let previous = journal.load(operation_id).await?;
let recovering = previous.is_some();
let record = if let Some(record) = previous {
anyhow::ensure!(
record.binding == binding,
"Settlement operation terms changed; do not redeem again"
);
record
} else {
let token = CashuToken::deserialize(token_str)
.map_err(|_| anyhow::anyhow!("Invalid incoming settlement token"))?;
anyhow::ensure!(
token.unit.as_deref().unwrap_or("sat") == "sat" && token.token.len() == 1,
"Settlement requires one sat-denominated mint"
);
let entry = &token.token[0];
anyhow::ensure!(
canonical_mint(&entry.mint)? == binding.mint_url,
"Incoming token mint does not match settlement terms"
);
let amount = entry
.proofs
.iter()
.try_fold(0u64, |sum, proof| sum.checked_add(proof.amount))
.context("Incoming amount overflow")?;
anyhow::ensure!(
amount >= minimum_sats
&& entry
.proofs
.iter()
.all(|proof| proof.amount.is_power_of_two()
&& !proof.secret.is_empty()
&& proof.c_as_pubkey().is_ok()),
"Invalid incoming settlement proofs or amount"
);
journal
.ensure_unclaimed(&binding.mint_url, &entry.proofs, Some(operation_id))
.await?;
let accepted = load_accepted_mints(data_dir).await?;
anyhow::ensure!(accepted.mints.iter().any(|mint| canonical_mint(mint).ok().as_deref() == Some(binding.mint_url.as_str())), "Settlement mint is not accepted");
let wallet = load_wallet(data_dir).await?;
anyhow::ensure!(
!entry
.proofs
.iter()
.any(|proof| wallet.proofs.iter().any(|stored| !stored.spent
&& canonical_mint(&stored.mint_url).ok().as_deref()
== Some(binding.mint_url.as_str())
&& stored.proof.secret == proof.secret)),
"Incoming settlement overlaps existing wallet funds"
);
anyhow::ensure!(
!wallet
.receive_commits
.contains_key(&format!("received:{operation_id}")),
"Settlement marker exists without its recovery record; manual recovery required"
);
let client = mint_client(data_dir, &binding.mint_url).await?;
let prepared = client.prepare_swap_at_least(&entry.proofs, &amount_to_denominations(amount), minimum_sats).await
.map_err(|_| anyhow::anyhow!("Could not prepare a recoverable settlement covering the agreed net price; no proofs redeemed"))?;
let existing = client.restore_prepared_swap(&prepared).await.map_err(|_| {
anyhow::anyhow!(
"The mint could not verify settlement recovery support; no proofs redeemed"
)
})?;
anyhow::ensure!(
existing.is_none(),
"New settlement outputs already exist; no proofs redeemed"
);
journal.prepare(binding.clone(), prepared).await?
};
if !matches!(record.phase, Phase::Prepared) {
return journal.commit_wallet(&binding).await;
}
let client = MintClient::new(&binding.mint_url)?;
let restored = if recovering {
client
.restore_prepared_swap(&record.request)
.await
.map_err(|_| {
anyhow::anyhow!(
"Could not recover this settlement yet; retain the original operation"
)
})?
} else {
None
};
let result = if let Some(result) = restored {
result
} else {
if recovering {
let states = client.check_state(record.request.inputs()).await
.map_err(|_| anyhow::anyhow!("Could not verify incoming settlement inputs; do not redeem or refund again"))?;
anyhow::ensure!(
states.iter().all(|state| state.state == "UNSPENT"),
"Incoming settlement remains pending at the mint; do not redeem or refund again"
);
}
client
.execute_prepared_swap(&record.request)
.await
.map_err(|_| {
anyhow::anyhow!(
"The mint did not confirm settlement; retry this same operation to recover it"
)
})?
};
journal.record_result(&binding, result.new_proofs).await?;
journal.commit_wallet(&binding).await
}
/// Receive a Cashu token from a peer — swaps proofs at the mint for fresh ones.
pub async fn receive_token(data_dir: &Path, token_str: &str) -> Result<u64> {
let _mutation = super::mutation::guard(data_dir).await?;
@@ -1375,6 +1523,11 @@ pub async fn receive_token(data_dir: &Path, token_str: &str) -> Result<u64> {
}
let token = CashuToken::deserialize(token_str)?;
for entry in &token.token {
super::receive_journal::Journal::new(&_mutation)
.ensure_unclaimed(&entry.mint, &entry.proofs, None)
.await?;
}
let total_amount = token.total_amount();
if total_amount == 0 {
@@ -1530,6 +1683,9 @@ pub async fn verify_and_receive_payment(
[entry] => entry,
_ => anyhow::bail!("Use a single-mint token for this payment"),
};
super::receive_journal::Journal::new(&_mutation)
.ensure_unclaimed(&entry.mint, &entry.proofs, None)
.await?;
let total = entry
.proofs
.iter()
@@ -1616,6 +1772,9 @@ pub struct RestoreOutcome {
/// time to press this button is when something already looks wrong.
pub async fn restore_from_seed(data_dir: &Path, mint_url: &str) -> Result<RestoreOutcome> {
let _mutation = super::mutation::guard(data_dir).await?;
super::receive_journal::Journal::new(&_mutation)
.ensure_restore_allowed(load_network(data_dir).await?, mint_url)
.await?;
let outgoing = super::send_journal::Journal::new(&_mutation)
.restore_exclusions(load_network(data_dir).await?, mint_url)
.await?;
+1
View File
@@ -12,3 +12,4 @@ mod mutation;
pub mod nut13;
pub mod profits;
mod send_journal;
mod receive_journal;
+562 -4
View File
@@ -729,16 +729,30 @@ async fn recoverable_send_rejects_broken_recovery_before_reserving_or_spending()
let id = uuid::Uuid::new_v4().to_string();
let context = "ab".repeat(32);
let error = send_token_recoverable(
root.path(), &id, EcashNetwork::Mainnet, &mint.url, 4, &context,
).await.unwrap_err();
root.path(),
&id,
EcashNetwork::Mainnet,
&mint.url,
4,
&context,
)
.await
.unwrap_err();
assert!(error.to_string().contains("recovery support"));
assert_eq!(load_wallet(root.path()).await.unwrap().balance(), 8);
assert!(mint.requests.lock().unwrap().is_empty());
// Once the mint responds correctly the same unspent operation can proceed.
*mint.restore_reply.lock().unwrap() = None;
assert!(send_token_recoverable(
root.path(), &id, EcashNetwork::Mainnet, &mint.url, 4, &context,
).await.is_ok());
root.path(),
&id,
EcashNetwork::Mainnet,
&mint.url,
4,
&context,
)
.await
.is_ok());
assert_eq!(mint.requests.lock().unwrap().len(), 1);
assert_eq!(load_wallet(root.path()).await.unwrap().balance(), 4);
}
@@ -1016,3 +1030,547 @@ async fn incomplete_duplicate_or_unknown_restoration_never_completes_a_swap() {
}
assert!(mint.requests.lock().unwrap().is_empty());
}
#[tokio::test]
async fn recoverable_receive_lost_reply_claims_inputs_and_recovers_once() {
let mint = Mint::start(0, None).await;
let root = mint.wallet().await;
let incoming = CashuToken::new(&mint.url, vec![proof(V2, 8), proof(ACTIVE, 4)]);
let token = incoming.serialize_v4().unwrap();
let id = uuid::Uuid::new_v4().to_string();
let context = "ab".repeat(32);
mint.lose_swap_reply
.store(true, std::sync::atomic::Ordering::SeqCst);
assert!(receive_token_recoverable(
root.path(),
&id,
EcashNetwork::Mainnet,
&mint.url,
&token,
12,
&context
)
.await
.is_err());
assert_eq!(mint.requests.lock().unwrap().len(), 1);
assert_eq!(load_wallet(root.path()).await.unwrap().balance(), 0);
assert!(restore_from_seed(root.path(), &mint.url)
.await
.unwrap_err()
.to_string()
.contains("pending receipts"));
for duplicate in [
token.clone(),
incoming.serialize().unwrap(),
CashuToken::new(&mint.url, vec![proof(ACTIVE, 4), proof(ACTIVE, 2)])
.serialize()
.unwrap(),
] {
let other = uuid::Uuid::new_v4().to_string();
assert!(receive_token_recoverable(
root.path(),
&other,
EcashNetwork::Mainnet,
&mint.url,
&duplicate,
1,
&context
)
.await
.unwrap_err()
.to_string()
.contains("another settlement"));
}
assert!(receive_token(root.path(), &token)
.await
.unwrap_err()
.to_string()
.contains("another settlement"));
*mint.restore_reply.lock().unwrap() = Some(json!({"outputs":[],"signatures":[]}));
assert!(receive_token_recoverable(
root.path(),
&id,
EcashNetwork::Mainnet,
&mint.url,
&token,
12,
&context
)
.await
.unwrap_err()
.to_string()
.contains("pending at the mint"));
assert_eq!(mint.requests.lock().unwrap().len(), 1);
*mint.restore_reply.lock().unwrap() = None;
assert_eq!(
receive_token_recoverable(
root.path(),
&id,
EcashNetwork::Mainnet,
&mint.url,
&token,
12,
&context
)
.await
.unwrap(),
12
);
let wallet = load_wallet(root.path()).await.unwrap();
assert_eq!(wallet.balance(), 12);
assert_eq!(wallet.transactions.len(), 1);
assert_eq!(wallet.transactions[0].id, format!("received:{id}"));
assert_eq!(wallet.receive_commits.len(), 1);
for output in &wallet.proofs {
assert_eq!(
output.proof.c,
signed_point(bdhke::hash_to_curve(output.proof.secret.as_bytes()).unwrap())
);
assert!(!incoming.token[0]
.proofs
.iter()
.any(|input| input.secret == output.proof.secret));
}
let before = std::fs::read(root.path().join("wallet/ecash.json")).unwrap();
assert_eq!(
receive_token_recoverable(
root.path(),
&id,
EcashNetwork::Mainnet,
&mint.url,
&token,
12,
&context
)
.await
.unwrap(),
12
);
assert_eq!(
std::fs::read(root.path().join("wallet/ecash.json")).unwrap(),
before
);
assert_eq!(mint.requests.lock().unwrap().len(), 1);
for (network, price, terms) in [
(EcashNetwork::Testnet, 12, context.clone()),
(EcashNetwork::Mainnet, 11, context.clone()),
(EcashNetwork::Mainnet, 12, "cd".repeat(32)),
] {
assert!(receive_token_recoverable(
root.path(),
&id,
network,
&mint.url,
&token,
price,
&terms
)
.await
.is_err());
}
// Completed receipts remain valid without re-crediting a pruned wallet.
std::fs::remove_file(root.path().join("wallet/ecash.json")).unwrap();
assert_eq!(
receive_token_recoverable(
root.path(),
&id,
EcashNetwork::Mainnet,
&mint.url,
&token,
12,
&context
)
.await
.unwrap(),
12
);
assert!(!root.path().join("wallet/ecash.json").exists());
assert!(receive_token_recoverable(
root.path(),
&uuid::Uuid::new_v4().to_string(),
EcashNetwork::Mainnet,
&mint.url,
&incoming.serialize().unwrap(),
12,
&context
)
.await
.is_err());
}
#[tokio::test]
async fn recoverable_receive_recovery_support_fees_and_terms_fail_before_spend() {
let mint = Mint::start(1000, None).await;
let root = mint.wallet().await;
let token = CashuToken::new(&mint.url, vec![proof(ACTIVE, 8)])
.serialize()
.unwrap();
let id = uuid::Uuid::new_v4().to_string();
let context = "ab".repeat(32);
assert!(receive_token_recoverable(
root.path(),
&id,
EcashNetwork::Mainnet,
&mint.url,
&token,
8,
&context
)
.await
.is_err());
*mint.restore_reply.lock().unwrap() = Some(json!({}));
assert!(receive_token_recoverable(
root.path(),
&id,
EcashNetwork::Mainnet,
&mint.url,
&token,
7,
&context
)
.await
.unwrap_err()
.to_string()
.contains("recovery support"));
assert!(mint.requests.lock().unwrap().is_empty());
assert!(!root.path().join("wallet/receive-operations").exists());
assert_eq!(load_wallet(root.path()).await.unwrap().balance(), 0);
let mut foreign = CashuToken::new(&mint.url, vec![proof(ACTIVE, 8)]);
foreign.unit = Some("usd".into());
assert!(receive_token_recoverable(
root.path(),
&id,
EcashNetwork::Mainnet,
&mint.url,
&foreign.serialize().unwrap(),
7,
&context
)
.await
.is_err());
foreign.unit = Some("sat".into());
foreign.token.push(foreign.token[0].clone());
assert!(receive_token_recoverable(
root.path(),
&id,
EcashNetwork::Mainnet,
&mint.url,
&foreign.serialize().unwrap(),
7,
&context
)
.await
.is_err());
*mint.restore_reply.lock().unwrap() = None;
assert_eq!(
receive_token_recoverable(
root.path(),
&id,
EcashNetwork::Mainnet,
&format!("{}/", mint.url),
&token,
7,
&context
)
.await
.unwrap(),
7
);
assert_eq!(load_wallet(root.path()).await.unwrap().balance(), 7);
assert_eq!(mint.requests.lock().unwrap().len(), 1);
}
fn rewrite_receive_phase(root: &std::path::Path, id: &str, phase: Value) {
use sha2::{Digest, Sha256};
let path = root.join(format!("wallet/receive-operations/{id}.json"));
let mut envelope: Value = serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap();
let mut record: Value = serde_json::from_str(envelope["payload"].as_str().unwrap()).unwrap();
record["phase"] = phase;
let payload = serde_json::to_string(&record).unwrap();
envelope["checksum"] = json!(hex::encode(Sha256::digest(payload.as_bytes())));
envelope["payload"] = json!(payload);
std::fs::write(path, serde_json::to_vec(&envelope).unwrap()).unwrap();
}
#[tokio::test]
async fn recoverable_receive_commit_boundaries_survive_history_pruning_without_recredit() {
use sha2::{Digest, Sha256};
let mint = Mint::start(0, None).await;
let root = mint.wallet().await;
let token = CashuToken::new(&mint.url, vec![proof(ACTIVE, 8)])
.serialize()
.unwrap();
let id = uuid::Uuid::new_v4().to_string();
let context = "ab".repeat(32);
assert_eq!(
receive_token_recoverable(
root.path(),
&id,
EcashNetwork::Mainnet,
&mint.url,
&token,
8,
&context
)
.await
.unwrap(),
8
);
let mut wallet = load_wallet(root.path()).await.unwrap();
let proofs: Vec<_> = wallet.proofs.iter().map(|p| p.proof.clone()).collect();
let committing =
json!({"Committing":{"proofs":proofs,"before":hex::encode(Sha256::digest(b"missing"))}});
let journal_path = root
.path()
.join(format!("wallet/receive-operations/{id}.json"));
let committed_record = std::fs::read(&journal_path).unwrap();
// Crash after purse save but before phase save; unrelated history pruning
// preserves the durable marker and must not restore already-spent outputs.
rewrite_receive_phase(root.path(), &id, committing.clone());
wallet.transactions.clear();
wallet.proofs.clear();
save_wallet(root.path(), &wallet).await.unwrap();
let purse = std::fs::read(root.path().join("wallet/ecash.json")).unwrap();
assert_eq!(
receive_token_recoverable(
root.path(),
&id,
EcashNetwork::Mainnet,
&mint.url,
&token,
8,
&context
)
.await
.unwrap(),
8
);
assert_eq!(
std::fs::read(root.path().join("wallet/ecash.json")).unwrap(),
purse
);
// Old writers dropping the marker cause a recovery hold, never a guessed credit.
rewrite_receive_phase(root.path(), &id, committing.clone());
wallet.receive_commits.clear();
save_wallet(root.path(), &wallet).await.unwrap();
assert!(receive_token_recoverable(
root.path(),
&id,
EcashNetwork::Mainnet,
&mint.url,
&token,
8,
&context
)
.await
.unwrap_err()
.to_string()
.contains("manual recovery"));
assert_eq!(load_wallet(root.path()).await.unwrap().balance(), 0);
// Crash before the purse save: exact absent pre-image authorizes first commit.
std::fs::remove_file(root.path().join("wallet/ecash.json")).unwrap();
assert_eq!(
receive_token_recoverable(
root.path(),
&id,
EcashNetwork::Mainnet,
&mint.url,
&token,
8,
&context
)
.await
.unwrap(),
8
);
assert_eq!(load_wallet(root.path()).await.unwrap().balance(), 8);
assert_eq!(mint.requests.lock().unwrap().len(), 1);
// Completed receipt survives a missing purse without rebuilding its proofs.
std::fs::write(journal_path, committed_record).unwrap();
std::fs::remove_file(root.path().join("wallet/ecash.json")).unwrap();
assert_eq!(
receive_token_recoverable(
root.path(),
&id,
EcashNetwork::Mainnet,
&mint.url,
&token,
8,
&context
)
.await
.unwrap(),
8
);
assert!(!root.path().join("wallet/ecash.json").exists());
}
#[tokio::test]
async fn recoverable_receive_corrupt_claims_and_write_failures_preserve_funds() {
let mint = Mint::start(0, None).await;
let root = mint.wallet().await;
let token = CashuToken::new(&mint.url, vec![proof(ACTIVE, 8)])
.serialize()
.unwrap();
let id = uuid::Uuid::new_v4().to_string();
let context = "ab".repeat(32);
// A directory at the target blocks the private journal replacement before POST.
let path = root
.path()
.join(format!("wallet/receive-operations/{id}.json"));
std::fs::create_dir_all(&path).unwrap();
assert!(receive_token_recoverable(
root.path(),
&id,
EcashNetwork::Mainnet,
&mint.url,
&token,
8,
&context
)
.await
.is_err());
assert!(mint.requests.lock().unwrap().is_empty());
std::fs::remove_dir(&path).unwrap();
mint.lose_swap_reply
.store(true, std::sync::atomic::Ordering::SeqCst);
assert!(receive_token_recoverable(
root.path(),
&id,
EcashNetwork::Mainnet,
&mint.url,
&token,
8,
&context
)
.await
.is_err());
let saved = std::fs::read(&path).unwrap();
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
assert_eq!(
std::fs::metadata(&path).unwrap().permissions().mode() & 0o777,
0o600
);
assert_eq!(
std::fs::metadata(path.parent().unwrap())
.unwrap()
.permissions()
.mode()
& 0o777,
0o700
);
}
std::fs::write(&path, b"damaged").unwrap();
assert!(receive_token_recoverable(
root.path(),
&id,
EcashNetwork::Mainnet,
&mint.url,
&token,
8,
&context
)
.await
.is_err());
assert!(receive_token_recoverable(
root.path(),
&uuid::Uuid::new_v4().to_string(),
EcashNetwork::Mainnet,
&mint.url,
&token,
8,
&context
)
.await
.is_err());
assert!(receive_token(root.path(), &token).await.is_err());
assert_eq!(mint.requests.lock().unwrap().len(), 1);
std::fs::write(&path, saved).unwrap();
assert_eq!(
receive_token_recoverable(
root.path(),
&id,
EcashNetwork::Mainnet,
&mint.url,
&token,
8,
&context
)
.await
.unwrap(),
8
);
}
#[tokio::test]
async fn recoverable_receive_unspent_retry_reuses_exact_request_and_waits_for_verified_state() {
let mint = Mint::start(0, Some(503)).await;
let root = mint.wallet().await;
let token = CashuToken::new(&mint.url, vec![proof(ACTIVE, 8)])
.serialize()
.unwrap();
let id = uuid::Uuid::new_v4().to_string();
let context = "ab".repeat(32);
assert!(receive_token_recoverable(
root.path(),
&id,
EcashNetwork::Mainnet,
&mint.url,
&token,
8,
&context
)
.await
.is_err());
assert_eq!(mint.requests.lock().unwrap().len(), 1);
// Legacy paid-content redemption must respect claims even while mint inputs
// remain unspent; otherwise it could steal the pending operation's proofs.
assert!(verify_and_receive_payment(root.path(), &token, 8)
.await
.unwrap_err()
.to_string()
.contains("another settlement"));
assert_eq!(mint.requests.lock().unwrap().len(), 1);
let original = mint.requests.lock().unwrap()[0].clone();
assert_eq!(load_wallet(root.path()).await.unwrap().balance(), 0);
// An empty state response must not authorize a second POST.
*mint.state_reply.lock().unwrap() = Some(json!({"states":[]}));
mint.failure.store(0, std::sync::atomic::Ordering::SeqCst);
assert!(receive_token_recoverable(
root.path(),
&id,
EcashNetwork::Mainnet,
&mint.url,
&token,
8,
&context
)
.await
.is_err());
assert_eq!(mint.requests.lock().unwrap().len(), 1);
*mint.state_reply.lock().unwrap() = None;
assert_eq!(
receive_token_recoverable(
root.path(),
&id,
EcashNetwork::Mainnet,
&mint.url,
&token,
8,
&context
)
.await
.unwrap(),
8
);
let requests = mint.requests.lock().unwrap();
assert_eq!(requests.len(), 2);
assert_eq!(requests[1], original);
drop(requests);
let wallet = load_wallet(root.path()).await.unwrap();
assert_eq!(wallet.balance(), 8);
assert_eq!(wallet.transactions.len(), 1);
assert_eq!(wallet.receive_commits.len(), 1);
}
@@ -0,0 +1,456 @@
//! Private incoming-proof claims and recoverable settlement. Incoming bearer
//! proofs never become spendable locally: only fresh, saved swap outputs do.
use super::{
cashu::Proof, ecash::EcashNetwork, mint_client::PreparedSwap, mutation::WalletMutation,
};
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use std::{collections::HashSet, path::PathBuf};
use tokio::{
fs,
io::{AsyncReadExt, AsyncWriteExt},
};
const MAX_BYTES: u64 = 1024 * 1024;
pub(super) fn canonical_mint(value: &str) -> Result<String> {
let url = reqwest::Url::parse(value).context("Invalid settlement mint")?;
anyhow::ensure!(
matches!(url.scheme(), "http" | "https")
&& url.host_str().is_some()
&& url.username().is_empty()
&& url.password().is_none()
&& url.query().is_none()
&& url.fragment().is_none(),
"Invalid settlement mint URL"
);
Ok(url.to_string().trim_end_matches('/').to_owned())
}
fn digest(bytes: &[u8]) -> String {
hex::encode(Sha256::digest(bytes))
}
fn is_hash(value: &str) -> bool {
value.len() == 64
&& value
.bytes()
.all(|c| c.is_ascii_digit() || (b'a'..=b'f').contains(&c))
}
#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub(super) struct Binding {
pub id: String,
pub network: EcashNetwork,
pub mint_url: String,
pub token_hash: String,
pub context_hash: String,
pub minimum_sats: u64,
}
impl Binding {
pub fn validate(&self) -> Result<()> {
anyhow::ensure!(
uuid::Uuid::parse_str(&self.id)
.ok()
.is_some_and(|id| id.to_string() == self.id),
"Invalid settlement identifier"
);
anyhow::ensure!(
self.minimum_sats > 0 && is_hash(&self.token_hash) && is_hash(&self.context_hash),
"Invalid settlement terms"
);
anyhow::ensure!(
canonical_mint(&self.mint_url)? == self.mint_url,
"Settlement mint is not canonical"
);
Ok(())
}
fn history_id(&self) -> String {
format!("received:{}", self.id)
}
}
#[derive(Clone, Serialize, Deserialize)]
pub(super) enum Phase {
Prepared,
Result(Vec<Proof>),
Committing {
proofs: Vec<Proof>,
before: String,
},
Committed {
amount_sats: u64,
commitment: String,
},
}
#[derive(Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub(super) struct Record {
pub binding: Binding,
pub request: PreparedSwap,
pub phase: Phase,
}
impl std::fmt::Debug for Record {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("ReceiveJournalRecord")
.field("id", &self.binding.id)
.finish_non_exhaustive()
}
}
#[derive(Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
struct Envelope {
version: u8,
payload: String,
checksum: String,
}
pub(super) struct Journal<'a> {
guard: &'a WalletMutation,
}
impl<'a> Journal<'a> {
pub fn new(guard: &'a WalletMutation) -> Self {
Self { guard }
}
fn directory(&self) -> PathBuf {
self.guard.data_dir.join("wallet/receive-operations")
}
fn path(&self, id: &str) -> Result<PathBuf> {
let uuid = uuid::Uuid::parse_str(id).context("Invalid settlement identifier")?;
anyhow::ensure!(
uuid.to_string() == id,
"Settlement identifier is not canonical"
);
Ok(self.directory().join(format!("{uuid}.json")))
}
fn validate(record: &Record) -> Result<()> {
record.binding.validate()?;
record.request.validate_for_mint(&record.binding.mint_url)?;
anyhow::ensure!(
record.request.covers_payment(record.binding.minimum_sats),
"Settlement does not cover the agreed price"
);
match &record.phase {
Phase::Prepared => (),
Phase::Result(proofs) => {
Self::validate_result(record, proofs)?;
}
Phase::Committing { proofs, before } => {
Self::validate_result(record, proofs)?;
anyhow::ensure!(is_hash(before), "Invalid settlement purse boundary");
}
Phase::Committed {
amount_sats,
commitment,
} => {
anyhow::ensure!(
*amount_sats >= record.binding.minimum_sats
&& record.request.covers_payment(*amount_sats)
&& (amount_sats
.checked_add(1)
.is_none_or(|next| !record.request.covers_payment(next)))
&& is_hash(commitment),
"Invalid committed settlement"
);
}
}
Ok(())
}
fn validate_result(record: &Record, proofs: &[Proof]) -> Result<u64> {
record.request.validate_result_proofs(proofs)?;
let amount = proofs
.iter()
.try_fold(0u64, |sum, proof| sum.checked_add(proof.amount))
.context("Settlement amount overflow")?;
anyhow::ensure!(
amount >= record.binding.minimum_sats,
"Settlement does not cover the agreed price"
);
Ok(amount)
}
pub async fn load(&self, id: &str) -> Result<Option<Record>> {
let mut options = fs::OpenOptions::new();
options.read(true);
#[cfg(unix)]
options.custom_flags(libc::O_NOFOLLOW | libc::O_NONBLOCK);
let file = match options.open(self.path(id)?).await {
Ok(file) => file,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
Err(e) => return Err(e).context("Could not read settlement recovery"),
};
anyhow::ensure!(
file.metadata().await?.is_file(),
"Settlement record is not a regular file"
);
let mut bytes = Vec::new();
file.take(MAX_BYTES + 1).read_to_end(&mut bytes).await?;
anyhow::ensure!(
bytes.len() as u64 <= MAX_BYTES,
"Settlement recovery exceeds its size limit"
);
let envelope: Envelope = serde_json::from_slice(&bytes)
.map_err(|_| anyhow::anyhow!("Settlement recovery is damaged; do not redeem again"))?;
anyhow::ensure!(
envelope.version == 1 && envelope.checksum == digest(envelope.payload.as_bytes()),
"Settlement recovery checksum/version failed"
);
let record: Record = serde_json::from_str(&envelope.payload)
.map_err(|_| anyhow::anyhow!("Settlement recovery contents are damaged"))?;
anyhow::ensure!(record.binding.id == id, "Settlement identity mismatch");
Self::validate(&record)?;
Ok(Some(record))
}
async fn records(&self) -> Result<Vec<Record>> {
let mut directory = match fs::read_dir(self.directory()).await {
Ok(directory) => directory,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(vec![]),
Err(e) => return Err(e).context("Cannot inspect incoming settlement claims"),
};
let mut records = Vec::new();
while let Some(entry) = directory.next_entry().await? {
let name = entry.file_name();
let name = name.to_str().context("Invalid settlement filename")?;
if name
.strip_prefix('.')
.and_then(|name| name.strip_suffix(".tmp"))
.is_some_and(|id| uuid::Uuid::parse_str(id).is_ok())
{
continue;
}
let id = name
.strip_suffix(".json")
.context("Unexpected settlement recovery entry")?;
records.push(
self.load(id)
.await?
.context("Settlement recovery disappeared")?,
);
}
Ok(records)
}
/// Claims are based on proof secrets, not token serialization/keyset aliases.
/// A partial overlap must not be treated as a new payment or a refund.
pub async fn ensure_unclaimed(
&self,
mint_url: &str,
inputs: &[Proof],
owner: Option<&str>,
) -> Result<()> {
let mint = canonical_mint(mint_url)?;
let secrets: HashSet<_> = inputs
.iter()
.map(|proof| digest(proof.secret.as_bytes()))
.collect();
anyhow::ensure!(
secrets.len() == inputs.len() && !inputs.is_empty(),
"Duplicate or missing settlement inputs"
);
for record in self.records().await? {
if record.binding.mint_url != mint || owner == Some(record.binding.id.as_str()) {
continue;
}
anyhow::ensure!(!record.request.inputs().iter().any(|proof| secrets.contains(&digest(proof.secret.as_bytes()))), "These incoming proofs already belong to another settlement; resume its original operation");
}
Ok(())
}
pub async fn ensure_restore_allowed(
&self,
network: EcashNetwork,
mint_url: &str,
) -> Result<()> {
let mint = canonical_mint(mint_url)?;
for record in self.records().await? {
if record.binding.network == network && record.binding.mint_url == mint {
anyhow::ensure!(
matches!(record.phase, Phase::Committed { .. }),
"Recover pending receipts before restoring this mint from the backup phrase"
);
}
}
Ok(())
}
pub async fn prepare(&self, binding: Binding, request: PreparedSwap) -> Result<Record> {
binding.validate()?;
if let Some(previous) = self.load(&binding.id).await? {
anyhow::ensure!(
previous.binding == binding,
"Settlement operation terms changed"
);
return Ok(previous);
}
self.ensure_unclaimed(&binding.mint_url, request.inputs(), Some(&binding.id))
.await?;
let record = Record {
binding,
request,
phase: Phase::Prepared,
};
Self::validate(&record)?;
self.write(&record).await?;
Ok(record)
}
async fn bound(&self, binding: &Binding) -> Result<Record> {
let record = self
.load(&binding.id)
.await?
.context("Settlement recovery is missing")?;
anyhow::ensure!(
&record.binding == binding,
"Settlement operation terms changed"
);
anyhow::ensure!(
super::ecash::load_network(&self.guard.data_dir).await? == binding.network,
"Switch back to the settlement's original network"
);
Ok(record)
}
pub async fn record_result(&self, binding: &Binding, proofs: Vec<Proof>) -> Result<()> {
let mut record = self.bound(binding).await?;
Self::validate_result(&record, &proofs)?;
match &record.phase {
Phase::Prepared => record.phase = Phase::Result(proofs),
Phase::Result(saved) | Phase::Committing { proofs: saved, .. } => {
anyhow::ensure!(
serde_json::to_vec(saved)? == serde_json::to_vec(&proofs)?,
"Settlement already has a different result"
);
return Ok(());
}
Phase::Committed { .. } => anyhow::bail!("Settlement already committed"),
}
self.write(&record).await
}
async fn purse_snapshot(&self, network: EcashNetwork) -> Result<String> {
// Hash exact on-disk bytes, not a reserialized WalletState. Absence and
// empty file are deliberately different (empty fails wallet loading).
match fs::read(self.guard.data_dir.join(network.wallet_file())).await {
Ok(bytes) => {
let mut tagged = b"existing:".to_vec();
tagged.extend(bytes);
Ok(digest(&tagged))
}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(digest(b"missing")),
Err(e) => Err(e).context("Cannot establish settlement purse boundary"),
}
}
pub async fn commit_wallet(&self, binding: &Binding) -> Result<u64> {
use super::ecash::{load_wallet, save_wallet, TransactionType};
let mut record = self.bound(binding).await?;
if let Phase::Committed { amount_sats, .. } = record.phase {
return Ok(amount_sats);
}
let mut wallet = load_wallet(&self.guard.data_dir).await?;
let (proofs, before) = match record.phase.clone() {
Phase::Prepared => anyhow::bail!("Settlement result is not durable yet"),
Phase::Result(proofs) => {
let before = self.purse_snapshot(binding.network).await?;
record.phase = Phase::Committing {
proofs: proofs.clone(),
before: before.clone(),
};
self.write(&record).await?;
(proofs, before)
}
Phase::Committing { proofs, before } => (proofs, before),
Phase::Committed { .. } => unreachable!(),
};
let amount = Self::validate_result(&record, &proofs)?;
let commitment = digest(&serde_json::to_vec(&(binding, amount, &proofs))?);
let history_id = binding.history_id();
if let Some(marker) = wallet.receive_commits.get(&history_id) {
anyhow::ensure!(
is_hash(marker) && *marker == commitment,
"Settlement purse marker does not match; manual recovery required"
);
} else {
anyhow::ensure!(
self.purse_snapshot(binding.network).await? == before,
"Settlement purse changed without its commit marker; manual recovery required"
);
anyhow::ensure!(
!wallet.transactions.iter().any(|tx| tx.id == history_id),
"Settlement history exists without its commit marker; manual recovery required"
);
anyhow::ensure!(
!proofs
.iter()
.any(|proof| wallet.proofs.iter().any(|stored| canonical_mint(
&stored.mint_url
)
.ok()
.as_deref()
== Some(binding.mint_url.as_str())
&& stored.proof.secret == proof.secret)),
"Settlement output exists without its commit marker; manual recovery required"
);
wallet.add_proofs(&binding.mint_url, proofs);
wallet.record_tx(
TransactionType::Receive,
amount,
"Received ecash",
&binding.mint_url,
"",
);
wallet
.transactions
.last_mut()
.context("Could not record settlement history")?
.id = history_id.clone();
wallet
.receive_commits
.insert(history_id, commitment.clone());
save_wallet(&self.guard.data_dir, &wallet).await?;
}
record.phase = Phase::Committed {
amount_sats: amount,
commitment,
};
self.write(&record).await?;
Ok(amount)
}
async fn write(&self, record: &Record) -> Result<()> {
Self::validate(record)?;
let payload = serde_json::to_string(record)?;
let bytes = serde_json::to_vec(&Envelope {
version: 1,
checksum: digest(payload.as_bytes()),
payload,
})?;
anyhow::ensure!(
bytes.len() as u64 <= MAX_BYTES,
"Settlement recovery exceeds its size limit"
);
let parent = self.directory();
fs::create_dir_all(&parent).await?;
anyhow::ensure!(
fs::symlink_metadata(&parent).await?.is_dir(),
"Settlement directory is not a regular directory"
);
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
fs::set_permissions(&parent, std::fs::Permissions::from_mode(0o700)).await?;
}
struct Temporary(PathBuf);
impl Drop for Temporary {
fn drop(&mut self) {
let _ = std::fs::remove_file(&self.0);
}
}
let temporary = Temporary(parent.join(format!(".{}.tmp", uuid::Uuid::new_v4())));
let mut options = fs::OpenOptions::new();
options.write(true).create_new(true);
#[cfg(unix)]
options.mode(0o600);
let mut file = options.open(&temporary.0).await?;
file.write_all(&bytes).await?;
file.sync_all().await?;
drop(file);
// No asynchronous commit may outlive the wallet mutation guard.
std::fs::rename(&temporary.0, self.path(&record.binding.id)?)?;
for directory in [
parent,
self.guard.data_dir.join("wallet"),
self.guard.data_dir.clone(),
] {
std::fs::File::open(directory)?.sync_all()?;
}
Ok(())
}
}
+4 -2
View File
@@ -979,14 +979,16 @@ impl<'a> Journal<'a> {
file.write_all(&bytes).await?;
file.sync_all().await?;
drop(file);
fs::rename(&temporary.0, &path).await?;
// Keep the commit synchronous under the mutation guard: cancellation
// cannot leave a rename queued after a newer wallet mutation starts.
std::fs::rename(&temporary.0, &path)?;
// Persist every new directory entry down from the existing node root.
for directory in [
parent.to_path_buf(),
self.guard.data_dir.join("wallet"),
self.guard.data_dir.clone(),
] {
fs::File::open(directory).await?.sync_all().await?;
std::fs::File::open(directory)?.sync_all()?;
}
Ok(())
}