Skip to content

Commit 7a0c65c

Browse files
fix(crawl): bound activation observation sweeps
1 parent 9f5e551 commit 7a0c65c

4 files changed

Lines changed: 54 additions & 11 deletions

File tree

docs/adr/0007-observed-network-upgrade-activation.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ Peers already report their start height and protocol version during the Zcash ha
1414

1515
Each crawler starts its `SeederChainTip` immediately below the newest compiled activation height. It raises the tip to that activation height only after an independent observer confirms all of these conditions:
1616

17-
- The observer samples 1 recently live, outbound, full node from each available IPv4 `/16` or IPv6 `/32` network group.
17+
- The observer uniformly samples at most 64 available IPv4 `/16` or IPv6 `/32` network groups, then chooses 1 recently live, outbound, full node from each selected group.
1818
- At least 12 network groups participate in a completed sweep.
1919
- At least 75% of the sampled groups report a start height at or above the activation height plus Zebra's maximum reorganization depth, negotiate the new protocol version, and advertise `NODE_NETWORK`.
2020
- The threshold holds for 3 consecutive sweeps, separated by the target block spacing. A timeout, failed handshake, or nonqualifying response remains in the denominator and counts as not ready.
@@ -25,14 +25,15 @@ Before raising the floor, Zeeder atomically persists an exact record of the acti
2525

2626
## Rationale
2727

28-
Network-group voting limits the weight of many addresses from one prefix, while the minimum group count prevents a small, internally consistent view from deciding activation. A fixed 75% threshold requires a supermajority without allowing a stalled minority to block the transition indefinitely. Requiring 3 spaced sweeps rejects brief height spikes and transient partitions, and waiting through the maximum reorganization depth avoids reacting at the activation boundary.
28+
Network-group voting limits the weight of many addresses from one prefix, while uniform selection prevents prefixes containing more addresses from gaining extra sampling weight. The 64-group cap bounds concurrent handshakes and prevents an attacker-influenced address book from expanding the quorum denominator. The minimum group count prevents a small, internally consistent view from deciding activation, and a fixed 75% threshold requires a supermajority without allowing a stalled minority to block the transition indefinitely. Requiring 3 spaced sweeps rejects brief height spikes and transient partitions, while waiting through the maximum reorganization depth avoids reacting at the activation boundary.
2929

3030
The algorithm treats missing evidence conservatively. Failed and timed-out probes do not disappear from the denominator, and any nonqualifying sweep resets the consecutive-sweep counter. Persistence makes the transition monotonic across ordinary restarts and fleet rolls.
3131

3232
## Consequences
3333

3434
- Zeeder can be deployed before activation without removing nodes that satisfy the previous protocol floor.
3535
- Each Zeeder instance decides independently from the peers it has discovered, so the design adds no node or endpoint dependency.
36+
- Each observation sweep opens at most 64 concurrent isolated handshakes.
3637
- The protocol floor can rise later than the chain reaches the confirmation height when the address book lacks 12 groups or fewer than 75% of groups qualify.
3738
- After confirmation, the servable-peer cache rechecks each peer's negotiated version against the new floor, which removes handshakes admitted under the previous floor from DNS responses immediately.
3839
- Peer start heights remain self-reported. An attacker that controls at least 75% of the sampled network groups, or fully eclipses a seeder, can still cause a false confirmation; this design raises the cost of false evidence but cannot authenticate chain work.

docs/architecture.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -119,7 +119,7 @@ sequenceDiagram
119119

120120
### Observed Activation
121121

122-
The activation observer separates a compiled upgrade target from proof that the network has reached it. Each sweep chooses 1 recently live peer per IPv4 `/16` or IPv6 `/32` group from the crawler's own address book, then performs an isolated handshake that accepts the previous protocol floor. At least 75% of 12 or more groups must report the target version and a height beyond the activation plus maximum reorganization depth for 3 consecutive sweeps.
122+
The activation observer separates a compiled upgrade target from proof that the network has reached it. Each sweep uniformly selects at most 64 IPv4 `/16` or IPv6 `/32` groups from the crawler's own address book, chooses 1 recently live peer per selected group, and performs isolated handshakes that accept the previous protocol floor. At least 75% of 12 or more sampled groups must report the target version and a height beyond the activation plus maximum reorganization depth for 3 consecutive sweeps.
123123

124124
After the final qualifying sweep, the observer persists the exact decision and advances `SeederChainTip`. zebra-network receives the height change through its `ChainTip` monitor, while the 5-second servable-peer refresh applies the same new minimum to cached handshakes. [ADR 0007](adr/0007-observed-network-upgrade-activation.md) defines the trust model, thresholds, and failure behavior.
125125

docs/network-upgrades.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ Each network crawler starts immediately below the newest activation height compi
88

99
The floor advances only after all of these conditions hold:
1010

