From 010e903cb2ee1a6ee82484e6118a7931b253c0a4 Mon Sep 17 00:00:00 2001 From: Florian Date: Wed, 22 Jul 2026 18:12:13 +0000 Subject: [PATCH 1/2] =?UTF-8?q?fix(lurker):=20PSS=20bin=20selection=20?= =?UTF-8?q?=E2=80=94=20base=20is=20min(PO(peer,target),=20L),=20not=20PO(p?= =?UTF-8?q?eer,target)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Correction due to Viktor Trón (inventor of the Swarm protocol), who reviewed the receiver design and asked exactly the right question: which PO bins is the receiver subscribing to? The trie geometry gives two regimes for where a trojan mined to an L-bit prefix of target t sits on a covering peer p with b_p = PO(p, t): - b_p < L: p diverges from t at bit b_p while the trojan still agrees there, so PO(c, p) = b_p EXACTLY. One deterministic bin; the old +3-deeper window pulled three bins that cannot contain it. - b_p >= L: the trojan agrees with p through bit L and is mined noise beyond, so PO(c, p) = L + Geom(1/2) — independent of b_p, i.e. around bin L, SHALLOWER than b_p. The old b_p..=b_p+3 selection missed it entirely once b_p > L + 3. Why the mainnet runs passed anyway: the demos mined 2-byte (16-bit) targets while covering storers sat at b_p ≈ 11–14 < L (the exact-bin regime, where the old base happened to be right), and the 5-peer covering redundancy masked the per-peer misses of deeper nodes (the symmetric-rendezvous co-resident at 18 shared bits was in exactly the broken regime). The fix pins L as a receiver-side convention (PSS_MINED_PREFIX_BITS = 16 — bee's de-facto 2-byte targets) and selects per covering peer: - GSOC: exactly bin b_p (the watch target IS the chunk address; never affected by this bug). - PSS, b_p < L: exactly bin b_p. - PSS, b_p >= L: bins L..=L+PSS_BIN_WINDOW (geometric tail: 15/16 of the mass per peer, N-way covering redundancy on top). - Mixed watch: the deduplicated union. The convention also bounds candidate cost (bins >= L carry ~2^-(L-d) of a depth-d storer's ingest) and is a storability floor (L must exceed the storage depth or no storer keeps the trojan) — and at L = 24 the ENTIRE trojan-bearing backlog of a reserve is ~1-2K chunks, which makes offline mailbox sweeps feasible (future work). Bin selection is now a pure covering_bins(b_p, want_gsoc, want_pss) with regression tests for both regimes, GSOC exactness, mixed-watch union/dedup, and the empty watch. --- crates/ant-p2p/src/lurker.rs | 205 ++++++++++++++++++++++++++++------- 1 file changed, 167 insertions(+), 38 deletions(-) diff --git a/crates/ant-p2p/src/lurker.rs b/crates/ant-p2p/src/lurker.rs index 9064982..1487c96 100644 --- a/crates/ant-p2p/src/lurker.rs +++ b/crates/ant-p2p/src/lurker.rs @@ -75,10 +75,37 @@ const PULL_BACKLOG: u64 = 8; /// regardless of which one got (or replicated) it first — the difference /// between reliable and flaky reception on a light node. const COVERING_PEERS: usize = 5; -/// Extra deeper bins to pull past the base neighborhood bin. GSOC's watch -/// target *is* the chunk address, so its base bin is exact; PSS mines a -/// short overlay prefix, so the chunk can sit a few bins deeper on a given -/// peer — this window covers that without pulling the whole reserve. +/// The PSS mined-prefix convention, in bits: receivers assume trojan +/// chunks are mined to share at least this many leading bits with the +/// target overlay (= 2-byte targets on bee's `/pss/send`, the de-facto +/// practice and what ant's own gateway demos use). +/// +/// Correction due to Viktor Trón: which bin a trojan `c` occupies on a +/// covering peer `p` depends on how `b_p = PO(p, target)` compares to +/// the mined prefix length `L` — the trie geometry gives two regimes: +/// +/// - **`b_p < L`**: `p` diverges from the target at bit `b_p` while `c` +/// still agrees there, so `PO(c, p) = b_p` **exactly**. One +/// deterministic bin; any window is dead weight. +/// - **`b_p >= L`**: `c` agrees with `p` through bit `L` and is mined +/// noise beyond, so `PO(c, p) = L + Geom(1/2)` — *independent of +/// `b_p`*, i.e. the trojan sits around bin `L`, **shallower** than +/// `b_p`. Pulling `b_p` and deeper (the previous behaviour) misses +/// it; the correct base is `L`, with a small deeper window for the +/// geometric tail (each +1 bin halves the missed mass). +/// +/// The convention also bounds cost: on a depth-`d` storer the bins +/// `>= L` hold only `~2^(22-(L-d))` of its ~2^22-chunk reserve +/// (`d≈12, L=16` → 1/16 of ingest; `L=24` → ~1-2K chunks total), so +/// candidate try-unwrap traffic stays small. `L` must also exceed the +/// storage depth or no storer keeps the trojan at all — another reason +/// this is a convention, not a per-sender free choice. +const PSS_MINED_PREFIX_BITS: u8 = 16; +/// Deeper bins pulled past bin [`PSS_MINED_PREFIX_BITS`] in the +/// `b_p >= L` regime, covering the geometric tail of `PO(c, p) = +/// L + Geom(1/2)`: a window of 3 captures 15/16 of the mass per peer, +/// and the [`COVERING_PEERS`]-way redundancy covers the rest. Unused in +/// the `b_p < L` regime, where the bin is exact. const PSS_BIN_WINDOW: u8 = 3; /// Highest proximity-order bin. const MAX_BIN: u8 = 31; @@ -385,19 +412,14 @@ pub async fn run( .unwrap_or_else(PoisonError::into_inner) .retain(|k, _| active.contains_key(k)); - // Deeper bins only matter for PSS (unknown address); GSOC's base - // bin is exact. Re-read each pass: the registry may have added - // or removed PSS topics since the last one, and the desired-set - // handover below then grows or retires the deeper-bin pullers. - let window = if watch - .read() - .unwrap_or_else(PoisonError::into_inner) - .pss_topics - .is_empty() - { - 0 - } else { - PSS_BIN_WINDOW + // Which bins each covering peer needs depends on the watch + // kinds (see `covering_bins`). Re-read each pass: the registry + // may have added or removed GSOC addresses / PSS topics since + // the last one, and the desired-set handover below then grows + // or retires pullers to match. + let (want_gsoc, want_pss) = { + let w = watch.read().unwrap_or_else(PoisonError::into_inner); + (!w.gsoc_addresses.is_empty(), !w.pss_topics.is_empty()) }; // Which covering (peer, bin)s do we want, and which peers still @@ -405,37 +427,34 @@ pub async fn run( // cursors concurrently (bounded) so one silent peer can't stall // every other peer's coverage. let mut desired: HashSet<(PeerId, u8)> = HashSet::new(); - let mut need_cursors: Vec<(PeerId, u8, u8)> = Vec::new(); + let mut need_cursors: Vec<(PeerId, Vec)> = Vec::new(); for (peer_id, peer_overlay) in closest_n(&mut peers, &target, COVERING_PEERS) { - let base_bin = proximity(&peer_overlay, &target); - let top_bin = base_bin.saturating_add(window).min(MAX_BIN); - for bin in base_bin..=top_bin { + let bins = covering_bins(proximity(&peer_overlay, &target), want_gsoc, want_pss); + for &bin in &bins { desired.insert((peer_id, bin)); } - if (base_bin..=top_bin).any(|b| !active.contains_key(&(peer_id, b))) { - need_cursors.push((peer_id, base_bin, top_bin)); + if bins.iter().any(|b| !active.contains_key(&(peer_id, *b))) { + need_cursors.push((peer_id, bins)); } } - let fetches = need_cursors - .into_iter() - .map(|(peer_id, base_bin, top_bin)| { - let mut ctl = control.clone(); - async move { - let cursors = tokio::time::timeout( - CURSOR_FETCH_TIMEOUT, - pullsync::get_cursors(&mut ctl, peer_id), - ) - .await; - (peer_id, base_bin, top_bin, cursors) - } - }); + let fetches = need_cursors.into_iter().map(|(peer_id, bins)| { + let mut ctl = control.clone(); + async move { + let cursors = tokio::time::timeout( + CURSOR_FETCH_TIMEOUT, + pullsync::get_cursors(&mut ctl, peer_id), + ) + .await; + (peer_id, bins, cursors) + } + }); let results = futures::future::join_all(fetches).await; - for (peer_id, base_bin, top_bin, cursors) in results { + for (peer_id, bins, cursors) in results { let Ok(Ok(cursors)) = cursors else { continue; // timed out or errored → try again next pass }; - for bin in base_bin..=top_bin { + for bin in bins { if active.contains_key(&(peer_id, bin)) { continue; } @@ -668,6 +687,45 @@ async fn pull_bin( } } +/// The bins to pull from one covering peer at proximity `b_p` to the +/// target, per watch kind. Small (≤ 1 + window+1 entries), duplicates +/// removed, order irrelevant (each bin becomes its own puller). +/// +/// - **GSOC**: the watch target *is* the chunk address, so +/// `PO(chunk, peer) = b_p` exactly — one bin. +/// - **PSS**: two regimes by the trie geometry (see +/// [`PSS_MINED_PREFIX_BITS`]): a peer shallower than the mined +/// prefix holds the trojan at exactly `b_p`; a peer at or deeper +/// than the prefix holds it around bin `L` (geometric tail), which +/// can be *shallower* than `b_p` — the bug the old +/// `b_p..=b_p+window` selection had. +fn covering_bins(b_p: u8, want_gsoc: bool, want_pss: bool) -> Vec { + let mut bins: Vec = Vec::new(); + if want_gsoc { + bins.push(b_p); + } + if want_pss { + if b_p < PSS_MINED_PREFIX_BITS { + // Deterministic regime: PO(c, p) = b_p exactly. + if !bins.contains(&b_p) { + bins.push(b_p); + } + } else { + // Geometric regime: PO(c, p) = L + Geom(1/2), independent + // of b_p. + let top = PSS_MINED_PREFIX_BITS + .saturating_add(PSS_BIN_WINDOW) + .min(MAX_BIN); + for bin in PSS_MINED_PREFIX_BITS..=top { + if !bins.contains(&bin) { + bins.push(bin); + } + } + } + } + bins +} + /// The `n` connected peers whose overlays are closest to `target`, /// deepest first. Marks the snapshot seen (`borrow_and_update`) so the /// driver's `changed()` wait really waits for the *next* change. @@ -770,6 +828,77 @@ mod tests { assert!(rx.try_recv().is_err()); } + /// Viktor Trón's correction, deterministic regime: a covering peer + /// SHALLOWER than the mined prefix (`b_p < L`) holds the trojan at + /// exactly bin `b_p` — the old `b_p..=b_p+3` window pulled three + /// bins that cannot contain it. + #[test] + fn pss_bins_shallow_peer_is_exact() { + assert_eq!(covering_bins(11, false, true), vec![11]); + assert_eq!(covering_bins(0, false, true), vec![0]); + assert_eq!( + covering_bins(PSS_MINED_PREFIX_BITS - 1, false, true), + vec![PSS_MINED_PREFIX_BITS - 1] + ); + } + + /// Viktor Trón's correction, geometric regime: a covering peer AT + /// or DEEPER than the mined prefix (`b_p >= L`) holds the trojan at + /// `L + Geom(1/2)` — *independent of `b_p`*, i.e. possibly + /// SHALLOWER than `b_p`. The old selection pulled `b_p..=b_p+3` and + /// missed the trojan entirely once `b_p > L + 3`. + #[test] + fn pss_bins_deep_peer_pulls_the_mined_prefix_window() { + let l = PSS_MINED_PREFIX_BITS; + let expect: Vec = (l..=l + PSS_BIN_WINDOW).collect(); + // b_p == L: same bins either way, but for the right reason. + assert_eq!(covering_bins(l, false, true), expect); + // b_p = 20 > L+3: the old code pulled 20..=23 — zero overlap + // with where the trojan actually sits. + assert_eq!(covering_bins(20, false, true), expect); + // Very deep peer (co-resident rendezvous node): same. + assert_eq!(covering_bins(30, false, true), expect); + } + + /// GSOC is unaffected by the correction: the watch target IS the + /// chunk address, so `PO(chunk, peer) = b_p` exactly — one bin, + /// window never applies. + #[test] + fn gsoc_bins_are_always_exact() { + assert_eq!(covering_bins(5, true, false), vec![5]); + assert_eq!(covering_bins(20, true, false), vec![20]); + } + + /// A mixed watch (GSOC + PSS on one shared lurker) takes the UNION: + /// the exact GSOC bin plus the PSS regime bins, deduplicated — + /// which for a deep peer means the GSOC bin sits deeper than the + /// whole PSS window. + #[test] + fn mixed_watch_takes_the_union_of_both_kinds() { + let l = PSS_MINED_PREFIX_BITS; + // Deep peer: exact GSOC bin 20 + PSS window 16..=19. + let bins = covering_bins(20, true, true); + assert!(bins.contains(&20)); + for bin in l..=l + PSS_BIN_WINDOW { + assert!(bins.contains(&bin), "missing PSS bin {bin}"); + } + assert_eq!(bins.len(), 1 + usize::from(PSS_BIN_WINDOW) + 1); + + // Shallow peer: both kinds want exactly b_p — deduplicated. + assert_eq!(covering_bins(9, true, true), vec![9]); + + // Peer inside the PSS window: GSOC bin dedups into it. + let bins = covering_bins(l + 1, true, true); + assert_eq!(bins.len(), usize::from(PSS_BIN_WINDOW) + 1); + } + + /// Nothing watched → no bins (the driver skips idle watches + /// upstream, but the function must not invent coverage). + #[test] + fn no_watch_no_bins() { + assert!(covering_bins(12, false, false).is_empty()); + } + #[test] fn closest_n_orders_deepest_first_and_marks_seen() { let target = overlay(0xff); From 573a54c410d1c7fda6422ae6880b283fadc71526 Mon Sep 17 00:00:00 2001 From: Florian Date: Wed, 22 Jul 2026 19:57:59 +0000 Subject: [PATCH 2/2] docs(lurker): record the L=16 mining-prefix decision (keep 16, not 24) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After discussion with Viktor Trón, keep the convention at L=16 (2-byte targets). His L=24 motivation was proof-of-work spam punishment on senders, not receiver efficiency. Two reasons 16 is the better fit for a light-node messaging client: - No receiver benefit at our residency. The deeper-prefix wins (cheaper candidate traffic, complete mailbox) require covering peers at b_p >= L. A light node's covering peers sit at b_p ~9-14, so covering_bins pulls bin b_p regardless of L — measured identical on mainnet at L=16 and L=24. L=24 buys the light-node receiver nothing. - L=24 costs the sender 256x (~2^24 hashes): seconds on a phone, and it trips the /pss/send timeout. PSS already carries an economic spam gate via the postage stamp, so the PoW punishment is largely redundant with postage and can't distinguish spammer from legitimate mobile user. Doc-only: flips PSS_MINED_PREFIX_BITS back to 16 (from the reverted L=24 experiment) with the reasoning. A deeper prefix as a deliberate network anti-spam PoW remains an open protocol-incentive question, tracked in the SWIP. --- crates/ant-p2p/src/lurker.rs | 27 +++++++++++++++++++++------ 1 file changed, 21 insertions(+), 6 deletions(-) diff --git a/crates/ant-p2p/src/lurker.rs b/crates/ant-p2p/src/lurker.rs index 1487c96..923835b 100644 --- a/crates/ant-p2p/src/lurker.rs +++ b/crates/ant-p2p/src/lurker.rs @@ -94,12 +94,27 @@ const COVERING_PEERS: usize = 5; /// it; the correct base is `L`, with a small deeper window for the /// geometric tail (each +1 bin halves the missed mass). /// -/// The convention also bounds cost: on a depth-`d` storer the bins -/// `>= L` hold only `~2^(22-(L-d))` of its ~2^22-chunk reserve -/// (`d≈12, L=16` → 1/16 of ingest; `L=24` → ~1-2K chunks total), so -/// candidate try-unwrap traffic stays small. `L` must also exceed the -/// storage depth or no storer keeps the trojan at all — another reason -/// this is a convention, not a per-sender free choice. +/// `L` must exceed the storage depth `d` or no storer keeps the trojan +/// at all — one reason this is a network convention (sender and receiver +/// MUST agree), not a per-sender free choice. +/// +/// **Why 16 and not 24.** A deeper prefix concentrates a trojan into a +/// smaller slice of the reserve (`~2^(reserve-(L-d))`), which in +/// principle cuts a *deeply-resident* receiver's candidate traffic and +/// shrinks the mailbox backlog. But that benefit needs covering peers at +/// `b_p >= L`: a light node's covering peers sit at `b_p ≈ 9-14`, so for +/// them `covering_bins` pulls bin `b_p` **regardless of `L`** (the +/// `b_p < L` regime). Measured on mainnet: at both `L=16` and `L=24` the +/// receiver pulls the same bins and downloads the same candidates — the +/// deeper prefix buys a light-node receiver nothing. It only *costs*: `L` +/// bits is `~2^L` mine hashes, so `L=24` is ~256× the sender work +/// (seconds on a phone, and it trips the send timeout), and PSS already +/// carries an economic spam gate via the postage stamp every send burns. +/// So we keep `L=16`: cheap to mine (mobile-friendly), identical receive +/// at light-node residency. (A deeper prefix as a network-wide anti-spam +/// proof-of-work is a protocol-incentive question, not a +/// receiver-efficiency one; tracked in the SWIP messaging extension's +/// "PSS mining depth" section.) const PSS_MINED_PREFIX_BITS: u8 = 16; /// Deeper bins pulled past bin [`PSS_MINED_PREFIX_BITS`] in the /// `b_p >= L` regime, covering the geometric tail of `PO(c, p) =