From 8f1326fe2617511e2187e38aeef50068478869ce Mon Sep 17 00:00:00 2001 From: zelig Date: Wed, 5 Aug 2026 09:56:11 +0200 Subject: [PATCH 1/4] =?UTF-8?q?add=20SWIP-61:=20BPS=20multihop=20=E2=80=94?= =?UTF-8?q?=20FCFS=20multicast=20tree?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- SWIPs/assets/swip-61/bps.proto | 173 +++++++++++++++ SWIPs/swip-61.md | 373 +++++++++++++++++++++++++++++++++ 2 files changed, 546 insertions(+) create mode 100644 SWIPs/assets/swip-61/bps.proto create mode 100644 SWIPs/swip-61.md diff --git a/SWIPs/assets/swip-61/bps.proto b/SWIPs/assets/swip-61/bps.proto new file mode 100644 index 00000000..a91a28f6 --- /dev/null +++ b/SWIPs/assets/swip-61/bps.proto @@ -0,0 +1,173 @@ +// Broadcast Pub/Sub (BPS) — protocol messages and types. +// Spec: swip-bps-singlehop.md (SWIP-60, base protocol) and +// swip-bps-multihop.md (SWIP-61, multihop control plane). +// +// Revision 2 (2026-08-05), after review on PR #104: Connect split into +// Open/Subscribe (subscribers carry no cohort metadata), broker capacity +// removed from CohortSpec (it is broker-side policy, not a cohort parameter), +// Ping dropped (liveness/RTT are transport concerns), and every frame carries +// the full SOC (no handshake/data split). Field numbers renumbered — the +// draft has no deployed compatibility surface. +// +// SWIP-61 (2026-08-05) fills the reserved multihop control plane: Ack gains +// attachment candidates, Broadcast gains Reparent / Probe / Candidates. +// CohortSpec is untouched. +// +// Enum zero values (*_UNSPECIFIED): proto3 requires a zero value; it is +// deliberately NOT a legitimate wire value. It exists so that an unset field +// is detectable and no implementation can silently rely on a default. +// Receivers MUST reject messages carrying it. +// +// Implementation groundwork: bee PR #5435 (hand-rolled byte framing with the +// same semantics). + +syntax = "proto3"; +package bps; + +option go_package = "github.com/ethersphere/bee/v2/pkg/bps/pb"; + +// --------------------------------------------------------------------------- +// Cohort genesis — the primitive decisions whose combinations are the "modes" +// --------------------------------------------------------------------------- + +// What the topic binds to (see SWIP-60: binding semantics). +enum TopicBinding { + TOPIC_BINDING_UNSPECIFIED = 0; // invalid on the wire (see header note) + ANCHOR = 1; // topic = full SOC/GSOC address; dedup on the wrapped CAC + SOC_ID = 2; // topic = SOC id; any owner with PO(addr, anchor) >= po_min + OWNER = 3; // topic = SOC owner; any id with PO(addr, anchor) >= po_min (MIC) + FEED_TOPIC = 4; // id = keccak256(topic ‖ index); graffiti MIC / feed streams +} + +// Who may author. +enum PublisherRegime { + PUBLISHER_REGIME_UNSPECIFIED = 0; // invalid on the wire (see header note) + EXPLICIT_SINGLE = 1; // opener is the sole publisher (live streaming) + EXPLICIT_LIST = 2; // set fixed at genesis: admin + publisher_list + // (dynamic grants/revocations: later revision) + IMPLICIT = 3; // authorship implied by the topic binding (PO constraint) + ALL = 4; // every peer publishes (gossipsub-equivalent cohort) +} + +// Fixed by the cohort's opener; immutable for the cohort's lifetime. +// NOTE: broker capacity is NOT a cohort parameter — a cohort cannot dictate a +// remote node's connection count. Each broker enforces its own per-topic +// stream limit and answers FULL when it is exhausted. +message CohortSpec { + bytes topic = 1; // 32 bytes, meaning per binding + TopicBinding binding = 2; + PublisherRegime publishers = 3; + bool history = 4; // deliver matching chunks from the local store + bytes admin = 5; // 20-byte eth address; set iff EXPLICIT_* + repeated bytes publisher_list = 6; // 20-byte eth addresses, excl. admin; + // set iff EXPLICIT_LIST + uint32 po_min = 7; // proximity order for implicit bindings (default 16) + bool closed = 8; // no audience: subscribers restricted to the publishers +} + +// --------------------------------------------------------------------------- +// Stream establishment, stream name "pubsub/1.0.0" — one stream per (peer, topic). +// The first message on a fresh stream is Open (fixes a new cohort) or +// Subscribe (joins an existing one); the broker answers with Ack. +// --------------------------------------------------------------------------- + +// Opener -> broker: the one peer that fixes the cohort. +message Open { + CohortSpec cohort = 1; + PublisherAuth auth = 2; // present iff the opener publishes (explicit regimes) +} + +// Joiner -> broker: names the topic — nothing more. Subscribers carry no +// cohort metadata; auth is present iff the joiner publishes (publishers +// connect directly to the broker). +message Subscribe { + bytes topic = 1; // 32 bytes + PublisherAuth auth = 2; // present iff publisher +} + +message PublisherAuth { + bytes owner = 1; // 20-byte eth address of the SOC owner key + bytes id = 2; // 32-byte SOC id, when the binding fixes it +} + +// Broker -> peer, answering Open or Subscribe. The echoed CohortSpec lets a +// subscriber verify every message end-to-end against the topic binding. +message Ack { + Status status = 1; + CohortSpec cohort = 2; // set iff status == OK + repeated Candidate candidates = 3; // SWIP-61: set iff FULL at a relaying + // node — the two shallowest attachment + // points its probe found +} + +enum Status { + STATUS_UNSPECIFIED = 0; // invalid on the wire (see header note) + OK = 1; + FULL = 2; // at per-topic capacity; a singlehop (SWIP-60) + // broker refuses — nothing else; a multihop + // (SWIP-61) relay attaches candidates + UNKNOWN_TOPIC = 3; // Subscribe for a topic the broker does not serve + REJECTED = 4; // e.g. publisher not on the list, invalid auth, + // non-publisher Subscribe on a closed cohort +} + +// --------------------------------------------------------------------------- +// Messages — SOC-only is a protocol feature +// --------------------------------------------------------------------------- + +// A full single-owner chunk in transit. Every frame is self-contained: no +// per-stream handshake state, and no format change if the stream model +// evolves (e.g. topic-muxed streams later). +message Soc { + bytes id = 1; // 32 bytes + bytes owner = 2; // 20 bytes (recoverable from signature; explicit for cheap filtering) + bytes signature = 3; // 65 bytes + bytes span = 4; // 8 bytes LE + bytes payload = 5; // wrapped-CAC data, <= 4096 bytes +} + +// Publisher -> broker. +message Publish { + Soc soc = 1; +} + +// The frame envelope. Control frames (SWIP-61) ride it in both directions on +// a (peer, topic) stream; frame type and direction disambiguate. +message Broadcast { + oneof frame { + Soc soc = 1; + Reparent reparent = 2; // SWIP-61, parent -> child + Probe probe = 3; // SWIP-61, parent -> child: find free slots below + Candidates found = 4; // SWIP-61, child -> parent: probe reply, min-2 filtered + // 5–15 remain reserved + } +} + +// --------------------------------------------------------------------------- +// Multihop control plane (SWIP-61) +// --------------------------------------------------------------------------- + +// Parent -> child: re-point, make-before-break — the child Subscribes to the +// new parent and drops the old stream only once the new one is live. +message Reparent { + bytes to = 1; // overlay address of the new parent +} + +// Parent -> child: find free slots below. depth is the depth of the receiver, +// incremented at each forwarding hop — no node stores its own position. +message Probe { + uint32 depth = 1; +} + +// Child -> parent: probe reply — at most two, smallest depth first. +message Candidates { + repeated Candidate candidates = 1; +} + +message Candidate { + bytes addr = 1; // overlay address of a node with a free slot + uint32 depth = 2; // its depth at probe time +} + +// Keepalive / RTT: none at the BPS level. Liveness is the transport's job +// (libp2p). diff --git a/SWIPs/swip-61.md b/SWIPs/swip-61.md new file mode 100644 index 00000000..f8bf10c0 --- /dev/null +++ b/SWIPs/swip-61.md @@ -0,0 +1,373 @@ +--- +SWIP: 61 +title: BPS multihop — FCFS multicast tree +author: Viktor Trón (@zelig), Viktor Tóth (@nugaon) +discussions-to: https://discord.gg/Q6BvSkCv +status: Draft +type: Standards Track (Networking) +created: 2026-08-04 +requires: 60 +--- + +- **Business line**: audiences beyond one node's capacity — live streaming and event + fan-out scaled by recruiting subscribers as relays, so a cohort's capacity grows with + its audience instead of being fixed by the broker's stream limit — and streams that + survive relay churn without interruption. +- **Dev line**: fill three reserved `Broadcast` control-plane fields of + [bps.proto](assets/swip-61/bps.proto) (`Reparent`, `Probe`, `Candidates`), extend `Ack` with + attachment candidates, and implement three behaviours — subtree probing, dual-parent + maintenance, make-before-break reparenting; done when a cohort scales past a single + node's capacity, masks a single relay failure with no delivery gap, and unmodified + SWIP-60 clients attach as leaves. +- DISC: the first time proper **forwarding by consumers** (subscribers as + relays) is implemented; a wire-protocol change (reserved frames become normative), + no storage or Bee-API change. +- Bandwidth-incentive integration is a separate SWIP (bps-bw-incentives). + +## Simple Summary + +SWIP-60 bounds a topic's audience by one broker's per-topic capacity. This SWIP removes +the ceiling: a full node answering a `Subscribe` **probes its own subtree over the +streams it already has** and returns the two shallowest free attachment points — and +the joiner attaches to **both**. Every node thus keeps two parents (typically sister +nodes): both feed it, duplicates are absorbed by SWIP-60's dedup rule, and the loss of +any single relay or link is masked, not suffered — the subtree below never notices, +while the affected node self-heals by re-running the join. A subscriber that accepts a +child thereby becomes a **relay**; the tree grows first-come-first-served, with no +control traffic while nobody is joining. End-to-end authentication is unchanged: a +relay can withhold, never forge — and with two feeds to compare, withholding shows. + +## Motivation + +One principle carries over from SWIP-60 and does all the work here: **forwarders are +subscribers**. A forwarder is simply a subscriber that fans out messages to subscribers +of its own; singlehop is the depth = 1 variant of the same structure, and the broker +remains the root in every configuration. Scaling to large audiences is then not a new +protocol but the removal of one restriction — the singlehop rule "at capacity, refuse +with `FULL` and nothing else" is replaced by referral to where capacity is. Placement +quality is explicitly **not** the join protocol's problem: FCFS means the first free +slot found wins. What multihop must not compromise is the contract itself: messages +arrive at **all subscribers** — a live stream that drops messages on relay churn is +broken, so churn tolerance is built into the topology, not patched on. + +## Specification + +### The seam from SWIP-60 + +Everything in SWIP-60 stands — cohort genesis (`CohortSpec` untouched), bindings and +dedup rules, publisher legitimacy, the `Open`/`Subscribe`/`Ack` establishment, +self-contained SOC frames, end-to-end verification — with exactly one amendment: + +**The capacity rule.** A relaying node at capacity answering a `Subscribe` MUST NOT +bare-refuse; it probes its subtree and answers `Ack(FULL)` **with the two shallowest +attachment points found** (new `candidates` field). A SWIP-60-only client ignores the +unknown field and reads a plain refusal. + +There is **no depth bound**: the tree grows as deep as joins take it. Depth is +self-limiting — per-hop pricing (bps-bw-incentives) charges upstream by depth, so +joiners are economically steered toward the root; a protocol bound would only duplicate +the price signal. And the one configuration that genuinely needs depth = 1 — the closed +cohort, where all and only publishers subscribe — is **structurally** singlehop already: +publishers connect directly to the root. + +Invariant: **publishers (and the opener) still connect directly to the root.** Multihop +restructures the audience side only. + +### Roles + +- **Broker** — as in SWIP-60: root of the tree, target of `Open`, sole entry point for + publishers, default entry point for joins. +- **Relay** — a subscriber with children: re-broadcasts every SOC frame down its own + direct streams, answers `Subscribe` like any parent, and forwards and aggregates + probes. There is no promotion step: **accepting your first child is what becoming a + relay means.** Relay capability is latent in every subscriber — the `Ack`-echoed + `CohortSpec` from its own join is exactly what it echoes to children of its own. Any + full-node subscriber is relay-eligible; NAT'd and light-client relays are reachable + if the transport can dial them (see NAT'd relays below), not required — a node unable + or unwilling to relay simply has capacity 0 and refuses. +- **Leaf** — a subscriber with no children; the SWIP-60 subscriber role verbatim. A + SWIP-60 client is a conformant leaf (single-parented — it forgoes the resilience, + not the stream). + +### Join: probe and attach (FCFS) + +A joiner sends `Subscribe(topic)` to the broker (or any cohort node it already knows). +A node with a free slot accepts on the spot: `Ack(OK, CohortSpec)`. A full relaying +node **searches its own subtree before answering** — over the streams it already has: + +1. **Probe down.** The full node sends `Probe{depth}` to its children, `depth` being + the receiver's depth (root's children = 1). A full child forwards the probe to its + own children, incrementing `depth`; a child with a free slot replies instead of + forwarding; a capacity-0 child replies empty (or not at all — see below). +2. **Filter up.** A node with a free slot answers `Candidates` naming itself + `{addr, depth}`. A forwarding node collects its children's replies within a bounded + wait (parameter), keeps the **two smallest-depth** candidates, and passes + them up. +3. **Answer.** The originating node answers the joiner `Ack(FULL, candidates)` — the + two shallowest attachment points of its subtree. No candidates ⇒ bare `Ack(FULL)`: + the probe came back empty, the subtree genuinely cannot host the joiner. +4. **Attach — to both.** The joiner `Subscribe`s at **both candidates**: the two + resulting streams are its two parents (see Resilience). A candidate lost to a race + is replaced by re-`Subscribe`ing at the origin. **A join is ideally a two-step + process** — one round to learn where, one to attach — regardless of tree size. + +The probe wave stops at the first non-full node on every path: nothing below a free +slot is ever searched. Depth travels **in the probe itself**, incremented hop by hop — +no node knows or stores its own position, so there is nothing to go stale when the tree +is reorganised. And there is no standing state anywhere: an idle cohort is perfectly +silent, state is gathered on demand and discarded. A node MAY reuse candidates from a +just-completed probe for immediately subsequent joins instead of re-probing. + +Preferring shallow slots is deliberate: min-depth filtering fills the tree +breadth-first, keeping delivery paths short — and it agrees with the incentive gradient +(upstream pricing is depth-scaled), so the protocol default and the economics point the +same way. It is still FCFS, not optimality: candidates race, replies are best-effort +within the wait bound, and the first slot won wins. + +### Resilience: two parents + +Churn-related misses are not tolerated: the contract says messages arrive at all +subscribers, and a live stream cannot pause for tree repair. So redundancy is +structural: + +- **Every node maintains two parents** — the probe's two candidates; by min-depth + filtering these sit at (near-)equal depth, typically **sister nodes**. The two + parents MUST be distinct; while the cohort is too small to offer two (e.g. the + broker's first children), a node stays single-parented and upgrades when it can. +- **Both parents feed.** Each parent treats the node as an ordinary child and streams + every frame; frames are self-contained SOCs and the binding's dedup rule (SWIP-60) + suppresses the duplicate — this is the make-before-break overlap made permanent. +- **Single failure masks.** If a relay or a link fails, every child of the failed node + keeps receiving through its other parent, so **the subtree below notices nothing**; + masking is recursive — each level's redundancy protects the level below. +- **Self-healing is the join protocol re-run.** The half-orphaned node acquires a + replacement second parent by `Subscribe`ing at its surviving parent (accepted + directly, or answered with fresh candidates via probe). No dedicated repair + machinery. +- **Withholding becomes visible.** With two independent feeds, a parent whose stream + persistently lags its sister's is caught by comparison; the node drops it and + replaces it the same way — demotion, not detection heroics. + +The price is stated, not hidden: each subscription consumes two tree slots and every +message crosses every level twice, roughly doubling bandwidth. Resilience is bought, +and per-hop pricing (bps-bw-incentives) is what will price it; an active/standby +variant (second parent connected but mute until asked) trades the gap-free guarantee +for half the traffic and is deliberately **not** specified here. + +### Reparenting (`Reparent`) — make-before-break + +`Reparent{to}` is the parent→child instruction to move: the child `Subscribe`s to the +new parent, waits for `Ack(OK)` confirming the stream is live, and **only then** drops +the old stream. During the overlap the dedup rule absorbs doubles, as everywhere. + +Two cases: + +- **graceful leave** — a departing relay `Reparent`s each child toward a replacement + (its own parents, or spread over its children **(?)**) before closing; the child's + flow continues on its other parent throughout the move; +- **churn repair** — is the *absence* case: BPS has no keepalive, so a parent's death + surfaces as a transport-level stream reset or connection loss; no `Reparent` arrives, + the surviving parent carries the stream, and the node re-runs the join for a + replacement. + +Only a *simultaneous* loss of both parents opens a delivery gap; that is then a +liveness fault like any other — repaired by rejoin from the broker, with no recovery of +the gap itself (a resumed subscriber is in the same position as a fresh one). + +### NAT'd relays + +Reaching a NAT'd relay is the transport's business, not this protocol's: libp2p circuit +relay + DCUtR hole punching (the dcutr work item, bee +[#5355](https://github.com/ethersphere/bee/issues/5355)). BPS carries no signalling for +it — the only tree-relevant observation is that a NAT'd child's **parent is its natural +circuit relay**, so a candidate a probe returns can be handed out under an address the +transport knows how to dial. + +### Information flow (probe and attach) + +```mermaid +sequenceDiagram + autonumber + participant J as joiner + participant B as broker (root, full) + participant C as child C (full) + participant X as grandchild X (free slot) + participant Y as grandchild Y (free slot) + + J->>B: Subscribe(topic) + Note over B: at capacity — probe the subtree
over existing streams + B->>C: Probe{depth: 1} + C->>X: Probe{depth: 2} + C->>Y: Probe{depth: 2} + X-->>C: Candidates[{X, 2}] + Y-->>C: Candidates[{Y, 2}] + C-->>B: Candidates[{X, 2}, {Y, 2}] (min-2 filtered) + B-->>J: Ack(FULL, candidates: [{X, 2}, {Y, 2}]) + par attach to both + J->>X: Subscribe(topic) + X-->>J: Ack(OK, CohortSpec) + and + J->>Y: Subscribe(topic) + Y-->>J: Ack(OK, CohortSpec) + end + Note over J,Y: two parents, sister nodes —
either may fail unnoticed +``` + +### Process: a subscriber's lifecycle + +```mermaid +flowchart TD + S["Subscribe at broker or
any known cohort node"] --> Q{"free slot?"} + Q -- "yes" --> A["Ack(OK, CohortSpec)
attached"] + Q -- "no — relaying node" --> P["probe subtree
over existing streams"] + Q -- "no — capacity 0" --> F["bare FULL"] + P --> C{"candidates
found?"} + C -- "two" --> D["attach to both:
two parents, sisters"] + C -- "none" --> F + A --> L + D --> L["LIVE: both parents feed,
dedup absorbs doubles"] + L -- "one parent fails
or withholds" --> M["survivor carries the stream —
subtree below notices nothing"] + M --> R["re-run join at survivor:
replacement second parent"] + R --> L + L -- "both parents fail" --> G["liveness fault"] + G -- "rejoin from broker" --> S +``` + +### Wire protocol + +Amendments to [bps.proto](assets/swip-61/bps.proto) — three reserved `Broadcast` control-plane fields +become normative, the rest stay reserved; control frames ride the `Broadcast` envelope +in **both directions** on a (peer, topic) stream (`Candidates` flows child→parent); +frame type and direction disambiguate. On submission the amended file goes to +`assets/swip-61/`. `CohortSpec` is untouched: + +```proto +message Ack { + Status status = 1; + CohortSpec cohort = 2; // set iff status == OK + repeated Candidate candidates = 3; // set iff FULL at a relaying node: the two + // shallowest attachment points the probe found +} + +message Broadcast { + oneof frame { + Soc soc = 1; // as SWIP-60 + Reparent reparent = 2; // parent -> child + Probe probe = 3; // parent -> child: find free slots below + Candidates found = 4; // child -> parent: probe reply, min-2 filtered + // 5–15 remain reserved + } +} + +// parent -> child: re-point, make-before-break +message Reparent { + bytes to = 1; // overlay address of the new parent +} + +// depth of the receiver, incremented at each forwarding hop — +// no node stores its own position +message Probe { + uint32 depth = 1; +} + +// at most two, smallest depth first +message Candidates { + repeated Candidate candidates = 1; +} + +message Candidate { + bytes addr = 1; // overlay address of a node with a free slot + uint32 depth = 2; // its depth at probe time +} +``` + +## Rationale + +**Why no standing capacity state — beacons, slot counts, subtree summaries?** Because +any standing summary must be kept fresh on every edge forever and is stale by +construction. The probe gathers exactly the state one join needs, at the moment of +needing it, and discards it; an idle cohort is perfectly silent — the same philosophy +that keeps keepalives and capacity advertisement out of BPS altogether (SWIP-60). + +**Why does the tree search, and not the joiner?** Cost asymmetry. A probe hop rides a +stream that already exists — one frame each way; a joiner's probe is a fresh dial to a +stranger — connection setup, handshake, possibly NAT traversal. Letting the tree run +the search over its own edges and handing the joiner two ready candidates turns a chain +of expensive dials into a wave of cheap frames plus two dials. + +**Why two live parents rather than a standby?** Because the failure this design must +mask is precisely the one a standby cannot: messages sent during the window between a +parent dying and anyone noticing. There is no keepalive to notice with — liveness is +the transport's job, and transport-level detection has latency. Two always-on feeds +close that window structurally, cost 2×, and come with a side effect no standby offers: +withholding is caught by comparing the feeds. + +**Why no depth bound?** Because depth already has a price. Per-hop publish pricing +(bps-bw-incentives) is depth-scaled upstream, so joiners are economically steered +toward the root and deep chains cost their occupants; a protocol-level `max_depth` +would duplicate that signal with a blunter instrument — and give the tree a notion of +"full" it doesn't otherwise need. The closed cohort, the one shape that must stay +depth 1, is structurally singlehop: publishers connect directly to the root. + +**Why do relays get nothing here?** They do — later. Metered per-hop pricing is +bps-bw-incentives' business; this SWIP keeps the edges it will price. Until then, +relaying is the same volunteer economics as singlehop brokering. + +**Why is withholding still tolerable with more intermediaries?** Unchanged from +SWIP-60 in principle — authentication is structural (SOC signature against the topic +binding, verified against the `Ack`-echoed `CohortSpec` at any depth), so adding relays +adds withholding points but no forging points — and strengthened in practice: a +withholding parent is exposed by its sister's feed and replaced by the ordinary +self-healing path. + +## Out of scope (deliberately) + +Paying for forwarded messages (bps-bw-incentives); message history — delivery of +messages from before the subscription (bps-history); broker discovery (SWIP-59 MEX); +NAT traversal — circuit relay and hole punching are transport business (dcutr item); +dynamic publisher-list changes (out of scope in SWIP-60, unaffected here). + +## Conformance (definition of done) + +An implementation is conformant when: + +1. a relaying node at capacity answers `Subscribe` with `Ack(FULL, candidates)` — the + two smallest-depth attachment points its probe returned; bare `FULL` only on an + empty probe, or from a node that cannot or will not relay; +2. a probe wave terminates: nodes with a free slot reply and do not forward, and + forwarding nodes answer within the wait bound with the min-2-by-depth filter of what + arrived; +3. an accept carries the echoed `CohortSpec`; +4. absent races, a join completes in two steps: one probe round, one (dual) attach; +5. a node offered two distinct parents maintains both; duplicate deliveries never + surface past the binding's dedup rule; +6. the failure of any single relay or link causes no delivery gap anywhere below it; + the half-orphaned children re-acquire a second parent via the ordinary join; +7. every reparent is make-before-break; +8. an unmodified SWIP-60 client attaches as a leaf anywhere in the tree and cannot + distinguish its parent from a singlehop broker; +9. a subscriber at any depth re-verifies every message end-to-end; loss of both + parents is a liveness fault repaired by rejoin. + +## Backwards compatibility + +Same stream name `pubsub/1.0.0`, no version bump: `Reparent`, `Probe` and `Candidates` +fill `Broadcast` oneof fields reserved by SWIP-60, and the single new `Ack` field +is invisible to old implementations — an old client ignores `Ack.candidates` and reads +a plain `FULL`. A SWIP-60 leaf that receives a `Probe` ignores the unknown frame and +never replies — exactly the empty answer the bounded wait absorbs, which is why old +leaves are compatible by construction (single-parented, without the resilience). +`CohortSpec` is untouched. SWIP-60's conformance item 4 ("`FULL` — and nothing else") +is superseded for nodes implementing this SWIP. + +## References + +[SWIP-60](https://github.com/ethersphere/SWIPs/pull/104) / PR +[#104](https://github.com/ethersphere/SWIPs/pull/104) · wire: [bps.proto](assets/swip-61/bps.proto) · +origin: [PR #93](https://github.com/ethersphere/SWIPs/pull/93) "Add: pubsub" · +implementation groundwork: bee [#5435](https://github.com/ethersphere/bee/pull/5435) · +dCUTr: [bee#5355](https://github.com/ethersphere/bee/issues/5355) + +## Copyright + +Copyright and related rights waived via [CC0](https://creativecommons.org/publicdomain/zero/1.0/). From 91e7506696487a88689859b03712b7e6cca63b59 Mon Sep 17 00:00:00 2001 From: zelig Date: Fri, 7 Aug 2026 12:18:49 +0200 Subject: [PATCH 2/4] =?UTF-8?q?swip-61:=20sync=20proto=20with=20swip-60=20?= =?UTF-8?q?rev=203=20=E2=80=94=20po=5Fmin=20->=20protocol=20constant=20PO?= =?UTF-8?q?=5FMIN?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- SWIPs/assets/swip-61/bps.proto | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/SWIPs/assets/swip-61/bps.proto b/SWIPs/assets/swip-61/bps.proto index a91a28f6..60bcefdb 100644 --- a/SWIPs/assets/swip-61/bps.proto +++ b/SWIPs/assets/swip-61/bps.proto @@ -34,8 +34,8 @@ option go_package = "github.com/ethersphere/bee/v2/pkg/bps/pb"; enum TopicBinding { TOPIC_BINDING_UNSPECIFIED = 0; // invalid on the wire (see header note) ANCHOR = 1; // topic = full SOC/GSOC address; dedup on the wrapped CAC - SOC_ID = 2; // topic = SOC id; any owner with PO(addr, anchor) >= po_min - OWNER = 3; // topic = SOC owner; any id with PO(addr, anchor) >= po_min (MIC) + SOC_ID = 2; // topic = SOC id; any owner with PO(addr, anchor) >= PO_MIN + OWNER = 3; // topic = SOC owner; any id with PO(addr, anchor) >= PO_MIN (MIC) FEED_TOPIC = 4; // id = keccak256(topic ‖ index); graffiti MIC / feed streams } @@ -53,6 +53,8 @@ enum PublisherRegime { // NOTE: broker capacity is NOT a cohort parameter — a cohort cannot dictate a // remote node's connection count. Each broker enforces its own per-topic // stream limit and answers FULL when it is exhausted. +// NOTE: the proximity constraint for implicit bindings is a protocol +// constant, PO_MIN = 16 — not a cohort parameter. message CohortSpec { bytes topic = 1; // 32 bytes, meaning per binding TopicBinding binding = 2; @@ -61,7 +63,7 @@ message CohortSpec { bytes admin = 5; // 20-byte eth address; set iff EXPLICIT_* repeated bytes publisher_list = 6; // 20-byte eth addresses, excl. admin; // set iff EXPLICIT_LIST - uint32 po_min = 7; // proximity order for implicit bindings (default 16) + reserved 7; // was po_min — now protocol constant PO_MIN = 16 bool closed = 8; // no audience: subscribers restricted to the publishers } From 1ca57ae0715bf6faf7bcd46e6cbfac2697cae743 Mon Sep 17 00:00:00 2001 From: zelig Date: Sun, 9 Aug 2026 10:35:37 +0200 Subject: [PATCH 3/4] swip-61: dual parenting not required on self-indexed cohorts Per SWIP-65 (PR #106): a delivery gap on a self-indexed cohort is detectable and recoverable from storage, so the contract is satisfied by deliver-or-recover. The second parent becomes a per-subscriber latency/cost choice - single-parented nodes were already well-formed, and a single-parented relay endangers only itself. Conformance items 5-6 bind only nodes that maintain a second parent. Co-Authored-By: Claude Fable 5 --- SWIPs/swip-61.md | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/SWIPs/swip-61.md b/SWIPs/swip-61.md index f8bf10c0..537fa8df 100644 --- a/SWIPs/swip-61.md +++ b/SWIPs/swip-61.md @@ -154,6 +154,16 @@ and per-hop pricing (bps-bw-incentives) is what will price it; an active/standby variant (second parent connected but mute until asked) trades the gap-free guarantee for half the traffic and is deliberately **not** specified here. +On cohorts running **self-indexed feeds +([SWIP-65](https://github.com/ethersphere/SWIPs/pull/106))** a delivery gap is +detectable at the next frame and recoverable from storage, so a second parent is **not +required** — for a subscriber content with recovery latency it is pure extra cost, and +forgoing it is a per-subscriber choice: single-parented nodes are already well-formed +(SWIP-60 leaves), and a single-parented relay endangers only itself — its children +hold their own second parents. Dual parenting remains the costlier-but-faster product +for gap-intolerant subscribers (the live edge). Conformance items 5–6 read +accordingly. + ### Reparenting (`Reparent`) — make-before-break `Reparent{to}` is the parent→child instruction to move: the child `Subscribe`s to the @@ -339,8 +349,10 @@ An implementation is conformant when: arrived; 3. an accept carries the echoed `CohortSpec`; 4. absent races, a join completes in two steps: one probe round, one (dual) attach; -5. a node offered two distinct parents maintains both; duplicate deliveries never - surface past the binding's dedup rule; +5. a node offered two distinct parents maintains both — except on self-indexed cohorts + ([SWIP-65](https://github.com/ethersphere/SWIPs/pull/106)), where the second parent + is optional per subscriber and items 5–6 bind only nodes that maintain one; + duplicate deliveries never surface past the binding's dedup rule; 6. the failure of any single relay or link causes no delivery gap anywhere below it; the half-orphaned children re-acquire a second parent via the ordinary join; 7. every reparent is make-before-break; From 00aa7897852d4883a5077ce3a49ae26287a45765 Mon Sep 17 00:00:00 2001 From: zelig Date: Sun, 30 Aug 2026 07:01:53 +0200 Subject: [PATCH 4/4] swip-61 rev 2: publishing travels rootward; no configuration needs depth 1 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Realigns SWIP-61 with SWIP-60 rev 4 and lifts the invariant that confined publishers to the root. ## Two amendments to SWIP-60, not one 1. The capacity rule (unchanged in substance): a relaying node at capacity answers Ack(FULL, candidates). 2. NEW — Publish travels rootward, forwarded hop by hop to the broker, which validates, dedups and broadcasts it back down. SWIP-60's direct attachment of publishers was a consequence of depth = 1, not an invariant. Multihop now restructures both directions, which is what an everyone-publishes cohort (SWIP-60's ALL, and any GRANTED cohort larger than one node's capacity) needs in order to exist at all. Open still goes to the broker — opening a cohort IS contacting the root — but its admin is thereafter an ordinary publisher and may sit at any depth. New section "Publishing from depth" covers: validation authoritative at the root (dedup is a statement about what the cohort has seen, and only the root sees everything) while relays MAY and SHOULD run the stateless checks early; publishing up BOTH parents, so a single relay cannot silently swallow a publish — the upstream twin of what two feeds buy downstream; and depth priced rather than bounded. Revocation across a tree: the roster update is an ordinary broadcast, so enforcement lands at the revoked publisher's parents, and the revokee learns of its own revocation from the same broadcast at the same time — which is what makes SWIP-60's second phase fair at any depth. ## The depth-bound argument, re-anchored It previously rested on the closed cohort being "structurally singlehop". That anchor is gone twice over: `closed` no longer exists in SWIP-60, and publishers no longer attach to the root. NO configuration requires depth 1. The argument now stands on the price alone — and is stronger, since depth-scaled upstream pricing bills a publisher for its depth, the case where the incentive bites hardest. ## What a relay owes a joiner SWIP-60 rev 4's Ack carries the echoed CohortSpec, the admin-signed genesis SOC and the latest service SOC with its index. A relay MUST pass on all three unchanged: depth must cost a subscriber nothing in what it can verify, so a leaf ten hops out checks cohort and roster against the admin's key exactly as the broker's own child does. Extra intermediaries add withholding points and no forging points; a relay serving a stale service SOC is withholding, detected as an index gap. `spectators: false` cohorts work at depth for the same reason — any node answering a Subscribe holds the roster. ## Wire bps.proto regenerated from SWIP-60 rev 4 rather than patched, so the two files no longer drift. Ack.candidates takes field 6, NOT 3 — fields 3-5 are SWIP-60's service SOCs, which the old draft would have collided with. ## Review items - Copilot: front matter split into `type: Standards Track` + `category: Networking`; proto header pointed at swip-60.md / swip-61.md instead of the nonexistent swip-bps-*.md; establishment comments corrected to say any cohort node answers Subscribe, not only the broker; DCUtR casing normalised. - The `(?)` on graceful leave is resolved: a departing relay reparents children to its own parents — known-live, known-compatible, one level shallower. Handing children to one another would deepen the tree and has no answer for the first child. - Both mermaid diagrams verified to parse (mermaid 11), after the semicolon defect found in SWIP-60's. Co-Authored-By: Claude Opus 5 --- SWIPs/assets/swip-61/bps.proto | 275 ++++++++++++++++++++++++--------- SWIPs/swip-61.md | 192 +++++++++++++++++------ 2 files changed, 345 insertions(+), 122 deletions(-) diff --git a/SWIPs/assets/swip-61/bps.proto b/SWIPs/assets/swip-61/bps.proto index 60bcefdb..772f4af6 100644 --- a/SWIPs/assets/swip-61/bps.proto +++ b/SWIPs/assets/swip-61/bps.proto @@ -1,25 +1,35 @@ // Broadcast Pub/Sub (BPS) — protocol messages and types. -// Spec: swip-bps-singlehop.md (SWIP-60, base protocol) and -// swip-bps-multihop.md (SWIP-61, multihop control plane). +// Spec: SWIP-60 (../../swip-60.md, base protocol) and +// SWIP-61 (../../swip-61.md, multihop control plane). // -// Revision 2 (2026-08-05), after review on PR #104: Connect split into -// Open/Subscribe (subscribers carry no cohort metadata), broker capacity -// removed from CohortSpec (it is broker-side policy, not a cohort parameter), -// Ping dropped (liveness/RTT are transport concerns), and every frame carries -// the full SOC (no handshake/data split). Field numbers renumbered — the -// draft has no deployed compatibility surface. +// SWIP-61 fills the multihop control plane reserved by SWIP-60: Ack gains +// attachment candidates, Broadcast gains Reparent / Probe / Candidates, and +// Publish travels rootward from any depth. CohortSpec is untouched. // -// SWIP-61 (2026-08-05) fills the reserved multihop control plane: Ack gains -// attachment candidates, Broadcast gains Reparent / Probe / Candidates. -// CohortSpec is untouched. +// Revision 7 (2026-08-25), per Viktor — the control plane splits out. +// +// The publisher roster leaves the CohortSpec: it is dynamic, the spec is +// immutable, and grantee identities are not public the way an admin's is. It +// travels instead as admin-signed SERVICE MESSAGES on a feed the admin owns, +// so that a subscriber verifies who may write against the admin's key rather +// than the broker's word, and so that gaps in the roster history are visible. +// What remains in the spec is immutable policy: admin, publisher regime, +// whether spectators are admitted. +// +// Earlier revisions of this draft, for the record: Open/Subscribe were wrapped +// in a Hello envelope (as bare frames they are indistinguishable on the wire, +// and proto3's permissive unmarshalling makes a wrong guess succeed silently); +// Auth became a recovered signature rather than an asserted address; `closed` +// was removed in favour of joining deciding a role. // // Enum zero values (*_UNSPECIFIED): proto3 requires a zero value; it is // deliberately NOT a legitimate wire value. It exists so that an unset field // is detectable and no implementation can silently rely on a default. // Receivers MUST reject messages carrying it. // -// Implementation groundwork: bee PR #5435 (hand-rolled byte framing with the -// same semantics). +// The singlehop (depth = 1) subset is concrete; multihop control-plane +// messages are reserved. Implementation groundwork: bee PR #5435 +// (hand-rolled byte framing with the same semantics). syntax = "proto3"; package bps; @@ -27,7 +37,7 @@ package bps; option go_package = "github.com/ethersphere/bee/v2/pkg/bps/pb"; // --------------------------------------------------------------------------- -// Cohort genesis — the primitive decisions whose combinations are the "modes" +// Cohort genesis — immutable policy. The roster is NOT here (see ServiceKind). // --------------------------------------------------------------------------- // What the topic binds to (see SWIP-60: binding semantics). @@ -35,82 +45,184 @@ enum TopicBinding { TOPIC_BINDING_UNSPECIFIED = 0; // invalid on the wire (see header note) ANCHOR = 1; // topic = full SOC/GSOC address; dedup on the wrapped CAC SOC_ID = 2; // topic = SOC id; any owner with PO(addr, anchor) >= PO_MIN - OWNER = 3; // topic = SOC owner; any id with PO(addr, anchor) >= PO_MIN (MIC) - FEED_TOPIC = 4; // id = keccak256(topic ‖ index); graffiti MIC / feed streams + OWNER = 3; // topic = keccak256(owner); any id, same PO constraint (MIC) + FEED_TOPIC = 4; // id = keccak256(topic || index); feed-update streams + MNEMONIC = 5; // the topic names the cohort and constrains nothing: any SOC + // from any owner qualifies (dedup on chunk address). What + // PublisherRegime.ALL needs -- authorship unrestricted, but + // never unattributable, since every message is SOC-signed. + // APPENDED, not inserted: 1-4 keep the numbering the bee + // prototype already implements. } -// Who may author. +// Who may author, when the cohort has an admin. With no admin the cohort is +// implicit: authorship follows the binding's SOC shape and this does not apply. enum PublisherRegime { PUBLISHER_REGIME_UNSPECIFIED = 0; // invalid on the wire (see header note) - EXPLICIT_SINGLE = 1; // opener is the sole publisher (live streaming) - EXPLICIT_LIST = 2; // set fixed at genesis: admin + publisher_list - // (dynamic grants/revocations: later revision) - IMPLICIT = 3; // authorship implied by the topic binding (PO constraint) - ALL = 4; // every peer publishes (gossipsub-equivalent cohort) + ADMIN_ONLY = 1; // the admin alone, for the cohort's whole life (live stream) + GRANTED = 2; // the admin plus whoever the current roster names (jam) + ALL = 3; // anyone attached; needs MNEMONIC binding (group chat) } // Fixed by the cohort's opener; immutable for the cohort's lifetime. -// NOTE: broker capacity is NOT a cohort parameter — a cohort cannot dictate a +// NOTE: broker capacity is NOT a cohort parameter -- a cohort cannot dictate a // remote node's connection count. Each broker enforces its own per-topic // stream limit and answers FULL when it is exhausted. -// NOTE: the proximity constraint for implicit bindings is a protocol -// constant, PO_MIN = 16 — not a cohort parameter. +// NOTE: the proximity constraint for implicit bindings is a protocol constant, +// PO_MIN = 16 -- not a cohort parameter (a proto3 unset uint32 is +// indistinguishable from 0, which would silently disable the constraint; and +// no use case varies it). message CohortSpec { - bytes topic = 1; // 32 bytes, meaning per binding - TopicBinding binding = 2; - PublisherRegime publishers = 3; - bool history = 4; // deliver matching chunks from the local store - bytes admin = 5; // 20-byte eth address; set iff EXPLICIT_* - repeated bytes publisher_list = 6; // 20-byte eth addresses, excl. admin; - // set iff EXPLICIT_LIST - reserved 7; // was po_min — now protocol constant PO_MIN = 16 - bool closed = 8; // no audience: subscribers restricted to the publishers + bytes topic = 1; // 32 bytes, meaning per binding + TopicBinding binding = 2; + bytes admin = 5; // 20-byte eth address: the opener, the + // cohort's authority, and always a member of + // its publisher set. Absent (length 0) => + // implicit authorship, and `publishers` and + // `spectators` do not apply. Length is the + // discriminator, so absent and set are + // intrinsically distinguishable. + PublisherRegime publishers = 3; // set iff admin is set + bool spectators = 9; // may peers outside the publisher set join? + // Real only under ADMIN_ONLY and GRANTED; + // under ALL and implicit authorship every + // attached peer is already a potential + // author, so openers MUST set it true. + bool history = 4; // deliver matching chunks from the local store + reserved 6, 7, 8; + // 6 was `publisher_list` -- now dynamic, carried as ServiceKind.ROSTER; + // 7 was `po_min` -- now the protocol constant PO_MIN; + // 8 was `closed` -- superseded by `spectators`, which is enforceable + // now that Auth is recovered rather than asserted. +} + +// --------------------------------------------------------------------------- +// The service feed — the admin's control plane. +// +// Service messages are ordinary SOCs on the ordinary path, owned by the admin: +// +// owner = admin id = keccak256("bps-service:v1" || topic || index) +// +// so a broker relays them and cannot author them, and a subscriber checks them +// with the same code as any broadcast. Sequential indices (SWIP-65 self-indexed +// feeds) make gaps visible: a single constant-id slot overwritten in place +// would make a stale roster undetectable, reintroducing forging-by-omission at +// the one point that decides who may write. +// --------------------------------------------------------------------------- + +enum ServiceKind { + SERVICE_KIND_UNSPECIFIED = 0; // invalid on the wire (see header note) + GENESIS = 1; // index 0: the CohortSpec, signed by the admin. Proves the + // cohort was opened by the address it names. + ROSTER = 2; // the full publisher set as of this index (not a delta) + END_OF_STREAM = 3; // the admin closes the cohort, attributably +} + +// The payload of a service SOC. +message ServiceMessage { + ServiceKind kind = 1; + CohortSpec spec = 2; // set iff GENESIS + repeated bytes publishers = 3; // set iff ROSTER: 20-byte eth addresses, the + // complete set excl. admin (who is always a + // publisher). Full state, not a delta, so a + // reader needs only the latest it can verify. } // --------------------------------------------------------------------------- // Stream establishment, stream name "pubsub/1.0.0" — one stream per (peer, topic). -// The first message on a fresh stream is Open (fixes a new cohort) or -// Subscribe (joins an existing one); the broker answers with Ack. +// The first message on a fresh stream is Hello, carrying Open (fixes a new +// cohort) or Subscribe (joins an existing one); the receiving cohort node +// answers with Ack. The first frame settles the peer's role. +// +// SWIP-61: Open still goes to the broker -- opening IS contacting the root -- +// but Subscribe is answered by ANY cohort node, not only the broker. A relay +// echoes to its own children exactly what it received on its own join. // --------------------------------------------------------------------------- -// Opener -> broker: the one peer that fixes the cohort. +// Peer -> broker: the first frame on a fresh stream. +// +// The envelope is load-bearing. As bare frames, Open and Subscribe are +// indistinguishable: both encode as a length-delimited field 1 followed by an +// optional Auth in field 2. proto3 unmarshalling is permissive, so a receiver +// that guesses wrong does not fail -- it silently succeeds and misreads the +// frame, then answers with a Status that describes the wrong problem. +message Hello { + oneof handshake { + Open open = 1; + Subscribe subscribe = 2; + } +} + +// Opener -> broker: the admin, fixing the cohort. The broker recovers the +// address from `auth` and checks it against cohort.admin before accepting. message Open { - CohortSpec cohort = 1; - PublisherAuth auth = 2; // present iff the opener publishes (explicit regimes) + CohortSpec cohort = 1; + Auth auth = 2; // required iff cohort.admin is set } -// Joiner -> broker: names the topic — nothing more. Subscribers carry no -// cohort metadata; auth is present iff the joiner publishes (publishers -// connect directly to the broker). +// Joiner -> any cohort node: names the topic — nothing more. Joiners carry no +// cohort metadata; auth is present iff the joiner claims a publisher role. message Subscribe { - bytes topic = 1; // 32 bytes - PublisherAuth auth = 2; // present iff publisher + bytes topic = 1; // 32 bytes + Auth auth = 2; } -message PublisherAuth { - bytes owner = 1; // 20-byte eth address of the SOC owner key - bytes id = 2; // 32-byte SOC id, when the binding fixes it +// Proved, not asserted -- and in one operation: ecrecover yields the owner +// address AND proves possession of its key, so no challenge round trip. +// +// owner = ecrecover( H("bps-join:v1" || topic || admin), signature ) +// +// The preimage is deliberately static and free of any node identity. Signing +// over the libp2p peer id would make this unreplayable, but would weld the +// publishing identity to the node holding the stream: the key could not be used +// from a second node without re-signing, and every join would link an eth +// identity to a peer id for anyone watching. An owner's identity is its own. +// +// The accepted consequence: a static preimage is replayable. It costs nothing, +// because a replayed role is worthless -- the replayer cannot sign, so its +// frames are dropped at Publish. Auth spares the broker from carrying peers +// whose frames could only ever be dropped; authorship rests on the message +// signature, never on the handshake. +// +// "bps-join:v1" is load-bearing: the same secp256k1 keys sign SOCs over +// (id || wrappedAddress), and the separator is what stops a join signature from +// ever being reinterpreted as a chunk signature, or the reverse. +message Auth { + bytes signature = 1; // 65 bytes + bytes id = 2; // 32-byte SOC id, where the binding does not fix it } -// Broker -> peer, answering Open or Subscribe. The echoed CohortSpec lets a -// subscriber verify every message end-to-end against the topic binding. +// Parent -> peer, answering Open or Subscribe. The echoed CohortSpec lets a +// subscriber verify every message end-to-end against the topic binding; the two +// service SOCs let it verify the cohort and the roster against the ADMIN, +// rather than taking any intermediary's word for either. A SWIP-61 relay +// relays all three unchanged -- it holds what it was given on its own join, so +// depth costs a subscriber nothing in what it can verify. message Ack { - Status status = 1; - CohortSpec cohort = 2; // set iff status == OK - repeated Candidate candidates = 3; // SWIP-61: set iff FULL at a relaying - // node — the two shallowest attachment - // points its probe found + Status status = 1; + CohortSpec cohort = 2; // set iff status == OK + Soc genesis = 3; // service feed index 0, iff the cohort has an admin + Soc service = 4; // latest service SOC (may equal genesis) + uint64 index = 5; // its feed index, so gaps are visible + repeated Candidate candidates = 6; // SWIP-61: set iff FULL at a relaying + // node -- the two shallowest attachment + // points its probe found. Field 6, not 3: + // 3-5 are SWIP-60's service SOCs. } enum Status { STATUS_UNSPECIFIED = 0; // invalid on the wire (see header note) OK = 1; - FULL = 2; // at per-topic capacity; a singlehop (SWIP-60) - // broker refuses — nothing else; a multihop - // (SWIP-61) relay attaches candidates - UNKNOWN_TOPIC = 3; // Subscribe for a topic the broker does not serve - REJECTED = 4; // e.g. publisher not on the list, invalid auth, - // non-publisher Subscribe on a closed cohort + FULL = 2; // node at its per-topic capacity; a singlehop broker + // refuses and nothing else, while a SWIP-61 relay + // attaches candidates + UNKNOWN_TOPIC = 3; // Subscribe for a topic this node does not serve + REJECTED = 4; // the SPEC is unacceptable -- e.g. Open naming an + // already-open topic with a mismatched CohortSpec, or + // an Auth that does not recover to cohort.admin. + // Also the answer to a non-publisher Subscribe when + // spectators == false -- the ONLY case in which a + // peer is refused for who it is. } // --------------------------------------------------------------------------- @@ -128,40 +240,50 @@ message Soc { bytes payload = 5; // wrapped-CAC data, <= 4096 bytes } -// Publisher -> broker. +// Publisher -> parent, forwarded rootward hop by hop until it reaches the +// broker, which validates, dedups and broadcasts it back down the tree. +// +// SWIP-60 had publishers attached directly to the broker; that was a +// consequence of depth = 1, not an invariant. A publisher at depth d sends up +// BOTH its parents where it has two: the dedup rule absorbs the double at the +// first node that sees both, and the redundancy buys upstream what the two +// downstream feeds buy -- a single relay cannot silently swallow a publish. +// Depth-scaled upstream pricing (bps-bw-incentives) is what makes a deep +// publisher pay for its depth. +// +// Any node MAY drop a Publish it can already tell is invalid (signature, +// binding, roster); the broker is where dedup is authoritative, since it is +// the single point every publish passes through. message Publish { Soc soc = 1; } -// The frame envelope. Control frames (SWIP-61) ride it in both directions on -// a (peer, topic) stream; frame type and direction disambiguate. +// Parent -> child, and (for Candidates) child -> parent: control frames ride +// the Broadcast envelope in both directions on a (peer, topic) stream; frame +// type and direction disambiguate. message Broadcast { oneof frame { - Soc soc = 1; - Reparent reparent = 2; // SWIP-61, parent -> child - Probe probe = 3; // SWIP-61, parent -> child: find free slots below - Candidates found = 4; // SWIP-61, child -> parent: probe reply, min-2 filtered + Soc soc = 1; // as SWIP-60 + Reparent reparent = 2; // parent -> child + Probe probe = 3; // parent -> child: find free slots below + Candidates found = 4; // child -> parent: probe reply, min-2 filtered // 5–15 remain reserved } } -// --------------------------------------------------------------------------- -// Multihop control plane (SWIP-61) -// --------------------------------------------------------------------------- - // Parent -> child: re-point, make-before-break — the child Subscribes to the -// new parent and drops the old stream only once the new one is live. +// new parent and only drops the old stream once that Ack(OK) has arrived. message Reparent { bytes to = 1; // overlay address of the new parent } -// Parent -> child: find free slots below. depth is the depth of the receiver, -// incremented at each forwarding hop — no node stores its own position. +// Depth of the receiver, incremented at each forwarding hop — no node stores +// its own position, so nothing goes stale when the tree is reorganised. message Probe { uint32 depth = 1; } -// Child -> parent: probe reply — at most two, smallest depth first. +// At most two, smallest depth first. message Candidates { repeated Candidate candidates = 1; } @@ -172,4 +294,5 @@ message Candidate { } // Keepalive / RTT: none at the BPS level. Liveness is the transport's job -// (libp2p). +// (libp2p), and latency metrics for reorganisation policies (SWATCH) are +// sourced there as well. diff --git a/SWIPs/swip-61.md b/SWIPs/swip-61.md index 537fa8df..48ea8fd9 100644 --- a/SWIPs/swip-61.md +++ b/SWIPs/swip-61.md @@ -1,10 +1,11 @@ --- SWIP: 61 title: BPS multihop — FCFS multicast tree -author: Viktor Trón (@zelig), Viktor Tóth (@nugaon) +author: Viktor Trón (@zelig), Viktor Tóth (@nugaon) discussions-to: https://discord.gg/Q6BvSkCv status: Draft -type: Standards Track (Networking) +type: Standards Track +category: Networking created: 2026-08-04 requires: 60 --- @@ -15,10 +16,10 @@ requires: 60 survive relay churn without interruption. - **Dev line**: fill three reserved `Broadcast` control-plane fields of [bps.proto](assets/swip-61/bps.proto) (`Reparent`, `Probe`, `Candidates`), extend `Ack` with - attachment candidates, and implement three behaviours — subtree probing, dual-parent - maintenance, make-before-break reparenting; done when a cohort scales past a single - node's capacity, masks a single relay failure with no delivery gap, and unmodified - SWIP-60 clients attach as leaves. + attachment candidates, forward `Publish` rootward, and implement three behaviours — + subtree probing, dual-parent maintenance, make-before-break reparenting; done when a + cohort scales past a single node's capacity in **both** directions, masks a single relay + failure with no delivery gap, and unmodified SWIP-60 clients attach as leaves. - DISC: the first time proper **forwarding by consumers** (subscribers as relays) is implemented; a wire-protocol change (reserved frames become normative), no storage or Bee-API change. @@ -55,31 +56,49 @@ broken, so churn tolerance is built into the topology, not patched on. ### The seam from SWIP-60 Everything in SWIP-60 stands — cohort genesis (`CohortSpec` untouched), bindings and -dedup rules, publisher legitimacy, the `Open`/`Subscribe`/`Ack` establishment, -self-contained SOC frames, end-to-end verification — with exactly one amendment: +dedup rules, publisher legitimacy, the admin's service feed, the `Hello`/`Ack` +establishment, self-contained SOC frames, end-to-end verification — with **two** +amendments: -**The capacity rule.** A relaying node at capacity answering a `Subscribe` MUST NOT +**1. The capacity rule.** A relaying node at capacity answering a `Subscribe` MUST NOT bare-refuse; it probes its subtree and answers `Ack(FULL)` **with the two shallowest -attachment points found** (new `candidates` field). A SWIP-60-only client ignores the -unknown field and reads a plain refusal. - -There is **no depth bound**: the tree grows as deep as joins take it. Depth is -self-limiting — per-hop pricing (bps-bw-incentives) charges upstream by depth, so -joiners are economically steered toward the root; a protocol bound would only duplicate -the price signal. And the one configuration that genuinely needs depth = 1 — the closed -cohort, where all and only publishers subscribe — is **structurally** singlehop already: -publishers connect directly to the root. - -Invariant: **publishers (and the opener) still connect directly to the root.** Multihop -restructures the audience side only. +attachment points found** (new `candidates` field, number 6 — 3–5 are SWIP-60's service +SOCs). A SWIP-60-only client ignores the unknown field and reads a plain refusal. + +**2. Publishing is no longer confined to the root.** SWIP-60's publishers attach directly +to the broker, but that is a consequence of depth = 1 rather than an invariant: `Publish` +now travels **rootward**, forwarded hop by hop to the broker, which validates, dedups and +broadcasts it back down. Multihop therefore restructures **both** directions, not the +audience side only — which is what an everyone-publishes cohort (SWIP-60's `ALL`, and any +`GRANTED` cohort larger than one node's capacity) needs in order to exist at all. + +`Open` still goes to the broker, because opening a cohort *is* contacting the root. Its +admin is thereafter an ordinary publisher and may sit at any depth like any other. + +There is **no depth bound**: the tree grows as deep as joins take it, and depth is +self-limiting because it is priced — see the Rationale. + +**What a relay owes a joiner.** SWIP-60's `Ack` carries the echoed `CohortSpec`, the +admin-signed genesis SOC and the latest service SOC with its index. A relay answering a +`Subscribe` MUST pass on all three, unchanged — it holds them from its own join, and +keeps the service SOC current from the broadcasts it already receives. **Depth must cost a +subscriber nothing in what it can verify**: a leaf ten hops out checks the cohort and the +roster against the admin's key exactly as the broker's own child does, so the extra +intermediaries add withholding points and no forging points. A relay that serves a stale +service SOC is withholding, which its subscriber detects as an index gap. + +Cohorts with `spectators: false` are unchanged in kind: any node answering a `Subscribe` +applies the same roster check, and it can, because it holds the roster. ### Roles -- **Broker** — as in SWIP-60: root of the tree, target of `Open`, sole entry point for - publishers, default entry point for joins. +- **Broker** — as in SWIP-60: root of the tree, target of `Open`, default entry point for + joins, and the point at which every `Publish` is validated and deduped, wherever it + originated. It is no longer the *attachment* point for publishers. - **Relay** — a subscriber with children: re-broadcasts every SOC frame down its own - direct streams, answers `Subscribe` like any parent, and forwards and aggregates - probes. There is no promotion step: **accepting your first child is what becoming a + direct streams, forwards its children's `Publish` frames up its own, answers `Subscribe` + like any parent (passing on the `CohortSpec` and the two service SOCs it holds), and + forwards and aggregates probes. There is no promotion step: **accepting your first child is what becoming a relay means.** Relay capability is latent in every subscriber — the `Ack`-echoed `CohortSpec` from its own join is exactly what it echoes to children of its own. Any full-node subscriber is relay-eligible; NAT'd and light-client relays are reachable @@ -124,6 +143,45 @@ breadth-first, keeping delivery paths short — and it agrees with the incentive same way. It is still FCFS, not optimality: candidates race, replies are best-effort within the wait bound, and the first slot won wins. +### Publishing from depth + +A publisher no longer needs a stream to the broker. `Publish` rides the same (peer, topic) +stream as everything else, in the **opposite direction** to `Broadcast`: a node accepts a +`Publish` from a child and forwards it to its own parent, hop by hop, until it reaches the +root. The broker validates it against the `CohortSpec` and the current roster, applies the +binding's dedup rule, and broadcasts it back down — so it returns to the publisher along +with everyone else, and a publisher's own message reaching it is its delivery receipt. + +- **Where validation happens.** Authoritatively at the broker, which is the single point + every publish passes through and therefore the only place dedup is well defined. Any + relay MAY drop a `Publish` it can already tell is invalid — it holds the `CohortSpec` and + the roster, so it can check the signature, the binding and the authorship — and SHOULD, + since carrying a doomed frame up d hops wastes every one of them. This is an + optimisation, never a substitute: a relay that forwards everything is conformant, and + one that drops a *valid* frame is withholding. +- **Publish up both parents.** A node with two parents sends each `Publish` up both. The + dedup rule absorbs the double at the first node that sees both — usually the sisters' + shared parent — and the redundancy buys upstream exactly what the two feeds buy + downstream: **a single relay cannot silently swallow a publish.** Where a subscriber has + chosen to be single-parented (see below), its publishes inherit that choice's exposure. +- **Depth is priced, not bounded.** Upstream pricing (bps-bw-incentives) is depth-scaled, + so a publisher d hops out pays for d hops. A publisher that cares about its own latency + or cost buys a shallower attachment; nothing in the protocol needs to stop it. + +The contract is unchanged in substance — messages come from publishers only and arrive at +all subscribers — but its enforcement point is now explicit: **authorship is decided at the +root, and verified again by every subscriber.** Relays in between are conveniences, and a +dishonest one can delay a message or drop it, never author one. + +**Revocation across a tree.** SWIP-60's two-phase revocation is unchanged in rule and +lands one hop out: the roster update is an ordinary broadcast, so it reaches every node, +and the revoked publisher's **parents** are where enforcement happens — they are the nodes +accepting its `Publish` frames. Before the update reaches them they drop those frames and +tolerate them, exactly as a singlehop broker would; after it arrives, a further publish is +a protocol violation and the parent breaks the stream. The revokee learns of its own +revocation from the same broadcast, at the same time, which is what makes the second phase +fair at any depth. + ### Resilience: two parents Churn-related misses are not tolerated: the contract says messages arrive at all @@ -172,9 +230,12 @@ the old stream. During the overlap the dedup rule absorbs doubles, as everywhere Two cases: -- **graceful leave** — a departing relay `Reparent`s each child toward a replacement - (its own parents, or spread over its children **(?)**) before closing; the child's - flow continues on its other parent throughout the move; +- **graceful leave** — a departing relay `Reparent`s each child toward **its own parents** + before closing; the child's flow continues on its other parent throughout the move. Its + parents are the right target because they are known-live, known-compatible and at the + departing node's own depth minus one, so the subtree gets shallower rather than deeper; + handing children to one another would deepen the tree and has no answer for the first + child; - **churn repair** — is the *absence* case: BPS has no keepalive, so a parent's death surfaces as a transport-level stream reset or connection loss; no `Reparent` arrives, the surviving parent carries the stream, and the node re-runs the join for a @@ -187,7 +248,7 @@ the gap itself (a resumed subscriber is in the same position as a fresh one). ### NAT'd relays Reaching a NAT'd relay is the transport's business, not this protocol's: libp2p circuit -relay + DCUtR hole punching (the dcutr work item, bee +relay + DCUtR hole punching (the DCUtR work item, bee [#5355](https://github.com/ethersphere/bee/issues/5355)). BPS carries no signalling for it — the only tree-relevant observation is that a NAT'd child's **parent is its natural circuit relay**, so a candidate a probe returns can be handed out under an address the @@ -253,10 +314,20 @@ frame type and direction disambiguate. On submission the amended file goes to ```proto message Ack { - Status status = 1; - CohortSpec cohort = 2; // set iff status == OK - repeated Candidate candidates = 3; // set iff FULL at a relaying node: the two - // shallowest attachment points the probe found + Status status = 1; + CohortSpec cohort = 2; // set iff status == OK + Soc genesis = 3; // SWIP-60: service feed index 0 + Soc service = 4; // SWIP-60: latest service SOC + uint64 index = 5; // SWIP-60: its feed index + repeated Candidate candidates = 6; // set iff FULL at a relaying node: the two + // shallowest attachment points the probe found. + // Field 6, not 3 — 3–5 are SWIP-60's service SOCs +} + +// unchanged from SWIP-60, but no longer publisher -> broker: a node forwards a +// child's Publish to its own parent(s) until it reaches the root +message Publish { + Soc soc = 1; } message Broadcast { @@ -312,12 +383,26 @@ the transport's job, and transport-level detection has latency. Two always-on fe close that window structurally, cost 2×, and come with a side effect no standby offers: withholding is caught by comparing the feeds. -**Why no depth bound?** Because depth already has a price. Per-hop publish pricing -(bps-bw-incentives) is depth-scaled upstream, so joiners are economically steered -toward the root and deep chains cost their occupants; a protocol-level `max_depth` -would duplicate that signal with a blunter instrument — and give the tree a notion of -"full" it doesn't otherwise need. The closed cohort, the one shape that must stay -depth 1, is structurally singlehop: publishers connect directly to the root. +**Why no depth bound?** Because depth already has a price, and now on both sides. Per-hop +pricing (bps-bw-incentives) is depth-scaled upstream, so joiners are economically steered +toward the root and deep chains cost their occupants; with `Publish` travelling rootward +the same gradient bills a *publisher* for its depth, which is the case where the incentive +bites hardest. A protocol-level `max_depth` would duplicate that signal with a blunter +instrument, and give the tree a notion of "full" it does not otherwise need. + +An earlier draft anchored this argument on the closed cohort — the one shape that had to +stay at depth 1 because its publishers attached directly to the root. That anchor is gone +twice over: `closed` no longer exists in SWIP-60, and publishers no longer attach to the +root. **No configuration requires depth 1.** The argument stands on the price alone, which +is where it belonged. + +**Why is a publish validated at the root rather than at the edge?** Because dedup needs a +single vantage point. The binding's dedup rule is a statement about what a cohort has +already seen, and only the broker sees everything; a relay applying it locally would +suppress a message some sibling subtree never received. Signature and authorship checks, by +contrast, are local and stateless, so relays may and should run those early — dropping a +frame that could never be accepted costs nothing and saves d hops. The split is the usual +one: **stateless checks anywhere, stateful ones where the state is.** **Why do relays get nothing here?** They do — later. Metered per-hop pricing is bps-bw-incentives' business; this SWIP keeps the edges it will price. Until then, @@ -334,8 +419,10 @@ self-healing path. Paying for forwarded messages (bps-bw-incentives); message history — delivery of messages from before the subscription (bps-history); broker discovery (SWIP-59 MEX); -NAT traversal — circuit relay and hole punching are transport business (dcutr item); -dynamic publisher-list changes (out of scope in SWIP-60, unaffected here). +NAT traversal — circuit relay and hole punching are transport business (the DCUtR item); +and the admin's control plane itself — grants, revocations and end-of-stream are SWIP-60's +service feed, and reach every depth as ordinary broadcasts, so this SWIP adds nothing to +them. ## Conformance (definition of done) @@ -347,7 +434,10 @@ An implementation is conformant when: 2. a probe wave terminates: nodes with a free slot reply and do not forward, and forwarding nodes answer within the wait bound with the min-2-by-depth filter of what arrived; -3. an accept carries the echoed `CohortSpec`; +3. an accept carries the echoed `CohortSpec` **and** the genesis and latest service SOCs + with the service index, unchanged, from whatever depth it is issued — a subscriber ten + hops out verifies the cohort and the roster against the admin exactly as the broker's + own child does; 4. absent races, a join completes in two steps: one probe round, one (dual) attach; 5. a node offered two distinct parents maintains both — except on self-indexed cohorts ([SWIP-65](https://github.com/ethersphere/SWIPs/pull/106)), where the second parent @@ -359,7 +449,15 @@ An implementation is conformant when: 8. an unmodified SWIP-60 client attaches as a leaf anywhere in the tree and cannot distinguish its parent from a singlehop broker; 9. a subscriber at any depth re-verifies every message end-to-end; loss of both - parents is a liveness fault repaired by rejoin. + parents is a liveness fault repaired by rejoin; +10. a `Publish` from any depth reaches the root, is validated and deduped there, and is + broadcast back down to the whole cohort including its own publisher; a node with two + parents publishes up both, and the dedup rule absorbs the double; +11. a relay MAY drop a `Publish` that fails a stateless check (signature, binding, + authorship) but MUST NOT apply the binding's dedup rule locally, and one that forwards + everything is conformant; +12. a revoked publisher's frames are dropped and tolerated by its parents until the + reduced roster reaches them, and the stream is broken only on a publish after that. ## Backwards compatibility @@ -369,8 +467,10 @@ is invisible to old implementations — an old client ignores `Ack.candidates` a a plain `FULL`. A SWIP-60 leaf that receives a `Probe` ignores the unknown frame and never replies — exactly the empty answer the bounded wait absorbs, which is why old leaves are compatible by construction (single-parented, without the resilience). -`CohortSpec` is untouched. SWIP-60's conformance item 4 ("`FULL` — and nothing else") -is superseded for nodes implementing this SWIP. +`CohortSpec` is untouched, and `Ack.candidates` takes field 6 so that SWIP-60's service +SOCs keep 3–5. SWIP-60's conformance item 4 ("`FULL` — and nothing else") is superseded for +nodes implementing this SWIP, as is its statement that a publisher is attached to the +broker — true at depth 1, and a consequence of it rather than a rule. ## References @@ -378,7 +478,7 @@ is superseded for nodes implementing this SWIP. [#104](https://github.com/ethersphere/SWIPs/pull/104) · wire: [bps.proto](assets/swip-61/bps.proto) · origin: [PR #93](https://github.com/ethersphere/SWIPs/pull/93) "Add: pubsub" · implementation groundwork: bee [#5435](https://github.com/ethersphere/bee/pull/5435) · -dCUTr: [bee#5355](https://github.com/ethersphere/bee/issues/5355) +DCUtR: [bee#5355](https://github.com/ethersphere/bee/issues/5355) ## Copyright