From 73cb2209bc8343689d6b2f431d55476a518d9579 Mon Sep 17 00:00:00 2001 From: Chris O'Neil Date: Sat, 13 Jun 2026 17:33:02 +0100 Subject: [PATCH 1/2] fix(payment): verify single-node issuer closeness against over-query window MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The single-node (legacy) median payment path rejects honest uploads on a network with NAT-stuck or slow peers, while the merkle batch path does not. On a 30%-NAT testnet this leaves small (single-node-paid) uploads failing a few percent per chunk — multiplicatively per file — with: Paid quote issuer is not among this node's local 7 closest peers The uploader selects single-node quotes by querying 2 * CLOSE_GROUP_SIZE peers and keeping the CLOSE_GROUP_SIZE closest *successful responders* (ant-client get_store_quotes). When closer peers are slow or NAT-stuck the honestly-paid issuer therefore sits anywhere in the top 2 * close_group_size by XOR distance. The verifier checked only the bare close_group_size of the node's *local* routing table with exact membership, so it rejected those honest payments — the same divergence the merkle path already tolerates via a 2 * CANDIDATES_PER_POOL window, an authoritative network lookup, and a majority threshold. Bring the single-node issuer check in line: - Widen to 2 * close_group_size, mirroring the uploader's over-query window. - Keep the XOR-only lookup (find_closest_nodes_local_with_self reranks by reachability and would demote XOR-close relay-only / NAT'd peers). - Hybrid source: try the cheap local routing-table view first, and only on a local miss fall back to an authoritative find_closest_nodes_network lookup (the same view the uploader used to choose the quotes), wrapped in the existing CLOSENESS_LOOKUP_TIMEOUT. Reject only if the issuer is in neither. This builds on #140 (which removed the reachability re-rank from these verification checks); that fix landed the bulk of the recovery, this closes the residual single-node-path gap. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/payment/verifier.rs | 66 +++++++++++++++++++++++++++++++++-------- 1 file changed, 54 insertions(+), 12 deletions(-) diff --git a/src/payment/verifier.rs b/src/payment/verifier.rs index e701545..8ffa792 100644 --- a/src/payment/verifier.rs +++ b/src/payment/verifier.rs @@ -919,24 +919,66 @@ impl PaymentVerifier { } }; - // Closeness *verification* must mirror the uploader's pure XOR-distance - // peer selection. `find_closest_nodes_local_with_self` reranks the local - // routing table by reachability (preferring directly-reachable peers, - // XOR only as a tiebreaker), which demotes an XOR-close relay-only / - // NAT'd peer out of the compared window and falsely rejects an honest - // payment that legitimately quoted that peer. Use the XOR-only sibling - // so this check matches how the client chose the quoted close group. - let close_group_size = self.config.close_group_size; - let closest = p2p_node + // Verify the paid quote issuer is a legitimate close-group peer for the + // chunk. Two properties govern the width and the lookup source: + // + // 1. WIDTH. The uploader selects single-node quotes by querying + // `2 * CLOSE_GROUP_SIZE` peers and keeping the `CLOSE_GROUP_SIZE` + // closest *successful responders* (ant-client `get_store_quotes`). + // When closer peers are slow or NAT-stuck the honestly-paid issuer + // can therefore sit anywhere in the top `2 * close_group_size` by XOR + // distance, so verifying against the bare `close_group_size` rejects + // honest payments with no security benefit. Mirror the uploader's + // over-query window (same rationale as the merkle path's + // `2 * CANDIDATES_PER_POOL`). + // + // 2. ORDERING / SOURCE. Use the XOR-only lookup, not + // `find_closest_nodes_local_with_self` (which reranks by reachability + // and would demote XOR-close relay-only / NAT'd peers the uploader + // legitimately quoted). Try the cheap local routing-table view first + // — it covers the common case with no network I/O — and only when the + // issuer is absent locally (our table may simply not know it yet) fall + // back to an authoritative network lookup, which is the same view the + // uploader used to choose the quote set. This mirrors the merkle + // path's authoritative-view check while keeping the hot path local. + let lookup_width = self.config.close_group_size.saturating_mul(2); + + let local = p2p_node .dht_manager() - .find_closest_nodes_local_by_distance_with_self(xorname, close_group_size) + .find_closest_nodes_local_by_distance_with_self(xorname, lookup_width) .await; - if closest.iter().any(|node| node.peer_id == *issuer_peer_id) { + if local.iter().any(|node| node.peer_id == *issuer_peer_id) { + return Ok(()); + } + + let network_lookup = p2p_node + .dht_manager() + .find_closest_nodes_network(xorname, lookup_width); + let network = match tokio::time::timeout(Self::CLOSENESS_LOOKUP_TIMEOUT, network_lookup).await + { + Ok(Ok(peers)) => peers, + Ok(Err(e)) => { + return Err(Error::Payment(format!( + "Paid quote issuer closeness could not be verified against the \ + authoritative network view for {}: {e}", + hex::encode(xorname) + ))); + } + Err(_) => { + return Err(Error::Payment(format!( + "Paid quote issuer closeness network lookup timed out after {:?} for {}", + Self::CLOSENESS_LOOKUP_TIMEOUT, + hex::encode(xorname) + ))); + } + }; + if network.iter().any(|node| node.peer_id == *issuer_peer_id) { return Ok(()); } Err(Error::Payment(format!( - "Paid quote issuer {} is not among this node's local {close_group_size} closest peers for {}", + "Paid quote issuer {} is not among the {lookup_width} closest peers for {} \ + (checked the local routing table and the authoritative network view)", issuer_peer_id.to_hex(), hex::encode(xorname) ))) From 68a212f4a57ddb5618837ea00e79d0f276478f06 Mon Sep 17 00:00:00 2001 From: Chris O'Neil Date: Sat, 13 Jun 2026 18:07:18 +0100 Subject: [PATCH 2/2] style(payment): rustfmt the issuer-closeness network fallback match Pure formatting; no behaviour change. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/payment/verifier.rs | 34 +++++++++++++++++----------------- 1 file changed, 17 insertions(+), 17 deletions(-) diff --git a/src/payment/verifier.rs b/src/payment/verifier.rs index 8ffa792..1eca1ca 100644 --- a/src/payment/verifier.rs +++ b/src/payment/verifier.rs @@ -954,24 +954,24 @@ impl PaymentVerifier { let network_lookup = p2p_node .dht_manager() .find_closest_nodes_network(xorname, lookup_width); - let network = match tokio::time::timeout(Self::CLOSENESS_LOOKUP_TIMEOUT, network_lookup).await - { - Ok(Ok(peers)) => peers, - Ok(Err(e)) => { - return Err(Error::Payment(format!( - "Paid quote issuer closeness could not be verified against the \ + let network = + match tokio::time::timeout(Self::CLOSENESS_LOOKUP_TIMEOUT, network_lookup).await { + Ok(Ok(peers)) => peers, + Ok(Err(e)) => { + return Err(Error::Payment(format!( + "Paid quote issuer closeness could not be verified against the \ authoritative network view for {}: {e}", - hex::encode(xorname) - ))); - } - Err(_) => { - return Err(Error::Payment(format!( - "Paid quote issuer closeness network lookup timed out after {:?} for {}", - Self::CLOSENESS_LOOKUP_TIMEOUT, - hex::encode(xorname) - ))); - } - }; + hex::encode(xorname) + ))); + } + Err(_) => { + return Err(Error::Payment(format!( + "Paid quote issuer closeness network lookup timed out after {:?} for {}", + Self::CLOSENESS_LOOKUP_TIMEOUT, + hex::encode(xorname) + ))); + } + }; if network.iter().any(|node| node.peer_id == *issuer_peer_id) { return Ok(()); }