go-libp2p-udx
Skill
go-libp2p-udx. Use when adding UDX to a go-libp2p host with github.com/stephanfeb/go-libp2p-udx-transport (package udxtransport): libp2p.Transport(udxtransport.NewTransport), /ip4//udp/ /udx listen and dial addresses, Noise and Yamux over UDX, hole punching, and interop with dart-libp2p and js-libp2p nodes. SKILL.md (Claude Code, Codex, Cursor) · go-libp2p-udx.md (Boubou) · install:
curl -fsSL https://cryptopeer.net/skills/install.sh | sh -s -- go-libp2p-udx
What it is
Section titled “What it is”A go-libp2p transport.Transport that carries libp2p connections over UDX
(go-udx). Each libp2p connection is one UDX connection; its first UDX stream
is handed to go-libp2p’s upgrader as a raw net.Conn, which runs
multistream-select, then Noise, then Yamux on it, as for TCP. This is the
same layering dart-libp2p’s UDX transport and js-libp2p’s
@go-ricochet/libp2p-udx use, which is what makes the three interoperate.
Use it to reach dart-libp2p (Flutter) and js-libp2p nodes over UDP, or to run
the Ricochet server stack. For plain UDX without libp2p, use go-udx (skill
udx-go); for a standard UDP transport between go-libp2p nodes only, QUIC
is the usual choice.
Install
Section titled “Install”go get github.com/stephanfeb/go-libp2p-udx-transport@v0.1.8v0.1.8 requires github.com/libp2p/go-libp2p v0.49.0,
github.com/multiformats/go-multiaddr v0.16.1,
github.com/stephanfeb/go-udx v0.1.3, and declares go 1.25.7.
import ( "github.com/libp2p/go-libp2p" "github.com/libp2p/go-libp2p/core/network" "github.com/libp2p/go-libp2p/p2p/muxer/yamux" "github.com/libp2p/go-libp2p/p2p/security/noise" udxtransport "github.com/stephanfeb/go-libp2p-udx-transport")Importing the package registers the udx multiaddr protocol (code 0x0300,
no value) in its init.
Core workflow
Section titled “Core workflow”A host on UDX
Section titled “A host on UDX”h, err := libp2p.New( libp2p.NoTransports, // or keep the defaults and add UDX libp2p.Transport(udxtransport.NewTransport), libp2p.Security(noise.ID, noise.New), libp2p.Muxer(yamux.ID, yamux.DefaultTransport), libp2p.ListenAddrStrings("/ip4/0.0.0.0/udp/4001/udx", "/ip6/::/udp/4001/udx"),)if err != nil { return err }defer h.Close()NewTransport(key ic.PrivKey, u transport.Upgrader, rcmgr network.ResourceManager)
is an fx constructor: libp2p supplies all three. A nil resource manager
becomes network.NullResourceManager.
Echo protocol
Section titled “Echo protocol”const proto = "/echo/1.0.0"server.SetStreamHandler(proto, func(s network.Stream) { defer s.Close() io.Copy(s, s)})
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)defer cancel()if err := client.Connect(ctx, peer.AddrInfo{ID: server.ID(), Addrs: server.Addrs()}); err != nil { return err}s, err := client.NewStream(ctx, server.ID(), proto)if err != nil { return err }s.Write([]byte("hello over UDX"))s.CloseWrite()reply, _ := io.ReadAll(s)Without the libp2p constructor
Section titled “Without the libp2p constructor”tr, err := udxtransport.NewTransport(priv, upg, nil) // upg from go-libp2p's p2p/net/upgraderif err != nil { return err }defer tr.Close() // closes the shared outbound socketsladdr, _ := ma.NewMultiaddr("/ip4/127.0.0.1/udp/0/udx")ln, err := tr.Listen(laddr) // ln.Multiaddr() has the real portconn, err := tr.Dial(ctx, ln.Multiaddr(), serverPeerID) // a secured, muxed CapableConnThe README’s two-argument NewTransport(priv, nil) and its claim that no
upgrader is needed are out of date; see the gaps list in the Reference section.
Yamux keep-alive matched to UDX’s idle timeout
Section titled “Yamux keep-alive matched to UDX’s idle timeout”ymx := *yamux.DefaultTransportymx.KeepAliveInterval = 15 * time.Second // under UDX's 30 s idle timeoutymx.ConnectionWriteTimeout = 10 * time.Secondlibp2p.Muxer(yamux.ID, &ymx)(The settings go-ricochet uses, from interop_test.go.)
Key concepts / API map
Section titled “Key concepts / API map”NewTransport(key, upgrader, rcmgr) (*Transport, error): the transport.(*Transport).Dial(ctx, raddr, peer.ID): dials, opens UDX stream 0, upgrades with Noise and Yamux.(*Transport).Listen(laddr): bindsudp4orudp6explicitly, returns an upgraded listener.(*Transport).CanDial(addr): true when the address has a/udxcomponent.(*Transport).Protocols():[]int{P_UDX};Proxy(): false;Close(): closes outbound multiplexers.P_UDX = 0x0300: the multiaddr code, shared with dart-libp2p and js-libp2p.- Address form:
/ip4/<ip>/udp/<port>/udxor/ip6/<ip>/udp/<port>/udx, optionally/p2p/<id>. - Internals worth knowing:
rawListenerwraps audx.Multiplexer;streamConnwraps stream 0 as amanet.Conn.
Gotchas
Section titled “Gotchas”- This is not a native-muxing transport. Despite the README and the site overview, UDX streams are not libp2p streams here: the transport opens exactly one UDX stream per connection and Yamux multiplexes above it. You must configure a security transport (Noise) and a muxer (Yamux); other stacks reach you only with Noise and Yamux.
- Dials leave from the listen port. When a listener on the same address
family can reach the target, dials share its socket (as QUIC does with
reuseport), so peers observe the NAT mapping of the listen port and
identify reports a reachable address, and hole punches go out from the
port the peer punches towards. Exceptions use a shared ephemeral socket
per family: go-libp2p’s uncoordinated force-direct pre-dial, a dial to the
listener’s own address, and a non-loopback dial when the only listener is
on loopback (a loopback socket cannot send elsewhere; macOS fails with
EADDRNOTAVAIL). Listen on0.0.0.0/::if you want NAT traversal. - Hole punching with dart-libp2p needs dart_udx 4.0.0 or later. Crossing dials are two UDX connections, one each way; dart_udx 3.x folded them into one connection that answered neither, so punches between Go and older Dart peers fail.
- Idle connections die after 30 s. go-udx closes a connection that has
received nothing for
max(30 s, 3 x PTO), silently. go-udx sends no keep-alives, so rely on Yamux keep-alive (go-yamux’s default interval is 30 s, the same as the idle timeout; use 15 s as above) or libp2p ping. js-libp2p peers ping every 10 s by default. - The pinned go-udx is v0.1.3. The 1200-byte datagram cap arrived in
go-udx v0.1.4, and PMTUD plus RFC 9002 loss recovery in v0.3.0. With
v0.1.3 a host sends stream data in datagrams of up to 1472 bytes, which
mobile carrier tunnels (464XLAT, GTP) drop. To get the newer behaviour,
require a newer go-udx in your own go.mod
(
go get github.com/stephanfeb/go-udx@v0.3.0) and run this repo’s tests against it; the go-udx API the transport uses has not changed. - IP addresses only.
fromUDXMultiaddrreads/ip4or/ip6and/udp;/dns4,/dns6and/dnsaddrare not resolved by the transport. - A closed listener reports
net.ErrClosed(v0.1.6+), which the swarm treats as a normal close. Wrap it the same way if you write a listener. - Accept errors per connection are swallowed. If the first stream never
arrives or the resource manager refuses, the raw listener closes that UDX
connection and keeps accepting. A dialer that never writes (and so never
sends its stream SYN) is never accepted; multistream-select always writes
first, so this matters only for custom code driving
streamConndirectly. - Large transfers are the real test. go-udx once stalled at about
256 KB per stream and every small-message test passed.
bulk_canary_test.gopushes megabytes through Noise and Yamux over one UDX stream for that reason; keep such a test in downstream repos too. - Wire version 3 on every peer. A v2 peer is dropped silently and looks
unreachable. Interop minimums from js-udx’s matrix: dart-libp2p 3.0.0+
(earlier ones miss a Yamux FIN carried on a window update), js-libp2p via
@go-ricochet/libp2p-udx0.1.x, and this transport v0.1.4+. - Browsers. A js-libp2p web app that gets a relay reservation listing
your
/udxaddress must register the codec (0x0300), or js-libp2p rejects the reservation withProtocol 768 was unknown. That is fixed on the browser side (@go-ricochet/libp2p-udx/multiaddr), not here.
Testing
Section titled “Testing”- Two hosts on loopback with
libp2p.NoTransports,libp2p.Transport(NewTransport), Noise, Yamux,libp2p.ListenAddrStrings("/ip4/127.0.0.1/udp/0/udx")andlibp2p.ResourceManager(&network.NullResourceManager{})(host_test.go). Useserver.Addrs()after start; port 0 is resolved. - Put a
context.WithTimeoutonConnectandNewStream, andSetReadDeadlineon streams: a lost path otherwise waits for the 30 s idle timeout. - Transport-level tests build an upgrader with
upgrader.Newandnoise.New(noise.ID, key, muxers)(transport_test.go,createUpgrader). - Hole-punch paths:
network.WithSimultaneousConnectandnetwork.WithForceDirectDialon the dial context (punch_test.go). go test -race ./....interop_test.goruns a Dart echo server from../dart-libp2p(dart run bin/interop_echo_server.dart) and needs Dart and that checkout.- js-udx’s
packages/libp2p-udx/test/interop.test.tsdrives this transport from js-libp2p in both directions (tools/go-libp2p-peer).
Red lines
Section titled “Red lines”- Never change
P_UDX(0x0300) or the address form; dart-libp2p and js-libp2p use the same code. - Never skip the upgrader or hand out stream 0 unsecured: UDX itself has no encryption or peer authentication; Noise provides both.
- Never open additional UDX streams on a transport connection expecting the peer to treat them as libp2p streams; peers only read stream 0.
- Call
Transport.Close()(libp2p does onhost.Close()) so the shared outbound sockets and their goroutines go away. - Do not bump go-udx across a wire version without bumping every peer.
Reference
Section titled “Reference”- Docs: https://cryptopeer.net/udx/go-libp2p-transport/ and https://cryptopeer.net/udx/
- Repo: https://github.com/stephanfeb/go-libp2p-udx-transport
- go-udx: https://cryptopeer.net/udx/go/, https://github.com/stephanfeb/go-udx
- Related skills: udx-go, udx-js, udx-dart
- Known doc gaps (README vs code at v0.1.8):
NewTransporttakes three arguments and requires an upgrader; the files aretransport.go,listener.go,stream_conn.goandmultiaddr.go(noconn.go/stream.go); the licence file is Apache 2.0 while the README says MIT.