Repository navigation
Conversation
|
Review against SWIP-74 rev 3 (ethersphere/SWIPs #111), which now specifies the challenge–response claim this PR started from. The findings were checked adversarially against the code, the spec and bee's p2p wrapper on master. (Edited: cursor semantics, test flow and counter list aligned with SWIP-74 rev 3 as pushed.) State of the PR. Seven commits 2026-09-12..24; 424 lines of non-generated Go and HeadlineThe scaffold follows SWIP-74 in three places and diverges from it in one that matters; Follows: one create-or-attach join ( Diverges — and the divergence is the right one. The publisher role is claimed by a
Lifecycle: the broker handler is fire-and-forget — it spawns the per-stream goroutine Wire, message by message
The takeover semantics in the Streams and state
Not started, and expected not to beValidation on the publish path (id substitution, SOC check with the address, owner == Tests
Small things
What to ask for
Open
|
Round 2, against the six commits since the review (d28ced3..14b89a5) and the seven "design decisions" from our chat, checked against SWIP-74 rev 4 (#111), which since yesterday carries the claim as a SOC — your idea, with the spec's fields inside it. Where a point was already in the review above and the code has not changed, it is only listed as still open. What improved
The scheme, decision by decision
The arguments in chat
The code
Where the two designs meet — a construction for the SWIPacud's best idea is to carry the claim as a SOC body so that one existing call verifies The broker (or any receiver of a claim) computes the expected address from the id it A point for the specThe same reasoning shows the What to ask acud
|
zelig
left a comment
There was a problem hiding this comment.
Overall, this is not what we settled on.
Please read the spec and keep to it, lets get rid of all other complications.
|
Round 3, against The code follows the spec now — frames and numbers, the id derivation, the five checks and their outcomes, pending streams, the six bounds, the counters — and I have gone through every open thread: 45 are resolved above as addressed or withdrawn (the cohort-derived challenge among them: @acud was right, a challenge derived from a cohort value is the same on every stream of the admin and every subscriber sees it, so anyone could replay one captured frame and hold the publisher identity), 8 stay open with a note. What a review of
On the spec side, rev 9 takes the implementation's names and settles what the code exposed: |
2873d26 to
e6803b7
Compare
There was a problem hiding this comment.
🔵 Needs a closer look
It introduces a large new p2p protocol, broker concurrency model, and public WebSocket API whose safety depends on subtle concurrency and crypto-validation invariants that warrant human review.
1 open finding
What changed in this PR
This PR adds BPS-lite (SWIP-74), a single-publisher live pub/sub stream over a feed brokered through one node, one hop. It introduces a new pkg/bps protocol package (p2p broker + client session, wire protobufs, metrics, SOC id/verification), and a new WebSocket HTTP API (/bps/subscribe/..., /bps/publish/...) in pkg/api, wired into node bootstrap and documented in the OpenAPI spec. Full nodes act as brokers (protocol registered only in FullNodeMode); any node can join a remote broker as a subscriber or publisher. The broker enforces capacity bounds, blocklists protocol violators, and exposes per-cohort/Prometheus metrics.
Changes:
- New
pkg/bpspackage: broker/session state machine, cohort/role management, DoS bounds, SOC-based challenge/verify, metrics, and protobuf wire format. - New
pkg/api/bps.goWebSocket bridge (subscribe/publish) with frame parsing, close codes (4001/4002/4003), auth timeout, and request validation; wired viaapi.go,router.go,export_test.go. - Node wiring in
pkg/node/node.go, OpenAPI docs, and comprehensive unit tests for both the protocol and the API.
| File | Description |
|---|---|
| pkg/bps/bps.go | Core broker/session logic, cohorts, roles, bounds, handshake, frame handling |
| pkg/bps/id.go | Session-topic/id derivation and SOC verify for untrusted frames |
| pkg/bps/metrics.go | Per-cohort counters and Prometheus collectors |
| pkg/bps/pb/* | Protobuf definitions and generated wire code |
| pkg/bps/{bps,export,main}_test.go | Protocol unit tests + goleak + exported test hooks |
| pkg/api/bps.go | WebSocket subscribe/publish handlers, validation, close codes |
| pkg/api/bps_test.go | API handler tests with fake bps service/session |
| pkg/api/{api,router,export_test,api_test}.go | Service wiring, routes, test hooks/options |
| pkg/api/status_test.go | Removes a stray blank line (formatting) |
| pkg/node/node.go | Constructs bps service, registers protocol (full node), metrics, closer |
| openapi/Swarm.yaml | Documents the two new WebSocket endpoints |
One convention issue was filed: the new package validates untrusted peer input but lacks the fuzz target the repo consistently ships for such packages. The core concurrency (per-iteration frame allocation avoiding pointer aliasing, ctx cancellation ordering around the writer goroutine, bounds/cleanup paths) and SOC validation (bounds-safe via soc.FromChunk) otherwise appear sound.
Files not reviewed (1)
- pkg/bps/pb/bps.pb.go: Generated file
🧠 Review effort: Balanced
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| return nil, fmt.Errorf("challenge length %d: %w", len(ack.Challenge), errProtocolError) | ||
| } | ||
|
|
||
| ctx, cancel := context.WithCancel(context.Background()) |
There was a problem hiding this comment.
might be better to also have a root context on the component level which can be cancelled and that the cancels all the downstream child contexts
There was a problem hiding this comment.
Lets give the service a root context that Close cancels and derive every session's context from it.
Or?
| counters counters | ||
|
|
||
| mtx sync.Mutex | ||
| cursor uint64 // the lowest DATA index accepted next |
There was a problem hiding this comment.
why index a theoretical value?
There was a problem hiding this comment.
"Next" is clean because of the zero state: cursor == 0 means nothing accepted yet, and index 0 is accepted like any other. It is also why index 2⁶⁴−1 is refused: the cursor must never wrap.
There was a problem hiding this comment.
still dont understand 'whhy index a theoretical value?'
There was a problem hiding this comment.
the cursor is actually maintaining an expected value, the next index, not the last accepted. That makes more sense because of the zero value: 0 means nothing accepted yet, and index 0 is accepted like any other, where a last-accepted value would need a sentinel. The field's comment now says so.
(cherry picked from commit 14b89a5)
(cherry picked from commit 2d7adcf)
(cherry picked from commit 0adf704)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> (cherry picked from commit 790d490)
The cursor is set to index+1 on acceptance; the last index would wrap it to 0 and reopen the whole feed. SWIP-74 rev 9 makes that index invalid on every feed; verify now treats it as an invalid SOC, so the broker, the subscriber and the WebSocket bridge all refuse it before the signature check. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> (cherry picked from commit 9583d4e)
A pending stream accepts exactly one frame: its AUTH, the empty chunk at index 0 of the session's AUTH feed. Anything else from it — an update, an unknown kind — is wrong_stream, before any signature is checked. DATA is accepted on a publisher stream only, so the retransmit check no longer depends on the role and nothing upgrades a stream from the data path. An AUTH on a publisher stream is a heartbeat: it counts as activity and does nothing else. An AUTH at another index is an invalid SOC. The WebSocket bridge closes a connection that sends a DATA frame before its AUTH (4002 "expected auth") instead of letting the broker reset and blocklist the node. Tests: pending() and publisher() helpers, a frame before the AUTH, an AUTH at index 1, the heartbeat. SWIP-74 rev 10. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> (cherry picked from commit 90dd563)
…s the owner as a subscriber Review of the AUTH-only change found three places where the bridge could still get its own node reset or blocklisted by the broker, and one stale string: - a frame before the AUTH is refused (4002 "expected auth") before its signature is checked, as the broker's step 1 precedes its step 5; a bad DATA before the AUTH no longer reports 4001; - the bridge's 30 s auth deadline is armed before the join, so it always falls before the broker's and the broker never blocklists this node for a slow dApp; - /bps/subscribe refuses identity == owner with 400: such a stream is a pending publisher stream at the broker, receives nothing, and would be blocklisted at the auth deadline; - errWrongStream reads "frame not what the stream may send", since it now also covers a pending stream's premature frame. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> (cherry picked from commit 51f9682)
The frame carries the index-th update of the session feed: a single-owner chunk whose id is derived from kind, challenge and index. The generated doc comment follows.
Seeded with valid AUTH and DATA chunks and with malformed frames, so that verify never panics on hostile peer input. Co-authored-by: zelig <769725+zelig@users.noreply.github.com>
refuse took any Status, OK included. It now takes the refusal's cause and answers FULL for a capacity bound and REJECTED for anything else, which is what both callers did by hand. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
A full node no longer serves bps cohorts by default. The broker protocol is registered only when the node starts with bps-broker, which requires full-node mode. Subscribing needs no opt-in. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: acud <12988138+acud@users.noreply.github.com>
…tant Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The subscribe socket takes roster=address:index,... — the roster it last received, each grantee with the lowest index of its updates to deliver — and rosterCursor, the lowest roster index to accept, next to the owner's cursor. The node admits by that roster until the broker delivers a later one, so a reconnecting dApp neither replays a grantee's updates nor takes an honest broker's re-sent roster for a rollback. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…t request Review (acud): the broker's Close uses a quit channel; cohort.end is members, and shutdown goes; join returns a leave closure in place of the leave method; a member has a constructor and methods in place of stored closures and sync.Once; reclaim and the frame path use defer and one lock. A stream's handler owns it and is the only code that closes or resets it. Anyone else — a revocation, an EOS, a full queue, an eviction, a reclaim, Close — asks through one of the member's two one-slot channels: a send that never blocks, a repeat that is dropped, a channel never closed, so no ask can panic and the stream ends exactly once; a reset asked for takes precedence. Because an ask never blocks, it is made under the cohort's lock, and the streams to reset are no longer collected and reset after unlocking. The auth and flush deadlines are timers of the handler, so a stuck write cannot hold the flush deadline off. A full queue now counts one queue_reset per stream. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… left The client side gets the broker's treatment. A session's read loop is the one place where it ends: as it returns, it sets the reason, closes Messages and Done and ends the stream, once. Close only asks, by cancelling the context, which stops a read or a delivery in progress. The goroutine that reset the stream on that cancel, a second party ending it, is gone, and with it the sync.Once around the end. granted is closed by the constructor for the principal, and otherwise at the cue by the read loop alone, so its check cannot race its close. forged, a stored closure, is now a method, and the session has a constructor. A Close now closes the stream gracefully, as the read loop's exit always meant to: the watcher's reset used to win the race, and a reset can drop the last frames a publisher wrote before it closed. The test fakes count Resets and Closes under their mutex, and the API's fake session closes as the real one does, by a cancel. TestSessionEnd: a Close (64 at once), an EOS and the broker going away each end the stream once, closed or reset as due. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
zelig
left a comment
There was a problem hiding this comment.
Notes to self / agent
only API etc not core yet
| loop := make(chan struct{}) | ||
| go func() { | ||
| defer close(loop) | ||
| sess.readLoop(st, r) |
There was a problem hiding this comment.
whitebox testing, not sure this is needed
| if err != nil { | ||
| return msg, false, err | ||
| } | ||
| s.mtx.Lock() |
There was a problem hiding this comment.
nit: since we essentially lock on all paths of this switch, maybe it is just better to put the lock + defer at the top.
you just earn the signature recovery time but it is not a lot of wall-clock time
|
|
||
| func (p Position) clone() Position { | ||
| c := Position{Cursors: make(map[string]uint64, len(p.Cursors)), RosterCursor: p.RosterCursor, Ended: p.Ended} | ||
| for k, v := range p.Cursors { |
| for k, v := range p.Cursors { | ||
| c.Cursors[k] = v | ||
| } | ||
| c.Roster = append([][]byte(nil), p.Roster...) |
…shes
The bridge had a subscribe and a publish socket whose loops were nearly the
same, and a publish socket with a seat was a subscribe socket that may talk.
As on the wire, the declared identity now settles what a stream is, so there
is one route, /bps/{owner}/{topic}, with identity required, and one loop:
bpsRun, with bpsHandleOutbox taking what the dApp sends through the session
and bpsHandleInbox bringing what the stream receives to the dApp. The first
reason to stop, kept as the context's cause, decides how the socket closes:
the handlers return errors and bpsCloseCode maps them to codes.
What a frame may be is the session's to say, not the bridge's: Publish runs the
broker's checks in the broker's order, and a roster naming the admin or an
address twice is refused there. Receivers still take such a roster, where it
changes nothing, as SWIP-87 says. Verify and ParseRoster leave the package's
surface, and the challenge check goes: Join has made it already.
Behaviour: an auth timeout has its own code, 4005; a shutdown closes with 1001
instead of an empty close frame; a grant is the text frame "granted"; a stream
other than the owner's has no auth deadline, since it may just listen; every
stream gets the challenge and may pass back its position.
A session's end is Done alone: Messages is not closed, which is safe because it
is unbuffered, so that a delivery is handed over before Done can close. The read
loop inlines its reader and ends by defer, and Done now closes after the stream
has ended.
id.go parses with the soc package rather than by hand and uses swarm's size
constants; a challenge of the wrong length or an index that would overflow the
cursor is ErrInvalidMessage, not ErrInvalidSOC. The broker's counter for them
stays invalid_soc, as SWIP-87 has it. MaxRosterSize is derived from the encoding.
isServiceKind, isEmptyKind and decodeRosterMsg are the new names; the roster
encoder is test-only. Position.clone uses maps.Clone and slices.Clone, and
accept takes its one lock at the top.
Tests: the session's checks and the 186 limit are tested in bps, a roster naming
the admin at the broker and a subscriber, a grant on reconnect; the session's end
is tested through Join; the bridge's tests cover its frames and the mapping.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…OPIC is 1
The one socket is now GET /bps/feeds/subscribe/{principal}/{topic}. BPS sits
over the SOC subscriptions rather than beside them, so the binding is in the
path; and the address there is the principal, since a seat signs its own
updates on its own feed.
identity is now owner everywhere, the proto field included: the owner of a
stream's frames, the wallet address signing its feed update SOCs. It may be
absent, on the wire too: a stream with none is a spectator within the fan-out
bound that no roster can name, so it only listens, and the bridge sends it no
challenge. TestNoOwner covers it, and a roster with an empty entry stays
invalid.
FEED_TOPIC is 1 on the wire; the SWIPs are retrofitted to it. The challenge
message loses its type field, being the only JSON frame. The cursor fields say
what they hold: the next index expected.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

Checklist
Description
Open API Spec Version Changes (if applicable)
Motivation and Context (Optional)
Related Issue (Optional)
Screenshots (if appropriate):
AI Disclosure