Persist immutable private send recovery records with guarded transitions
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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)]
|
||||||
|
|||||||
@@ -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(())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
|
|||||||
Reference in New Issue
Block a user