use crate::config;
use crate::data::client::merkle::MerkleBatchPaymentResult;
use crate::error::Result;
use ant_protocol::payment::{deserialize_merkle_proof, serialize_merkle_proof};
use std::fs::{self, DirEntry, File, OpenOptions};
use std::hash::{Hash, Hasher};
use std::io::{BufReader, BufWriter};
use std::path::{Path, PathBuf};
use std::time::{SystemTime, UNIX_EPOCH};
use tracing::{debug, info, warn};
const PAYMENT_EXPIRATION_SECS: u64 = 7 * 24 * 60 * 60;
const PAYMENTS_SUBDIR: &str = "payments";
fn payments_dir() -> Result<PathBuf> {
let dir = config::data_dir()?.join(PAYMENTS_SUBDIR);
fs::create_dir_all(&dir)?;
Ok(dir)
}
fn file_hash_key(file_path: &str) -> String {
let mut hasher = std::collections::hash_map::DefaultHasher::new();
file_path.hash(&mut hasher);
format!("{:016x}", hasher.finish())
}
pub fn save(file_path: &str, result: &MerkleBatchPaymentResult) -> Result<PathBuf> {
let dir = payments_dir()?;
let ts = if result.merkle_payment_timestamp > 0 {
result.merkle_payment_timestamp
} else {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0)
};
let path = dir.join(format!("{ts}_{}", file_hash_key(file_path)));
let handle = File::create(&path)?;
rmp_serde::encode::write(&mut BufWriter::new(handle), result)
.map_err(|e| crate::error::Error::Io(std::io::Error::other(e.to_string())))?;
debug!(
"Cached merkle payment receipt for {file_path:?} to {}",
path.display()
);
Ok(path)
}
pub fn try_save(file_path: &str, result: &MerkleBatchPaymentResult) {
if let Err(e) = save(file_path, result) {
warn!(
"Failed to cache merkle payment receipt for {file_path:?}: {e}. \
Upload will proceed without resume support."
);
}
}
pub fn load_for_file(file_path: &str) -> Result<Option<(PathBuf, MerkleBatchPaymentResult)>> {
cleanup_outdated();
let dir = payments_dir()?;
let key = file_hash_key(file_path);
let read_dir = match fs::read_dir(&dir) {
Ok(rd) => rd,
Err(e) => {
debug!("Could not read payments dir {}: {e}", dir.display());
return Ok(None);
}
};
for entry in read_dir.flatten() {
let path = entry.path();
if !path.is_file() {
continue;
}
let Some(name) = path.file_name().and_then(|n| n.to_str()) else {
continue;
};
if !name.contains(&key) {
continue;
}
if is_expired_filename(name) {
continue;
}
match read_receipt(&path) {
Ok(receipt) => {
info!(
"Found previous merkle upload attempt for {file_path}, \
resuming with payment cached at {}",
path.display()
);
return Ok(Some((path, receipt)));
}
Err(e) => {
warn!(
"Cached merkle receipt at {} is unreadable ({e}). \
Ignoring and starting a fresh upload.",
path.display()
);
}
}
}
Ok(None)
}
pub fn try_load_for_file(file_path: &str) -> Option<(PathBuf, MerkleBatchPaymentResult)> {
match load_for_file(file_path) {
Ok(opt) => opt,
Err(e) => {
warn!(
"Failed to look up cached merkle receipt for {file_path:?}: {e}. \
Starting a fresh upload."
);
None
}
}
}
pub fn delete_for_file(file_path: &str) -> Result<()> {
let dir = payments_dir()?;
let key = file_hash_key(file_path);
if let Ok(read_dir) = fs::read_dir(&dir) {
for entry in read_dir.flatten() {
let path = entry.path();
if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
if name.contains(&key) {
let _ = fs::remove_file(&path);
debug!("Deleted cached merkle receipt {}", path.display());
}
}
}
}
Ok(())
}
pub fn try_delete_for_file(file_path: &str) {
if let Err(e) = delete_for_file(file_path) {
warn!(
"Failed to delete cached merkle receipt for {file_path:?}: {e}. \
Will be cleaned up after expiry."
);
}
}
pub fn cleanup_outdated() {
let Ok(dir) = payments_dir() else {
return;
};
let Ok(read_dir) = fs::read_dir(&dir) else {
return;
};
for entry in read_dir.flatten() {
if is_expired_entry(&entry) {
let path = entry.path();
info!(
"Removing expired cached merkle payment file: {}",
path.display()
);
let _ = fs::remove_file(path);
}
}
}
fn is_expired_entry(entry: &DirEntry) -> bool {
let path = entry.path();
if !path.is_file() {
return false;
}
let Some(name) = path.file_name().and_then(|n| n.to_str()) else {
return false;
};
is_expired_filename(name)
}
fn is_expired_filename(name: &str) -> bool {
let ts_str = match name.split_once('_') {
Some((ts, _)) => ts,
None => return false,
};
let Ok(ts) = ts_str.parse::<u64>() else {
return false;
};
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
now > ts.saturating_add(PAYMENT_EXPIRATION_SECS)
}
fn read_receipt(path: &Path) -> Result<MerkleBatchPaymentResult> {
let handle = File::open(path)?;
let mut receipt: MerkleBatchPaymentResult =
rmp_serde::decode::from_read(BufReader::new(handle))
.map_err(|e| crate::error::Error::Io(std::io::Error::other(e.to_string())))?;
if strip_commitment_sidecars(&mut receipt) {
info!(
"Stripped legacy commitment sidecars from cached merkle receipt at {}",
path.display()
);
if let Err(e) = overwrite_receipt(path, &receipt) {
warn!(
"Failed to persist slimmed merkle receipt at {}: {e}",
path.display()
);
}
}
Ok(receipt)
}
fn strip_commitment_sidecars(receipt: &mut MerkleBatchPaymentResult) -> bool {
let mut stripped = false;
for proof_bytes in receipt.proofs.values_mut() {
let Ok(mut proof) = deserialize_merkle_proof(proof_bytes) else {
continue;
};
if proof.commitment_sidecars.is_empty() {
continue;
}
proof.commitment_sidecars.clear();
match serialize_merkle_proof(&proof) {
Ok(slim) => {
*proof_bytes = slim;
stripped = true;
}
Err(e) => warn!("Failed to re-serialize slimmed cached merkle proof: {e}"),
}
}
stripped
}
fn overwrite_receipt(path: &Path, receipt: &MerkleBatchPaymentResult) -> Result<()> {
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0);
let pid = std::process::id();
let tmp_path = path.with_extension(format!("{pid}-{nanos}.tmp"));
{
let handle = OpenOptions::new()
.write(true)
.create_new(true)
.open(&tmp_path)?;
let mut writer = BufWriter::new(handle);
if let Err(e) = rmp_serde::encode::write(&mut writer, receipt) {
let _ = fs::remove_file(&tmp_path);
return Err(crate::error::Error::Io(std::io::Error::other(
e.to_string(),
)));
}
let handle = match writer.into_inner() {
Ok(handle) => handle,
Err(e) => {
let _ = fs::remove_file(&tmp_path);
return Err(crate::error::Error::Io(std::io::Error::other(format!(
"BufWriter flush failed: {e}"
))));
}
};
if let Err(e) = handle.sync_all() {
let _ = fs::remove_file(&tmp_path);
return Err(e.into());
}
}
if let Err(e) = fs::rename(&tmp_path, path) {
let _ = fs::remove_file(&tmp_path);
return Err(e.into());
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::HashMap;
fn dummy_receipt(ts: u64) -> MerkleBatchPaymentResult {
let mut proofs: HashMap<[u8; 32], Vec<u8>> = HashMap::new();
proofs.insert([0u8; 32], vec![1, 2, 3]);
MerkleBatchPaymentResult {
proofs,
chunk_count: 1,
storage_cost_atto: "0".to_string(),
gas_cost_wei: 0,
merkle_payment_timestamp: ts,
}
}
fn fat_merkle_proof_bytes(ts: u64) -> Vec<u8> {
use ant_protocol::evm::{
Amount, MerklePaymentCandidateNode, MerklePaymentCandidatePool, MerklePaymentProof,
MerkleTree, RewardsAddress, CANDIDATES_PER_POOL,
};
use xor_name::XorName;
let xornames: Vec<XorName> = (0..4u8).map(|i| XorName([i; 32])).collect();
let tree = MerkleTree::from_xornames(xornames.clone()).unwrap();
let midpoint = tree.reward_candidates(ts).unwrap().remove(0);
let candidate_nodes: [MerklePaymentCandidateNode; CANDIDATES_PER_POOL] =
std::array::from_fn(|i| MerklePaymentCandidateNode {
pub_key: vec![i as u8; 32],
price: Amount::from(1024u64),
reward_address: RewardsAddress::new([i as u8; 20]),
merkle_payment_timestamp: ts,
signature: vec![i as u8; 64],
committed_key_count: 9_000,
commitment_pin: Some([7u8; 32]),
});
let pool = MerklePaymentCandidatePool {
midpoint_proof: midpoint,
candidate_nodes,
};
let address_proof = tree.generate_address_proof(0, xornames[0]).unwrap();
let mut proof = MerklePaymentProof::new(xornames[0], address_proof, pool);
proof.commitment_sidecars = vec![vec![0xAB; 5_000]; CANDIDATES_PER_POOL];
serialize_merkle_proof(&proof).unwrap()
}
#[test]
fn strip_removes_legacy_sidecars_and_is_idempotent() {
let ts = 1_000_000;
let fat = fat_merkle_proof_bytes(ts);
let mut proofs: HashMap<[u8; 32], Vec<u8>> = HashMap::new();
proofs.insert([0u8; 32], fat.clone());
proofs.insert([1u8; 32], vec![1, 2, 3]);
let mut receipt = MerkleBatchPaymentResult {
proofs,
chunk_count: 2,
storage_cost_atto: "0".to_string(),
gas_cost_wei: 0,
merkle_payment_timestamp: ts,
};
assert!(strip_commitment_sidecars(&mut receipt));
let slim = receipt.proofs.get(&[0u8; 32]).unwrap();
assert!(slim.len() < fat.len(), "stripped proof must shrink");
let proof = deserialize_merkle_proof(slim).unwrap();
assert!(proof.commitment_sidecars.is_empty());
assert_eq!(receipt.proofs.get(&[1u8; 32]).unwrap(), &vec![1, 2, 3]);
assert!(!strip_commitment_sidecars(&mut receipt));
}
#[test]
fn overwrite_receipt_is_atomic_and_leaves_no_tmp() -> Result<()> {
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_nanos();
let dir = std::env::temp_dir().join(format!("anselme-merkle-overwrite-test-{nanos}"));
fs::create_dir_all(&dir)?;
let path = dir.join("123_abcd");
fs::write(&path, b"pre-fix receipt bytes")?;
overwrite_receipt(&path, &dummy_receipt(42))?;
let reloaded = read_receipt(&path)?;
assert_eq!(reloaded.merkle_payment_timestamp, 42);
let leftover_tmps = fs::read_dir(&dir)?
.flatten()
.filter(|e| {
e.path()
.extension()
.is_some_and(|ext| ext.eq_ignore_ascii_case("tmp"))
})
.count();
assert_eq!(leftover_tmps, 0, "no tmp sibling may survive");
fs::remove_dir_all(&dir).ok();
Ok(())
}
#[test]
fn file_hash_key_is_stable() {
let a = file_hash_key("/tmp/some/file.bin");
let b = file_hash_key("/tmp/some/file.bin");
assert_eq!(a, b);
let c = file_hash_key("/tmp/some/other.bin");
assert_ne!(a, c);
}
#[test]
fn expired_filename_detected() {
let stale = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_secs()
.saturating_sub(PAYMENT_EXPIRATION_SECS + 60);
let name = format!("{stale}_abcd1234");
assert!(is_expired_filename(&name));
let fresh = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_secs()
.saturating_sub(60);
let name = format!("{fresh}_abcd1234");
assert!(!is_expired_filename(&name));
}
#[test]
fn malformed_filename_is_not_expired() {
assert!(!is_expired_filename("nonsense"));
assert!(!is_expired_filename("not_a_number_abcd1234"));
}
#[test]
fn roundtrip_save_load_delete() -> Result<()> {
let file_path = format!(
"/tmp/anselme-resumable-merkle-test-{}",
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos()
);
let ts = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_secs();
let receipt = dummy_receipt(ts);
let saved_path = save(&file_path, &receipt)?;
assert!(saved_path.exists());
let loaded = load_for_file(&file_path)?;
let (loaded_path, loaded_receipt) = loaded.expect("receipt should be loadable");
assert_eq!(loaded_path, saved_path);
assert_eq!(loaded_receipt.chunk_count, receipt.chunk_count);
assert_eq!(loaded_receipt.merkle_payment_timestamp, ts);
delete_for_file(&file_path)?;
assert!(load_for_file(&file_path)?.is_none());
Ok(())
}
}