//! 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 { 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), Committing { proofs: Vec, 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 { 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 { 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> { 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> { 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 { 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 { 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) -> 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 { // 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 { 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(()) } }