If you give libp2p a static relay in your listen addresses (/dns4/relay/.../p2p/Qm.../p2p-circuit), the reservation only ever gets made once. When the relay restarts (or the connection drops for any reason), the client reconnects fine but never re-reserves, so it's unreachable through the relay until you restart the whole process. There's no error either, it just silently stays broken. The internal re-add path assumes every relay was found via discovery and throws HadEnoughRelaysError for static ones, then swallows it. Bonus problem: if the relay happens to be down when your node boots, start() just throws. Repro script attached, ~100 lines, fails on 4.2.11 and current main.
import { createLibp2p } from "libp2p";
import { webSockets } from "@libp2p/websockets";
import { noise } from "@chainsafe/libp2p-noise";
import { yamux } from "@chainsafe/libp2p-yamux";
import { identify } from "@libp2p/identify";
import { circuitRelayServer, circuitRelayTransport } from "@libp2p/circuit-relay-v2";
import { generateKeyPair } from "@libp2p/crypto/keys";
import { peerIdFromPrivateKey } from "@libp2p/peer-id";
import { multiaddr } from "@multiformats/multiaddr";
const RELAY_PORT = 40881;
function makeRelay(privateKey) {
return createLibp2p({
privateKey,
addresses: { listen: [`/ip4/127.0.0.1/tcp/${RELAY_PORT}/ws`] },
transports: [webSockets()],
connectionEncrypters: [noise()],
streamMuxers: [yamux()],
services: {
identify: identify(),
relay: circuitRelayServer(),
},
});
}
function makeClient(relayListenAddr) {
return createLibp2p({
addresses: { listen: [relayListenAddr] },
transports: [webSockets(), circuitRelayTransport()],
connectionEncrypters: [noise()],
streamMuxers: [yamux()],
services: {
identify: identify(),
},
});
}
const hasCircuitAddr = (node) =>
node.getMultiaddrs().some((ma) => ma.toString().includes("/p2p-circuit"));
async function pollFor(label, predicate, timeoutMs) {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
if (predicate()) {
return true;
}
await new Promise((resolve) => setTimeout(resolve, 250));
}
return false;
}
const results = [];
const report = (name, ok, detail) => {
results.push({ name, ok });
console.log(`${ok ? " PASS" : " FAIL"} ${name}${detail ? ` — ${detail}` : ""}`);
};
// ---------------------------------------------------------------- scenario A
console.log("scenario A: reservation is never re-established after relay restart");
const relayKey = await generateKeyPair("Ed25519");
const relayId = peerIdFromPrivateKey(relayKey);
const relayWsAddr = `/ip4/127.0.0.1/tcp/${RELAY_PORT}/ws/p2p/${relayId}`;
const relayListenAddr = `${relayWsAddr}/p2p-circuit`;
let relay = await makeRelay(relayKey);
const client = await makeClient(relayListenAddr);
report(
"initial reservation established",
await pollFor("reserve", () => hasCircuitAddr(client), 10_000),
client.getMultiaddrs().map(String).join(", ") || "no addresses announced",
);
await relay.stop();
report(
"reservation withdrawn after relay stops",
await pollFor("withdraw", () => !hasCircuitAddr(client), 10_000),
);
relay = await makeRelay(relayKey);
await client.dial(multiaddr(relayWsAddr));
const reconnected = await pollFor(
"reconnect",
() => client.getConnections(relayId).length > 0,
10_000,
);
report("client reconnected to the restarted relay", reconnected);
// Give the topology/identify/relay:discover path every opportunity to heal it.
const healed = await pollFor("re-reserve", () => hasCircuitAddr(client), 30_000);
if (healed) {
report("reservation re-established after reconnect", true, "bug NOT reproduced on this version");
} else {
report(
"BUG REPRODUCED: connected to the relay for 30s, reservation never re-established",
true,
`announced: [${client.getMultiaddrs().map(String).join(", ") || "nothing"}]`,
);
}
await client.stop();
await relay.stop();
// ---------------------------------------------------------------- scenario B
console.log("scenario B: node start() fails outright when the configured relay is down");
try {
const orphan = await makeClient(relayListenAddr); // relay is stopped now
await orphan.stop();
report("BUG NOT REPRODUCED: start() succeeded with the relay down", true);
} catch (error) {
report(
`BUG REPRODUCED: start() threw with the relay down`,
true,
`${error.name}: ${String(error.message).slice(0, 120)}`,
);
}
console.log(`\n${results.every((r) => r.ok) ? "all checks completed" : "CHECKS FAILED"}`);
process.exit(results.every((r) => r.ok) ? 0 : 1);
Version:
Verified in
@libp2p/circuit-relay-v2@4.2.11(withlibp2p@3.3.8), and therelevant code is unchanged on
mainat the time of filing(
packages/transport-circuit-relay-v2/src/transport/index.tsandtransport/listener.ts).Platform:
Linux 7.0.0-29-generic Remove word below #29-Ubuntu SMP PREEMPT_DYNAMIC Fri Jul 17 20:52:35 UTC 2026 x86_64 GNU/Linux
Subsystem:
circuit-relay-v2
Severity:
High
Description:
If you give libp2p a static relay in your listen addresses (/dns4/relay/.../p2p/Qm.../p2p-circuit), the reservation only ever gets made once. When the relay restarts (or the connection drops for any reason), the client reconnects fine but never re-reserves, so it's unreachable through the relay until you restart the whole process. There's no error either, it just silently stays broken. The internal re-add path assumes every relay was found via discovery and throws HadEnoughRelaysError for static ones, then swallows it. Bonus problem: if the relay happens to be down when your node boots, start() just throws. Repro script attached, ~100 lines, fails on 4.2.11 and current main.
Steps to reproduce the error:
Scenario A — the headline bug:
<relayAddr>/p2p/<relayId>/p2p-circuit.../p2p-circuit(sanity check)/p2p-circuitaddress comes backActual (bug): it never does
Scenario B — the boot-time sharp edge:
With the relay down, a node configured with the same listen address fails
start()outright (no retry exists).