11-
- 1 peer is sampled from each available IPv4 `/16` or IPv6 `/32` network group.
11+
- At most 64 available IPv4 `/16` or IPv6 `/32` network groups are selected uniformly, with 1 peer sampled from each selected group.
1212
- At least 12 groups participate.
1313
- At least 75% of those groups report the activation height plus Zebra's maximum reorganization depth, negotiate the target protocol version, and advertise `NODE_NETWORK`.
1414
- The same threshold holds for 3 consecutive sweeps, separated by the target block spacing.
@@ -71,7 +71,7 @@ To force fresh observation after investigating a suspected false confirmation, s
7171
|----------|--------|
7272
| Does an upgrade require Zeeder configuration changes? | No |
7373
| Can the new image be deployed before activation? | Yes; deployment keeps the previous floor |
74-
| What causes the floor to rise? | 75% of at least 12 network groups qualifying across 3 consecutive sweeps after the confirmation height |
74+
| What causes the floor to rise? | 75% of a uniform sample of 12 to 64 network groups qualifying across 3 consecutive sweeps after the confirmation height |
7575
| Does Zeeder depend on a node or endpoint? | No; each instance observes peers from its own address book |
7676
| Must the peer cache be cleared? | No; preserve it for observation and restart continuity |
7777
| What is the recovery control? | Delete only the affected `.activation` record, then restart |

src/crawl/activation.rs

Lines changed: 48 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
//! make one impossible under a group-supermajority Sybil or full eclipse.
99
1010
use std::{
11-
collections::HashSet,
11+
collections::HashMap,
1212
io,
1313
net::IpAddr,
1414
path::{Path, PathBuf},
@@ -39,6 +39,9 @@ use crate::{
3939
/// Minimum independent network groups required for an activation decision.
4040
const MIN_NETWORK_GROUPS: usize = 12;
4141

42+
/// Maximum independent network groups probed concurrently in one sweep.
43+
const MAX_NETWORK_GROUPS_PER_SWEEP: usize = 64;
44+
4245
/// Fixed supermajority required in each sweep.
4346
const QUORUM_NUMERATOR: usize = 3;
4447
const QUORUM_DENOMINATOR: usize = 4;
@@ -181,6 +184,7 @@ pub(crate) fn spawn(
181184
ready_groups = evidence.ready_groups,
182185
qualifying_sweeps = gate.qualifying_sweeps(),
183186
required_groups = MIN_NETWORK_GROUPS,
187+
maximum_groups = MAX_NETWORK_GROUPS_PER_SWEEP,
184188
required_sweeps = REQUIRED_QUALIFYING_SWEEPS,
185189
quorum_numerator = QUORUM_NUMERATOR,
186190
quorum_denominator = QUORUM_DENOMINATOR,
@@ -277,7 +281,7 @@ fn sample_network_groups(
277281
network: &Network,
278282
minimum_version: Version,
279283
) -> Vec<PeerSocketAddr> {
280-
let mut candidates = {
284+
let candidates = {
281285
let book = match address_book.lock() {
282286
Ok(book) => book,
283287
Err(poisoned) => {
@@ -293,10 +297,24 @@ fn sample_network_groups(
293297
.collect::<Vec<_>>()
294298
};
295299

300+
sample_network_group_candidates(candidates)
301+
}
302+
303+
/// Choose one random peer per group, then uniformly bound the group sample.
304+
fn sample_network_group_candidates(mut candidates: Vec<PeerSocketAddr>) -> Vec<PeerSocketAddr> {
296305
candidates.shuffle(&mut rng());
297-
let mut sampled_groups = HashSet::new();
298-
candidates.retain(|addr| sampled_groups.insert(network_group(addr.ip())));
299-
candidates
306+
307+
let mut representative_by_group = HashMap::new();
308+
for addr in candidates {
309+
representative_by_group
310+
.entry(network_group(addr.ip()))
311+
.or_insert(addr);
312+
}
313+
314+
let mut sampled_peers = representative_by_group.into_values().collect::<Vec<_>>();
315+
sampled_peers.shuffle(&mut rng());
316+
sampled_peers.truncate(MAX_NETWORK_GROUPS_PER_SWEEP);
317+
sampled_peers
300318
}
301319

302320
async fn observe_sweep(
@@ -362,8 +380,9 @@ async fn persist_confirmation(path: Option<PathBuf>, target: ActivationTarget) -
362380
#[cfg(test)]
363381
mod tests {
364382
use std::{
383+
collections::HashSet,
365384
error::Error,
366-
net::{IpAddr, Ipv4Addr, Ipv6Addr},
385+
net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr},
367386
};
368387

369388
use super::*;
@@ -447,6 +466,29 @@ mod tests {
447466
);
448467
}
449468

469+
#[test]
470+
fn sampling_caps_unique_network_groups() {
471+
let candidates = (1..=80)
472+
.flat_map(|group| {
473+
[1, 2].map(|host| {
474+
PeerSocketAddr::from(SocketAddr::new(
475+
IpAddr::V4(Ipv4Addr::new(group, 1, host, 1)),
476+
8233,
477+
))
478+
})
479+
})
480+
.collect();
481+
482+
let sampled = sample_network_group_candidates(candidates);
483+
let sampled_groups = sampled
484+
.iter()
485+
.map(|addr| network_group(addr.ip()))
486+
.collect::<HashSet<_>>();
487+
488+
assert_eq!(sampled.len(), MAX_NETWORK_GROUPS_PER_SWEEP);
489+
assert_eq!(sampled_groups.len(), sampled.len());
490+
}
491+
450492
#[test]
451493
fn latest_mainnet_target_requires_reorg_safe_nu6_3_depth() {
452494
let target = ActivationTarget::latest(&Network::Mainnet);

0 commit comments

Comments
 (0)