Skip to content

feat(p2p): add a TCP (noise + yamux) transport alongside QUIC - #600

Open
MegaRedHand wants to merge 2 commits into
mainfrom
feat/tcp-noise-yamux
Open

feat(p2p): add a TCP (noise + yamux) transport alongside QUIC#600
MegaRedHand wants to merge 2 commits into
mainfrom
feat/tcp-noise-yamux

Conversation

@MegaRedHand

@MegaRedHand MegaRedHand commented Aug 31, 2026

Copy link
Copy Markdown
Collaborator

What changes

A libp2p TCP transport (noise + yamux) is added alongside QUIC, bound to the same port number as the existing QUIC listener. TCP and UDP are separate namespaces, so one --gossipsub-port still names both.

The reason it matters: a peer that advertises a quic entry nothing answers on leaves a QUIC-only node with no second address to try. The dial times out and the peer is never reached. With both transports offered, libp2p races a peer's quic and tcp addresses within one dial attempt.

Area Before After
Swarm transports QUIC only .with_tcp(nodelay, noise, yamux) then .with_quic()
Listeners /udp/<port>/quic-v1 that, plus /tcp/<port>
Admission quic entry required (NoQuicPort) quic or tcp (NoDialableTransport)
Dial target one Multiaddr DialOpts with the full list, QUIC first
Bootnode quic_port, udp_port plus tcp_port, same "port 0 is absent" rule
Local ENR ip/udp/quic/secp256k1/eth2/attnets plus tcp
as_discovery_node hands ethrex tcp_port: 0 hands it the real advertised port

SwarmCommand::Dial and SwarmHandle::dial now carry a DialOpts (peer id plus address list) rather than a single Multiaddr, since that list is what makes the per-dial race possible. Static bootnode dialing, bootnode redialing, and the discovery dial loop all go through it.

"Peer connected" gains a transport field, read off the connection's own multiaddr rather than off which address we dialed, so a live run can show which path actually carried it.

Advertising tcp also clears lighthouse's discovery predicate, which requires enr.tcp4().is_some() || enr.tcp6().is_some() on top of the fork_digest comparison. That is only half the story: the lean fork digest is still the cross-client dummy 0x12345678, so a beacon-chain client rejects us on that instead. The docs say so rather than claiming the gap is closed.

No manifest change. The pinned libp2p fork (2f14d0ec, features = ["full"]) already carries libp2p-tcp, libp2p-noise and libp2p-yamux.

Tests

  • Four new admission cases: tcp-only, quic-only, both (asserting QUIC is ordered first), and each transport at port 0 with the other absent.
  • two_swarms_connect_over_tcp builds two real swarms through build_swarm (port 0, so it cannot collide with a running node), learns the listener's TCP address off its own NewListenAddr, dials it, and polls both until each reports ConnectionEstablished over tcp. A regression to QUIC-only hangs to the 10s timeout rather than passing.
  • Two existing tests are inverted along with the decision they pinned: local_enr_advertises_udp_and_quic_but_no_tcplocal_enr_advertises_udp_quic_and_tcp, and the two NoQuicPort rejections are renamed to say "and no tcp".

make lint clean; make test green, spec fixtures included (122 forkchoice, 119 SSZ).

Deployment note

Nodes now bind an extra TCP port at --gossipsub-port and advertise it in their ENR. A containerised devnet that maps only -p 9001:9001/udp will publish a tcp entry that is unreachable from outside. QUIC is dialed first and still works, so this degrades to today's behaviour rather than breaking, but adding the TCP mapping is what makes the fallback usable.

A peer that advertises a `quic` entry nothing answers on leaves a
QUIC-only node with no second address to try: the dial times out and the
peer is never reached. Add a TCP transport (noise + yamux) alongside
QUIC, bound to the same port number as the existing QUIC listener, so
libp2p can race a peer's `tcp` address in the same dial attempt when its
`quic` does not answer. TCP and UDP are separate namespaces, so one
`--gossipsub-port` still names both listeners.

Admission now accepts a peer advertising `quic`, `tcp`, or both, and
orders the resulting dial list QUIC first so it stays the preferred
path; it rejects only when neither is present, which renames
`NoQuicPort` to `NoDialableTransport`. `SwarmCommand::Dial` and
`SwarmHandle::dial` carry a full `DialOpts` (peer id plus address list)
rather than a single `Multiaddr`, since that list is what makes the
per-dial race possible. Static bootnode dialing, bootnode redialing and
the discovery dial loop all go through it. `Bootnode` gains a
`tcp_port`, read from the ENR under the same "a `0` port is absent"
rule the other two follow, and `as_discovery_node` now hands ethrex the
real port instead of the hardcoded `0`.

The local ENR advertises `tcp` too, at the same port number, so a peer
applying the same rule we do can reach us over the transport that just
landed. That also clears lighthouse's discovery predicate, which
requires `enr.tcp4().is_some() || enr.tcp6().is_some()` on top of the
`fork_digest` comparison; the lean digest is still the cross-client
dummy, so a beacon-chain client rejects us on that instead.

"Peer connected" gains a `transport` field, read off the connection's
own multiaddr rather than off which address we dialed, so a live run can
show which path actually carried it.

The libp2p fork already exposes what this needs: a `.with_tcp(...)`
insertion ahead of `.with_quic()` in the SwarmBuilder chain, no manifest
change.

