Persist immutable private send recovery records with guarded transitions

This commit is contained in:
archipelago
2026-10-06 16:29:34 -04:00
parent a4ede20204
commit d4b359dbae
5 changed files with 707 additions and 70 deletions
+95 -64
View File
@@ -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::<Result<_>>()?;
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. /// Result of a mint operation.
pub struct MintResult { pub struct MintResult {
pub proofs: Vec<Proof>, pub proofs: Vec<Proof>,
@@ -656,70 +750,7 @@ impl MintClient {
} }
fn validate_prepared_swap(&self, prepared: &PreparedSwap) -> Result<()> { fn validate_prepared_swap(&self, prepared: &PreparedSwap) -> Result<()> {
anyhow::ensure!( prepared.validate_for_mint(&self.url)
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(())
} }
/// Execute only an already prepared request. Callers implementing recovery /// Execute only an already prepared request. Callers implementing recovery
+1
View File
@@ -11,3 +11,4 @@ pub mod mint_client;
mod mutation; mod mutation;
pub mod nut13; pub mod nut13;
pub mod profits; pub mod profits;
mod send_journal;
+20 -6
View File
@@ -10,7 +10,7 @@ use tokio::sync::{Mutex, OwnedMutexGuard};
type Registry = HashMap<PathBuf, Weak<Mutex<()>>>; type Registry = HashMap<PathBuf, Weak<Mutex<()>>>;
static LOCKS: OnceLock<RegistryMutex<Registry>> = OnceLock::new(); static LOCKS: OnceLock<RegistryMutex<Registry>> = OnceLock::new();
async fn lock_for(data_dir: &Path) -> Result<Arc<Mutex<()>>> { async fn canonical_lock(data_dir: &Path) -> Result<(PathBuf, Arc<Mutex<()>>)> {
tokio::fs::create_dir_all(data_dir) tokio::fs::create_dir_all(data_dir)
.await .await
.context("Could not prepare wallet data directory")?; .context("Could not prepare wallet data directory")?;
@@ -23,15 +23,29 @@ async fn lock_for(data_dir: &Path) -> Result<Arc<Mutex<()>>> {
.map_err(|_| anyhow::anyhow!("Wallet mutation lock registry is unavailable"))?; .map_err(|_| anyhow::anyhow!("Wallet mutation lock registry is unavailable"))?;
registry.retain(|_, lock| lock.strong_count() > 0); registry.retain(|_, lock| lock.strong_count() > 0);
if let Some(lock) = registry.get(&canonical).and_then(Weak::upgrade) { if let Some(lock) = registry.get(&canonical).and_then(Weak::upgrade) {
return Ok(lock); return Ok((canonical, lock));
} }
let lock = Arc::new(Mutex::new(())); let lock = Arc::new(Mutex::new(()));
registry.insert(canonical, Arc::downgrade(&lock)); registry.insert(canonical.clone(), Arc::downgrade(&lock));
Ok(lock) Ok((canonical, lock))
} }
pub(super) async fn guard(data_dir: &Path) -> Result<OwnedMutexGuard<()>> { pub(super) struct WalletMutation {
Ok(lock_for(data_dir).await?.lock_owned().await) pub(super) data_dir: PathBuf,
_held: OwnedMutexGuard<()>,
}
pub(super) async fn guard(data_dir: &Path) -> Result<WalletMutation> {
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<Arc<Mutex<()>>> {
Ok(canonical_lock(data_dir).await?.1)
} }
#[cfg(test)] #[cfg(test)]
+560
View File
@@ -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<Proof> },
Swap(PreparedSwap),
}
#[derive(Clone, Serialize, Deserialize)]
pub(super) struct Outcome {
pub token: String,
pub change: Vec<Proof>,
}
#[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<PathBuf> {
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<Option<Record>> {
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<Record> {
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<Record> {
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<Record> {
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(())
}
}
+31
View File
@@ -193,3 +193,34 @@ suite: **1,767 pass, zero failures, five existing skips**, in
This introduces the request/recovery primitive. Existing swap callers still This introduces the request/recovery primitive. Existing swap callers still
execute immediately; durable wallet reservations and the correlated purchase execute immediately; durable wallet reservations and the correlated purchase
journal are not wired yet. No live money, wallet state or app deployment changed. 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.