Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,7 @@ jobs:
anvil --version
forge --version
- name: Run E2E tests (serial)
run: cargo test -p ant-core --features test-utils --test e2e_chunk --test e2e_data --test e2e_file --test e2e_payment --test e2e_security --test e2e_cost_estimate -- --test-threads=1
run: cargo test -p ant-core --features test-utils --test e2e_chunk --test e2e_data --test e2e_file --test e2e_payment --test e2e_security --test e2e_cost_estimate --test e2e_adr0004 -- --test-threads=1

test-merkle:
name: Merkle E2E (${{ matrix.os }})
Expand Down
18 changes: 9 additions & 9 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

12 changes: 6 additions & 6 deletions ant-core/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ tower-http = { version = "0.6.8", features = ["cors"] }
# under `ant_protocol::{evm, transport, pqc}`. This is the ONE pin for
# those three deps — do not add direct evmlib/saorsa-core/saorsa-pqc
# deps here or the version can skew between ant-client and ant-node.
ant-protocol = "2.2.2"
ant-protocol = "2.3.0"
xor_name = "5"
self_encryption = "0.36"
futures = "0.3"
Expand All @@ -62,10 +62,10 @@ sysinfo = { version = "0.32", default-features = false, features = ["system"] }
# `ant-protocol` pin above — a version skew pulls a second copy of
# `saorsa-core` into the graph and makes `ant_node`'s and `ant_protocol`'s
# `MultiAddr` mutually incompatible in `node/devnet.rs`. While the runtime
# `ant-protocol` pin above points at a git branch, this ant-node must point at
# the matching ant-node branch carrying the same saorsa-core / ant-protocol
# lineage rather than a released version.
ant-node = { version = "0.14.2", optional = true }
# `ant-protocol` pin above points at a released version, this ant-node must
# track the matching released version carrying the same saorsa-core /
# ant-protocol lineage.
ant-node = { version = "0.14.3", optional = true }
tracing-subscriber = { version = "0.3", features = ["env-filter"] }

[target.'cfg(unix)'.dependencies]
Expand Down Expand Up @@ -96,7 +96,7 @@ test-utils = []
# always compile even without the `devnet` feature. Pinned to the same
# version as the runtime dep so there is a single ant-node /
# saorsa-core version across the whole graph.
ant-node = { version = "0.14.2", features = ["test-utils"] }
ant-node = { version = "0.14.3", features = ["test-utils"] }
serial_test = "3"
anyhow = "1"
alloy = { version = "1.6", features = ["node-bindings"] }
Expand Down
2 changes: 1 addition & 1 deletion ant-core/examples/bench-quoting.rs
Original file line number Diff line number Diff line change
Expand Up @@ -531,7 +531,7 @@ async fn bench_merkle_once(client: &Client, rep: usize, concurrency: usize) -> R
&addrs_clone,
|body| match body {
ChunkMessageBody::MerkleCandidateQuoteResponse(
MerkleCandidateQuoteResponse::Success { candidate_node },
MerkleCandidateQuoteResponse::Success { candidate_node, .. },
) => match rmp_serde::from_slice::<MerklePaymentCandidateNode>(
&candidate_node,
) {
Expand Down
19 changes: 18 additions & 1 deletion ant-core/src/data/client/batch.rs
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,10 @@ pub struct PreparedChunk {
pub payment: SingleNodePayment,
/// Peer quotes for building `ProofOfPayment`.
pub peer_quotes: Vec<(EncodedPeerId, PaymentQuote)>,
/// ADR-0004: the signed commitments the bound quotes shipped, forwarded as
/// sidecars in the PUT bundle so storers cross-check synchronously. Empty
/// when every quote was baseline (no commitment to pin).
pub commitment_sidecars: Vec<Vec<u8>>,
}

/// Chunk paid but not yet stored. Produced by [`Client::batch_pay`].
Expand Down Expand Up @@ -210,6 +214,9 @@ fn build_paid_chunks(
peer_quotes: chunk.peer_quotes,
},
tx_hashes,
// ADR-0004: forward the bound quotes' commitments so storers
// cross-check synchronously; stripped before persistence node-side.
commitment_sidecars: chunk.commitment_sidecars,
};

let proof_bytes = serialize_single_node_proof(&proof)
Expand Down Expand Up @@ -272,11 +279,17 @@ impl Client {
// Use node-reported prices directly — no contract price fetch needed.
let mut peer_quotes = Vec::with_capacity(quotes_with_peers.len());
let mut quotes_for_payment = Vec::with_capacity(quotes_with_peers.len());
// ADR-0004: forward each bound quote's commitment sidecar (baseline
// quotes ship none); `get_store_quotes` already verified the binding.
let mut commitment_sidecars = Vec::new();

for (peer_id, _addrs, quote, price) in quotes_with_peers {
for (peer_id, _addrs, quote, price, commitment) in quotes_with_peers {
let encoded = peer_id_to_encoded(&peer_id)?;
peer_quotes.push((encoded, quote.clone()));
quotes_for_payment.push((quote, price));
if let Some(sidecar) = commitment {
commitment_sidecars.push(sidecar);
}
}

let payment = SingleNodePayment::from_quotes(quotes_for_payment)
Expand All @@ -288,6 +301,7 @@ impl Client {
quoted_peers,
payment,
peer_quotes,
commitment_sidecars,
}))
}