Tests: admission cases for tcp-only, quic-only, both (QUIC first), and
each transport at port 0 with the other absent; a new test builds two
real swarms through `build_swarm` and proves they complete a TCP
connection end to end, so a regression to QUIC-only hangs to the timeout
rather than passing.
discovery.md: the ENR entry table lists `tcp` alongside `quic` and
`udp`, with a note on why they share a port number and what advertising
`tcp` buys against lighthouse's discovery predicate. The "which peers
get dialed" rule accepts either transport, a paragraph explains that a
dial carries every address at once rather than one per retry, and the
bootnode table drops the old "quic absent implies seed-only" framing
since `tcp` can now carry a bootnode too.

CLAUDE.md: the Networking section's transport line mentions TCP, and the
discovery bullets track the new ENR entry and admission rule.
@github-actions

Copy link
Copy Markdown

🤖 Kimi Code Review

⚠️ Review failed: Kimi API request failed with status 404: Not found the model kimi-k2.5 or Permission denied


Automated review by Kimi (Moonshot AI)

@github-actions

Copy link
Copy Markdown

🤖 Codex Code Review

No blocking findings.

  • crates/net/p2p/src/discovery/mod.rs:3-6 is now stale: it still says discovered peers are dialed “over libp2p QUIC” and that static bootnode dialing is untouched, but this PR changes both paths to race QUIC and TCP. Updating that comment would avoid future confusion.
  • crates/net/p2p/src/discovery/mod.rs:181-184 is also slightly stale: it still describes the advertised ENR as carrying eth2, attnets, and quic, but tcp is now part of the published record too.

The actual transport change looks internally consistent: ENR generation advertises tcp, admission accepts tcp-only peers, static bootnode dialing and discovery dialing both use a single DialOpts with QUIC-first ordering, and the existing connected_peers guard still prevents a failed raced address from tearing down bookkeeping for a live connection.

I could not run cargo test -p ethlambda-p2p here. cargo/rustup tries to write under /home/runner/.rustup and /home/runner/.cargo, which are read-only in this environment, and switching to a writable cargo home would still require fetching an unfetched git dependency (leanSig) with network access unavailable.


Automated review by OpenAI Codex · gpt-5.4 · custom prompt

@github-actions

Copy link
Copy Markdown

🤖 Claude Code Review

Review: PR 600 — TCP transport alongside QUIC

Overall this is a careful, well-tested port. Admission, dial-target construction, ENR handling, and bootnode parsing were all updated consistently — I couldn't find a stale reference to the old NoQuicPort variant or single-Multiaddr Bootnode.addr anywhere in the crate. The Vec::with_capacity(2) / QUIC-first ordering in both admit() (crates/net/p2p/src/discovery/admission.rs:173-179) and bootnode_dial_addrs() (crates/net/p2p/src/lib.rs:657-666) is duplicated but consistent, and the port-0-means-absent rule is applied uniformly to both transports. The new two_swarms_connect_over_tcp integration test is a real end-to-end check (not just a compile-time proof), which is the right level of rigor for a transport change.

Findings, in descending order of severity:

  1. --gossipsub-port CLI help text is now inaccurate (bin/ethlambda/src/cli.rs:24-27). The doc comment still reads "UDP port for the libp2p QUIC listener," with no mention that it now also binds a TCP listener on the same port number. docs/discovery.md and CLAUDE.md were both updated to describe the dual-transport bind, but the flag's own --help text — the first place an operator looks — wasn't. Worth a one-line addition so ethlambda --help doesn't undersell what the flag does (e.g. for the deployment-note scenario in the PR body, where the fix is adding a TCP port mapping).

  2. The core benefit depends on the pinned libp2p fork's dial concurrency, which isn't verified here. The whole design rests on the claim that "libp2p races a peer's quic and tcp addresses within one dial attempt" (PR body, and the comments at crates/net/p2p/src/discovery/dial.rs:113-115 and crates/net/p2p/src/lib.rs:333-335). Upstream libp2p does this via dial_concurrency_factor (default 8, concurrent), but this project pins a custom LambdaClass fork (2f14d0ec). If that fork's Swarm::dial happens to try addresses sequentially with a full per-address timeout instead, the fallback still works correctly but takes much longer to kick in than the docs imply. Given how central this assumption is to the PR's stated motivation, it'd be worth a quick confirmation against the fork's dial_opts/pool code (or an integration test that fails a first address and asserts the second connects well under a full dial timeout) rather than relying on upstream libp2p's documented behavior.

  3. Minor grammar nit in the new error message at crates/net/p2p/src/lib.rs:639: "node advertises neither a quic, tcp nor a udp port" reads awkwardly — "node advertises none of quic, tcp, or udp" would parse more cleanly. Cosmetic only.

  4. Expanded attack surface is acknowledged but worth double-checking operationally, not a code defect: this opens a second raw listener (TCP+noise+yamux) on every node, in addition to QUIC. The PR's "Deployment note" already flags the devnet port-mapping implication; the same applies to any firewall/security-group rules gating the existing QUIC UDP port — those now need a matching TCP rule for the fallback to actually be reachable from outside, not just bound locally.

No issues found in the fork-choice/attestation/state-transition/signature code — this PR doesn't touch any of it, as expected for a pure networking change.


Automated review by Claude (Anthropic) · sonnet · custom prompt

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant