Skip to content

libp2p/circuit-relay-v2: a static relay reservation (CircuitListen) is never re-established after the relay connection closes #3601

Description

@Chandelier-02
  • Version:
    Verified in @libp2p/circuit-relay-v2@4.2.11 (with libp2p@3.3.8), and the
    relevant code is unchanged on main at the time of filing
    (packages/transport-circuit-relay-v2/src/transport/index.ts and
    transport/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:

  1. Relay up; client listens on <relayAddr>/p2p/<relayId>/p2p-circuit
  2. Reservation established — client announces .../p2p-circuit (sanity check)
  3. Relay stops; reservation removed
  4. Relay restarts with the same key and port; client dials it again, identify completes
  5. Expected: the /p2p-circuit address comes back
    Actual (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).

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);

Metadata

Metadata

Assignees

No one assigned

    Labels

    kind/bugA bug in existing code (including security flaws)

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions