diff --git a/Cargo.lock b/Cargo.lock
index 93aace8a..8b8cc783 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -5217,6 +5217,8 @@ dependencies = [
 [[package]]
 name = "saorsa-core"
 version = "0.18.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0d3d05b97f789b0e0b7d54b2fe05f05edfafb94f72d065482fc20ce1e9fab69e"
 dependencies = [
  "anyhow",
  "async-trait",
diff --git a/src/bin/ant-node/platform.rs b/src/bin/ant-node/platform.rs
index 50e02e84..e4a3749e 100644
--- a/src/bin/ant-node/platform.rs
+++ b/src/bin/ant-node/platform.rs
@@ -24,6 +24,7 @@ pub type AppNapActivity = objc2::rc::Retained<objc2::runtime::NSObject>;
 ///
 /// Returns an error string if the activity could not be created.
 #[cfg(target_os = "macos")]
+#[allow(clippy::unnecessary_wraps)] // Result kept for caller compatibility with non-macOS variant
 pub fn disable_app_nap() -> Result<AppNapActivity, String> {
     #[allow(unsafe_code)]
     // SAFETY: We call well-documented Cocoa APIs through the objc2 safe
diff --git a/src/payment/mod.rs b/src/payment/mod.rs
index 9eb46352..d6cd0d50 100644
--- a/src/payment/mod.rs
+++ b/src/payment/mod.rs
@@ -59,5 +59,8 @@ pub use quote::{
     XorName,
 };
 pub use single_node::SingleNodePayment;
-pub use verifier::{EvmVerifierConfig, PaymentStatus, PaymentVerifier, PaymentVerifierConfig};
+pub use verifier::{
+    EvmVerifierConfig, PaymentStatus, PaymentVerifier, PaymentVerifierConfig,
+    MAX_PAYMENT_PROOF_SIZE_BYTES, MIN_PAYMENT_PROOF_SIZE_BYTES,
+};
 pub use wallet::{is_valid_address, parse_rewards_address, WalletConfig};
diff --git a/src/payment/quote.rs b/src/payment/quote.rs
index 8f62510b..d645c2a3 100644
--- a/src/payment/quote.rs
+++ b/src/payment/quote.rs
@@ -746,4 +746,154 @@ mod tests {
             "Tampered timestamp should invalidate the ML-DSA-65 signature"
         );
     }
+
+    // =========================================================================
+    // verify_merkle_candidate_signature — direct tests
+    // =========================================================================
+
+    /// Helper: create a validly-signed `MerklePaymentCandidateNode`.
+    fn make_valid_merkle_candidate() -> MerklePaymentCandidateNode {
+        let ml_dsa = MlDsa65::new();
+        let (public_key, secret_key) = ml_dsa.generate_keypair().expect("keygen");
+
+        let rewards_address = RewardsAddress::new([0xABu8; 20]);
+        let timestamp = std::time::SystemTime::now()
+            .duration_since(std::time::UNIX_EPOCH)
+            .expect("system time")
+            .as_secs();
+        let metrics = ant_evm::QuotingMetrics {
+            data_size: 4096,
+            data_type: 0,
+            close_records_stored: 10,
+            records_per_type: vec![],
+            max_records: 500,
+            received_payment_count: 3,
+            live_time: 600,
+            network_density: None,
+            network_size: None,
+        };
+
+        let msg = MerklePaymentCandidateNode::bytes_to_sign(&metrics, &rewards_address, timestamp);
+        let sk = MlDsaSecretKey::from_bytes(secret_key.as_bytes()).expect("sk");
+        let signature = ml_dsa.sign(&sk, &msg).expect("sign").as_bytes().to_vec();
+
+        MerklePaymentCandidateNode {
+            pub_key: public_key.as_bytes().to_vec(),
+            quoting_metrics: metrics,
+            reward_address: rewards_address,
+            merkle_payment_timestamp: timestamp,
+            signature,
+        }
+    }
+
+    #[test]
+    fn test_verify_merkle_candidate_valid_signature() {
+        let candidate = make_valid_merkle_candidate();
+        assert!(
+            verify_merkle_candidate_signature(&candidate),
+            "Freshly signed merkle candidate must verify"
+        );
+    }
+
+    #[test]
+    fn test_verify_merkle_candidate_tampered_pub_key() {
+        let mut candidate = make_valid_merkle_candidate();
+        // Flip a byte in the public key
+        if let Some(byte) = candidate.pub_key.first_mut() {
+            *byte ^= 0xFF;
+        }
+        assert!(
+            !verify_merkle_candidate_signature(&candidate),
+            "Tampered pub_key must invalidate the signature"
+        );
+    }
+
+    #[test]
+    fn test_verify_merkle_candidate_tampered_reward_address() {
+        let mut candidate = make_valid_merkle_candidate();
+        candidate.reward_address = RewardsAddress::new([0xFFu8; 20]);
+        assert!(
+            !verify_merkle_candidate_signature(&candidate),
+            "Tampered reward_address must invalidate the signature"
+        );
+    }
+
+    #[test]
+    fn test_verify_merkle_candidate_tampered_metrics() {
+        let mut candidate = make_valid_merkle_candidate();
+        candidate.quoting_metrics.data_size = 999_999;
+        assert!(
+            !verify_merkle_candidate_signature(&candidate),
+            "Tampered quoting_metrics must invalidate the signature"
+        );
+    }
+
+    #[test]
+    fn test_verify_merkle_candidate_tampered_signature_byte() {
+        let mut candidate = make_valid_merkle_candidate();
+        if let Some(byte) = candidate.signature.first_mut() {
+            *byte ^= 0xFF;
+        }
+        assert!(
+            !verify_merkle_candidate_signature(&candidate),
+            "Tampered signature byte must fail verification"
+        );
+    }
+
+    #[test]
+    fn test_verify_merkle_candidate_empty_pub_key() {
+        let mut candidate = make_valid_merkle_candidate();
+        candidate.pub_key = vec![];
+        assert!(
+            !verify_merkle_candidate_signature(&candidate),
+            "Empty pub_key must fail verification"
+        );
+    }
+
+    #[test]
+    fn test_verify_merkle_candidate_empty_signature() {
+        let mut candidate = make_valid_merkle_candidate();
+        candidate.signature = vec![];
+        assert!(
+            !verify_merkle_candidate_signature(&candidate),
+            "Empty signature must fail verification"
+        );
+    }
+
+    #[test]
+    fn test_verify_merkle_candidate_wrong_length_signature() {
+        let mut candidate = make_valid_merkle_candidate();
+        // ML-DSA-65 signatures are 3309 bytes; use a truncated one
+        candidate.signature = vec![0xAA; 100];
+        assert!(
+            !verify_merkle_candidate_signature(&candidate),
+            "Wrong-length signature must fail verification"
+        );
+    }
+
+    #[test]
+    fn test_verify_merkle_candidate_wrong_length_pub_key() {
+        let mut candidate = make_valid_merkle_candidate();
+        // ML-DSA-65 pub keys are 1952 bytes; use a truncated one
+        candidate.pub_key = vec![0xBB; 100];
+        assert!(
+            !verify_merkle_candidate_signature(&candidate),
+            "Wrong-length pub_key must fail verification"
+        );
+    }
+
+    #[test]
+    fn test_verify_merkle_candidate_cross_key_rejection() {
+        // Sign with one key pair, then swap in a different valid public key
+        let candidate = make_valid_merkle_candidate();
+        let ml_dsa = MlDsa65::new();
+        let (other_pk, _) = ml_dsa.generate_keypair().expect("keygen");
+
+        let mut swapped = candidate;
+        swapped.pub_key = other_pk.as_bytes().to_vec();
+        assert!(
+            !verify_merkle_candidate_signature(&swapped),
+            "Signature from key A must not verify under key B"
+        );
+    }
 }
diff --git a/src/payment/verifier.rs b/src/payment/verifier.rs
index 7c00a788..3d3d798b 100644
--- a/src/payment/verifier.rs
+++ b/src/payment/verifier.rs
@@ -26,7 +26,7 @@ use tracing::{debug, info};
 ///
 /// This minimum ensures the proof contains at least a basic cryptographic hash or identifier.
 /// Proofs smaller than this are rejected as they cannot contain sufficient payment information.
-const MIN_PAYMENT_PROOF_SIZE_BYTES: usize = 32;
+pub const MIN_PAYMENT_PROOF_SIZE_BYTES: usize = 32;
 
 /// Maximum allowed size for a payment proof in bytes (256 KB).
 ///
@@ -34,7 +34,7 @@ const MIN_PAYMENT_PROOF_SIZE_BYTES: usize = 32;
 /// Merkle proofs include 16 candidate nodes (each with ~1,952-byte ML-DSA pub key
 /// and ~3,309-byte signature) plus merkle branch hashes, totaling ~130 KB.
 /// 256 KB provides headroom while still capping memory during verification.
-const MAX_PAYMENT_PROOF_SIZE_BYTES: usize = 262_144;
+pub const MAX_PAYMENT_PROOF_SIZE_BYTES: usize = 262_144;
 
 /// Maximum age of a payment quote before it's considered expired (24 hours).
 /// Prevents replaying old cheap quotes against nearly-full nodes.
@@ -1490,4 +1490,455 @@ mod tests {
             assert!(found.is_none(), "Unknown pool hash should not be in cache");
         }
     }
