Skip to content

p2p/discover/topicindex: enforce waiting-time lower bound#81

Open
srene wants to merge 4 commits into
topdiscfrom
feat/waiting-time-lower-bound
Open

p2p/discover/topicindex: enforce waiting-time lower bound#81
srene wants to merge 4 commits into
topdiscfrom
feat/waiting-time-lower-bound

Conversation

@srene

@srene srene commented Jun 30, 2026

Copy link
Copy Markdown
Member

Implements the TopDisc waiting-time lower bound (paper §6 / compliance §2.1.5). Closes #80.

Invariant

Once a registrant is quoted a wait w(t1), a later quote must satisfy w(t2) >= w(t1) - (t2 - t1) — a re-quote can only decay at real-time rate, never reset lower.

TopicTable.WaitTime was memoryless, so an incumbent could let its ad expire and re-register through the brief occupancy dip at a near-zero re-quote, permanently holding the registrars closest to a topic. With the bound, accumulated wait persists, the slot becomes contestable, and the close set rotates.

Changes (topictable.go)

  • waitTimeState keeps per-(topic, id) and per-(topic, IP-prefix) lower-bound tuples (value, timestamp); WaitTime raises the computed wait to the highest still-active decayed bound and records the new bound. Expire prunes fully-decayed tuples.
  • IPv6 (Task 2.5: IPv6 prefix policy specification for DISC-NG IP-diversity scoring #53): the per-IP bound aggregates by prefix — /24 v4 (matching regBucketSubnet), /64 v6 per docs/disc-ng-ipv6-policy.md §3.2 — so it can't be evaded by rotating within one allocation. Transition-address re-routing stays with Task 2.6: IPv6 prefix policy implementation for DISC-NG IP-diversity scoring #54.
  • G floor (§2.1.3): G is derived from a real duration — G = waitTimeFloor / (waitBaseModifier * AdLifetime) with waitTimeFloor = 2s — so the empty-table floor is 2s regardless of AdLifetime

Implement the DISC-NG / TopDisc paper §6 (spec §2.1.5) anti-gaming lower
bound on registration wait times. Once a registrant has been quoted a
wait w(t1), any later quote w(t2) must satisfy w(t2) >= w(t1) - (t2 - t1):
a re-quoted wait can only decay at real elapsed time, never be reset.

Previously WaitTime was memoryless, so an incumbent could reset its
accumulated wait to ~0 by re-registering through a brief occupancy dip
(e.g. on ad expiry) and re-acquire the close-to-topic registrar slot
uncontested. Outsiders that hit a full registrar got a wait above budget
and dropped it. The close-registrar membership therefore never rotated,
capping continuous-searcher recall in the 10k sims.

The bound is tracked per (topic, advertiser id) and per (topic, IP) as a
(value, timestamp) tuple in waitTimeState, consulted and refreshed in
WaitTime via the table clock. Tuples are dropped once fully decayed
(during TopicTable.Expire), so the maps stay bounded by the set of
actively-waiting registrants.

Adds TestTopicTableWaitTimeLowerBound, which loads the table, quotes an
outsider, drains the table to collapse the instantaneous wait, and
asserts the prior quote is not reset below w1 - elapsed (it fails without
this change, where the quote drops straight back to ~0).

Refs #80.
@srene srene changed the title p2p/discover/topicindex: enforce waiting-time lower bound (§2.1.5) p2p/discover/topicindex: enforce waiting-time lower bound Jun 30, 2026
…refix

The per-IP anti-gaming bound keyed on the full address, so an attacker
could evade it by rotating the last octet (v4) or any of the low 64 bits
(v6). Key it on the family's prefix instead — /24 for IPv4 (matching
regBucketSubnet) and /64 for IPv6, the smallest real subnet boundary per
docs/disc-ng-ipv6-policy.md §3.2 (#53). Transition-address re-routing
(6to4/Teredo/NAT64) stays with the full policy in #54.

Adds TestTopicTableWaitTimeBoundPrefix (v4 /24 and v6 /64); updates the
lower-bound test's fresh node to a different /24 since same-/24 nodes now
share a bound.
@srene srene self-assigned this Jul 13, 2026
@srene
srene marked this pull request as ready for review July 13, 2026 11:31
@srene
srene requested a review from fjl as a code owner July 13, 2026 11:31
The wait modifier used max(topicMod+ipMod, 1e-6), so a fresh registrant
(both modifiers ~0) owed a microsecond wait — far below RTT and the 1s
admission slack, i.e. effectively instant registration. Replace the hack
with the paper's additive G term (§6), waitTimeG=0.05, which floors the
empty-table wait near 4.5s at the default 15-min AdLifetime. Now that the
lower bound persists accumulated wait, G has a defined role.

Adds TestTopicTableWaitTimeFloor.
Derive G = waitTimeFloor / (waitBaseModifier * AdLifetime) instead of a
fixed dimensionless 0.05. AdLifetime then cancels in the empty-table wait,
so the safety floor is a fixed duration (waitTimeFloor = 2s) regardless of
AdLifetime, rather than the old 0.05 which meant 4.5s at 15m but 0.15s at
30s. 2s sits just above the 1s admission slack (~1s effective wait).

Tests assert the floor is ~waitTimeFloor across AdLifetimes 30s/15m/1h.
@harnen
harnen self-requested a review July 20, 2026 12:51
return time.Duration(math.Ceil(neededTime * float64(time.Second)))
// g is the §6 safety floor, derived so the empty-table floor equals
// waitTimeFloor independent of AdLifetime (AdLifetime cancels baseTime).
g := waitTimeFloor.Seconds() / (waitBaseModifier * tab.config.AdLifetime.Seconds())

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why can't we set g to a constant?

// Lower-bound anti-gaming state (§6 / spec §2.1.5): per active registrant, the
// last wait quoted and when. idBounds keys by (topic, id); ipBounds by
// (topic, IP prefix) so ID rotation from one address can't evade it.
idBounds map[idBoundKey]waitBound

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If I understand correctly, this measn that we're storing value per registrar. We should only keep the value per IP and topic

// last wait quoted and when. idBounds keys by (topic, id); ipBounds by
// (topic, IP prefix) so ID rotation from one address can't evade it.
idBounds map[idBoundKey]waitBound
ipBounds map[ipBoundKey]waitBound

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we have to store IP bounds as a tree as well. Direct comparison won't work. E.g., the attacker's IP my not be in the topic table.


// ipBoundKeys returns the per-IP lower-bound keys for n, one per address family,
// each aggregated to its prefix.
func (wt *waitTimeState) ipBoundKeys(t TopicID, n *enode.Node) []ipBoundKey {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is it possible to have both IPv6 and IPv4 in n? If yes, we should discuss how to handle that.

}

// ipBoundKeyFor builds the bound key for ip, aggregated to its family's prefix
// (/24 for v4, /64 for v6). It reports ok=false for LAN or malformed addresses.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why we have /24 for v4 and /64 for v6


// expireBounds drops lower-bound tuples that have fully decayed at 'now'.
func (wt *waitTimeState) expireBounds(now mclock.AbsTime) {
for k, b := range wt.idBounds {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A for lop migh be costly here. Maybe we should investigate some sorted data structures?


// waitBaseModifier scales the occupancy-driven base wait (paper's E factor,
// tuned down 10x for the testbed).
waitBaseModifier = 0.1

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure why this is needed. And what does the tuning down for testbed means?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Task 2.7: Implement Waiting-Time Lower Bound (DISC-NG spec §2.1.5 / paper §6)

2 participants