Expand Down Expand Up @@ -1095,6 +1109,7 @@ mod tests {
quoted_peers: Vec::new(),
payment: SingleNodePayment { quotes },
peer_quotes: Vec::new(),
commitment_sidecars: Vec::new(),
}
}

Expand Down Expand Up @@ -1206,6 +1221,8 @@ mod tests {
rewards_address: RewardsAddress::new([1u8; 20]),
pub_key: vec![],
signature: vec![],
committed_key_count: 0,
commitment_pin: None,
};
(EncodedPeerId::from([i as u8; 32]), quote)
})
Expand Down
203 changes: 200 additions & 3 deletions ant-core/src/data/client/cached_merkle.rs
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,8 @@
use crate::config;
use crate::data::client::merkle::MerkleBatchPaymentResult;
use crate::error::Result;
use std::fs::{self, DirEntry, File};
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};
Expand Down Expand Up @@ -283,11 +284,112 @@ fn is_expired_filename(name: &str) -> bool {

fn read_receipt(path: &Path) -> Result<MerkleBatchPaymentResult> {
let handle = File::open(path)?;
let receipt: MerkleBatchPaymentResult = rmp_serde::decode::from_read(BufReader::new(handle))
.map_err(|e| crate::error::Error::Io(std::io::Error::other(e.to_string())))?;
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()
);
// Best-effort write-back so the strip happens once; a failure here
// only means we re-strip on the next load.
if let Err(e) = overwrite_receipt(path, &receipt) {
warn!(
"Failed to persist slimmed merkle receipt at {}: {e}",
path.display()
);
}
}

Ok(receipt)
}

/// Strip ADR-0004 commitment sidecars from every proof in a cached receipt.
///
/// Receipts saved by clients built before sidecars were dropped from the
/// per-chunk merkle proofs carry all 16 winner-pool sidecars in EVERY proof
/// (~214 KB per proof), which pushed the proof past the storer's
/// payment-proof size cap — resuming with them would replay the exact
/// failure the slim proofs fixed. Stripping is always safe: the pool hash
/// and address branch stay exactly as paid on-chain, and storers resolve
/// commitment pins from gossip or a `GetCommitmentByPin` fetch. Returns
/// whether anything was stripped.
fn strip_commitment_sidecars(receipt: &mut MerkleBatchPaymentResult) -> bool {
let mut stripped = false;
for proof_bytes in receipt.proofs.values_mut() {
// Non-merkle or unreadable proof bytes are left untouched; the
// storer remains the judge of those.
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
}

/// Overwrite a cached receipt via `tmp + fsync + rename` (same canonical
/// path), mirroring `cached_single::write_receipt_atomic`: an interrupted
/// write must never truncate the only copy of a paid receipt — losing it
/// forces the user to re-pay. The tmp name carries pid + nanos and is opened
/// with `create_new`, so concurrent migrations (threads or processes) can
/// never share a tmp inode — a residual name collision fails this best-effort
/// write instead of corrupting it, and the next load simply re-strips. A
/// leftover tmp from a crash is harmless (the canonical stays intact until
/// rename) and ages out with the same `<ts>_` filename prefix.
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(),
)));
}
// `into_inner` flushes; a swallowed flush error here would defeat
// the atomicity, so surface it.
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::*;
Expand All @@ -305,6 +407,101 @@ mod tests {
}
}

/// A serialized merkle proof carrying legacy commitment sidecars, as saved
/// by clients built before sidecars were dropped from per-chunk proofs.
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()
}

/// DEV-01 recovery: a receipt cached by a pre-fix client carries proofs
/// with all 16 commitment sidecars (~342 KB each on the wire) — resuming
/// with them would replay the storer's size rejection. Loading must strip
/// the sidecars while leaving the paid pool and address branch intact.
#[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());
// A non-merkle blob must pass through untouched.
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]);

// Second pass finds nothing left to strip.
assert!(!strip_commitment_sidecars(&mut receipt));
}

/// The strip write-back must replace the canonical receipt atomically:
/// new content lands, and no `.tmp` sibling survives a successful write.
#[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");
Expand Down
Loading
Loading