+
+    // =========================================================================
+    // Merkle verification unit tests
+    // =========================================================================
+
+    /// Helper: build 16 validly-signed ML-DSA-65 candidate nodes.
+    fn make_candidate_nodes(
+        timestamp: u64,
+    ) -> [ant_evm::merkle_payments::MerklePaymentCandidateNode;
+           ant_evm::merkle_payments::CANDIDATES_PER_POOL] {
+        use ant_evm::merkle_payments::{MerklePaymentCandidateNode, CANDIDATES_PER_POOL};
+        use saorsa_core::MlDsa65;
+        use saorsa_pqc::pqc::types::MlDsaSecretKey;
+        use saorsa_pqc::pqc::MlDsaOperations;
+
+        std::array::from_fn::<_, CANDIDATES_PER_POOL, _>(|i| {
+            let ml_dsa = MlDsa65::new();
+            let (pub_key, secret_key) = ml_dsa.generate_keypair().expect("keygen");
+            let metrics = ant_evm::QuotingMetrics {
+                data_size: 1024,
+                data_type: 0,
+                close_records_stored: i * 10,
+                records_per_type: vec![],
+                max_records: 500,
+                received_payment_count: 0,
+                live_time: 100,
+                network_density: None,
+                network_size: None,
+            };
+            #[allow(clippy::cast_possible_truncation)]
+            let reward_address = RewardsAddress::new([i as u8; 20]);
+            let msg =
+                MerklePaymentCandidateNode::bytes_to_sign(&metrics, &reward_address, timestamp);
+            let sk = MlDsaSecretKey::from_bytes(secret_key.as_bytes()).expect("sk");
+            let signature = ml_dsa.sign(&sk, &msg).expect("sign").as_bytes().to_vec();
+
+            MerklePaymentCandidateNode {
+                pub_key: pub_key.as_bytes().to_vec(),
+                quoting_metrics: metrics,
+                reward_address,
+                merkle_payment_timestamp: timestamp,
+                signature,
+            }
+        })
+    }
+
+    /// Helper: build a valid `MerklePaymentProof` with real ML-DSA-65
+    /// signatures. Returns the raw proof, pool hash, xorname, and timestamp.
+    fn make_valid_merkle_proof() -> (
+        ant_evm::merkle_payments::MerklePaymentProof,
+        evmlib::merkle_batch_payment::PoolHash,
+        [u8; 32],
+        u64,
+    ) {
+        use ant_evm::merkle_payments::{
+            MerklePaymentCandidatePool, MerklePaymentProof, MerkleTree,
+        };
+
+        let timestamp = std::time::SystemTime::now()
+            .duration_since(std::time::UNIX_EPOCH)
+            .expect("system time")
+            .as_secs();
+
+        let addresses: Vec<xor_name::XorName> = (0..4u8)
+            .map(|i| xor_name::XorName::from_content(&[i]))
+            .collect();
+        let tree = MerkleTree::from_xornames(addresses.clone()).expect("tree");
+
+        let candidate_nodes = make_candidate_nodes(timestamp);
+
+        let reward_candidates = tree
+            .reward_candidates(timestamp)
+            .expect("reward candidates");
+        let midpoint_proof = reward_candidates
+            .first()
+            .expect("at least one candidate")
+            .clone();
+
+        let pool = MerklePaymentCandidatePool {
+            midpoint_proof,
+            candidate_nodes,
+        };
+
+        let first_address = *addresses.first().expect("first address");
+        let address_proof = tree
+            .generate_address_proof(0, first_address)
+            .expect("proof");
+
+        let merkle_proof = MerklePaymentProof::new(first_address, address_proof, pool);
+        let pool_hash = merkle_proof.winner_pool_hash();
+        let xorname = first_address.0;
+
+        (merkle_proof, pool_hash, xorname, timestamp)
+    }
+
+    /// Helper: build a minimal valid `MerklePaymentProof` with real ML-DSA-65
+    /// signatures. Returns `(xorname, serialized_tagged_proof, pool_hash, timestamp)`.
+    fn make_valid_merkle_proof_bytes() -> (
+        [u8; 32],
+        Vec<u8>,
+        evmlib::merkle_batch_payment::PoolHash,
+        u64,
+    ) {
+        let (merkle_proof, pool_hash, xorname, timestamp) = make_valid_merkle_proof();
+        let tagged = crate::payment::proof::serialize_merkle_proof(&merkle_proof)
+            .expect("serialize merkle proof");
+        (xorname, tagged, pool_hash, timestamp)
+    }
+
+    #[tokio::test]
+    async fn test_merkle_address_mismatch_rejected() {
+        let verifier = create_test_verifier();
+        let (_correct_xorname, tagged_proof, _pool_hash, _ts) = make_valid_merkle_proof_bytes();
+
+        // Use a DIFFERENT xorname than what the proof was built for
+        let wrong_xorname = [0xFFu8; 32];
+
+        let result = verifier
+            .verify_payment(&wrong_xorname, Some(&tagged_proof))
+            .await;
+
+        assert!(
+            result.is_err(),
+            "Should reject merkle proof address mismatch"
+        );
+        let err_msg = format!("{}", result.expect_err("should fail"));
+        assert!(
+            err_msg.contains("address mismatch") || err_msg.contains("Merkle proof address"),
+            "Error should mention address mismatch: {err_msg}"
+        );
+    }
+
+    #[tokio::test]
+    async fn test_merkle_malformed_body_rejected() {
+        let verifier = create_test_verifier();
+        let xorname = [0xA3u8; 32];
+
+        // Valid merkle tag but truncated/corrupted msgpack body
+        let mut bad_proof = vec![crate::ant_protocol::PROOF_TAG_MERKLE];
+        bad_proof.extend_from_slice(&[0xDE, 0xAD, 0xBE, 0xEF]);
+        bad_proof.extend_from_slice(&[0x00; 10]);
+        // pad to minimum size
+        while bad_proof.len() < MIN_PAYMENT_PROOF_SIZE_BYTES {
+            bad_proof.push(0x00);
+        }
+
+        let result = verifier.verify_payment(&xorname, Some(&bad_proof)).await;
+
+        assert!(result.is_err(), "Should reject malformed merkle body");
+        let err_msg = format!("{}", result.expect_err("should fail"));
+        assert!(
+            err_msg.contains("deserialize") || err_msg.contains("Failed"),
+            "Error should mention deserialization: {err_msg}"
+        );
+    }
+
+    #[test]
+    fn test_merkle_proof_serialized_size_within_limits() {
+        let (_xorname, tagged_proof, _pool_hash, _ts) = make_valid_merkle_proof_bytes();
+
+        // 16 ML-DSA-65 candidates (~1952 pub key + ~3309 sig each) ≈ 84 KB + tree data
+        assert!(
+            tagged_proof.len() >= MIN_PAYMENT_PROOF_SIZE_BYTES,
+            "Merkle proof ({} bytes) should be >= min {} bytes",
+            tagged_proof.len(),
+            MIN_PAYMENT_PROOF_SIZE_BYTES
+        );
+        assert!(
+            tagged_proof.len() <= MAX_PAYMENT_PROOF_SIZE_BYTES,
+            "Merkle proof ({} bytes) should be <= max {} bytes",
+            tagged_proof.len(),
+            MAX_PAYMENT_PROOF_SIZE_BYTES
+        );
+    }
+
+    #[test]
+    fn test_merkle_proof_tag_is_correct() {
+        let (_xorname, tagged_proof, _pool_hash, _ts) = make_valid_merkle_proof_bytes();
+
+        assert_eq!(
+            tagged_proof.first().copied(),
+            Some(crate::ant_protocol::PROOF_TAG_MERKLE),
+            "First byte must be the merkle tag"
+        );
+        assert_eq!(
+            crate::payment::proof::detect_proof_type(&tagged_proof),
+            Some(crate::payment::proof::ProofType::Merkle)
+        );
+    }
+
+    #[test]
+    fn test_pool_cache_eviction() {
+        use evmlib::merkle_batch_payment::PoolHash;
+
+        let config = PaymentVerifierConfig {
+            evm: EvmVerifierConfig::default(),
+            cache_capacity: 100,
+            local_rewards_address: RewardsAddress::new([1u8; 20]),
+        };
+        let verifier = PaymentVerifier::new(config);
+
+        // Fill the pool cache to capacity (DEFAULT_POOL_CACHE_CAPACITY = 1000)
+        for i in 0..DEFAULT_POOL_CACHE_CAPACITY {
+            let mut hash: PoolHash = [0u8; 32];
+            // Write index bytes into the hash
+            let idx_bytes = i.to_le_bytes();
+            for (j, b) in idx_bytes.iter().enumerate() {
+                if j < 32 {
+                    hash[j] = *b;
+                }
+            }
+            let info = ant_evm::merkle_payments::OnChainPaymentInfo {
+                depth: 4,
+                merkle_payment_timestamp: 1_700_000_000,
+                paid_node_addresses: vec![],
+            };
+            verifier.pool_cache.lock().put(hash, info);
+        }
+
+        assert_eq!(
+            verifier.pool_cache.lock().len(),
+            DEFAULT_POOL_CACHE_CAPACITY
+        );
+
+        // Insert one more — should evict the oldest
+        let overflow_hash: PoolHash = [0xFFu8; 32];
+        let info = ant_evm::merkle_payments::OnChainPaymentInfo {
+            depth: 8,
+            merkle_payment_timestamp: 1_800_000_000,
+            paid_node_addresses: vec![],
+        };
+        verifier.pool_cache.lock().put(overflow_hash, info);
+
+        // Size should still be at capacity (not capacity + 1)
+        assert_eq!(
+            verifier.pool_cache.lock().len(),
+            DEFAULT_POOL_CACHE_CAPACITY
+        );
+
+        // The new entry should be present
+        let found = verifier.pool_cache.lock().get(&overflow_hash).cloned();
+        assert!(
+            found.is_some(),
+            "Newly inserted pool hash should be present"
+        );
+        assert_eq!(found.expect("info").depth, 8);
+    }
+
+    #[test]
+    fn test_pool_cache_concurrent_access() {
+        use evmlib::merkle_batch_payment::PoolHash;
+        use std::sync::Arc;
+
+        let verifier = Arc::new(create_test_verifier());
+
+        let mut handles = Vec::new();
+        for i in 0..20u8 {
+            let v = verifier.clone();
+            handles.push(std::thread::spawn(move || {
+                let hash: PoolHash = [i; 32];
+                let info = ant_evm::merkle_payments::OnChainPaymentInfo {
+                    depth: i,
+                    merkle_payment_timestamp: u64::from(i) * 1000,
+                    paid_node_addresses: vec![],
+                };
+                v.pool_cache.lock().put(hash, info);
+
+                // Read back
+                let found = v.pool_cache.lock().get(&hash).cloned();
+                assert!(found.is_some(), "Entry {i} should be readable after insert");
+            }));
+        }
+
+        for handle in handles {
+            handle.join().expect("thread panicked");
+        }
+
+        // All 20 entries should be present (well under 1000 capacity)
+        assert_eq!(verifier.pool_cache.lock().len(), 20);
+    }
+
+    #[tokio::test]
+    async fn test_merkle_tampered_candidate_signature_rejected() {
+        let verifier = create_test_verifier();
+
+        let (mut merkle_proof, _pool_hash, xorname, timestamp) = make_valid_merkle_proof();
+
+        // Tamper the first candidate's signature
+        if let Some(byte) = merkle_proof
+            .winner_pool
+            .candidate_nodes
+            .first_mut()
+            .and_then(|c| c.signature.first_mut())
+        {
+            *byte ^= 0xFF;
+        }
+
+        // Recompute pool hash after tampering (signature change alters the hash)
+        let tampered_pool_hash = merkle_proof.winner_pool_hash();
+
+        // Pre-populate pool cache so we skip the on-chain query
+        {
+            let info = ant_evm::merkle_payments::OnChainPaymentInfo {
+                depth: 4,
+                merkle_payment_timestamp: timestamp,
+                paid_node_addresses: vec![],
+            };
+            verifier.pool_cache.lock().put(tampered_pool_hash, info);
+        }
+
+        let tagged =
+            crate::payment::proof::serialize_merkle_proof(&merkle_proof).expect("serialize");
+
+        let result = verifier.verify_payment(&xorname, Some(&tagged)).await;
+
+        assert!(
+            result.is_err(),
+            "Should reject merkle proof with tampered candidate signature"
+        );
+        let err_msg = format!("{}", result.expect_err("should fail"));
+        assert!(
+            err_msg.contains("Invalid ML-DSA-65 signature"),
+            "Error should mention invalid signature: {err_msg}"
+        );
+    }
+
+    #[tokio::test]
+    async fn test_merkle_timestamp_mismatch_rejected() {
+        let verifier = create_test_verifier();
+
+        let (xorname, tagged, pool_hash, timestamp) = make_valid_merkle_proof_bytes();
+
+        // Pre-populate pool cache with a DIFFERENT timestamp than the candidates
+        {
+            let mismatched_ts = timestamp + 9999;
+            let info = ant_evm::merkle_payments::OnChainPaymentInfo {
+                depth: 4,
+                merkle_payment_timestamp: mismatched_ts,
+                paid_node_addresses: vec![],
+            };
+            verifier.pool_cache.lock().put(pool_hash, info);
+        }
+
+        let result = verifier.verify_payment(&xorname, Some(&tagged)).await;
+
+        assert!(
+            result.is_err(),
+            "Should reject merkle proof with timestamp mismatch"
+        );
+        let err_msg = format!("{}", result.expect_err("should fail"));
+        assert!(
+            err_msg.contains("timestamp mismatch"),
+            "Error should mention timestamp mismatch: {err_msg}"
+        );
+    }
+
+    #[tokio::test]
+    async fn test_merkle_paid_node_index_out_of_bounds_rejected() {
+        let verifier = create_test_verifier();
+        let (xorname, tagged_proof, pool_hash, ts) = make_valid_merkle_proof_bytes();
+
+        // The test tree has 4 addresses → depth 2. We must match the tree depth
+        // so verify_merkle_proof passes the depth check, then the paid node
+        // index out-of-bounds check fires.
+        {
+            let info = ant_evm::merkle_payments::OnChainPaymentInfo {
+                depth: 2,
+                merkle_payment_timestamp: ts,
+                paid_node_addresses: vec![
+                    // First paid node: valid (matches candidate 0)
+                    (RewardsAddress::new([0u8; 20]), 0),
+                    // Second paid node: index 999 is way beyond CANDIDATES_PER_POOL (16)
+                    (RewardsAddress::new([1u8; 20]), 999),
+                ],
+            };
+            verifier.pool_cache.lock().put(pool_hash, info);
+        }
+
+        let result = verifier.verify_payment(&xorname, Some(&tagged_proof)).await;
+
+        assert!(
+            result.is_err(),
+            "Should reject paid node index out of bounds"
+        );
+        let err_msg = format!("{}", result.expect_err("should fail"));
+        assert!(
+            err_msg.contains("out of bounds"),
+            "Error should mention out of bounds: {err_msg}"
+        );
+    }
+
+    #[tokio::test]
+    async fn test_merkle_paid_node_address_mismatch_rejected() {
+        let verifier = create_test_verifier();
+        let (xorname, tagged_proof, pool_hash, ts) = make_valid_merkle_proof_bytes();
+
+        // Tree has depth 2, so provide 2 paid node entries.
+        // Both use valid indices but the second has a wrong reward address.
+        {
+            let info = ant_evm::merkle_payments::OnChainPaymentInfo {
+                depth: 2,
+                merkle_payment_timestamp: ts,
+                paid_node_addresses: vec![
+                    // Index 0 with matching address [0x00; 20]
+                    (RewardsAddress::new([0u8; 20]), 0),
+                    // Index 1 with WRONG address — candidate 1's address is [0x01; 20]
+                    (RewardsAddress::new([0xFF; 20]), 1),
+                ],
+            };
+            verifier.pool_cache.lock().put(pool_hash, info);
+        }
+
+        let result = verifier.verify_payment(&xorname, Some(&tagged_proof)).await;
+
+        assert!(result.is_err(), "Should reject paid node address mismatch");
+        let err_msg = format!("{}", result.expect_err("should fail"));
+        assert!(
+            err_msg.contains("address mismatch"),
+            "Error should mention address mismatch: {err_msg}"
+        );
+    }
+
+    #[tokio::test]
+    async fn test_merkle_wrong_depth_rejected() {
+        let verifier = create_test_verifier();
+        let (xorname, tagged_proof, pool_hash, ts) = make_valid_merkle_proof_bytes();
+
+        // Pre-populate pool cache with depth=3 but only 1 paid node address
+        // (depth must equal paid_node_addresses.len())
+        {
+            let info = ant_evm::merkle_payments::OnChainPaymentInfo {
+                depth: 3,
+                merkle_payment_timestamp: ts,
+                paid_node_addresses: vec![(RewardsAddress::new([0u8; 20]), 0)],
+            };
+            verifier.pool_cache.lock().put(pool_hash, info);
+        }
+
+        let result = verifier.verify_payment(&xorname, Some(&tagged_proof)).await;
+
+        assert!(
+            result.is_err(),
+            "Should reject mismatched depth vs paid node count"
+        );
+        let err_msg = format!("{}", result.expect_err("should fail"));
+        assert!(
+            err_msg.contains("Wrong number of paid nodes")
+                || err_msg.contains("verification failed"),
+            "Error should mention depth/count mismatch: {err_msg}"
+        );
+    }
 }
