diff --git a/cmd/server/repeater_enrich_bulk.go b/cmd/server/repeater_enrich_bulk.go index 220d4720..1182f81d 100644 --- a/cmd/server/repeater_enrich_bulk.go +++ b/cmd/server/repeater_enrich_bulk.go @@ -129,7 +129,10 @@ func (s *PacketStore) computeRepeaterRelayInfoMap(windowHours float64) map[strin } } } - visit := func(txs []*StoreTx) { + // viaPrefix marks the 1-byte wire-prefix bucket, shared by every + // node with the same first pubkey byte. See collectRelayEntriesLocked + // for the counters-vs-scopes split (#662 / #1902). + visit := func(txs []*StoreTx, viaPrefix bool) { for _, tx := range txs { if tx == nil { continue @@ -152,7 +155,11 @@ func (s *PacketStore) computeRepeaterRelayInfoMap(windowHours float64) map[strin // unparseable first_seen still proves the repeater // transported that scope. RelayCount/LastRelayed below // remain timestamp-gated. - if tx.ScopeName != "" { + // + // #1902: it IS gated on full-pubkey attribution — a 1-byte + // hop cannot prove which of the nodes sharing that byte + // carried the packet. + if tx.ScopeName != "" && !viaPrefix { if scopeSet == nil { scopeSet = map[string]struct{}{} } @@ -180,11 +187,11 @@ func (s *PacketStore) computeRepeaterRelayInfoMap(windowHours float64) map[strin } } } - visit(list) + visit(list, false) if seen != nil { prefix := key[:2] if prefix != key { - visit(snap[prefix]) + visit(snap[prefix], true) } } info.TransportedScopes = sortedCappedScopes(scopeSet) diff --git a/cmd/server/repeater_liveness.go b/cmd/server/repeater_liveness.go index e2d44152..d5eec01d 100644 --- a/cmd/server/repeater_liveness.go +++ b/cmd/server/repeater_liveness.go @@ -114,6 +114,11 @@ type relayEntry struct { // scope is the tx's region scope name (transmissions.scope_name). // Empty when absent / on older schemas. Used for TransportedScopes (#1751). scope string + // viaPrefix marks an entry that was only reachable through the 1-byte + // wire-prefix bucket, i.e. some node whose pubkey starts with the same + // byte carried it — not necessarily this one. Counters accept that + // ambiguity (#662); scope attribution does not (#1902). + viaPrefix bool } // collectRelayEntriesLocked returns deduplicated relayEntry snapshots for @@ -128,7 +133,9 @@ type relayEntry struct { // // The 1-byte prefix lookup CAN over-count when multiple nodes share the // same first byte. This trades a possible over-count for clearly false -// zeros (issue #662). +// zeros (issue #662). Entries reached only that way are flagged viaPrefix +// so TransportedScopes can refuse them (#1902) while the counters keep the +// trade-off. func (s *PacketStore) collectRelayEntriesLocked(key string) []relayEntry { txList := s.byPathHop[key] var prefixList []*StoreTx @@ -147,7 +154,7 @@ func (s *PacketStore) collectRelayEntriesLocked(key string) []relayEntry { hint := len(txList) + len(prefixList) entries := make([]relayEntry, 0, hint) seen := make(map[int]bool, hint) - collect := func(list []*StoreTx) { + collect := func(list []*StoreTx, viaPrefix bool) { for _, tx := range list { if tx == nil || seen[tx.ID] { continue @@ -161,11 +168,11 @@ func (s *PacketStore) collectRelayEntriesLocked(key string) []relayEntry { if tx.RouteType != nil { rt = *tx.RouteType } - entries = append(entries, relayEntry{ts: tx.FirstSeen, pt: pt, rt: rt, scope: tx.ScopeName}) + entries = append(entries, relayEntry{ts: tx.FirstSeen, pt: pt, rt: rt, scope: tx.ScopeName, viaPrefix: viaPrefix}) } } - collect(txList) - collect(prefixList) + collect(txList, false) + collect(prefixList, true) return entries } @@ -189,7 +196,12 @@ func computeRelayInfoFromEntries(entries []relayEntry, windowHours float64) Repe // #1751: accumulate transported scopes BEFORE the timestamp gate — // a non-advert path-hop tx proves scope transport even if its // first_seen is unparseable. Mirrors the bulk path. - if e.scope != "" { + // + // #1902: but only when the tx named this node by its full pubkey. + // A 1-byte hop names one of every node sharing that byte, which is + // enough for an approximate count and not enough to claim the node + // serves a region. + if e.scope != "" && !e.viaPrefix { if scopeSet == nil { scopeSet = map[string]struct{}{} } diff --git a/cmd/server/transported_scopes_1751_test.go b/cmd/server/transported_scopes_1751_test.go index 49cf3706..4d65ff84 100644 --- a/cmd/server/transported_scopes_1751_test.go +++ b/cmd/server/transported_scopes_1751_test.go @@ -100,27 +100,52 @@ func TestTransportedScopes_EmptyWhenNoScope(t *testing.T) { } } -// TestTransportedScopes_CrossBucketFold covers the bulk path's prefix fold: -// for a full-pubkey key it also folds in the matching 1-byte raw-prefix bucket -// (deduping by tx.ID). A scope seen only in the prefix bucket must surface on -// the full key, and a tx present in BOTH buckets must not be double-processed. -func TestTransportedScopes_CrossBucketFold(t *testing.T) { - full := scopeTx(1, 2, "region-direct") // only in the full-key bucket - prefixOnly := scopeTx(2, 2, "region-via-prefix") // only in the 1-byte bucket - shared := scopeTx(3, 2, "region-shared") // in BOTH buckets (dedup by ID) +// TestTransportedScopes_PrefixBucketNotAttributed is the #1902 regression. +// +// A 1-byte hop prefix is shared by every node whose pubkey starts with that +// byte, so the prefix bucket holds other nodes' traffic. Scopes are a +// categorical claim about which regions a repeater serves — a 1-byte hop +// names one of N candidates and cannot substantiate it. On the live network +// this badged Belgian repeaters with #de/#de-nw purely because a German +// repeater shared their first pubkey byte. +// +// So TransportedScopes must come from the full-key bucket only, while the +// counters keep the #662 prefix fold (over-count beats false zeros). +// Asserted on BOTH computation paths so they stay in parity. +func TestTransportedScopes_PrefixBucketNotAttributed(t *testing.T) { + direct := scopeTx(1, 2, "region-direct") // only in the full-key bucket + foreign := scopeTx(2, 2, "region-foreign") // only in the 1-byte bucket + shared := scopeTx(3, 2, "region-shared") // in BOTH buckets (dedup by ID) store := &PacketStore{ byPathHop: map[string][]*StoreTx{ - scope1751Key: {full, shared}, - scope1751Key[:2]: {prefixOnly, shared}, + scope1751Key: {direct, shared}, + scope1751Key[:2]: {foreign, shared}, }, mu: sync.RWMutex{}, } - got := store.computeRepeaterRelayInfoMap(24)[scope1751Key].TransportedScopes - want := []string{"region-direct", "region-shared", "region-via-prefix"} - if !reflect.DeepEqual(got, want) { - t.Fatalf("cross-bucket fold TransportedScopes = %v, want %v", got, want) + // "region-foreign" is only evidenced by a 1-byte hop — it must not be + // attributed. "region-shared" appears under the full key too, so it stays. + want := []string{"region-direct", "region-shared"} + + bulk := store.computeRepeaterRelayInfoMap(24)[scope1751Key] + if !reflect.DeepEqual(bulk.TransportedScopes, want) { + t.Fatalf("bulk TransportedScopes = %v, want %v (prefix-bucket scopes excluded)", bulk.TransportedScopes, want) + } + + perNode := store.GetRepeaterRelayInfo(scope1751Key, 24) + if !reflect.DeepEqual(perNode.TransportedScopes, want) { + t.Fatalf("per-node TransportedScopes = %v, want %v (prefix-bucket scopes excluded)", perNode.TransportedScopes, want) + } + + // The #662 fold itself is untouched: all three in-window packets still + // count, deduped by tx ID. Narrowing scopes must not narrow the counters. + if bulk.RelayCount24h != 3 { + t.Fatalf("bulk RelayCount24h = %d, want 3 (prefix fold must still feed the counters)", bulk.RelayCount24h) + } + if perNode.RelayCount24h != 3 { + t.Fatalf("per-node RelayCount24h = %d, want 3 (prefix fold must still feed the counters)", perNode.RelayCount24h) } } diff --git a/public/nodes.js b/public/nodes.js index 7613706d..c13c1e38 100644 --- a/public/nodes.js +++ b/public/nodes.js @@ -673,7 +673,7 @@ const btooltip = "Normalized betweenness centrality (0..1). How often this node sits on the shortest path between other pairs of nodes in the affinity graph. 1.0 = the most structurally critical node on the mesh. High Bridge + low Traffic share = a quiet but irreplaceable chokepoint."; return `