diff --git a/core/archipelago/src/wallet/mint_client.rs b/core/archipelago/src/wallet/mint_client.rs index fa77de01..a065bfd4 100644 --- a/core/archipelago/src/wallet/mint_client.rs +++ b/core/archipelago/src/wallet/mint_client.rs @@ -82,6 +82,100 @@ impl std::fmt::Debug for PreparedSwap { } } +impl PreparedSwap { + pub(super) fn validate_for_mint(&self, mint_url: &str) -> Result<()> { + anyhow::ensure!( + self.mint_url == mint_url, + "Prepared swap belongs to a different mint" + ); + anyhow::ensure!( + !self.inputs.is_empty() + && !self.outputs.is_empty() + && self.outputs.len() == self.blinding.len(), + "Invalid prepared swap structure" + ); + let commitments: std::collections::HashSet<_> = self + .outputs + .iter() + .map(|output| output.b_prime.as_str()) + .collect(); + anyhow::ensure!( + commitments.len() == self.outputs.len(), + "Prepared swap contains duplicate outputs" + ); + let input_secrets: std::collections::HashSet<_> = self + .inputs + .iter() + .map(|proof| proof.secret.as_str()) + .collect(); + anyhow::ensure!( + input_secrets.len() == self.inputs.len(), + "Prepared swap contains duplicate inputs" + ); + anyhow::ensure!( + self.keyset.unit == "sat", + "Prepared swap is not denominated in sats" + ); + let input_total = self + .inputs + .iter() + .try_fold(0u64, |sum, proof| sum.checked_add(proof.amount)) + .context("Prepared input amount overflow")?; + let output_total = self + .outputs + .iter() + .try_fold(0u64, |sum, output| sum.checked_add(output.amount)) + .context("Prepared output amount overflow")?; + anyhow::ensure!( + output_total <= input_total, + "Prepared swap output value exceeds its inputs" + ); + for (output, secret) in self.outputs.iter().zip(&self.blinding) { + anyhow::ensure!( + output.id == self.keyset.id + && output.amount == secret.amount + && output.amount.is_power_of_two(), + "Prepared swap output metadata changed" + ); + let factor = secp256k1::SecretKey::from_slice(&secret.factor) + .context("Invalid prepared blinding factor")?; + let blinded = bdhke::blind_message(&secret.secret, &factor)?; + anyhow::ensure!( + output.b_prime == hex::encode(blinded.b_prime.serialize()), + "Prepared swap output commitment changed" + ); + std::str::from_utf8(&secret.secret).context("Invalid prepared secret encoding")?; + self.keyset.key_for_amount(output.amount)?; + } + Ok(()) + } + + pub(super) fn validate_result_proofs(&self, proofs: &[Proof]) -> Result<()> { + anyhow::ensure!( + proofs.len() == self.blinding.len(), + "Payment result has missing or extra proofs" + ); + let mut expected: std::collections::HashMap<_, _> = self + .blinding + .iter() + .map(|output| Ok((std::str::from_utf8(&output.secret)?, output.amount))) + .collect::>()?; + for proof in proofs { + anyhow::ensure!( + proof.id == self.keyset.id + && expected.remove(proof.secret.as_str()) == Some(proof.amount), + "Payment result does not match prepared outputs" + ); + proof.c_as_pubkey()?; + } + anyhow::ensure!( + expected.is_empty(), + "Payment result omitted prepared outputs" + ); + Ok(()) + } +} + /// Result of a mint operation. pub struct MintResult { pub proofs: Vec, @@ -656,70 +750,7 @@ impl MintClient { } fn validate_prepared_swap(&self, prepared: &PreparedSwap) -> Result<()> { - anyhow::ensure!( - prepared.mint_url == self.url, - "Prepared swap belongs to a different mint" - ); - anyhow::ensure!( - !prepared.inputs.is_empty() - && !prepared.outputs.is_empty() - && prepared.outputs.len() == prepared.blinding.len(), - "Invalid prepared swap structure" - ); - let commitments: std::collections::HashSet<_> = prepared - .outputs - .iter() - .map(|output| output.b_prime.as_str()) - .collect(); - anyhow::ensure!( - commitments.len() == prepared.outputs.len(), - "Prepared swap contains duplicate outputs" - ); - let input_secrets: std::collections::HashSet<_> = prepared - .inputs - .iter() - .map(|proof| proof.secret.as_str()) - .collect(); - anyhow::ensure!( - input_secrets.len() == prepared.inputs.len(), - "Prepared swap contains duplicate inputs" - ); - anyhow::ensure!( - prepared.keyset.unit == "sat", - "Prepared swap is not denominated in sats" - ); - let input_total = prepared - .inputs - .iter() - .try_fold(0u64, |sum, proof| sum.checked_add(proof.amount)) - .context("Prepared input amount overflow")?; - let output_total = prepared - .outputs - .iter() - .try_fold(0u64, |sum, output| sum.checked_add(output.amount)) - .context("Prepared output amount overflow")?; - anyhow::ensure!( - output_total <= input_total, - "Prepared swap output value exceeds its inputs" - ); - for (output, secret) in prepared.outputs.iter().zip(&prepared.blinding) { - anyhow::ensure!( - output.id == prepared.keyset.id - && output.amount == secret.amount - && output.amount.is_power_of_two(), - "Prepared swap output metadata changed" - ); - let factor = secp256k1::SecretKey::from_slice(&secret.factor) - .context("Invalid prepared blinding factor")?; - let blinded = bdhke::blind_message(&secret.secret, &factor)?; - anyhow::ensure!( - output.b_prime == hex::encode(blinded.b_prime.serialize()), - "Prepared swap output commitment changed" - ); - std::str::from_utf8(&secret.secret).context("Invalid prepared secret encoding")?; - prepared.keyset.key_for_amount(output.amount)?; - } - Ok(()) + prepared.validate_for_mint(&self.url) } /// Execute only an already prepared request. Callers implementing recovery diff --git a/core/archipelago/src/wallet/mod.rs b/core/archipelago/src/wallet/mod.rs index 5d5e0ce9..94a10e8f 100644 --- a/core/archipelago/src/wallet/mod.rs +++ b/core/archipelago/src/wallet/mod.rs @@ -11,3 +11,4 @@ pub mod mint_client; mod mutation; pub mod nut13; pub mod profits; +mod send_journal; diff --git a/core/archipelago/src/wallet/mutation.rs b/core/archipelago/src/wallet/mutation.rs index f6f33c44..df66ec15 100644 --- a/core/archipelago/src/wallet/mutation.rs +++ b/core/archipelago/src/wallet/mutation.rs @@ -10,7 +10,7 @@ use tokio::sync::{Mutex, OwnedMutexGuard}; type Registry = HashMap>>; static LOCKS: OnceLock> = OnceLock::new(); -async fn lock_for(data_dir: &Path) -> Result>> { +async fn canonical_lock(data_dir: &Path) -> Result<(PathBuf, Arc>)> { tokio::fs::create_dir_all(data_dir) .await .context("Could not prepare wallet data directory")?; @@ -23,15 +23,29 @@ async fn lock_for(data_dir: &Path) -> Result>> { .map_err(|_| anyhow::anyhow!("Wallet mutation lock registry is unavailable"))?; registry.retain(|_, lock| lock.strong_count() > 0); if let Some(lock) = registry.get(&canonical).and_then(Weak::upgrade) { - return Ok(lock); + return Ok((canonical, lock)); } let lock = Arc::new(Mutex::new(())); - registry.insert(canonical, Arc::downgrade(&lock)); - Ok(lock) + registry.insert(canonical.clone(), Arc::downgrade(&lock)); + Ok((canonical, lock)) } -pub(super) async fn guard(data_dir: &Path) -> Result> { - Ok(lock_for(data_dir).await?.lock_owned().await) +pub(super) struct WalletMutation { + pub(super) data_dir: PathBuf, + _held: OwnedMutexGuard<()>, +} + +pub(super) async fn guard(data_dir: &Path) -> Result { + let (data_dir, lock) = canonical_lock(data_dir).await?; + Ok(WalletMutation { + data_dir, + _held: lock.lock_owned().await, + }) +} + +#[cfg(test)] +async fn lock_for(data_dir: &Path) -> Result>> { + Ok(canonical_lock(data_dir).await?.1) } #[cfg(test)] diff --git a/core/archipelago/src/wallet/send_journal.rs b/core/archipelago/src/wallet/send_journal.rs new file mode 100644 index 00000000..441448e2 --- /dev/null +++ b/core/archipelago/src/wallet/send_journal.rs @@ -0,0 +1,560 @@ +//! Private write-ahead records for recoverable sends. Kept separate from the +//! legacy purse so an older purse writer cannot discard recovery metadata. +//! This module does not itself spend, reserve proofs, or authorize a purchase. +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::path::PathBuf; +use tokio::{ + fs, + io::{AsyncReadExt, AsyncWriteExt}, +}; + +const MAX_BYTES: u64 = 1024 * 1024; + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub(super) struct Binding { + pub id: String, + pub network: EcashNetwork, + pub mint_url: String, + pub amount_sats: u64, + /// Hash of immutable caller-owned purchase/transfer terms, never a token. + pub context_hash: String, +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::wallet::{cashu::CashuToken, mutation}; + + fn fixture() -> (Binding, Request, Outcome) { + let binding = Binding { + id: uuid::Uuid::new_v4().to_string(), + network: EcashNetwork::Mainnet, + mint_url: "https://mint.example".into(), + amount_sats: 2, + context_hash: "ab".repeat(32), + }; + // Storage fixture only; mint signatures are exercised by payment_tests. + let proofs = vec![Proof { + amount: 2, + id: "009a1f293253e41e".into(), + secret: "private-journal-fixture".into(), + c: "0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798".into(), + }]; + let outcome = Outcome { + token: CashuToken::new(&binding.mint_url, proofs.clone()) + .serialize() + .unwrap(), + change: vec![], + }; + (binding, Request::Exact { proofs }, outcome) + } + + #[tokio::test] + async fn restart_preserves_original_request_and_private_files() { + use std::os::unix::fs::PermissionsExt; + let root = tempfile::tempdir().unwrap(); + let (binding, request, _) = fixture(); + { + let held = mutation::guard(root.path()).await.unwrap(); + let journal = Journal::new(&held); + assert!(journal.load(&binding.id).await.unwrap().is_none()); + let record = journal + .prepare(binding.clone(), request.clone()) + .await + .unwrap(); + assert!(!format!("{record:?}").contains("private-journal-fixture")); + let path = journal.path(&binding.id).unwrap(); + assert_eq!( + fs::metadata(&path).await.unwrap().permissions().mode() & 0o777, + 0o600 + ); + assert_eq!( + fs::metadata(path.parent().unwrap()) + .await + .unwrap() + .permissions() + .mode() + & 0o777, + 0o700 + ); + } + let held = mutation::guard(root.path()).await.unwrap(); + let journal = Journal::new(&held); + let original = journal.load(&binding.id).await.unwrap().unwrap(); + // A retry cannot replace material potentially already sent to the mint. + let retry = journal + .prepare(binding, Request::Exact { proofs: vec![] }) + .await + .unwrap(); + assert_eq!( + serde_json::to_value(original).unwrap(), + serde_json::to_value(retry).unwrap() + ); + } + + #[tokio::test] + async fn changed_terms_never_replace_original_record() { + let root = tempfile::tempdir().unwrap(); + let held = mutation::guard(root.path()).await.unwrap(); + let journal = Journal::new(&held); + let (binding, request, _) = fixture(); + journal + .prepare(binding.clone(), request.clone()) + .await + .unwrap(); + let path = journal.path(&binding.id).unwrap(); + let before = fs::read(&path).await.unwrap(); + let mut variants = vec![binding.clone(); 4]; + variants[0].amount_sats = 4; + variants[1].mint_url = "https://different.example".into(); + variants[2].context_hash = "cd".repeat(32); + variants[3].network = EcashNetwork::Testnet; + for changed in variants { + assert!(journal.prepare(changed, request.clone()).await.is_err()); + assert_eq!(fs::read(&path).await.unwrap(), before); + } + } + + #[tokio::test] + async fn phase_transitions_require_durable_result_and_are_idempotent() { + let root = tempfile::tempdir().unwrap(); + let (binding, request, outcome) = fixture(); + { + let held = mutation::guard(root.path()).await.unwrap(); + let journal = Journal::new(&held); + assert!(journal + .record_result(&binding, outcome.clone()) + .await + .is_err()); + journal.prepare(binding.clone(), request).await.unwrap(); + assert!(journal.mark_committed(&binding).await.is_err()); + journal + .record_result(&binding, outcome.clone()) + .await + .unwrap(); + } + let held = mutation::guard(root.path()).await.unwrap(); + let journal = Journal::new(&held); + assert!(matches!( + journal.load(&binding.id).await.unwrap().unwrap().phase, + Phase::Result(_) + )); + journal + .record_result(&binding, outcome.clone()) + .await + .unwrap(); + journal.mark_committed(&binding).await.unwrap(); + journal.mark_committed(&binding).await.unwrap(); + assert!(matches!( + journal + .record_result(&binding, outcome) + .await + .unwrap() + .phase, + Phase::Committed(_) + )); + } + + #[tokio::test] + async fn invalid_outcomes_preserve_prepared_record() { + let root = tempfile::tempdir().unwrap(); + let held = mutation::guard(root.path()).await.unwrap(); + let journal = Journal::new(&held); + let (binding, request, outcome) = fixture(); + journal.prepare(binding.clone(), request).await.unwrap(); + let path = journal.path(&binding.id).unwrap(); + let before = fs::read(&path).await.unwrap(); + let token = CashuToken::deserialize(&outcome.token).unwrap(); + let mut variants = vec![outcome.clone(); 5]; + variants[0].token = "broken".into(); + variants[1].token = CashuToken::new("https://other.example", token.token[0].proofs.clone()) + .serialize() + .unwrap(); + let mut wrong = token.token[0].proofs.clone(); + wrong[0].amount = 4; + variants[2].token = CashuToken::new(&binding.mint_url, wrong) + .serialize() + .unwrap(); + variants[3].change = token.token[0].proofs.clone(); + let mut wrong_unit = token.clone(); + wrong_unit.unit = Some("usd".into()); + variants[4].token = wrong_unit.serialize().unwrap(); + for invalid in variants { + assert!(journal.record_result(&binding, invalid).await.is_err()); + assert_eq!(fs::read(&path).await.unwrap(), before); + } + } + + #[tokio::test] + async fn damaged_or_unsupported_record_cannot_be_treated_as_new_payment() { + let root = tempfile::tempdir().unwrap(); + let held = mutation::guard(root.path()).await.unwrap(); + let journal = Journal::new(&held); + let (binding, request, _) = fixture(); + journal + .prepare(binding.clone(), request.clone()) + .await + .unwrap(); + let path = journal.path(&binding.id).unwrap(); + let original = fs::read(&path).await.unwrap(); + let mut checksum: Envelope = serde_json::from_slice(&original).unwrap(); + checksum.checksum = "00".repeat(32); + let mut version: Envelope = serde_json::from_slice(&original).unwrap(); + version.version = 2; + for damaged in [ + vec![], + b"{".to_vec(), + serde_json::to_vec(&checksum).unwrap(), + serde_json::to_vec(&version).unwrap(), + vec![b' '; MAX_BYTES as usize + 1], + ] { + fs::write(&path, &damaged).await.unwrap(); + assert!(journal.load(&binding.id).await.is_err()); + assert!(journal + .prepare(binding.clone(), request.clone()) + .await + .is_err()); + assert_eq!(fs::read(&path).await.unwrap(), damaged); + } + assert!(journal.load("../../wallet").await.is_err()); + } + + #[tokio::test] + async fn dangling_record_symlink_is_not_an_absent_operation() { + let root = tempfile::tempdir().unwrap(); + let held = mutation::guard(root.path()).await.unwrap(); + let journal = Journal::new(&held); + let (binding, request, _) = fixture(); + let path = journal.path(&binding.id).unwrap(); + fs::create_dir_all(path.parent().unwrap()).await.unwrap(); + std::os::unix::fs::symlink(root.path().join("missing"), &path).unwrap(); + assert!(journal.load(&binding.id).await.is_err()); + assert!(journal.prepare(binding, request).await.is_err()); + assert!(fs::symlink_metadata(path) + .await + .unwrap() + .file_type() + .is_symlink()); + } +} + +#[derive(Clone, Serialize, Deserialize)] +pub(super) enum Request { + Exact { proofs: Vec }, + Swap(PreparedSwap), +} + +#[derive(Clone, Serialize, Deserialize)] +pub(super) struct Outcome { + pub token: String, + pub change: Vec, +} + +#[derive(Clone, Serialize, Deserialize)] +pub(super) enum Phase { + Prepared, + Result(Outcome), + Committed(Outcome), +} + +#[derive(Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub(super) struct Record { + pub binding: Binding, + pub request: Request, + pub phase: Phase, +} + +impl std::fmt::Debug for Record { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("SendJournalRecord") + .field("id", &self.binding.id) + .field("amount_sats", &self.binding.amount_sats) + .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 path(&self, id: &str) -> Result { + let id = uuid::Uuid::parse_str(id).context("Invalid payment operation identifier")?; + Ok(self + .guard + .data_dir + .join("wallet/send-operations") + .join(format!("{id}.json"))) + } + + fn validate_binding(binding: &Binding) -> Result<()> { + let id = + uuid::Uuid::parse_str(&binding.id).context("Invalid payment operation identifier")?; + anyhow::ensure!( + id.to_string() == binding.id, + "Payment operation identifier is not canonical" + ); + anyhow::ensure!(binding.amount_sats > 0, "Payment amount must be positive"); + anyhow::ensure!( + binding.context_hash.len() == 64 + && binding + .context_hash + .bytes() + .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b)), + "Invalid payment context hash" + ); + let url = reqwest::Url::parse(&binding.mint_url).context("Invalid payment mint")?; + anyhow::ensure!( + matches!(url.scheme(), "http" | "https") + && url.username().is_empty() + && url.password().is_none() + && url.query().is_none() + && url.fragment().is_none(), + "Invalid payment mint URL" + ); + Ok(()) + } + + fn validate_request(binding: &Binding, request: &Request) -> Result<()> { + match request { + Request::Exact { proofs } => { + let total = proofs + .iter() + .try_fold(0u64, |sum, proof| sum.checked_add(proof.amount)) + .context("Payment input amount overflow")?; + anyhow::ensure!( + total == binding.amount_sats && !proofs.is_empty(), + "Exact payment inputs do not match the amount" + ); + let mut secrets = std::collections::HashSet::new(); + for proof in proofs { + anyhow::ensure!( + proof.amount.is_power_of_two() && secrets.insert(&proof.secret), + "Invalid or duplicate payment input" + ); + proof.c_as_pubkey()?; + } + } + Request::Swap(prepared) => prepared.validate_for_mint(&binding.mint_url)?, + } + Ok(()) + } + + fn validate_outcome(record: &Record, outcome: &Outcome) -> Result<()> { + let token = super::cashu::CashuToken::deserialize(&outcome.token) + .map_err(|_| anyhow::anyhow!("Payment result token is invalid"))?; + anyhow::ensure!( + token.unit.as_deref().unwrap_or("sat") == "sat", + "Payment result is not denominated in sats" + ); + anyhow::ensure!( + token.token.len() == 1 + && token.token[0].mint.trim_end_matches('/') + == record.binding.mint_url.trim_end_matches('/'), + "Payment result belongs to a different mint" + ); + let proofs = &token.token[0].proofs; + let amount = proofs + .iter() + .try_fold(0u64, |sum, proof| sum.checked_add(proof.amount)) + .context("Payment result amount overflow")?; + anyhow::ensure!( + amount == record.binding.amount_sats, + "Payment result amount changed" + ); + match &record.request { + Request::Exact { proofs: expected } => { + anyhow::ensure!( + outcome.change.is_empty() + && serde_json::to_value(proofs)? == serde_json::to_value(expected)?, + "Exact payment result changed its proofs" + ); + } + Request::Swap(prepared) => { + let mut all = proofs.clone(); + all.extend(outcome.change.iter().cloned()); + prepared.validate_result_proofs(&all)?; + } + } + Ok(()) + } + + pub async fn load(&self, id: &str) -> Result> { + let path = self.path(id)?; + 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(path).await { + Ok(file) => file, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(error) => return Err(error).context("Could not read payment recovery record"), + }; + anyhow::ensure!( + file.metadata().await?.is_file(), + "Payment recovery 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, + "Payment recovery record exceeds its size limit" + ); + let envelope: Envelope = serde_json::from_slice(&bytes) + .map_err(|_| anyhow::anyhow!("Payment recovery record is damaged; do not pay again"))?; + anyhow::ensure!( + envelope.version == 1, + "Unsupported payment recovery record version" + ); + anyhow::ensure!( + envelope.checksum == hex::encode(Sha256::digest(envelope.payload.as_bytes())), + "Payment recovery record checksum failed; do not pay again" + ); + let record: Record = serde_json::from_str(&envelope.payload).map_err(|_| { + anyhow::anyhow!("Payment recovery record contents are damaged; do not pay again") + })?; + Self::validate_binding(&record.binding)?; + Self::validate_request(&record.binding, &record.request)?; + match &record.phase { + Phase::Prepared => (), + Phase::Result(outcome) | Phase::Committed(outcome) => { + Self::validate_outcome(&record, outcome)? + } + } + anyhow::ensure!( + record.binding.id == id, + "Payment recovery record identity mismatch" + ); + Ok(Some(record)) + } + + /// Repeating the same binding returns the original immutable request. + /// A new preparation must never replace a request that may already be sent. + pub async fn prepare(&self, binding: Binding, request: Request) -> Result { + Self::validate_binding(&binding)?; + if let Some(record) = self.load(&binding.id).await? { + anyhow::ensure!( + record.binding == binding, + "Payment operation terms changed; no new spend allowed" + ); + return Ok(record); + } + Self::validate_request(&binding, &request)?; + let record = Record { + binding, + request, + phase: Phase::Prepared, + }; + self.write(&record).await?; + Ok(record) + } + + pub async fn record_result(&self, binding: &Binding, outcome: Outcome) -> Result { + let mut record = self + .load(&binding.id) + .await? + .context("Payment recovery record is missing")?; + anyhow::ensure!( + &record.binding == binding, + "Payment operation terms changed" + ); + Self::validate_outcome(&record, &outcome)?; + match &record.phase { + Phase::Prepared => record.phase = Phase::Result(outcome), + Phase::Result(previous) | Phase::Committed(previous) => { + anyhow::ensure!( + serde_json::to_value(previous)? == serde_json::to_value(&outcome)?, + "Payment operation already has a different result" + ); + return Ok(record); + } + } + self.write(&record).await?; + Ok(record) + } + + /// Call only after the wallet commit is durable. This cannot skip Result. + pub async fn mark_committed(&self, binding: &Binding) -> Result { + let mut record = self + .load(&binding.id) + .await? + .context("Payment recovery record is missing")?; + anyhow::ensure!( + &record.binding == binding, + "Payment operation terms changed" + ); + record.phase = match record.phase { + Phase::Prepared => anyhow::bail!("Payment result is not durable yet"), + Phase::Result(outcome) | Phase::Committed(outcome) => Phase::Committed(outcome), + }; + self.write(&record).await?; + Ok(record) + } + + async fn write(&self, record: &Record) -> Result<()> { + let payload = serde_json::to_string(record)?; + let checksum = hex::encode(Sha256::digest(payload.as_bytes())); + let bytes = serde_json::to_vec(&Envelope { + version: 1, + payload, + checksum, + })?; + anyhow::ensure!( + bytes.len() as u64 <= MAX_BYTES, + "Payment recovery record exceeds its size limit" + ); + let path = self.path(&record.binding.id)?; + let parent = path + .parent() + .context("Payment recovery directory is missing")?; + fs::create_dir_all(parent).await?; + #[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); + fs::rename(&temporary.0, &path).await?; + // 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?; + } + Ok(()) + } +} diff --git a/docs/paid-content-recovery-followup.md b/docs/paid-content-recovery-followup.md index 3ad9f21a..a88ed3c7 100644 --- a/docs/paid-content-recovery-followup.md +++ b/docs/paid-content-recovery-followup.md @@ -193,3 +193,34 @@ suite: **1,767 pass, zero failures, five existing skips**, in This introduces the request/recovery primitive. Existing swap callers still execute immediately; durable wallet reservations and the correlated purchase journal are not wired yet. No live money, wallet state or app deployment changed. + +### Operation journal qualification in progress + +A separate private write-ahead store now records immutable operation ID, network, +mint, amount and purchase-context hash alongside the exact request material. +Records advance from prepared to saved result to committed; they cannot skip the +saved-result boundary. Retries retain the original request, changed terms are +rejected, and damaged/unsupported/oversized records block a fresh operation. +Files use0600, the journal directory0700, atomic replacement and file/directory +flushes. A wallet mutation guard scopes writes to the canonical node directory. +The checksum detects accidental corruption; it is not authorization against a +process able to edit the node's private state. + +The initial full isolated run passed1,773tests with zero failures and five existing +skips (`/tmp/archy-send-journal-full-tests.log`). Review subsequently added explicit +sat-unit validation and a negative regression. The final full isolated run also +passed1,773tests, zero failures and five existing skips +(`/tmp/archy-send-journal-final-tests.log`). +This storage module is not yet connected to wallet reservation/commit or paid-file +purchase/receipt handling and has not been deployed. Do not infer complete +payment recovery from the storage tests. + +The next integration must reserve selected inputs with an operation owner before +any remote request, recover the exact prepared outputs, and commit change/history +once. A restored result must match all outputs; absence is not proof of failure. +An input-state response must account for every requested proof without foreign or +duplicate entries. Pending/spent/unknown states must never authorize a new payment. +See the current [NUT-07](https://github.com/cashubtc/nuts/blob/main/07.md) and +[NUT-09](https://github.com/cashubtc/nuts/blob/main/09.md) specifications. Wallet +integration still needs crash-boundary fixtures, followed by purchase-context and +seller-receipt integration before any new paid-content acceptance claim.