diff --git a/tests/e2e/merkle_payment.rs b/tests/e2e/merkle_payment.rs
new file mode 100644
index 00000000..4cb7a7ee
--- /dev/null
+++ b/tests/e2e/merkle_payment.rs
@@ -0,0 +1,485 @@
+//! E2E tests for merkle batch payment verification across live nodes.
+//!
+//! These tests validate merkle payment security and correctness by sending
+//! crafted merkle proofs to live testnet nodes with `payment_enforcement: true`.
+//!
+//! **Test Coverage**:
+//! - Merkle-tagged garbage rejected
+//! - Valid merkle proof with wrong xorname rejected
+//! - Merkle proof with tampered candidate signatures rejected
+//! - Merkle proof construction, serialization, and size validation
+//! - Concurrent merkle proof verification across multiple nodes
+
+#![allow(clippy::unwrap_used, clippy::expect_used)]
+
+use super::harness::TestHarness;
+use super::testnet::TestNetworkConfig;
+use ant_evm::merkle_payments::{
+    MerklePaymentCandidateNode, MerklePaymentCandidatePool, MerklePaymentProof, MerkleTree,
+    CANDIDATES_PER_POOL,
+};
+use ant_evm::RewardsAddress;
+use ant_node::ant_protocol::{
+    ChunkMessage, ChunkMessageBody, ChunkPutRequest, ChunkPutResponse, ProtocolError,
+    PROOF_TAG_MERKLE,
+};
+use ant_node::compute_address;
+use ant_node::payment::{
+    serialize_merkle_proof, MAX_PAYMENT_PROOF_SIZE_BYTES, MIN_PAYMENT_PROOF_SIZE_BYTES,
+};
+use evmlib::quoting_metrics::QuotingMetrics;
+use evmlib::testnet::Testnet;
+use rand::Rng;
+use saorsa_core::MlDsa65;
+use saorsa_pqc::pqc::types::MlDsaSecretKey;
+use saorsa_pqc::pqc::MlDsaOperations;
+use serial_test::serial;
+use std::time::Duration;
+use tokio::time::sleep;
+use tracing::info;
+
+// ---------------------------------------------------------------------------
+// Helpers
+// ---------------------------------------------------------------------------
+
+/// Check if a `ChunkMessageBody` indicates a rejection (payment or address error).
+fn is_rejection(body: &ChunkMessageBody) -> bool {
+    matches!(
+        body,
+        ChunkMessageBody::PutResponse(
+            ChunkPutResponse::PaymentRequired { .. }
+                | ChunkPutResponse::Error(
+                    ProtocolError::PaymentFailed(_) | ProtocolError::AddressMismatch { .. }
+                )
+        )
+    )
+}
+
+/// Send a PUT request directly to a node's `AntProtocol` handler.
+async fn send_put_to_node(
+    harness: &TestHarness,
+    node_index: usize,
+    request: ChunkPutRequest,
+) -> Result<ChunkMessage, String> {
+    let node = harness
+        .test_node(node_index)
+        .ok_or_else(|| format!("Node {node_index} not found"))?;
+    let protocol = node
+        .ant_protocol
+        .as_ref()
+        .ok_or("No ant_protocol on node")?;
+
+    let request_id: u64 = rand::thread_rng().gen();
+    let message = ChunkMessage {
+        request_id,
+        body: ChunkMessageBody::PutRequest(request),
+    };
+    let message_bytes = message
+        .encode()
+        .map_err(|e| format!("Encode failed: {e}"))?;
+    let response_bytes = protocol
+        .try_handle_request(&message_bytes)
+        .await
+        .map_err(|e| format!("Handle failed: {e}"))?
+        .ok_or("No response returned (unexpected None)")?;
+    ChunkMessage::decode(&response_bytes).map_err(|e| format!("Decode failed: {e}"))
+}
+
+/// Create a lightweight test harness with payment enforcement and Anvil wiring.
+async fn setup_enforcement_env() -> Result<(TestHarness, Testnet), Box<dyn std::error::Error>> {
+    let testnet = Testnet::new().await;
+    let network = testnet.to_network();
+    let config = TestNetworkConfig::minimal()
+        .with_payment_enforcement()
+        .with_evm_network(network);
+    let harness = TestHarness::setup_with_config(config).await?;
+    sleep(Duration::from_secs(5)).await;
+    Ok((harness, testnet))
+}
+
+/// Data for a single entry in the merkle tree: the content bytes and the
+/// BLAKE3-derived xorname used as the chunk address.
+struct TreeEntry {
+    content: Vec<u8>,
+    address: xor_name::XorName,
+}
+
+/// Result of building a valid merkle proof.
+struct ValidMerkleProof {
+    /// The tree entry the proof is bound to (first address).
+    target: TreeEntry,
+    /// The serialized & tagged proof bytes.
+    tagged_proof: Vec<u8>,
+    /// All entries in the tree (including `target`).
+    entries: Vec<TreeEntry>,
+}
+
+/// Build a valid `MerklePaymentProof` with real ML-DSA-65 signatures.
+///
+/// Addresses are derived via BLAKE3 (`compute_address`) so that they match
+/// the protocol handler's address verification step.
+fn build_valid_merkle_proof() -> ValidMerkleProof {
+    let timestamp = std::time::SystemTime::now()
+        .duration_since(std::time::UNIX_EPOCH)
+        .expect("system time")
+        .as_secs();
+
+    // Build content blobs and derive BLAKE3 addresses for the merkle tree.
+    let entries: Vec<TreeEntry> = (0..4u8)
+        .map(|i| {
+            let content = vec![i; 64];
+            let blake3_hash = compute_address(&content);
+            TreeEntry {
+                content,
+                address: xor_name::XorName(blake3_hash),
+            }
+        })
+        .collect();
+
+    let addresses: Vec<xor_name::XorName> = entries.iter().map(|e| e.address).collect();
+    let tree = MerkleTree::from_xornames(addresses).expect("tree");
+
+    let candidate_nodes: [MerklePaymentCandidateNode; CANDIDATES_PER_POOL] =
+        build_candidate_nodes(timestamp);
+
+    let reward_candidates = tree
+        .reward_candidates(timestamp)
+        .expect("reward candidates");
+    let midpoint_proof = reward_candidates
+        .first()
+        .expect("at least one candidate")
+        .clone();
+
+    let pool = MerklePaymentCandidatePool {
+        midpoint_proof,
+        candidate_nodes,
+    };
+
+    let first_address = entries.first().expect("first entry").address;
+    let address_proof = tree
+        .generate_address_proof(0, first_address)
+        .expect("proof");
+
+    let merkle_proof = MerklePaymentProof::new(first_address, address_proof, pool);
+    let tagged = serialize_merkle_proof(&merkle_proof).expect("serialize merkle proof");
+
+    ValidMerkleProof {
+        target: TreeEntry {
+            content: entries.first().expect("first entry").content.clone(),
+            address: first_address,
+        },
+        tagged_proof: tagged,
+        entries,
+    }
+}
+
+/// Build 16 validly-signed ML-DSA-65 candidate nodes for a merkle proof.
+fn build_candidate_nodes(timestamp: u64) -> [MerklePaymentCandidateNode; CANDIDATES_PER_POOL] {
+    std::array::from_fn(|i| {
+        let ml_dsa = MlDsa65::new();
+        let (pub_key, secret_key) = ml_dsa.generate_keypair().expect("keygen");
+        let metrics = QuotingMetrics {
+            data_size: 1024,
+            data_type: 0,
+            close_records_stored: i * 10,
+            records_per_type: vec![],
+            max_records: 500,
+            received_payment_count: 0,
+            live_time: 100,
+            network_density: None,
+            network_size: None,
+        };
+        #[allow(clippy::cast_possible_truncation)]
+        let reward_address = RewardsAddress::new([i as u8; 20]);
+        let msg = MerklePaymentCandidateNode::bytes_to_sign(&metrics, &reward_address, timestamp);
+        let sk = MlDsaSecretKey::from_bytes(secret_key.as_bytes()).expect("sk");
+        let signature = ml_dsa.sign(&sk, &msg).expect("sign").as_bytes().to_vec();
+
+        MerklePaymentCandidateNode {
+            pub_key: pub_key.as_bytes().to_vec(),
+            quoting_metrics: metrics,
+            reward_address,
+            merkle_payment_timestamp: timestamp,
+            signature,
+        }
+    })
+}
+
+// ===========================================================================
+// Category 1: Merkle-tagged garbage (Direct Protocol Handler)
+// ===========================================================================
+
+/// Attack: Send merkle-tagged garbage to a live node.
+/// Node MUST reject with a deserialization/payment error.
+#[tokio::test(flavor = "multi_thread")]
+#[serial]
+async fn test_attack_merkle_tagged_garbage() -> Result<(), Box<dyn std::error::Error>> {
+    info!("MERKLE ATTACK TEST: merkle-tagged garbage bytes");
+
+    let (harness, _testnet) = setup_enforcement_env().await?;
+
+    let test_data = b"Attack: merkle-tagged garbage";
+    let address = compute_address(test_data);
+
+    // Build garbage with correct merkle tag but invalid body
+    let mut garbage = vec![PROOF_TAG_MERKLE];
+    garbage.extend_from_slice(&[0xDE, 0xAD, 0xBE, 0xEF]);
+    // Pad to minimum proof size
+    while garbage.len() < MIN_PAYMENT_PROOF_SIZE_BYTES {
+        garbage.push(0x00);
+    }
+
+    let request = ChunkPutRequest::with_payment(address, test_data.to_vec(), garbage);
+
+    let response = send_put_to_node(&harness, 0, request)
+        .await
+        .map_err(|e| format!("Send failed: {e}"))?;
+
+    assert!(
+        is_rejection(&response.body),
+        "Merkle-tagged garbage MUST be rejected, got: {response:?}"
+    );
+    info!("Correctly rejected: merkle-tagged garbage");
+
+    harness.teardown().await?;
+    Ok(())
+}
+
+// ===========================================================================
+// Category 2: Valid merkle proof, wrong xorname
+// ===========================================================================
+
+/// Attack: Send a structurally valid merkle proof but for the wrong chunk.
+/// Node MUST reject with address mismatch.
+#[tokio::test(flavor = "multi_thread")]
+#[serial]
+async fn test_attack_merkle_proof_wrong_xorname() -> Result<(), Box<dyn std::error::Error>> {
+    info!("MERKLE ATTACK TEST: valid merkle proof, wrong xorname");
+
+    let (harness, _testnet) = setup_enforcement_env().await?;
+
+    // Build a valid merkle proof for one xorname
+    let proof = build_valid_merkle_proof();
+
+    // Try to use it for a completely different chunk
+    let wrong_data = b"This chunk was never paid for via this merkle proof";
+    let wrong_address = compute_address(wrong_data);
+
+    let request =
+        ChunkPutRequest::with_payment(wrong_address, wrong_data.to_vec(), proof.tagged_proof);
+
+    let response = send_put_to_node(&harness, 0, request)
+        .await
+        .map_err(|e| format!("Send failed: {e}"))?;
+
+    assert!(
+        is_rejection(&response.body),
+        "Merkle proof for wrong xorname MUST be rejected, got: {response:?}"
+    );
+    info!("Correctly rejected: merkle proof for wrong xorname");
+
+    harness.teardown().await?;
+    Ok(())
+}
+
+// ===========================================================================
+// Category 3: Tampered candidate signatures
+// ===========================================================================
+
+/// Attack: Send a merkle proof where one candidate's ML-DSA-65 signature is tampered.
+/// Node MUST reject — the tampered signature prevents pool verification.
+#[tokio::test(flavor = "multi_thread")]
+#[serial]
+async fn test_attack_merkle_tampered_candidate_signature() -> Result<(), Box<dyn std::error::Error>>
+{
+    info!("MERKLE ATTACK TEST: tampered candidate signature");
+
+    let (harness, _testnet) = setup_enforcement_env().await?;
+
+    // Build a valid proof then tamper the first candidate's signature
+    let proof = build_valid_merkle_proof();
+    let mut tampered_proof =
+        ant_node::payment::deserialize_merkle_proof(&proof.tagged_proof).expect("deserialize");
+    if let Some(byte) = tampered_proof
+        .winner_pool
+        .candidate_nodes
+        .first_mut()
+        .and_then(|c| c.signature.first_mut())
+    {
+        *byte ^= 0xFF;
+    }
+    let tagged_proof = serialize_merkle_proof(&tampered_proof).expect("re-serialize");
+
+    // Build request with correct content whose BLAKE3 hash matches the proof address
+    let content = proof.target.content;
+    let address = proof.target.address.0;
+    let request = ChunkPutRequest::with_payment(address, content, tagged_proof);
+
+    let response = send_put_to_node(&harness, 0, request)
+        .await
+        .map_err(|e| format!("Send failed: {e}"))?;
+
+    assert!(
+        is_rejection(&response.body),
+        "Merkle proof with tampered signature MUST be rejected, got: {response:?}"
+    );
+    info!("Correctly rejected: tampered candidate signature");
+
+    harness.teardown().await?;
+    Ok(())
+}
+
+// ===========================================================================
+// Category 4: Merkle proof construction and serialization validation
+// ===========================================================================
+
+/// Verify that a full merkle proof with 16 ML-DSA-65 candidates
+/// serializes within the allowed size limits.
+#[test]
+fn test_merkle_proof_serialized_size_e2e() {
+    let proof = build_valid_merkle_proof();
+
+    // 16 candidates with ~1952-byte pub keys and ~3309-byte signatures ≈ ~130 KB
+    assert!(
+        proof.tagged_proof.len() >= MIN_PAYMENT_PROOF_SIZE_BYTES,
+        "Merkle proof ({} bytes) must be >= {MIN_PAYMENT_PROOF_SIZE_BYTES} bytes",
+        proof.tagged_proof.len()
+    );
+    assert!(
+        proof.tagged_proof.len() <= MAX_PAYMENT_PROOF_SIZE_BYTES,
+        "Merkle proof ({} bytes) must be <= {MAX_PAYMENT_PROOF_SIZE_BYTES} bytes",
+        proof.tagged_proof.len()
+    );
+}
+
+/// Verify merkle proof tag detection works correctly end-to-end.
+#[test]
+fn test_merkle_proof_tag_detection_e2e() {
+    let proof = build_valid_merkle_proof();
+
+    assert_eq!(
+        proof.tagged_proof.first().copied(),
+        Some(PROOF_TAG_MERKLE),
+        "First byte must be PROOF_TAG_MERKLE (0x02)"
+    );
+
+    let detected = ant_node::payment::detect_proof_type(&proof.tagged_proof);
+    assert_eq!(
+        detected,
+        Some(ant_node::payment::ProofType::Merkle),
+        "detect_proof_type must identify as Merkle"
+    );
+}
+
+// ===========================================================================
+// Category 5: Concurrent merkle attacks across multiple nodes
+// ===========================================================================
+
+/// Attack: Send merkle-tagged garbage to ALL nodes concurrently.
+/// Every node MUST reject independently.
+#[tokio::test(flavor = "multi_thread")]
+#[serial]
+async fn test_attack_merkle_garbage_all_nodes_concurrent() -> Result<(), Box<dyn std::error::Error>>
+{
+    info!("MERKLE ATTACK TEST: garbage to all nodes concurrently");
+
+    let (harness, _testnet) = setup_enforcement_env().await?;
+
+    let test_data = b"Attack: concurrent merkle garbage";
+    let address = compute_address(test_data);
+
+    let mut garbage = vec![PROOF_TAG_MERKLE];
+    garbage.extend_from_slice(&[0xCA, 0xFE, 0xBA, 0xBE]);
+    while garbage.len() < MIN_PAYMENT_PROOF_SIZE_BYTES {
+        garbage.push(0x00);
+    }
+
+    // Send to all available nodes concurrently
+    let node_count = harness.node_count();
+    let mut handles = Vec::new();
+
+    for i in 0..node_count {
+        if harness.test_node(i).is_some() {
+            let request =
+                ChunkPutRequest::with_payment(address, test_data.to_vec(), garbage.clone());
+            let msg = ChunkMessage {
+                request_id: rand::thread_rng().gen(),
+                body: ChunkMessageBody::PutRequest(request),
+            };
+            let msg_bytes = msg.encode().expect("encode");
+
+            if let Some(protocol) = harness.test_node(i).and_then(|n| n.ant_protocol.as_ref()) {
+                let proto = protocol.clone();
+                handles.push(tokio::spawn(async move {
+                    let resp_bytes = proto
+                        .try_handle_request(&msg_bytes)
+                        .await
+                        .map_err(|e| format!("Node {i}: {e}"))?
+                        .ok_or_else(|| format!("Node {i}: unexpected None response"))?;
+                    let resp = ChunkMessage::decode(&resp_bytes)
+                        .map_err(|e| format!("Node {i} decode: {e}"))?;
+                    Ok::<(usize, ChunkMessage), String>((i, resp))
+                }));
+            }
+        }
+    }
+
+    let mut rejection_count = 0;
+    for handle in handles {
+        let (node_idx, response) = handle.await.expect("task panicked")?;
+        assert!(
+            is_rejection(&response.body),
+            "Node {node_idx} MUST reject merkle garbage, got: {response:?}"
+        );
+        rejection_count += 1;
+    }
+
+    assert!(
+        rejection_count > 0,
+        "At least one node should have been tested"
+    );
+    info!("All {rejection_count} nodes correctly rejected merkle garbage");
+
+    harness.teardown().await?;
+    Ok(())
+}
+
+// ===========================================================================
+// Category 6: Replay / cross-chunk proof reuse
+// ===========================================================================
+
+/// Attack: Build a valid merkle proof for address[0] in the tree, then try
+/// to use it to store a chunk whose address matches address[1] in the same
+/// tree. The proof's `address` field is bound to address[0], so the node
+/// must reject the mismatch.
+#[tokio::test(flavor = "multi_thread")]
+#[serial]
+async fn test_attack_merkle_proof_cross_address_replay() -> Result<(), Box<dyn std::error::Error>> {
+    info!("MERKLE ATTACK TEST: cross-address replay within same tree");
+
+    let (harness, _testnet) = setup_enforcement_env().await?;
+
+    // Build proof bound to entries[0]
+    let proof = build_valid_merkle_proof();
+
+    // Try to use it for entries[1] (different address in the same tree)
+    let second = proof.entries.get(1).expect("should have 4 entries");
+
+    // Build request with second entry's correct content/address pair,
+    // but using the proof that was bound to entries[0].
+    let request =
+        ChunkPutRequest::with_payment(second.address.0, second.content.clone(), proof.tagged_proof);
+
+    let response = send_put_to_node(&harness, 0, request)
+        .await
+        .map_err(|e| format!("Send failed: {e}"))?;
+
+    assert!(
+        is_rejection(&response.body),
+        "Cross-address replay MUST be rejected, got: {response:?}"
+    );
+    info!("Correctly rejected: cross-address replay within same tree");
+
+    harness.teardown().await?;
+    Ok(())
+}
diff --git a/tests/e2e/mod.rs b/tests/e2e/mod.rs
index 2186feb7..3da2bbd0 100644
--- a/tests/e2e/mod.rs
+++ b/tests/e2e/mod.rs
@@ -54,6 +54,9 @@ mod payment_flow;
 #[cfg(test)]
 mod complete_payment_e2e;
 
+#[cfg(test)]
+mod merkle_payment;
+
 #[cfg(test)]
 mod security_attacks;
 
