From d0fd9524e62703b656eb9bcca571b077660b0caf Mon Sep 17 00:00:00 2001 From: zelig Date: Mon, 3 Aug 2026 12:42:42 +0200 Subject: [PATCH 1/9] =?UTF-8?q?add=20SWIP-60:=20BPS=20singlehop=20?= =?UTF-8?q?=E2=80=94=20brokered=20broadcast=20pub/sub,=20base=20protocol?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Base SWIP of the Broadcast Pub/Sub (BPS) family — the decomposition of the monolithic PubSub SWIP (PR #93) into work-package-sized SWIPs. Companion wire spec: assets/swip-60/bps.proto (singlehop concrete, multihop control frames reserved). Co-Authored-By: Claude Fable 5 --- SWIPs/assets/swip-60/bps.proto | 113 +++++++++++++++ SWIPs/swip-60.md | 242 +++++++++++++++++++++++++++++++++ 2 files changed, 355 insertions(+) create mode 100644 SWIPs/assets/swip-60/bps.proto create mode 100644 SWIPs/swip-60.md diff --git a/SWIPs/assets/swip-60/bps.proto b/SWIPs/assets/swip-60/bps.proto new file mode 100644 index 00000000..36381999 --- /dev/null +++ b/SWIPs/assets/swip-60/bps.proto @@ -0,0 +1,113 @@ +// Broadcast Pub/Sub (BPS) — protocol messages and types. +// Spec: SWIP-60 (../../swip-60.md). +// +// Deliberately incomplete as of 2026-08-02: the singlehop (depth = 1) subset is +// concrete; multihop control-plane messages are named but reserved. The existing +// implementation (bee PR #5435) uses hand-rolled byte framing with the same +// semantics; this file is the normative description of the message structure, +// and — bee protocols being protobuf-over-libp2p elsewhere — the candidate +// replacement framing. + +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 epic: "What does the topic bind to?"). +enum TopicBinding { + TOPIC_BINDING_UNSPECIFIED = 0; + 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 (see epic: genesis dimensions). +enum PublisherRegime { + PUBLISHER_REGIME_UNSPECIFIED = 0; + EXPLICIT_SINGLE = 1; // opener is admin and sole publisher (live streaming) + EXPLICIT_LIST = 2; // admin dictates who the other publishers are + IMPLICIT = 3; // authorship implied by the topic binding (PO constraint) + ALL = 4; // every peer publishes (gossipsub-equivalent cohort) +} + +// The (partial) decisions fixed the moment the first full node is contacted. +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_* + uint32 po_min = 6; // proximity order for implicit bindings (default 16) + uint32 cap = 7; // max direct streams the broker accepts for this topic (0 = broker default) + bool closed = 8; // no audience: subscribers restricted to the publisher list +} + +// --------------------------------------------------------------------------- +// Stream establishment (client -> broker), stream name "pubsub/1.0.0" +// --------------------------------------------------------------------------- + +enum Role { + ROLE_UNSPECIFIED = 0; + SUBSCRIBER = 1; + PUBLISHER = 2; // implies direct connection to the broker (necessary, not sufficient) +} + +message Connect { + CohortSpec cohort = 1; + Role role = 2; + PublisherAuth auth = 3; // present iff role == 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 +} + +// --------------------------------------------------------------------------- +// Messages — SOC-only is a protocol feature +// --------------------------------------------------------------------------- + +// A full single-owner chunk in transit. +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. No type prefix needed: the stream's role was declared at Connect. +message Publish { + Soc soc = 1; +} + +// Broker -> subscriber: exactly one of the following per frame. +message Broadcast { + oneof frame { + Soc handshake = 1; // first frame on a stream: full SOC identity + DataFrame data = 2; // subsequent frames: signature ‖ span ‖ payload only + Ping ping = 3; // keepalive; parent measures RTT off the echo + } +} + +message DataFrame { + bytes signature = 1; + bytes span = 2; + bytes payload = 3; +} + +message Ping {} + +// --------------------------------------------------------------------------- +// Multihop control plane — RESERVED, named to fix intent (not final for AFM) +// --------------------------------------------------------------------------- +// message Beacon {} // child -> parent capacity/score summary (0xFE) +// message Reparent {} // parent -> child: REPARENT{to, gateway?} (0xFD) +// message Expect {} // parent -> relay: EXPECT{children} (0xFC) +// message DcutrSignal {} // via circuit relay (0xFB) +// message SwapProposal {} // promotion swap propose/ack (0xFA) diff --git a/SWIPs/swip-60.md b/SWIPs/swip-60.md new file mode 100644 index 00000000..56243765 --- /dev/null +++ b/SWIPs/swip-60.md @@ -0,0 +1,242 @@ +--- +SWIP: 60 +title: BPS singlehop — brokered broadcast pub/sub, base protocol +author: Viktor Trón (@zelig), Viktor Tóth (@nugaon) +discussions-to: https://discord.gg/Q6BvSkCv +status: Draft +type: Standards Track (Networking) +created: 2026-08-03 +--- + + + +- **Business line**: real-time topic streams for dApps without storing chunks or polling — + enough on its own for small closed collaboration cohorts (collaborative remix editing, a + strudel livecoding session, multiparty games) and basic single-publisher limited-audience + live streaming. +- **Dev line**: implement one libp2p protocol (`pubsub/1.0.0`, messages in + [bps.proto](assets/swip-60/bps.proto)) plus a WebSocket bridge on the Bee API; done when + a broker, publishers and subscribers interoperate per the conformance section. Groundwork + exists in bee [#5435](https://github.com/ethersphere/bee/pull/5435). +- Bandwidth-incentive integration is a separate SWIP (bps-bw-incentives). +- Broker discovery integration is from a separate SWIP (bps-broker-discovery, building on + [SWIP-58 MEX](https://github.com/ethersphere/SWIPs/pull/103)). + +## Simple Summary + +A real-time messaging protocol: WebSocket clients publish and subscribe to topic streams +through Bee nodes. One full node per topic acts as **broker**, re-broadcasting each message +over direct, long-lived p2p streams to at most **cap** connected peers. Messages are +single-owner chunks, so every subscriber verifies authorship end-to-end; the broker can +withhold, never forge. + +## Motivation + +Swarm's event primitives (GSOC, PSS) require full-node operation; light clients can only +poll storage. BPS singlehop is the smallest protocol that fixes this: one broker, direct +streams, authenticated messages, an explicit connection cap. Everything larger — multihop +trees, adaptive reorganisation, incentives, discovery — is layered on top by later SWIPs +without changing the semantics defined here. + +## Specification + +### The contract + +Per topic-cohort: + +- messages come from **publishers, and publishers only**; +- they arrive at **all subscribers**. + +### Cohort genesis: the parameters + +A cohort is fully described by a `CohortSpec` ([bps.proto](assets/swip-60/bps.proto)), +fixed the moment the first peer contacts a BPS-speaking full node with a topic. There is +no mode enum; **modes are combinations of these parameters**. + +| parameter | values | meaning | +|---|---|---| +| `topic` | 32 bytes | interpreted per `binding` | +| `binding` | `ANCHOR` / `SOC_ID` / `OWNER` / `FEED_TOPIC` | what the topic binds to; fixes which SOCs qualify as messages and the dedup rule | +| `publishers` | `EXPLICIT_SINGLE` / `EXPLICIT_LIST` / `IMPLICIT` / `ALL` | who may author | +| `admin` | eth address | set iff explicit publishers; may extend the publisher list, nothing more | +| `history` | bool | deliver matching chunks already in the local store (mechanism in bps-history; a singlehop broker MAY refuse) | +| `po_min` | uint (default 16) | proximity constraint for implicit bindings: `PO(socAddr, anchor) ≥ po_min` | +| `cap` | uint | **max direct streams the broker accepts for this topic**; 0 = broker's default | +| `closed` | bool | no audience: subscribers are restricted to the publisher list (all and only publishers subscribe) | + +Binding semantics (dedup rule in parentheses): + +- **`ANCHOR`** — topic = full SOC/GSOC address; all messages share one address (dedup on + the wrapped CAC). +- **`SOC_ID`** — topic = SOC id; any owner with `PO(socAddr(id, owner), anchor) ≥ po_min` + qualifies (dedup on chunk address). +- **`OWNER`** — topic = SOC owner; any id under the same PO constraint — MIC semantics + (dedup on chunk address). +- **`FEED_TOPIC`** — id = `keccak256(topic ‖ index)`; feed-update streams, graffiti MIC + (dedup on chunk address). + +### Roles and the cap + +- **Broker**: the first full node contacted; root of the (here, depth = 1) multicast tree. + Accepts at most `cap` concurrent streams for the topic. **At cap it MUST answer a + `Connect` with a refusal** (`FULL`); referral to another attachment point is reserved + for bps-multihop — a singlehop-only broker simply refuses. +- **Publisher**: sends and receives. MUST be directly connected to the broker; direct + connection is necessary, not sufficient — with explicit publishers, the admin's list + decides. +- **Subscriber**: receives only. Does not exist in `closed` cohorts. + +### Information flow + +```mermaid +sequenceDiagram + autonumber + participant PD as publisher dApp + participant PN as publisher's bee node
(WS bridge) + participant B as broker
(root, full node) + participant SN as subscriber's bee node
(WS bridge + mux) + participant SD as subscriber dApp(s) + + Note over B: cohort open: topic set,
genesis parameters fixed + SN->>B: Connect(CohortSpec, SUBSCRIBER) + PN->>B: Connect(CohortSpec, PUBLISHER, auth) + Note over PN,B: publisher ⇒ direct connection to broker
(necessary, not sufficient — admin's list decides) + + loop keepalive (30 s) + B->>SN: Ping + SN-->>B: echo (RTT measured by parent) + end + + PD->>PN: WS: payload + PN->>B: Publish(SOC) + B->>B: validate: SOC sig ⊨ topic binding
(+ dedup per binding) + + par fan-out to every subscriber stream + B->>SN: Broadcast: handshake frame (full SOC identity, first) /
data frame (sig ‖ span ‖ payload, after) + SN->>SN: mux: one p2p stream → N WS sessions + SN->>SD: WS: payload + and publisher's own subscription (if subscriber too) + B->>PN: Broadcast + PN->>PD: WS: payload + end +``` + +The broadcast is **end-to-end authenticated**: every subscriber re-verifies the SOC +signature against the topic binding regardless of path. + +### Wire protocol + +Messages are defined in [bps.proto](assets/swip-60/bps.proto). Framing notes: + +- Transport: libp2p stream `pubsub/1.0.0`, one stream per (peer, topic); `Connect` as the + first message (protobuf-over-libp2p, as bee protocols elsewhere) — bee #5435 + currently uses stream headers. +- Frame-type byte split: service frames grow downward from `0xFF` (ping `0xFF`; multihop + control frames `0xFE`… reserved), data frames grow upward from `0x00` — no collision. +- Broker→subscriber: first frame per stream is the **handshake** frame carrying full SOC + identity (id, owner); subsequent **data** frames carry `sig ‖ span ‖ payload` only. +- Publisher→broker frames carry no type prefix: the stream's role was declared at + `Connect`. +- Broker validation on `Publish`: SOC signature verifies against the topic binding, PO + constraint holds where applicable, sender is a legitimate publisher, message is not a + duplicate per the binding's dedup rule. Invalid ⇒ drop; repeated invalid ⇒ disconnect + (blocklisting policy). + +### API (WebSocket bridge) + +WS clients see raw mode payloads only; all p2p framing is transparent. One p2p stream is +muxed to N local WS sessions per topic. Endpoint shape per bee +[#5435](https://github.com/ethersphere/bee/pull/5435). + +### Configurations (worked examples) + +Modes are rows over the parameters; two normative examples: + +**The 4-seat jam cohort** — collaborative remix editing, a strudel livecoding session, a +multiparty game. + +``` +binding: ANCHOR (topic = mnemonic anchor) publishers: EXPLICIT_LIST (admin + ≤3) +closed: true (all and only publishers subscribe) cap: 4 history: false +``` + +Every seat sends and receives; there is no audience; a fifth `Connect` gets `FULL`. + +**Basic live streaming** — single publisher, open audience: + +``` +binding: FEED_TOPIC (sequential index) publishers: EXPLICIT_SINGLE +closed: false cap: broker default history: false +``` + +### The modes — enumerated as combinations of dimension choices + +Known use cases attach here; each mode is nothing more than a row — a combination of +publisher/subscriber info, topic match type, and history. (`+/−` = both configurations +meaningful.) + +| # of pubs | pubs implicit? | subscribers | topic / anchor match | history | use case | +|---|---|---|---|---|---| +| 1 | — | all | feed topic, index sequential | — | live video streaming | +| any | — | all | feed topic, index sequential | — | live videoconference | +| — | + | all | feed topic | +/— | tags, adverts; private co-authoring | +| all | — | all | topic a mere mnemonic of the cohort | +/— | gossip cohort for multi-party / group chat | +| any | + | all | anchor (ephemeral GSOC) | +/— | anythread comments / troll-box | +| any | + | all | ID = `keccak256(topic ‖ index)` | +/— | following one or more feeds | +| — | + | all | feed special, mined index | +/— | following graffiti soc | + +The audience is bounded by the broker's cap; scaling past it is bps-multihop's business. + +Rows requiring implicit publishers or history are specified in bps-implicit-publisher and +bps-history respectively. + +## Rationale: why not gossipsub + +libp2p ships gossipsub, a battle-tested mesh multicast. BPS builds its own protocol +because gossipsub's core mechanisms — flooding to a random mesh, IHAVE/IWANT +pull-recovery — are exactly what an incentivised network rejects: **no node wants to pay +for a message it did not ask for.** That one economic fact dissolves gossipsub's +machinery: metered edges mean no redundant paths and no transport-level duplicates; a +cohort's `CohortSpec` scopes every session; authentication is structural (SOC-signed +against the topic binding), so brokers and relays forward without being trusted — an +intermediate can withhold, never forge; and withholding is a liveness fault recoverable +by re-pointing or relocating the topic. Multihop forwarding (bps-multihop) adds capacity +without reintroducing flooding: every edge still pays upstream, every node still receives +only its topic's stream. + +## Out of scope (deliberately) + +Multihop relaying and referral (bps-multihop), reorganisation policies (SWATCH, SPORE — +policy SWIPs over this protocol's events and actions, no new frames), bandwidth incentives +(bps-bw-incentives), broker discovery (SWIP-58 MEX; early deployments hardcode brokers), +history delivery mechanism (bps-history), implicit-publisher event sourcing +(bps-implicit-publisher). + +## Conformance (definition of done) + +An implementation is conformant when: + +1. a broker enforces cap, publisher legitimacy, per-binding validation and dedup; +2. a subscriber re-verifies every message end-to-end and detects (only) liveness faults; +3. the two worked configurations above interoperate across independent implementations + against the frames in [bps.proto](assets/swip-60/bps.proto); +4. a `FULL` refusal is issued at cap — and nothing else is (no referral). + +## Backwards compatibility + +New protocol; no existing behaviour changes. Frame-byte split reserves the service range +so bps-multihop extends without version bump. + +## References + +Wire: [bps.proto](assets/swip-60/bps.proto) · origin: +[PR #93](https://github.com/ethersphere/SWIPs/pull/93) "Add: pubsub" · broker discovery: +[SWIP-58 MEX, PR #103](https://github.com/ethersphere/SWIPs/pull/103) · implementation: +bee [#5435](https://github.com/ethersphere/bee/pull/5435), bee-js +[#1151](https://github.com/ethersphere/bee-js/pull/1151) + +## Copyright + +Copyright and related rights waived via [CC0](https://creativecommons.org/publicdomain/zero/1.0/). From 25f6f084e2cfe93151fe5dd9dbd903793c41fc2e Mon Sep 17 00:00:00 2001 From: zelig Date: Wed, 5 Aug 2026 01:11:26 +0200 Subject: [PATCH 2/9] swip-60: revision 2 after acud's review - Connect split into Open (opener fixes CohortSpec) / Subscribe (topic only, no cohort metadata); broker Ack echoes the spec to subscribers for end-to-end verification; Role enum gone - broker capacity removed from CohortSpec: broker-side policy, not a cohort parameter; jam-cohort seat bound now = genesis publisher list - EXPLICIT_LIST mechanics specified: repeated publisher_list fixed at genesis; dynamic grants/revocations deferred (out of scope) - every frame carries the full SOC: handshake/data split dropped; stream-model rationale added (per-topic streams, mux-migration safe) - Ping dropped: liveness/RTT are transport concerns - *_UNSPECIFIED enum zero values documented as invalid on the wire Co-Authored-By: Claude Fable 5 --- SWIPs/assets/swip-60/bps.proto | 128 +++++++++++++++++++-------------- SWIPs/swip-60.md | 94 +++++++++++++----------- 2 files changed, 130 insertions(+), 92 deletions(-) diff --git a/SWIPs/assets/swip-60/bps.proto b/SWIPs/assets/swip-60/bps.proto index 36381999..db495975 100644 --- a/SWIPs/assets/swip-60/bps.proto +++ b/SWIPs/assets/swip-60/bps.proto @@ -1,12 +1,21 @@ // Broadcast Pub/Sub (BPS) — protocol messages and types. // Spec: SWIP-60 (../../swip-60.md). // -// Deliberately incomplete as of 2026-08-02: the singlehop (depth = 1) subset is -// concrete; multihop control-plane messages are named but reserved. The existing -// implementation (bee PR #5435) uses hand-rolled byte framing with the same -// semantics; this file is the normative description of the message structure, -// and — bee protocols being protobuf-over-libp2p elsewhere — the candidate -// replacement framing. +// 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. +// +// 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. +// +// 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; @@ -17,50 +26,59 @@ 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 epic: "What does the topic bind to?"). +// What the topic binds to (see SWIP-60: binding semantics). enum TopicBinding { - TOPIC_BINDING_UNSPECIFIED = 0; + 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 (see epic: genesis dimensions). +// Who may author. enum PublisherRegime { - PUBLISHER_REGIME_UNSPECIFIED = 0; - EXPLICIT_SINGLE = 1; // opener is admin and sole publisher (live streaming) - EXPLICIT_LIST = 2; // admin dictates who the other publishers are + 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) } -// The (partial) decisions fixed the moment the first full node is contacted. +// 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_* - uint32 po_min = 6; // proximity order for implicit bindings (default 16) - uint32 cap = 7; // max direct streams the broker accepts for this topic (0 = broker default) - bool closed = 8; // no audience: subscribers restricted to the publisher list + 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 (client -> broker), stream name "pubsub/1.0.0" +// 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. // --------------------------------------------------------------------------- -enum Role { - ROLE_UNSPECIFIED = 0; - SUBSCRIBER = 1; - PUBLISHER = 2; // implies direct connection to the broker (necessary, not sufficient) +// Opener -> broker: the one peer that fixes the cohort. +message Open { + CohortSpec cohort = 1; + PublisherAuth auth = 2; // present iff the opener publishes (explicit regimes) } -message Connect { - CohortSpec cohort = 1; - Role role = 2; - PublisherAuth auth = 3; // present iff role == PUBLISHER +// 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 { @@ -68,11 +86,30 @@ message PublisherAuth { 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 +} + +enum Status { + STATUS_UNSPECIFIED = 0; // invalid on the wire (see header note) + OK = 1; + FULL = 2; // broker at its per-topic capacity; + // a singlehop broker refuses — nothing else + 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. +// 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) @@ -81,33 +118,20 @@ message Soc { bytes payload = 5; // wrapped-CAC data, <= 4096 bytes } -// Publisher -> broker. No type prefix needed: the stream's role was declared at Connect. +// Publisher -> broker. message Publish { Soc soc = 1; } -// Broker -> subscriber: exactly one of the following per frame. +// Broker -> subscriber. message Broadcast { oneof frame { - Soc handshake = 1; // first frame on a stream: full SOC identity - DataFrame data = 2; // subsequent frames: signature ‖ span ‖ payload only - Ping ping = 3; // keepalive; parent measures RTT off the echo + Soc soc = 1; + // 2–15 reserved: multihop control plane (Beacon, Reparent, Expect, + // DcutrSignal, SwapProposal) — named to fix intent, not final. } } -message DataFrame { - bytes signature = 1; - bytes span = 2; - bytes payload = 3; -} - -message Ping {} - -// --------------------------------------------------------------------------- -// Multihop control plane — RESERVED, named to fix intent (not final for AFM) -// --------------------------------------------------------------------------- -// message Beacon {} // child -> parent capacity/score summary (0xFE) -// message Reparent {} // parent -> child: REPARENT{to, gateway?} (0xFD) -// message Expect {} // parent -> relay: EXPECT{children} (0xFC) -// message DcutrSignal {} // via circuit relay (0xFB) -// message SwapProposal {} // promotion swap propose/ack (0xFA) +// Keepalive / RTT: none at the BPS level. Liveness is the transport's job +// (libp2p), and latency metrics for reorganisation policies (SWATCH) are +// sourced there as well. diff --git a/SWIPs/swip-60.md b/SWIPs/swip-60.md index 56243765..0b28bbca 100644 --- a/SWIPs/swip-60.md +++ b/SWIPs/swip-60.md @@ -60,11 +60,14 @@ no mode enum; **modes are combinations of these parameters**. | `topic` | 32 bytes | interpreted per `binding` | | `binding` | `ANCHOR` / `SOC_ID` / `OWNER` / `FEED_TOPIC` | what the topic binds to; fixes which SOCs qualify as messages and the dedup rule | | `publishers` | `EXPLICIT_SINGLE` / `EXPLICIT_LIST` / `IMPLICIT` / `ALL` | who may author | -| `admin` | eth address | set iff explicit publishers; may extend the publisher list, nothing more | +| `admin` + `publisher_list` | eth addresses | set iff explicit publishers; with `EXPLICIT_LIST` the full publisher set is **fixed at genesis** (dynamic grants/revocations are deferred to a later revision) | | `history` | bool | deliver matching chunks already in the local store (mechanism in bps-history; a singlehop broker MAY refuse) | | `po_min` | uint (default 16) | proximity constraint for implicit bindings: `PO(socAddr, anchor) ≥ po_min` | -| `cap` | uint | **max direct streams the broker accepts for this topic**; 0 = broker's default | -| `closed` | bool | no audience: subscribers are restricted to the publisher list (all and only publishers subscribe) | +| `closed` | bool | no audience: subscribers are restricted to the publisher set (all and only publishers subscribe) | + +Broker **capacity is deliberately 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. Binding semantics (dedup rule in parentheses): @@ -77,16 +80,20 @@ Binding semantics (dedup rule in parentheses): - **`FEED_TOPIC`** — id = `keccak256(topic ‖ index)`; feed-update streams, graffiti MIC (dedup on chunk address). -### Roles and the cap +### Roles and capacity - **Broker**: the first full node contacted; root of the (here, depth = 1) multicast tree. - Accepts at most `cap` concurrent streams for the topic. **At cap it MUST answer a - `Connect` with a refusal** (`FULL`); referral to another attachment point is reserved - for bps-multihop — a singlehop-only broker simply refuses. + Enforces its own per-topic capacity. **At capacity it MUST answer `Open`/`Subscribe` + with a refusal** (`FULL`); referral to another attachment point is reserved for + bps-multihop — a singlehop-only broker simply refuses. +- **Opener**: the one peer that fixes the `CohortSpec` (`Open`); with explicit publisher + regimes the opener publishes. - **Publisher**: sends and receives. MUST be directly connected to the broker; direct - connection is necessary, not sufficient — with explicit publishers, the admin's list + connection is necessary, not sufficient — with explicit publishers, the genesis list decides. -- **Subscriber**: receives only. Does not exist in `closed` cohorts. +- **Subscriber**: receives only; joins by naming the topic (`Subscribe`) and carries no + cohort metadata — the broker echoes the `CohortSpec` back so every message can be + verified end-to-end. Does not exist in `closed` cohorts. ### Information flow @@ -99,26 +106,23 @@ sequenceDiagram participant SN as subscriber's bee node
(WS bridge + mux) participant SD as subscriber dApp(s) - Note over B: cohort open: topic set,
genesis parameters fixed - SN->>B: Connect(CohortSpec, SUBSCRIBER) - PN->>B: Connect(CohortSpec, PUBLISHER, auth) - Note over PN,B: publisher ⇒ direct connection to broker
(necessary, not sufficient — admin's list decides) - - loop keepalive (30 s) - B->>SN: Ping - SN-->>B: echo (RTT measured by parent) - end + PN->>B: Open(CohortSpec, auth) + Note over PN,B: opener fixes the cohort; publisher ⇒
direct connection to broker + B-->>PN: Ack(OK) + SN->>B: Subscribe(topic) + B-->>SN: Ack(OK, CohortSpec) + Note over B,SN: echoed spec ⇒ subscriber verifies
every message end-to-end PD->>PN: WS: payload PN->>B: Publish(SOC) B->>B: validate: SOC sig ⊨ topic binding
(+ dedup per binding) par fan-out to every subscriber stream - B->>SN: Broadcast: handshake frame (full SOC identity, first) /
data frame (sig ‖ span ‖ payload, after) + B->>SN: Broadcast(SOC) — every frame self-contained SN->>SN: mux: one p2p stream → N WS sessions SN->>SD: WS: payload and publisher's own subscription (if subscriber too) - B->>PN: Broadcast + B->>PN: Broadcast(SOC) PN->>PD: WS: payload end ``` @@ -130,15 +134,18 @@ signature against the topic binding regardless of path. Messages are defined in [bps.proto](assets/swip-60/bps.proto). Framing notes: -- Transport: libp2p stream `pubsub/1.0.0`, one stream per (peer, topic); `Connect` as the - first message (protobuf-over-libp2p, as bee protocols elsewhere) — bee #5435 - currently uses stream headers. -- Frame-type byte split: service frames grow downward from `0xFF` (ping `0xFF`; multihop - control frames `0xFE`… reserved), data frames grow upward from `0x00` — no collision. -- Broker→subscriber: first frame per stream is the **handshake** frame carrying full SOC - identity (id, owner); subsequent **data** frames carry `sig ‖ span ‖ payload` only. -- Publisher→broker frames carry no type prefix: the stream's role was declared at - `Connect`. +- Transport: libp2p stream `pubsub/1.0.0`, one stream per (peer, topic), + protobuf-over-libp2p as bee protocols elsewhere. The first message on a fresh stream is + `Open` (fixes a new cohort) or `Subscribe` (joins one — topic only, no cohort + metadata); the broker answers with `Ack`, echoing the `CohortSpec` to subscribers. +- **Stream model rationale**: per-topic streams give per-cohort flow control, teardown + and role typing, and match bee's protocol idiom. Because every frame carries the full + SOC (self-contained, no per-stream handshake state), a later move to topic-muxed + streams requires no format change. +- Every `Broadcast` frame carries the **full SOC** (id, owner, signature, span, payload); + there is no handshake/data frame split. +- No BPS-level keepalive or RTT probing: liveness is the transport's job, and latency + metrics for reorganisation policies are sourced there too. - Broker validation on `Publish`: SOC signature verifies against the topic binding, PO constraint holds where applicable, sender is a legitimate publisher, message is not a duplicate per the binding's dedup rule. Invalid ⇒ drop; repeated invalid ⇒ disconnect @@ -158,17 +165,18 @@ Modes are rows over the parameters; two normative examples: multiparty game. ``` -binding: ANCHOR (topic = mnemonic anchor) publishers: EXPLICIT_LIST (admin + ≤3) -closed: true (all and only publishers subscribe) cap: 4 history: false +binding: ANCHOR (topic = mnemonic anchor) publishers: EXPLICIT_LIST (admin + 3) +closed: true (all and only publishers subscribe) history: false ``` -Every seat sends and receives; there is no audience; a fifth `Connect` gets `FULL`. +Every seat sends and receives; there is no audience; the genesis list **is** the seat +bound — a fifth peer's `Subscribe` gets `REJECTED`. **Basic live streaming** — single publisher, open audience: ``` binding: FEED_TOPIC (sequential index) publishers: EXPLICIT_SINGLE -closed: false cap: broker default history: false +closed: false history: false ``` ### The modes — enumerated as combinations of dimension choices @@ -187,7 +195,8 @@ meaningful.) | any | + | all | ID = `keccak256(topic ‖ index)` | +/— | following one or more feeds | | — | + | all | feed special, mined index | +/— | following graffiti soc | -The audience is bounded by the broker's cap; scaling past it is bps-multihop's business. +The audience is bounded by the broker's capacity; scaling past it is bps-multihop's +business. Rows requiring implicit publishers or history are specified in bps-implicit-publisher and bps-history respectively. @@ -212,22 +221,27 @@ Multihop relaying and referral (bps-multihop), reorganisation policies (SWATCH, policy SWIPs over this protocol's events and actions, no new frames), bandwidth incentives (bps-bw-incentives), broker discovery (SWIP-58 MEX; early deployments hardcode brokers), history delivery mechanism (bps-history), implicit-publisher event sourcing -(bps-implicit-publisher). +(bps-implicit-publisher), and **dynamic publisher-list changes** — grants/revocations +after genesis are deferred to a later revision; the `EXPLICIT_LIST` set is fixed at +`Open`. ## Conformance (definition of done) An implementation is conformant when: -1. a broker enforces cap, publisher legitimacy, per-binding validation and dedup; -2. a subscriber re-verifies every message end-to-end and detects (only) liveness faults; +1. a broker enforces its per-topic capacity, publisher legitimacy, per-binding validation + and dedup; +2. a subscriber re-verifies every message end-to-end (against the `Ack`-echoed + `CohortSpec`) and detects (only) liveness faults; 3. the two worked configurations above interoperate across independent implementations against the frames in [bps.proto](assets/swip-60/bps.proto); -4. a `FULL` refusal is issued at cap — and nothing else is (no referral). +4. a `FULL` refusal is issued at capacity — and nothing else is (no referral). ## Backwards compatibility -New protocol; no existing behaviour changes. Frame-byte split reserves the service range -so bps-multihop extends without version bump. +New protocol; no existing behaviour changes. Reserved `Broadcast` frame fields hold the +multihop control plane, so bps-multihop extends without a version bump; self-contained +frames mean a change of stream model needs no format change either. ## References From 77f60889cd84aac328141c6ab25c41201bf9547b Mon Sep 17 00:00:00 2001 From: zelig Date: Wed, 5 Aug 2026 01:19:47 +0200 Subject: [PATCH 3/9] swip-60: cap wording in summary/motivation follows capacity change Co-Authored-By: Claude Fable 5 --- SWIPs/swip-60.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/SWIPs/swip-60.md b/SWIPs/swip-60.md index 0b28bbca..bb7956df 100644 --- a/SWIPs/swip-60.md +++ b/SWIPs/swip-60.md @@ -28,7 +28,7 @@ assets/swip-60/bps.proto. --> A real-time messaging protocol: WebSocket clients publish and subscribe to topic streams through Bee nodes. One full node per topic acts as **broker**, re-broadcasting each message -over direct, long-lived p2p streams to at most **cap** connected peers. Messages are +over direct, long-lived p2p streams to a capacity-bounded set of connected peers. Messages are single-owner chunks, so every subscriber verifies authorship end-to-end; the broker can withhold, never forge. @@ -36,7 +36,7 @@ withhold, never forge. Swarm's event primitives (GSOC, PSS) require full-node operation; light clients can only poll storage. BPS singlehop is the smallest protocol that fixes this: one broker, direct -streams, authenticated messages, an explicit connection cap. Everything larger — multihop +streams, authenticated messages, an explicit capacity bound. Everything larger — multihop trees, adaptive reorganisation, incentives, discovery — is layered on top by later SWIPs without changing the semantics defined here. From 74812864a31ab78d2dc370ee6659f8509e4e5296 Mon Sep 17 00:00:00 2001 From: zelig Date: Wed, 5 Aug 2026 05:19:44 +0200 Subject: [PATCH 4/9] swip-60: MEX renumbered SWIP-58 -> SWIP-59 Co-Authored-By: Claude Fable 5 --- SWIPs/swip-60.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/SWIPs/swip-60.md b/SWIPs/swip-60.md index bb7956df..9a3fb3ba 100644 --- a/SWIPs/swip-60.md +++ b/SWIPs/swip-60.md @@ -22,7 +22,7 @@ assets/swip-60/bps.proto. --> exists in bee [#5435](https://github.com/ethersphere/bee/pull/5435). - Bandwidth-incentive integration is a separate SWIP (bps-bw-incentives). - Broker discovery integration is from a separate SWIP (bps-broker-discovery, building on - [SWIP-58 MEX](https://github.com/ethersphere/SWIPs/pull/103)). + [SWIP-59 MEX](https://github.com/ethersphere/SWIPs/pull/103)). ## Simple Summary @@ -219,7 +219,7 @@ only its topic's stream. Multihop relaying and referral (bps-multihop), reorganisation policies (SWATCH, SPORE — policy SWIPs over this protocol's events and actions, no new frames), bandwidth incentives -(bps-bw-incentives), broker discovery (SWIP-58 MEX; early deployments hardcode brokers), +(bps-bw-incentives), broker discovery (SWIP-59 MEX; early deployments hardcode brokers), history delivery mechanism (bps-history), implicit-publisher event sourcing (bps-implicit-publisher), and **dynamic publisher-list changes** — grants/revocations after genesis are deferred to a later revision; the `EXPLICIT_LIST` set is fixed at @@ -247,7 +247,7 @@ frames mean a change of stream model needs no format change either. Wire: [bps.proto](assets/swip-60/bps.proto) · origin: [PR #93](https://github.com/ethersphere/SWIPs/pull/93) "Add: pubsub" · broker discovery: -[SWIP-58 MEX, PR #103](https://github.com/ethersphere/SWIPs/pull/103) · implementation: +[SWIP-59 MEX, PR #103](https://github.com/ethersphere/SWIPs/pull/103) · implementation: bee [#5435](https://github.com/ethersphere/bee/pull/5435), bee-js [#1151](https://github.com/ethersphere/bee-js/pull/1151) From 22e83255e22c5a684e43987a8e29296456359cfb Mon Sep 17 00:00:00 2001 From: zelig Date: Fri, 7 Aug 2026 12:12:30 +0200 Subject: [PATCH 5/9] swip-60 rev 3: specify the API (WebSocket bridge); po_min -> protocol constant PO_MIN Co-Authored-By: Claude Fable 5 --- SWIPs/assets/swip-60/bps.proto | 10 ++++-- SWIPs/swip-60.md | 60 ++++++++++++++++++++++++++++++---- 2 files changed, 61 insertions(+), 9 deletions(-) diff --git a/SWIPs/assets/swip-60/bps.proto b/SWIPs/assets/swip-60/bps.proto index db495975..7384870b 100644 --- a/SWIPs/assets/swip-60/bps.proto +++ b/SWIPs/assets/swip-60/bps.proto @@ -30,8 +30,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 } @@ -49,6 +49,10 @@ 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 (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; @@ -57,7 +61,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 bool closed = 8; // no audience: subscribers restricted to the publishers } diff --git a/SWIPs/swip-60.md b/SWIPs/swip-60.md index 9a3fb3ba..dd01beb0 100644 --- a/SWIPs/swip-60.md +++ b/SWIPs/swip-60.md @@ -62,9 +62,13 @@ no mode enum; **modes are combinations of these parameters**. | `publishers` | `EXPLICIT_SINGLE` / `EXPLICIT_LIST` / `IMPLICIT` / `ALL` | who may author | | `admin` + `publisher_list` | eth addresses | set iff explicit publishers; with `EXPLICIT_LIST` the full publisher set is **fixed at genesis** (dynamic grants/revocations are deferred to a later revision) | | `history` | bool | deliver matching chunks already in the local store (mechanism in bps-history; a singlehop broker MAY refuse) | -| `po_min` | uint (default 16) | proximity constraint for implicit bindings: `PO(socAddr, anchor) ≥ po_min` | | `closed` | bool | no audience: subscribers are restricted to the publisher set (all and only publishers subscribe) | +The proximity constraint for implicit bindings is a **protocol constant**, not a cohort +parameter: `PO_MIN = 16`. (Making it a parameter invited proto3's unset-equals-0 +footgun — an omitted value silently disabling the constraint — and no use case varies +it.) + Broker **capacity is deliberately 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. @@ -73,7 +77,7 @@ Binding semantics (dedup rule in parentheses): - **`ANCHOR`** — topic = full SOC/GSOC address; all messages share one address (dedup on the wrapped CAC). -- **`SOC_ID`** — topic = SOC id; any owner with `PO(socAddr(id, owner), anchor) ≥ po_min` +- **`SOC_ID`** — topic = SOC id; any owner with `PO(socAddr(id, owner), anchor) ≥ PO_MIN` qualifies (dedup on chunk address). - **`OWNER`** — topic = SOC owner; any id under the same PO constraint — MIC semantics (dedup on chunk address). @@ -153,9 +157,51 @@ Messages are defined in [bps.proto](assets/swip-60/bps.proto). Framing notes: ### API (WebSocket bridge) -WS clients see raw mode payloads only; all p2p framing is transparent. One p2p stream is -muxed to N local WS sessions per topic. Endpoint shape per bee -[#5435](https://github.com/ethersphere/bee/pull/5435). +One endpoint pair on the Bee API. Endpoint shape follows bee +[#5435](https://github.com/ethersphere/bee/pull/5435), generalised from its single +hardcoded mode to the full parameter space; serialization conventions follow the SOC +subscription family — GSOC/MIC/MOC (bee +[#5486](https://github.com/ethersphere/bee/pull/5486), +[#5497](https://github.com/ethersphere/bee/pull/5497)) — whose `/mic/subscribe/{owner}` +and `/moc/subscribe/{id}` endpoints are the storage-fed counterparts of the `OWNER` and +`SOC_ID` bindings, so a dApp switches between stored and live feeds without +reformatting. All p2p framing is transparent to WS clients; one p2p stream is muxed to +N local WS sessions per topic. + +**`GET /pubsub/{topic}`** — upgrades to a WebSocket session on the topic. `{topic}` is +the 32-byte topic hex-encoded, or an arbitrary string hashed to 32 bytes (mnemonic +topics). Query parameters: + +| parameter | maps to | meaning | +|---|---|---| +| `peer` | — | broker underlay multiaddr; required until broker discovery exists (bps-broker-discovery) — early deployments configure it | +| `binding`, `publishers`, `admin`, `publisher-list`, `closed`, `history` | `CohortSpec` | **presence of cohort parameters makes the session the opener**: the node sends `Open` with the assembled spec; absence makes it a joiner: the node sends `Subscribe(topic)` and learns the spec from the `Ack` echo | +| `owner` (+ `id` where the binding does not fix it) | `PublisherAuth` | **presence makes the session a publisher** (read–write); absence, a subscriber (read-only) | + +Headers: + +- `swarm-keep-alive` (seconds, default 60): ping period of the **local WS link only** — + not to be confused with the p2p layer, which has no keepalive. +- `swarm-soc-fields` (per bee [#5497](https://github.com/ethersphere/bee/pull/5497)): + comma-separated SOC fields serialized per outbound message — `address`, + `recoveredPubKey`, `identifier`, `signature`, `wrappedAddress`, `span`, `payload`; + default `payload`. This is how dApps on implicit-binding streams (`OWNER`, `SOC_ID`, + feed) attribute messages — no BPS-specific frame format. +- `swarm-cache-wrapped-chunk` (per bee + [#5497](https://github.com/ethersphere/bee/pull/5497)): when true, the wrapped chunk + of every incoming message is stored in the local cache, resolvable through the bytes + endpoint — for streams whose messages reference content larger than one chunk. + +**`GET /pubsub/`** — lists the node's active topics: topic address, cohort parameters, +own role (broker / subscriber), connected peers. + +**Signing — the key-holding rule.** Message signing is the dApp's business: **the node +never holds publisher keys**. Inbound (publisher → node): `sig ‖ span ‖ payload`, +signed client-side (bee-js); where the binding does not fix the SOC id (e.g. +`FEED_TOPIC` with a moving index), the frame is prefixed with the id: +`id ‖ sig ‖ span ‖ payload` **(?)**. The node assembles the SOC, validates it exactly +as a broker would, and publishes. End-to-end verification against the `Ack`-echoed +`CohortSpec` is performed by the local node — node and dApp are one trust domain. ### Configurations (worked examples) @@ -235,7 +281,9 @@ An implementation is conformant when: `CohortSpec`) and detects (only) liveness faults; 3. the two worked configurations above interoperate across independent implementations against the frames in [bps.proto](assets/swip-60/bps.proto); -4. a `FULL` refusal is issued at capacity — and nothing else is (no referral). +4. a `FULL` refusal is issued at capacity — and nothing else is (no referral); +5. the WS bridge round-trips both worked configurations end to end — open, publish, + subscribe — with all signing on the client side (the node holds no publisher keys). ## Backwards compatibility From 4ea5c9ed580f47ef9278a61bc282b23885b46fc6 Mon Sep 17 00:00:00 2001 From: zelig Date: Sat, 8 Aug 2026 05:30:56 +0200 Subject: [PATCH 6/9] swip-60: OWNER topic = keccak256(owner); idempotent Open; id unconstrained under explicit regimes (SWIP-65 pointer); worked API calls Co-Authored-By: Claude Fable 5 --- SWIPs/swip-60.md | 51 +++++++++++++++++++++++++++++++++++++++++------- 1 file changed, 44 insertions(+), 7 deletions(-) diff --git a/SWIPs/swip-60.md b/SWIPs/swip-60.md index dd01beb0..dde35196 100644 --- a/SWIPs/swip-60.md +++ b/SWIPs/swip-60.md @@ -79,11 +79,20 @@ Binding semantics (dedup rule in parentheses): the wrapped CAC). - **`SOC_ID`** — topic = SOC id; any owner with `PO(socAddr(id, owner), anchor) ≥ PO_MIN` qualifies (dedup on chunk address). -- **`OWNER`** — topic = SOC owner; any id under the same PO constraint — MIC semantics - (dedup on chunk address). +- **`OWNER`** — topic = `keccak256(owner)`; any id under the same PO constraint — MIC + semantics (dedup on chunk address). The broker never inverts the hash: it recovers + the owner from the SOC signature and checks `keccak256(owner) == topic`; the topic + doubles as the PO anchor. - **`FEED_TOPIC`** — id = `keccak256(topic ‖ index)`; feed-update streams, graffiti MIC (dedup on chunk address). +Under **explicit publisher regimes**, legitimacy is list membership, not proximity — +the PO constraint does not apply — and where dedup is on the wrapped CAC (`ANCHOR`), +the SOC id does no protocol work: it is **unconstrained**, and publishers MAY use it as +a plain sequence number. The full sequential construction — signed as a feed update, +carried as a bare index, making missed updates detectable and recoverable — is +**self-indexed feeds, SWIP-65 (forthcoming)**. + ### Roles and capacity - **Broker**: the first full node contacted; root of the (here, depth = 1) multicast tree. @@ -142,6 +151,10 @@ Messages are defined in [bps.proto](assets/swip-60/bps.proto). Framing notes: protobuf-over-libp2p as bee protocols elsewhere. The first message on a fresh stream is `Open` (fixes a new cohort) or `Subscribe` (joins one — topic only, no cohort metadata); the broker answers with `Ack`, echoing the `CohortSpec` to subscribers. +- **`Open` is idempotent**: naming an already-open topic with an **identical** spec is + equivalent to `Subscribe`; with a mismatched spec it is answered `REJECTED`. + Implicit-publisher cohorts rely on this — the first subscriber is the opener, so a + client need not know whether it is first. - **Stream model rationale**: per-topic streams give per-cohort flow control, teardown and role typing, and match bee's protocol idiom. Because every frame carries the full SOC (self-contained, no per-stream handshake state), a later move to topic-muxed @@ -197,11 +210,35 @@ own role (broker / subscriber), connected peers. **Signing — the key-holding rule.** Message signing is the dApp's business: **the node never holds publisher keys**. Inbound (publisher → node): `sig ‖ span ‖ payload`, -signed client-side (bee-js); where the binding does not fix the SOC id (e.g. -`FEED_TOPIC` with a moving index), the frame is prefixed with the id: -`id ‖ sig ‖ span ‖ payload` **(?)**. The node assembles the SOC, validates it exactly -as a broker would, and publishes. End-to-end verification against the `Ack`-echoed -`CohortSpec` is performed by the local node — node and dApp are one trust domain. +signed client-side (bee-js). Where the binding does not fix the SOC id, the frame is +prefixed with it — for feed bindings the prefix is the bare index, the signed id being +the feed id `keccak256(topic ‖ index)` (self-indexed feeds, SWIP-65 forthcoming); +under explicit regimes with `ANCHOR` binding the id does no work and there is no +prefix. The node assembles the SOC, validates it exactly as a broker would, and +publishes. End-to-end verification against the `Ack`-echoed `CohortSpec` is performed +by the local node — node and dApp are one trust domain. + +**Worked API calls — the jam cohort** (see Configurations below). Seat A opens — cohort +parameters present ⇒ `Open`, `owner` present ⇒ read–write: + +``` +wss://node:1633/pubsub/jam-tuesday?peer= + &binding=anchor&publishers=list&closed=true + &admin=0xA…&publisher-list=0xB…,0xC…,0xD…&owner=0xA… +``` + +Seats B–D join — no cohort parameters ⇒ `Subscribe`, spec learned from the `Ack` echo: + +``` +wss://node:1633/pubsub/jam-tuesday?peer=&owner=0xB… +``` + +The join URL minus `owner` is the complete out-of-band invite (topic mnemonic + broker) +until broker discovery exists. A fifth peer's `Subscribe` gets `REJECTED`. A live MIC — +all SOCs of one owner, the light-client twin of `/mic/subscribe/{owner}` — is the +implicit case: first subscriber opens with +`?binding=owner&publishers=implicit` (idempotent `Open`), topic = `keccak256(owner)`, +read-only, `swarm-soc-fields: identifier,payload`. ### Configurations (worked examples) From 98e89183ebfecb02428726a1dc40db18f73b5b52 Mon Sep 17 00:00:00 2001 From: zelig Date: Sun, 9 Aug 2026 10:34:50 +0200 Subject: [PATCH 7/9] swip-60: ANCHOR dedup soundness note; SWIP-65 links Wrapped-CAC dedup under ANCHOR guards against unsolicited republication of old SOCs, and is sound only if the application guarantees distinct payloads - i.e. includes some index in the payload (per the SWIP-65 discussion: without self-indexing the sequence requirement moves above the protocol, unspecified). The two 'SWIP-65 (forthcoming)' anchors now link PR #106. Co-Authored-By: Claude Fable 5 --- SWIPs/swip-60.md | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/SWIPs/swip-60.md b/SWIPs/swip-60.md index dde35196..f6f3f836 100644 --- a/SWIPs/swip-60.md +++ b/SWIPs/swip-60.md @@ -76,7 +76,9 @@ answers `FULL` when it is exhausted. Binding semantics (dedup rule in parentheses): - **`ANCHOR`** — topic = full SOC/GSOC address; all messages share one address (dedup on - the wrapped CAC). + the wrapped CAC — the guard against unsolicited republication of old SOCs, sound only + under an application-level requirement: payloads are distinct, i.e. the application + includes some index in the payload). - **`SOC_ID`** — topic = SOC id; any owner with `PO(socAddr(id, owner), anchor) ≥ PO_MIN` qualifies (dedup on chunk address). - **`OWNER`** — topic = `keccak256(owner)`; any id under the same PO constraint — MIC @@ -91,7 +93,7 @@ the PO constraint does not apply — and where dedup is on the wrapped CAC (`ANC the SOC id does no protocol work: it is **unconstrained**, and publishers MAY use it as a plain sequence number. The full sequential construction — signed as a feed update, carried as a bare index, making missed updates detectable and recoverable — is -**self-indexed feeds, SWIP-65 (forthcoming)**. +**self-indexed feeds, [SWIP-65](https://github.com/ethersphere/SWIPs/pull/106)**. ### Roles and capacity @@ -212,7 +214,8 @@ own role (broker / subscriber), connected peers. never holds publisher keys**. Inbound (publisher → node): `sig ‖ span ‖ payload`, signed client-side (bee-js). Where the binding does not fix the SOC id, the frame is prefixed with it — for feed bindings the prefix is the bare index, the signed id being -the feed id `keccak256(topic ‖ index)` (self-indexed feeds, SWIP-65 forthcoming); +the feed id `keccak256(topic ‖ index)` (self-indexed feeds, +[SWIP-65](https://github.com/ethersphere/SWIPs/pull/106)); under explicit regimes with `ANCHOR` binding the id does no work and there is no prefix. The node assembles the SOC, validates it exactly as a broker would, and publishes. End-to-end verification against the `Ack`-echoed `CohortSpec` is performed From 10df5e9c98f0e3584fa44fcd8852236b1ed976fc Mon Sep 17 00:00:00 2001 From: zelig Date: Sat, 29 Aug 2026 13:32:11 +0200 Subject: [PATCH 8/9] swip-60 rev 4: five cohort configurations, an admin control plane, proved Auth MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Revision after implementation feedback from the bee prototype (acud, PR #104 comment of 2026-08-17) and a restructuring pass. ## Cohort spec carries immutable policy; the roster does not Five configurations, distinguished by three fields: jam admin GRANTED spectators:false spectator-jam admin GRANTED spectators:true live-stream admin ADMIN_ONLY spectators:true group-chat admin ALL spectators:true implicit no admin SOC shape decides - New binding `MNEMONIC`: the topic names the cohort and constrains nothing — any SOC from any owner. This is what ALL needs; authorship there is unrestricted but never unattributable, since every message is still SOC-signed, so a group chat knows who said what without an authorised set to check against. - `publishers` = ADMIN_ONLY / GRANTED / ALL. ADMIN_ONLY is an immutable promise ("this stream will never have a second author"), not a roster that happens to be empty. - `spectators` replaces the previous `closed` and is enforceable, because Auth is recovered rather than asserted. It is the only refusal for identity in the protocol; openers MUST set it true under ALL and implicit, where every attached peer is already a potential author. - The admin is always in the publisher set. A non-publishing moderator is just an admin that never sends — being a publisher obliges nobody to publish. - `publisher_list`, `po_min` and `closed` are reserved in CohortSpec. ## The service feed — the admin's control plane owner = admin id = keccak256("bps-service:v1" || topic || index) index 0 GENESIS the CohortSpec, signed by the admin index n ROSTER the full publisher set at version n last END_OF_STREAM the admin closes the cohort, attributably The roster is dynamic and the spec is immutable, so the roster cannot live in it; grantee identities are also not public the way an admin's is. A feed rather than one constant-id slot: overwriting in place would make a stale roster undetectable, reintroducing forging-by-omission at the one point that decides who may write. Sequential indices make gaps visible, so withholding stays a liveness fault (SWIP-65 carries the construction). Ack now delivers the echoed CohortSpec, the admin-signed genesis SOC and the latest service SOC with its index, so a joiner verifies the cohort and its roster against the admin rather than the broker. END_OF_STREAM separates "over" from "the broker stopped relaying". Revocation is two-phase, and the boundary is the moment the reduced roster reaches subscribers. Before it the revoked peer cannot know, so its frames are dropped and TOLERATED — no penalty, no teardown, because it is not misbehaving. After it the peer has been told on the same feed as everyone else, so publishing is a protocol violation and the connection is broken. The announcement is what converts an unknowing publisher into a violating one: disconnecting first would punish a peer for a rule it had not been given, and never publishing the roster leaves the violation unable to begin at all, which is an ordinary visible withholding fault. It also makes the revocation legible to the rest of the cohort, which learns why a publisher fell silent from an admin-signed message rather than from an unattributable disconnection. ## Wire - `Open` and `Subscribe` wrapped in a `Hello` envelope. As bare frames they are byte-indistinguishable (length-delimited field 1 + optional Auth in field 2) and proto3's permissive unmarshalling makes a wrong guess succeed silently, misread the frame, and answer with a Status describing the wrong problem. (acud, finding 1.) - `Auth` carries a signature and no address: owner = ecrecover over H("bps-join:v1" || topic || admin), so identity and proof arrive in one operation and the handshake stays one frame each way. No libp2p peer id in the preimage — binding to the node would weld the publishing identity to the node holding the stream and leak an eth-identity/peer-id link on every join. The preimage is therefore static and replayable, which costs nothing: a replayed role is worthless without the signing key. The "bps-join:v1" separator keeps the join-signature space disjoint from the SOC-signature space the same keys serve. (acud, finding 2.) - Dedup horizon: implementation-defined but MUST be bounded; replay of an evicted message by a legitimate publisher is the accepted consequence. - Cohort lifetime: broker-side, not tied to the opener, reclaimable when unattached — except by END_OF_STREAM, which is attributable. - A conformant broker bounds how many cohorts it will create; `Open` is otherwise an unbounded allocation primitive. (acud, finding 3.) ## Prose New "Security considerations": the admin and the cohort are authenticated by the genesis message; the publisher role is proved, not asserted; defence in depth is the real guarantee, so no challenge round trip; audience control exists only as `spectators` and is not confidentiality — BPS offers none at any layer, and a bounded audience is payload encryption, application-side. "Why not gossipsub" gains both halves of the trade: rootward-then-leafward carries each edge exactly once, so a single-parented tree needs no duplicate suppression at all and beats a mesh on closely knit topologies — and the concession that a genuinely gossip-shaped use case should just use libp2p gossipsub. Publishers' direct attachment to the broker is now stated as a consequence of depth = 1 rather than a protocol invariant: bps-multihop forwards Publish rootward from the leaves, which is what lets an everyone-publishes cohort outgrow one broker. (SWIP-61 needs the matching change.) API: `publishers`/`spectators` query parameters, no publisher list, `auth` replacing `owner`, and POST /pubsub/{topic}/service for the admin's grants, revocations and close. Co-Authored-By: Claude Opus 5 --- SWIPs/.Rhistory | 0 SWIPs/assets/swip-60/bps.proto | 198 ++++++++++--- SWIPs/swip-60.md | 525 +++++++++++++++++++++++++++------ 3 files changed, 590 insertions(+), 133 deletions(-) create mode 100644 SWIPs/.Rhistory diff --git a/SWIPs/.Rhistory b/SWIPs/.Rhistory new file mode 100644 index 00000000..e69de29b diff --git a/SWIPs/assets/swip-60/bps.proto b/SWIPs/assets/swip-60/bps.proto index 7384870b..052da6bf 100644 --- a/SWIPs/assets/swip-60/bps.proto +++ b/SWIPs/assets/swip-60/bps.proto @@ -1,12 +1,21 @@ // Broadcast Pub/Sub (BPS) — protocol messages and types. // Spec: SWIP-60 (../../swip-60.md). // -// 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. +// 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 @@ -23,7 +32,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). @@ -31,80 +40,173 @@ 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 (a proto3 unset uint32 is -// indistinguishable from 0, which would silently disable the constraint; -// and no use case varies it). +// 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 - 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 broker answers with Ack. +// The first frame settles the peer's role. // --------------------------------------------------------------------------- -// 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 -> broker: 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. +// 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 the broker's word for either. message Ack { - Status status = 1; - CohortSpec cohort = 2; // set iff status == OK + 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 } enum Status { STATUS_UNSPECIFIED = 0; // invalid on the wire (see header note) OK = 1; FULL = 2; // broker at its per-topic capacity; - // a singlehop broker refuses — nothing else + // a singlehop broker refuses -- nothing else 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 + 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. } // --------------------------------------------------------------------------- diff --git a/SWIPs/swip-60.md b/SWIPs/swip-60.md index f6f3f836..97182c13 100644 --- a/SWIPs/swip-60.md +++ b/SWIPs/swip-60.md @@ -13,9 +13,11 @@ PubSub SWIP (ethersphere/SWIPs PR #93) into work-package-sized SWIPs. Companion assets/swip-60/bps.proto. --> - **Business line**: real-time topic streams for dApps without storing chunks or polling — - enough on its own for small closed collaboration cohorts (collaborative remix editing, a - strudel livecoding session, multiparty games) and basic single-publisher limited-audience - live streaming. + enough on its own for the five cohort shapes it defines: **jam** (a closed set of authors: + collaborative remix editing, a strudel livecoding session, a multiparty game), + **spectator-jam** (the same before an audience), **live-stream** (one author, an audience), + **group-chat** (everyone speaks) and **implicit** (a live feed with no authority at all). + An admin grants and revokes authors while a cohort runs, without redefining it. - **Dev line**: implement one libp2p protocol (`pubsub/1.0.0`, messages in [bps.proto](assets/swip-60/bps.proto)) plus a WebSocket bridge on the Bee API; done when a broker, publishers and subscribers interoperate per the conformance section. Groundwork @@ -51,30 +53,53 @@ Per topic-cohort: ### Cohort genesis: the parameters -A cohort is fully described by a `CohortSpec` ([bps.proto](assets/swip-60/bps.proto)), -fixed the moment the first peer contacts a BPS-speaking full node with a topic. There is -no mode enum; **modes are combinations of these parameters**. +A cohort is fully described by a `CohortSpec` ([bps.proto](assets/swip-60/bps.proto)), fixed +the moment the first peer contacts a BPS-speaking full node with a topic, and **immutable for +the cohort's lifetime**. There is no mode enum; **modes are combinations of these +parameters**. | parameter | values | meaning | |---|---|---| | `topic` | 32 bytes | interpreted per `binding` | -| `binding` | `ANCHOR` / `SOC_ID` / `OWNER` / `FEED_TOPIC` | what the topic binds to; fixes which SOCs qualify as messages and the dedup rule | -| `publishers` | `EXPLICIT_SINGLE` / `EXPLICIT_LIST` / `IMPLICIT` / `ALL` | who may author | -| `admin` + `publisher_list` | eth addresses | set iff explicit publishers; with `EXPLICIT_LIST` the full publisher set is **fixed at genesis** (dynamic grants/revocations are deferred to a later revision) | +| `binding` | `MNEMONIC` / `ANCHOR` / `SOC_ID` / `OWNER` / `FEED_TOPIC` | what the topic binds to; fixes which SOCs qualify as messages and the dedup rule | +| `admin` | eth address | the opener, the cohort's authority, and a member of its publisher set. **Absent ⇒ implicit authorship**, and the two fields below do not apply | +| `publishers` | `ADMIN_ONLY` / `GRANTED` / `ALL` | who may author besides the admin | +| `spectators` | bool | whether peers outside the publisher set may join | | `history` | bool | deliver matching chunks already in the local store (mechanism in bps-history; a singlehop broker MAY refuse) | -| `closed` | bool | no audience: subscribers are restricted to the publisher set (all and only publishers subscribe) | -The proximity constraint for implicit bindings is a **protocol constant**, not a cohort -parameter: `PO_MIN = 16`. (Making it a parameter invited proto3's unset-equals-0 -footgun — an omitted value silently disabling the constraint — and no use case varies -it.) - -Broker **capacity is deliberately 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. +**The publisher list is deliberately not here.** It is dynamic — an admin grants and revokes +while the cohort runs — and this spec is immutable, so it cannot live in it without making +every roster change a new cohort. It is also not public in the way the rest of the spec is: +the owner of a stream, or of a co-edited file, is naturally known to its subscribers, but the +other grantees are not. The roster therefore travels as **admin-signed service messages on a +feed of its own** (below), where it changes without the cohort changing, and where a +subscriber verifies it against the admin's key rather than against the broker's word. + +#### The five configurations + +| configuration | `admin` | `publishers` | `spectators` | who may author | +|---|---|---|---|---| +| **jam** | set | `GRANTED` | false | admin + current grantees; nobody else attends | +| **spectator-jam** | set | `GRANTED` | true | admin + current grantees, before an audience | +| **live-stream** | set | `ADMIN_ONLY` | true | the admin alone, before an audience | +| **group-chat** | set | `ALL` | true | anyone attached — each peer signs its own SOCs | +| **implicit** | absent | — | true | whoever the binding's SOC shape admits | + +`spectators` does real work only in the `GRANTED` and `ADMIN_ONLY` rows — which is exactly +the audience / no-audience distinction. Under `ALL` and under implicit authorship every +attached peer is already a potential author, so excluding non-publishers excludes nobody; +openers MUST set it true there. + +**The admin is always in the publisher set**, and being a publisher obliges nobody to +publish — no peer waits on another — so a practically non-publishing **moderator** needs no +role of its own: it is simply an admin that never sends. Binding semantics (dedup rule in parentheses): +- **`MNEMONIC`** — the topic constrains nothing: it names the cohort and no more. Any SOC + from any owner qualifies (dedup on chunk address). This is what `ALL` needs. Authorship is + unrestricted but never *unattributable*: every message is still SOC-signed, so a group chat + knows exactly who said what without there being an authorised set to check it against. - **`ANCHOR`** — topic = full SOC/GSOC address; all messages share one address (dedup on the wrapped CAC — the guard against unsolicited republication of old SOCs, sound only under an application-level requirement: payloads are distinct, i.e. the application @@ -88,27 +113,179 @@ Binding semantics (dedup rule in parentheses): - **`FEED_TOPIC`** — id = `keccak256(topic ‖ index)`; feed-update streams, graffiti MIC (dedup on chunk address). -Under **explicit publisher regimes**, legitimacy is list membership, not proximity — -the PO constraint does not apply — and where dedup is on the wrapped CAC (`ANCHOR`), -the SOC id does no protocol work: it is **unconstrained**, and publishers MAY use it as -a plain sequence number. The full sequential construction — signed as a feed update, -carried as a bare index, making missed updates detectable and recoverable — is -**self-indexed feeds, [SWIP-65](https://github.com/ethersphere/SWIPs/pull/106)**. +Under **explicit authorship** legitimacy is membership of the current roster, not proximity: +the PO constraint does not apply. Under **implicit authorship** nothing is checked against a +roster — there is none, and no admin either — and authorship is decided by **the shape of the +SOC** the binding fixes: + +| binding | SOC shape | implicit publishers | who qualifies | +|---|---|---|---| +| `MNEMONIC` | any | **any** | anyone; the cohort has no authority and no roster | +| `ANCHOR` | GSOC | **one** | the holder of the shared GSOC key — one address, one identity | +| `OWNER` | MIC | **one** | the owner the topic names (`topic = keccak256(owner)`); the id varies | +| `FEED_TOPIC` | feed | **one** | the feed's owner; the id is `keccak256(topic ‖ index)` | +| `SOC_ID` | MOC | **many** | any owner that mines `PO(socAddr(id, owner), anchor) ≥ PO_MIN`; the id is fixed, the owner varies | + +Where authorship is explicit and dedup is on the wrapped CAC (`ANCHOR`), the SOC id does no +protocol work: it is **unconstrained**, and publishers MAY use it as a plain sequence number. +The full sequential construction — signed as a feed update, carried as a bare index, making +missed updates detectable and recoverable — is **self-indexed feeds, +[SWIP-65](https://github.com/ethersphere/SWIPs/pull/106)**. + +The proximity constraint for implicit bindings is a **protocol constant**, not a cohort +parameter: `PO_MIN = 16`. (Making it a parameter invited proto3's unset-equals-0 +footgun — an omitted value silently disabling the constraint — and no use case varies +it.) + +Broker **capacity is deliberately 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. + +**Cohort lifetime** is broker-side in the same way, with one exception. A cohort lives for as +long as its broker keeps serving the topic; it is not tied to its opener, and a broker MAY +reclaim a cohort that has no attached streams, which is unobservable beyond a later +`Subscribe` being answered `UNKNOWN_TOPIC`. The exception is the **end-of-stream** service +message, by which an admin ends its own cohort deliberately and *attributably* (below). + +### The service feed: the admin's control plane + +Everything the admin says about the cohort — that it exists, who may write to it, and that it +is over — travels as SOCs on a feed the admin owns: + +``` +owner = admin id = keccak256("bps-service:v1" ‖ topic ‖ index) +``` + +| index | message | carries | +|---|---|---| +| `0` | **genesis** | the `CohortSpec`, signed by the admin | +| `n` | **roster** | the full publisher set as of version `n` | +| last | **end-of-stream** | the cohort is closed by its admin | + +Three properties follow, and each of them is the point: + +- **The admin is authenticated, and so is the spec.** A broker cannot invent a cohort in + somebody's name: `admin` is an address anyone can read, and index 0 is that address's own + signature over the spec it is claimed to have opened. Nothing else in the handshake needs + to be trusted. +- **It is a feed, not a single mutable slot.** The obvious alternative — one constant-id SOC + overwritten in place — makes a stale roster **undetectable**, which would reintroduce + forging-by-omission at the one point that decides who may write. Sequential indices make + gaps visible, so withholding stays a *liveness* fault like every other withholding in this + protocol, and **self-indexing** feeds ([SWIP-65](https://github.com/ethersphere/SWIPs/pull/106)) + carry the construction. +- **The roster is verified end-to-end, like every message.** Service messages are ordinary + SOCs on the ordinary path — storable, re-fetchable, and checked with the same code as any + broadcast. A broker relays them; it cannot author them. + +**`Ack` therefore carries the genesis SOC and the latest service SOC** (with its index) +alongside the echoed `CohortSpec`. A joiner learns who may write from the admin, not from the +broker, before it has received a single message. + +#### `Auth`: recovered, not asserted, and not tied to a node + +`Auth` carries **a signature and no address**: the owner is the ecrecover output, so +presenting it is possession of a key, not a claim about one — identity and proof arrive in +the same operation and the handshake stays one frame each way, with 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 the credential unreplayable, but at the cost of welding the +publishing identity to the node holding the stream: the same 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. Neither is acceptable — an owner's identity is its own, not its node's. + +The consequence, taken deliberately: a static preimage is **replayable**. It costs nothing, +because a replayed role is worthless — the replayer cannot sign, so every frame it sends is +dropped at `Publish`. What `Auth` buys is that the broker need not carry peers whose frames +could only ever be dropped; **authorship rests on the message signature, never on the +handshake.** + +The **`"bps-join:v1"` domain separator is load-bearing**. These are the same secp256k1 keys +that sign SOCs, over the preimage `id ‖ wrappedAddress`. Without separation a join signature +could be reinterpreted as a chunk signature, or a chunk signature coaxed out of a peer and +replayed as a join. The prefix makes the two preimage spaces disjoint by construction. + +Under implicit authorship there is no `Auth` at all: the SOC itself is the credential, and +its shape is checked at `Publish`. + +### The first frame settles the role + +A peer's role is fixed by its **first frame**, before any data flows: + +- the **admin** sends `Open`, carrying the `CohortSpec` and its `Auth`. The broker recovers + the address, checks it against `CohortSpec.admin`, and stores the genesis service SOC; +- everyone else sends `Subscribe`, optionally carrying `Auth`. The broker recovers the + address and matches it against the **current roster**: + +| outcome | `spectators: true` | `spectators: false` | +|---|---|---| +| recovered address is in the roster | joins as **publisher** | joins as **publisher** | +| no match, or no `Auth` | joins as **spectator**, read-only | `REJECTED` | + +`spectators: false` is the only configuration in which a peer is turned away for *who it is*, +and it is enforceable precisely because `Auth` is recovered rather than asserted. Everywhere +else `REJECTED` means the *spec* is unacceptable — an `Open` naming an already-open topic with +a mismatched spec — and `FULL` means capacity, nothing more. + +#### Grant and revocation + +An admin changes the roster by publishing the next service message; the cohort spec never +changes. A **grant** takes effect for the granted peer on its next join, or immediately if it +is already attached as a spectator. + +A **revocation** has two phases, and the boundary between them is the moment the reduced +roster reaches subscribers: + +1. **Before it is published**, the revoked peer has no way to know it has been revoked — + nothing has told it. Its `Publish` frames are therefore **dropped and tolerated**: + silently ignored, no penalty, the connection untouched. There is nothing else a broker can + honestly do, because the peer is not misbehaving. +2. **After it is published**, the peer has been told — it receives the service message like + every other subscriber, on the same feed. Publishing from that point is a **protocol + violation**, and the broker MUST break the connection. + +The announcement is therefore not only for the audience's benefit: **it is what converts an +unknowing publisher into a violating one.** A broker that tore the stream down before +publishing the reduced roster would be punishing a peer for a rule it had not been given; a +broker that never publishes it leaves everyone — the revokee included — in a state where the +violation can never begin, which is an ordinary, visible withholding fault. The penalty +itself is the protocol's existing one: repeated invalid frames end the connection +(blocklisting policy). + +Announcing first also makes the revocation legible to everyone else: subscribers learn *why* +a publisher fell silent from an admin-signed message rather than inferring it from a +disconnection they cannot attribute. + ### Roles and capacity - **Broker**: the first full node contacted; root of the (here, depth = 1) multicast tree. Enforces its own per-topic capacity. **At capacity it MUST answer `Open`/`Subscribe` with a refusal** (`FULL`); referral to another attachment point is reserved for - bps-multihop — a singlehop-only broker simply refuses. -- **Opener**: the one peer that fixes the `CohortSpec` (`Open`); with explicit publisher - regimes the opener publishes. -- **Publisher**: sends and receives. MUST be directly connected to the broker; direct - connection is necessary, not sufficient — with explicit publishers, the genesis list - decides. -- **Subscriber**: receives only; joins by naming the topic (`Subscribe`) and carries no - cohort metadata — the broker echoes the `CohortSpec` back so every message can be - verified end-to-end. Does not exist in `closed` cohorts. + bps-multihop — a singlehop-only broker simply refuses. Because `Open` is an allocation + primitive available to any peer, a conformant broker also bounds **how many cohorts it + will create**, not only the streams within one; the two limits are independent policy. +- **Admin = opener**: the one peer that fixes the `CohortSpec` (`Open`), always a member of + the publisher set, and the cohort's only authority: it grants, revokes and ends, each by + publishing a service message. Its address is public in the spec — as a stream's or a + co-edited file's owner naturally is — while its grantees' are not. An admin that never + sends is a **moderator**; no separate role is needed, since being a publisher obliges + nobody to publish. +- **Publisher**: sends and receives. At depth = 1 every peer is attached to the broker, so + publishers are too — this is a **consequence of singlehop, not a protocol invariant**. + bps-multihop lifts it by forwarding `Publish` rootward as well as `Broadcast` leafward, + so a publisher may sit several hops out; that is what lets an everyone-publishes cohort + grow past one broker's capacity. Attachment is in any case necessary, not sufficient — + under explicit authorship, the current roster decides. +- **Spectator**: receives only; joins by naming the topic (`Subscribe`) and carries no + cohort metadata — the broker echoes the `CohortSpec` and the admin's service SOCs back, so + the cohort, its roster and every message are verified end-to-end. Every peer receives, so + publishing is the *additional* capability and this role is what remains without it; a + cohort with `spectators: false` has none. ### Information flow @@ -121,11 +298,11 @@ sequenceDiagram participant SN as subscriber's bee node
(WS bridge + mux) participant SD as subscriber dApp(s) - PN->>B: Open(CohortSpec, auth) - Note over PN,B: opener fixes the cohort; publisher ⇒
direct connection to broker + PN->>B: Hello(Open(CohortSpec, Auth)) + Note over PN,B: opener fixes the cohort and is its admin
at depth = 1 every publisher is attached to the broker B-->>PN: Ack(OK) - SN->>B: Subscribe(topic) - B-->>SN: Ack(OK, CohortSpec) + SN->>B: Hello(Subscribe(topic, Auth?)) + B-->>SN: Ack(OK, CohortSpec, genesis SOC, latest ROSTER) Note over B,SN: echoed spec ⇒ subscriber verifies
every message end-to-end PD->>PN: WS: payload @@ -151,8 +328,17 @@ Messages are defined in [bps.proto](assets/swip-60/bps.proto). Framing notes: - Transport: libp2p stream `pubsub/1.0.0`, one stream per (peer, topic), protobuf-over-libp2p as bee protocols elsewhere. The first message on a fresh stream is - `Open` (fixes a new cohort) or `Subscribe` (joins one — topic only, no cohort - metadata); the broker answers with `Ack`, echoing the `CohortSpec` to subscribers. + **`Hello`**, carrying either `Open` (fixes a new cohort) or `Subscribe` (joins one — + topic only, no cohort metadata); the broker answers with `Ack`, carrying the echoed + `CohortSpec` together with the admin-signed genesis SOC and the latest service SOC, so + the joiner verifies the cohort and its roster against the admin rather than the broker. +- **Why the `Hello` envelope**: as bare frames, `Open` and `Subscribe` are + indistinguishable on the wire — both are a length-delimited field 1 followed by an + optional `Auth` in field 2 — and proto3's permissive unmarshalling means a + receiver that guesses wrong does not fail: it succeeds and misreads the frame, then + rejects it for an unrelated reason with a misleading `Status`. The `oneof` makes the + choice explicit at no cost. (The alternative — two libp2p protocol ids — needs no proto + change but splits the one-stream-per-(peer, topic) model across two stream names.) - **`Open` is idempotent**: naming an already-open topic with an **identical** spec is equivalent to `Subscribe`; with a mismatched spec it is answered `REJECTED`. Implicit-publisher cohorts rely on this — the first subscriber is the opener, so a @@ -169,6 +355,13 @@ Messages are defined in [bps.proto](assets/swip-60/bps.proto). Framing notes: constraint holds where applicable, sender is a legitimate publisher, message is not a duplicate per the binding's dedup rule. Invalid ⇒ drop; repeated invalid ⇒ disconnect (blocklisting policy). +- **The dedup *horizon* is implementation-defined, but it MUST be bounded**: the binding + fixes what counts as a duplicate, not how far back the broker remembers, and an + unbounded seen-set is a memory-exhaustion vector. A broker keeps a bounded window over + recent message identifiers; the accepted consequence is that a legitimate publisher can + overrun that window and replay an evicted message. Applications that cannot tolerate + replay carry their own sequencing — which the sequential construction of + [SWIP-65](https://github.com/ethersphere/SWIPs/pull/106) gives for free. ### API (WebSocket bridge) @@ -190,8 +383,13 @@ topics). Query parameters: | parameter | maps to | meaning | |---|---|---| | `peer` | — | broker underlay multiaddr; required until broker discovery exists (bps-broker-discovery) — early deployments configure it | -| `binding`, `publishers`, `admin`, `publisher-list`, `closed`, `history` | `CohortSpec` | **presence of cohort parameters makes the session the opener**: the node sends `Open` with the assembled spec; absence makes it a joiner: the node sends `Subscribe(topic)` and learns the spec from the `Ack` echo | -| `owner` (+ `id` where the binding does not fix it) | `PublisherAuth` | **presence makes the session a publisher** (read–write); absence, a subscriber (read-only) | +| `binding`, `admin`, `publishers`, `spectators`, `history` | `CohortSpec` | **presence of cohort parameters makes the session the opener**: the node sends `Open` with the assembled spec; absence makes it a joiner: the node sends `Subscribe(topic)` and learns the spec from the `Ack` echo. `admin` omitted ⇒ implicit authorship. No publisher list here — it is not part of the spec | +| `auth` (+ `id` where the binding does not fix it) | `Auth` | 65-byte join signature over `H("bps-join:v1" ‖ topic ‖ admin)`. **Presence claims a publisher role** (read–write); absence, a spectator (read-only). Signed client-side, like every other signature here — the node holds no publisher keys, and the signature is over no node identity, so the same key works from any node | + +**`POST /pubsub/{topic}/service`** — the admin's control plane: submits a service message +(`ROSTER` or `END_OF_STREAM`) as the next update on the service feed. The SOC is signed +client-side by the admin key; the node relays it. Granting or revoking a publisher is one +call here and touches no cohort parameter. Headers: @@ -226,66 +424,131 @@ parameters present ⇒ `Open`, `owner` present ⇒ read–write: ``` wss://node:1633/pubsub/jam-tuesday?peer= - &binding=anchor&publishers=list&closed=true - &admin=0xA…&publisher-list=0xB…,0xC…,0xD…&owner=0xA… + &binding=anchor&admin=0xA…&publishers=granted&spectators=false&auth=0x3f2a… ``` Seats B–D join — no cohort parameters ⇒ `Subscribe`, spec learned from the `Ack` echo: ``` -wss://node:1633/pubsub/jam-tuesday?peer=&owner=0xB… +wss://node:1633/pubsub/jam-tuesday?peer=&auth=0x9c14… ``` -The join URL minus `owner` is the complete out-of-band invite (topic mnemonic + broker) -until broker discovery exists. A fifth peer's `Subscribe` gets `REJECTED`. A live MIC — -all SOCs of one owner, the light-client twin of `/mic/subscribe/{owner}` — is the -implicit case: first subscriber opens with -`?binding=owner&publishers=implicit` (idempotent `Open`), topic = `keccak256(owner)`, -read-only, `swarm-soc-fields: identifier,payload`. +Seats B–D are not named in this URL and never appear in a cohort parameter: A grants them +with a `POST /pubsub/jam-tuesday/service` carrying a `ROSTER` message, and can revoke or add a +fifth seat later without any of the above changing. Each seat is sorted into the publisher +role by the address recovered from its `auth`; because `spectators` is false, a peer with no +listed key is `REJECTED` rather than admitted read-only. The join URL minus `auth` is the +complete out-of-band invite (topic mnemonic + broker) until broker discovery exists — and it +is genuinely an invite: only a holder of a rostered key can turn it into a session at all. +A live MIC — all SOCs of one owner, the light-client twin +of `/mic/subscribe/{owner}` — is the implicit case: first subscriber opens with +`?binding=owner`, no `admin` and no `auth` (idempotent `Open`), +topic = `keccak256(owner)`, read-only, `swarm-soc-fields: identifier,payload`. ### Configurations (worked examples) -Modes are rows over the parameters; two normative examples: +The five configurations, as `CohortSpec` rows. + +**Jam** — a 4-seat collaborative remix edit, a strudel livecoding session, a multiparty game. + +``` +binding: ANCHOR (topic = mnemonic anchor) admin: 0xA… +publishers: GRANTED spectators: false history: false +``` + +Seat A opens; B, C and D are granted by a `ROSTER` service message, and each is sorted into +the publisher role on joining because the address recovered from its `Auth` is on the roster +it can verify against A's key. A fifth peer is `REJECTED` — this is the one configuration in +which a peer is refused for who it is, and it is enforceable because `Auth` is recovered, not +asserted. A may grant a fifth seat, or revoke one, without the cohort spec changing at all. +Confidentiality is still not on offer: the broker holds plaintext, and a jam that needs it +encrypts payloads. + +**Spectator-jam** — the same, opened to an audience. + +``` +binding: ANCHOR admin: 0xA… +publishers: GRANTED spectators: true history: false +``` + +Identical authorship, but an unrecognised joiner is admitted read-only instead of refused. +The audience verifies the roster from the admin's feed, so it knows exactly whose messages +are legitimate without trusting the broker. -**The 4-seat jam cohort** — collaborative remix editing, a strudel livecoding session, a -multiparty game. +**Live-stream** — single publisher, open audience. ``` -binding: ANCHOR (topic = mnemonic anchor) publishers: EXPLICIT_LIST (admin + 3) -closed: true (all and only publishers subscribe) history: false +binding: FEED_TOPIC (sequential index) admin: the streamer +publishers: ADMIN_ONLY spectators: true history: false ``` -Every seat sends and receives; there is no audience; the genesis list **is** the seat -bound — a fifth peer's `Subscribe` gets `REJECTED`. +`ADMIN_ONLY` is an immutable promise, not merely an empty roster: this stream will never have +a second author, and a subscriber knows that from genesis rather than from the roster +happening to be empty so far. The streamer ends it with an `END_OF_STREAM` service message, +which is what distinguishes "over" from "the broker stopped relaying". -**Basic live streaming** — single publisher, open audience: +**Group-chat** — anyone attached may speak. ``` -binding: FEED_TOPIC (sequential index) publishers: EXPLICIT_SINGLE -closed: false history: false +binding: MNEMONIC (the topic is just the cohort's name) admin: 0xA… +publishers: ALL spectators: true history: false ``` +No roster, no `Auth`, no constraint on the SOCs: each peer signs and sends its own. The topic +binds nothing — it names the cohort, and that is all it does. Authorship is unrestricted but +never *unattributable*: every message is SOC-signed, so the chat knows exactly who said what +without there being an authorised set to check against. The admin here is not a gatekeeper — +it cannot be, since everyone may write — but it still owns the service feed, so it can end +the cohort. This is the row that outgrows a single broker fastest, and the one +[SWIP-61](https://github.com/ethersphere/SWIPs/pull/105) exists to scale: with `Publish` +forwarded from the leaves towards the root, a member need not be attached to the broker to +speak. Where a cohort wants no authorship guarantees at all, see "why not gossipsub". + +**Implicit** — no admin, no roster, no authority. + +``` +binding: OWNER (topic = keccak256(owner)) admin: absent +history: false +``` + +A live MIC: all SOCs of one owner, the light-client twin of `/mic/subscribe/{owner}`. There +is no admin, so no service feed, no grants and no end-of-stream — nothing to authenticate, +because **the chunk carries its own legitimacy** and the binding's SOC shape is the whole +check. `SOC_ID` gives the multi-author version of this (MOC: id fixed, each publisher mining +its own owner into the anchor neighbourhood — own-identity writers, as in +[SWIP-66](https://github.com/ethersphere/SWIPs/pull/107)), and `MNEMONIC` the unconstrained +one, which is group-chat minus the authority to end it. + ### The modes — enumerated as combinations of dimension choices Known use cases attach here; each mode is nothing more than a row — a combination of publisher/subscriber info, topic match type, and history. (`+/−` = both configurations meaningful.) -| # of pubs | pubs implicit? | subscribers | topic / anchor match | history | use case | -|---|---|---|---|---|---| -| 1 | — | all | feed topic, index sequential | — | live video streaming | -| any | — | all | feed topic, index sequential | — | live videoconference | -| — | + | all | feed topic | +/— | tags, adverts; private co-authoring | -| all | — | all | topic a mere mnemonic of the cohort | +/— | gossip cohort for multi-party / group chat | -| any | + | all | anchor (ephemeral GSOC) | +/— | anythread comments / troll-box | -| any | + | all | ID = `keccak256(topic ‖ index)` | +/— | following one or more feeds | -| — | + | all | feed special, mined index | +/— | following graffiti soc | - -The audience is bounded by the broker's capacity; scaling past it is bps-multihop's -business. - -Rows requiring implicit publishers or history are specified in bps-implicit-publisher and -bps-history respectively. +| configuration | binding | spectators | history | use case | +|---|---|---|---|---| +| live-stream | feed topic, index sequential | + | — | live video streaming | +| spectator-jam | feed topic, index sequential | + | — | live videoconference | +| jam | anchor | — | +/— | private co-authoring, remix editing | +| group-chat | mnemonic — no constraint | + | +/— | multi-party / group chat | +| implicit | anchor (ephemeral GSOC) | + | +/— | anythread comments / troll-box | +| implicit | id fixed, owner mined (MOC) | + | +/— | own-identity writers on a shared id | +| implicit | id = `keccak256(topic ‖ index)` | + | +/— | following one or more feeds | +| implicit | feed special, mined index | + | +/— | following graffiti soc | +| implicit | owner (MIC) | + | +/— | tags, adverts | + +At depth = 1 the broker's capacity bounds **both** directions: the audience by its stream +count, and — since every publisher is attached to it — the publisher count too. Scaling +either past one broker is bps-multihop's business +([SWIP-61](https://github.com/ethersphere/SWIPs/pull/105)), which forwards `Publish` +rootward as well as `Broadcast` leafward. The everyone-publishes rows above — group chat, +videoconference, troll-box — are the ones that need it. + +The implicit rows and history are specified in bps-implicit-publisher and bps-history +respectively — with the split that **this** SWIP fixes *who* an implicit publisher is (the +binding-to-SOC-shape table above, and the cardinality that follows from it), because that is +validation the broker cannot operate without, while bps-implicit-publisher keeps the +event-sourcing mechanism built on top. ## Rationale: why not gossipsub @@ -299,7 +562,85 @@ against the topic binding), so brokers and relays forward without being trusted intermediate can withhold, never forge; and withholding is a liveness fault recoverable by re-pointing or relocating the topic. Multihop forwarding (bps-multihop) adds capacity without reintroducing flooding: every edge still pays upstream, every node still receives -only its topic's stream. +only its topic's stream — and publishing from depth > 1 is metered the same way, priced by +depth (bps-bw-incentives). + +**And in the happy case the tree wins on traffic, not only on trust.** A publish in a +multihop cohort travels **rootward** from wherever it originates and then **leafward** to +everyone: each edge carries the message **exactly once**. A single-parented tree therefore +needs no duplicate suppression at all — no seen-set, no IHAVE/IWANT pull-recovery, no +mesh-degree multiplier applied at every hop. Gossipsub pays D copies per node by +construction and recovers the remainder by asking. Where the tree is well matched to the +underlay — a **closely knit topology**, peers whose tree edges are also their short paths — +rootward-then-leafward is simply the cheaper delivery, and a publisher sitting at depth d +pays those d hops once, on the way up. Duplicates in BPS are a deliberate purchase rather +than a structural cost: dual parenting in +[SWIP-61](https://github.com/ethersphere/SWIPs/pull/105) buys withholding-masking with a +second copy, and that is the case in which the dedup horizon above earns its keep. + +**The concession.** Where an application genuinely wants *gossip* — a large symmetric +cohort with no publisher structure, every member a source, message-level flooding the +point, and no interest in who signed what — **libp2p gossipsub is the better tool and the +application should simply use it.** BPS is not trying to win that comparison. It earns its +keep where the cohort has shape: authorship that is structurally authenticated (SOC-signed +against the topic binding, verifiable regardless of path, so an intermediate can withhold +but never forge), edges that are bounded and metered, messages that are chunks and so +re-fetchable from storage, and a `CohortSpec` that states who may write. The implicit cohort +exists for symmetric groups that want *those* properties — a group chat whose messages are +verifiable signed chunks — not to reimplement a mesh. + +## Security considerations + +**The admin is authenticated, and so is the cohort.** `admin` is a public address, and the +genesis service message is that address's own signature over the spec it is claimed to have +opened. A broker therefore cannot invent a cohort in somebody's name, nor serve a spec its +admin never signed. Nothing else in the handshake needs to be trusted, because the roster +arrives the same way — signed by the admin, on a feed whose gaps are visible. + +**The publisher role is proved, not asserted.** `Auth` carries a signature and no address: +the owner is recovered from it, so presenting it is possession of a key. The preimage is +static and carries **no node identity** — deliberately. Binding it to the libp2p peer id +would make it 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, not +its node's. + +The accepted consequence: a static preimage is **replayable**, and a replayed role is +worthless. Which is the deeper point — + +**Defence in depth is the real guarantee.** Even a peer that obtains the publisher role gains +nothing by it: every message is validated at `Publish` against the SOC signature and the +current roster (or, for an implicit cohort, the binding's SOC shape). `Auth` spares the +broker from carrying peers whose frames could only ever be dropped; **authorship rests on the +message signature, never on the handshake.** A **challenge round trip** is therefore not +specified: it would cost a frame in an otherwise one-each-way establishment to harden a +credential that grants nothing on its own. + +**Audience control exists in exactly one form, and it is not confidentiality.** +`spectators: false` refuses a joiner outside the roster, and is enforceable because `Auth` is +recovered rather than asserted. It bounds *attendance at this broker*, nothing more. **BPS +provides no confidentiality at any layer**: the broker sees every message in plaintext, and so +does everyone it admits. Applications needing a bounded audience **encrypt payloads** — SOC +wrapping is orthogonal to payload encryption, and key distribution is the application's +business. A jam is private because it encrypts, not because it refuses spectators. + +**Revocation is announced before it is enforced, and the announcement is what makes +enforcement legitimate.** Between an admin's revocation and the reduced roster reaching +subscribers, the revoked peer cannot know its status has changed: its frames are dropped and +tolerated, with no penalty and no teardown, because it is not misbehaving. Once the roster is +published the peer has been told — on the same feed as everyone else — so publishing after +that is a protocol violation and the connection is broken. A broker that disconnected first +would be punishing a peer for a rule it had not been given; a broker that never publishes the +roster leaves the violation unable to begin at all, which is an ordinary, visible withholding +fault. Announcing first also makes the revocation legible to the rest of the cohort, which +learns *why* a publisher fell silent from an admin-signed message rather than from an +unattributable disconnection. + +**Resource bounds are broker policy, and all three are required.** A conformant broker +bounds its per-topic stream count (`FULL`), the number of cohorts it will create (`Open` is +otherwise an unbounded allocation primitive for any peer), and its dedup window (see the +horizon note above). The bounded dedup window admits replay of an evicted message by an +already-legitimate publisher: a cohort-internal nuisance, not a break of authorship. ## Out of scope (deliberately) @@ -307,9 +648,9 @@ Multihop relaying and referral (bps-multihop), reorganisation policies (SWATCH, policy SWIPs over this protocol's events and actions, no new frames), bandwidth incentives (bps-bw-incentives), broker discovery (SWIP-59 MEX; early deployments hardcode brokers), history delivery mechanism (bps-history), implicit-publisher event sourcing -(bps-implicit-publisher), and **dynamic publisher-list changes** — grants/revocations -after genesis are deferred to a later revision; the `EXPLICIT_LIST` set is fixed at -`Open`. +(bps-implicit-publisher), and **confidentiality of any kind** — encrypt payloads, see +Security considerations. Dynamic publisher lists are **no longer out of scope**: grants and +revocations are the service feed's business, and neither changes the cohort. ## Conformance (definition of done) @@ -317,13 +658,27 @@ An implementation is conformant when: 1. a broker enforces its per-topic capacity, publisher legitimacy, per-binding validation and dedup; -2. a subscriber re-verifies every message end-to-end (against the `Ack`-echoed - `CohortSpec`) and detects (only) liveness faults; -3. the two worked configurations above interoperate across independent implementations - against the frames in [bps.proto](assets/swip-60/bps.proto); +2. a subscriber re-verifies every message end-to-end — against the `Ack`-echoed + `CohortSpec`, itself checked against the admin-signed genesis SOC — and detects (only) + liveness faults; +3. the **five** configurations above — jam, spectator-jam, live-stream, group-chat and + implicit — interoperate across independent implementations against the frames in + [bps.proto](assets/swip-60/bps.proto); 4. a `FULL` refusal is issued at capacity — and nothing else is (no referral); -5. the WS bridge round-trips both worked configurations end to end — open, publish, - subscribe — with all signing on the client side (the node holds no publisher keys). +5. the WS bridge round-trips each worked configuration end to end — open, publish, + subscribe — with all signing on the client side (the node holds no publisher keys); +6. the handshake is read from the `Hello` envelope, never guessed from the frame body; +7. an absent `admin` is treated as implicit authorship — validated strictly per the + binding's SOC shape — and a present one authenticated by the genesis service message, + whose signature MUST recover to it; +8. `Auth` is verified by recovery over `H("bps-join:v1" ‖ topic ‖ admin)`, and a joiner + outside the roster is admitted read-only where `spectators` is true and `REJECTED` where + it is false — the only refusal for identity in the protocol; +9. an admin grants and revokes by publishing `ROSTER` service messages; a revoked + publisher's frames are **dropped and tolerated** until the reduced roster is published, + and its connection is broken only if it publishes **after** that point; +10. a subscriber takes the roster from the admin's service feed, never from the broker, and + treats an index gap in that feed as a liveness fault. ## Backwards compatibility From 87f6b714fbb807fea83c4b9488876abf5d2a52d3 Mon Sep 17 00:00:00 2001 From: zelig Date: Sun, 30 Aug 2026 07:31:04 +0200 Subject: [PATCH 9/9] swip-60: split type/category per SWIP-0 SWIP-0 specifies `type: Standards Track` with the subcategory in a separate `category:` header (one of Core / Networking / Interface), as swip-19 and swip-20 do. This file carried the category inside `type:`, which is the only form in the repo and may break tooling that parses the front matter. Co-Authored-By: Claude Opus 5 --- SWIPs/swip-60.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/SWIPs/swip-60.md b/SWIPs/swip-60.md index 97182c13..a47eea35 100644 --- a/SWIPs/swip-60.md +++ b/SWIPs/swip-60.md @@ -4,7 +4,8 @@ title: BPS singlehop — brokered broadcast pub/sub, base protocol 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-03 ---