Simulate a DoS-protected Mix network with allocated RLN memberships

What the user achieves

A five-node docker-compose mixnet is running against the hosted LEZ testnet in which one funded node (the gifter) registered its own RLN membership on-chain and then allocated distinct on-chain memberships to the four other nodes — none of which ever held funds or signed a transaction — proven by bidirectional request/reply traffic through a 3-hop Sphinx mix with RLN spam protection verified at every hop.

Why it matters

Self-service RLN registration is a barrier to entry — it needs funds, gas, and
chain access, and links a user’s network identity to their RLN identity — and
LIP-158 removes it: a membership provider registers the client’s identity
commitment on the client’s behalf without ever learning the client’s secret, so
it cannot forge the client’s proofs. This journey proves that primitive end to end
on live infrastructure (real sequencer transactions, nothing mocked), in the
setting that needs it most: a mixnet where every relay must be a member before it
can forward a single packet (LIP-144).

Key components

All five nodes run the same logoscore daemon image (wallet + RLN + libp2p/mix
modules); their roles differ only in how the orchestrator drives them.

  • relay1 — the gifter — holds the sim’s only funded wallet; it registers
    its own RLN membership on-chain, then mounts the LIP-158 allocation service
    (/logos/rln/membership/1.0.0, EIP-191 allowlist) and funds + signs the
    registrations for the other four nodes, while also serving as a mix relay.
  • relay2, relay3 — mix relays — gifted clients whose job is forwarding:
    together with relay1 they form the 3-hop Sphinx paths, and each hop verifies
    the incoming RLN proof and regenerates a fresh one for the next hop — which is
    why relays need memberships at all.
  • sender, dest — traffic endpoints — gifted clients that originate the
    bidirectional request/reply round-trips through the mix (one RLN proof per
    originated message); the sender is also the subject of the negative runs
    (unregistered / refused → zero delivery).
  • The hosted LEZ testnet (external service, https://testnet.lez.logos.co/)
    — where the RLN group actually lives: registrations are real sequencer
    transactions that spend RLNTOK and grow the on-chain Merkle tree that every
    node’s wallet syncs and every proof commits to.
  • The orchestrator (orchestrate.sh, host-side) — drives all five daemons
    over logoscore call: per-node setup, the serialized registration barriers,
    mix meshing, the exchange, and the final verdict scraped from daemon logs.

Repository

Runtime target

testnet v0.2

Prerequisites

  • Linux or macOS with Docker running (compose v2 plugin; ~30 GB free in its
    VM — the image is ~17 GB).
  • Host tools: bash, git, python3 (stdlib only), curl, rsync — all
    preinstalled on stock macOS and most Linux distros.
  • Outbound network to GitHub, the Nix caches, and the LEZ testnet RPC
    (https://testnet.lez.logos.co/). No offline mode.
  • No accounts, keys, or toolchain — everything builds inside Docker. The
    checked-in EIP-191 fixtures are demo keys, NOT for production.

Commands and expected outputs

# 1. Clone and bootstrap (clone 4 sibling repos + build the .lgx + the image;
#    ~30-45 min first run, fast on re-runs)
git clone https://github.com/logos-co/logos-rln-mix-sim.git
cd logos-rln-mix-sim
bash docker/testnet/mix_e2e/bootstrap.sh

# 2. Run the full E2E (~15 min; the 5 sequential on-chain registrations dominate)
cd docker/testnet/mix_e2e
bash orchestrate.sh

# 3. Tear down
docker compose down

Real output of a passing run (peer IDs, leaf indices, and block heights vary per
run; section headers, log strings, counts, and the verdict are stable):

=== up: 5 daemons (force-recreate for FRESH daemons) ===
  config=FUhP8quu5WKEL33oALSgDnXq9JZ8Qx72en7zSrzmPrDC holding(funder)=9xhSHTkuFj8m4BbB1QA5W3pQHAeiRMkQdk4TGG8fCZz4
=== per-node setup (load chain -> wallet+rln -> start -> mixSetNodeInfo -> peerInfo -> register) ===
  relay1 wallet synced to 3385
  relay1 peerId=16Uiu2HAm3c6...  leaf_opt=58 leaf_actual=58 confirmed=true rlnIsReady=True
  relay1 gifter service mounted (/logos/rln/membership/1.0.0, allowlist=4 clients)
  relay2 peerId=16Uiu2HAmCCm...  leaf_opt=59 leaf_actual=59 confirmed=true rlnIsReady=True
  relay3 peerId=16Uiu2HAmUqo...  leaf_opt=60 leaf_actual=60 confirmed=true rlnIsReady=True
  dest   peerId=16Uiu2HAmGJr...  leaf_opt=61 leaf_actual=61 confirmed=true rlnIsReady=True
  sender peerId=16Uiu2HAmMSU...  leaf_opt=62 leaf_actual=62 confirmed=true rlnIsReady=True
=== mesh: every node adds the other 4 ===
  meshed.
=== rlnIsReady status (each node was confirmed ready before the next registered) ===
   relay1=True relay2=True relay3=True dest=True sender=True
=== root convergence: wait until every node's valid-roots window includes the final root ===
  valid-roots converged across all 5 nodes; settling one epoch for verifier windows
=== register dest-read-behavior on all nodes (the SURB exit is random) ===
  registered (/ipfs/ping/1.0.0, READ_EXACTLY, 32 bytes)
=== exchange: 3 request/reply round-trip(s) per initiator ===
  sender->dest: 3/3 replies received
  dest->sender: 3/3 replies received
=== observe: RLN proofs (forward request + SURB reply legs) ===
  relay1: generated=8 verified=8
  relay2: generated=11 verified=11
  relay3: generated=11 verified=11
  dest: generated=3 verified=3
  sender: generated=3 verified=3
  replies: sender->dest=3 dest->sender=3 ; total verifications=36 ; sender proofs=3
  gifter(relay1): 'RLN gifter registration succeeded' x4 (expect 4 in the happy path)
=== VERDICT ===
  PASS: every round-trip got a reply (sender->dest=3 dest->sender=3).
DONE (NEG=0)

How to read it — each output section is one orchestration stage:

  1. up + config=… holding(funder)=… — compose force-recreates 5 fresh
    daemons; the entrypoint installs the freshly-built .lgx and sets the libp2p
    listen address to the container IP (not 0.0.0.0, which would also
    advertise loopback and break mix/SURB/gifter dials). The two accounts come from
    the baked deployment profile: the RLN instance’s config account and the funded
    payment account — only relay1 ever spends from it.
  2. per-node setup — per node: load wallet + RLN + libp2p modules, open and
    sync the wallet (wallet synced to <block>; clients open it read-only
    only relay1 signs or spends), rlnEnable, start, mixSetNodeInfo. Then the
    allocation line: leaf_opt (optimistic leaf) must equal leaf_actual
    (on-chain), confirmed=true is the on-chain membership check, rlnIsReady=True
    means the node holds its identity + Merkle proof. relay1 self-registers and
    mounts the gifter; each client generates its identity locally, signs EIP-191
    over the idCommitment with its fixture key, dials relay1, and adopts the
    returned leaf. Allocations are serialized by the on-chain confirmation
    barrier
    , keeping the gifter wallet’s txs nonce-ordered and every membership
    on a distinct leaf.
  3. mesh — every node mixNodepoolAdds the other four (pubkeys derived
    host-side by keys.py).
  4. root convergence — every registration advanced the on-chain tree and a
    verifier’s valid-roots window refreshes only on its ~10 s epoch timer, so the
    sim re-syncs wallets and polls get_valid_roots until all 5 nodes agree —
    otherwise a hop can reject the first proof (Proof rejected: invalid Merkle root).
  5. register dest-read-behavior — the SURB exit is random, so every node is
    taught to echo /ipfs/ping.
  6. exchange — 3 round-trips per direction (mixDialWithReply
    streamWritestreamReadExactly, reply over the SURB path); RLN is
    generated and verified at every hop on both legs.
  7. observe — counts scraped from daemon logs: originators generate exactly
    3 proofs each; relays run higher, uneven counts (verify + regenerate per
    forwarded packet, paths re-randomized per message); ≈6 verifications per
    round-trip × 6 round-trips = 36; the gifter line confirms exactly 4 gifted
    registrations.

The negative runs prove RLN gates delivery (PASS (negative), 0 replies, 0
sender proofs): NEG=1 — the sender never asks the gifter and stays unregistered;
NEG=2 — the sender signs with a non-allowlisted key and the gifter refuses
authentication.

Success command

cd docker/testnet/mix_e2e && docker compose logs relay1 | grep -c 'RLN gifter registration succeeded' && docker compose logs | grep -c 'Proof verified successfully'

Expected result

`VERDICT: PASS`, exit code 0. The allocation lines are the point: **5 distinct
on-chain leaves (1 self + 4 gifted), `leaf_opt == leaf_actual`, `confirmed=true`,
`rlnIsReady=True` on every node**, all funded by the single gifter. Then the mix
exercise: `sender->dest: 3/3` and `dest->sender: 3/3` replies, ~36 per-hop RLN
verifications, `'RLN gifter registration succeeded' x4`. Peer IDs and leaf
indices are non-deterministic across runs; counts, log strings, and the verdict
are stable. A failing run exits nonzero.

Configuration details

Near-zero-config: bash orchestrate.sh runs the full E2E. Fixed in
orchestrate.sh: 3 round-trips each way, /ipfs/ping echo, RLN
userMessageLimit=100, epochDurationSeconds=10.0. Endpoints: LEZ testnet RPC
https://testnet.lez.logos.co/; each daemon listens for libp2p/mix on its
container IP, port 9000/tcp, on the compose bridge network. The knobs:

Knob Purpose Example
NEG (env) Negative enforcement tests: 0 (default) full E2E; 1 sender never asks the gifter → rejected; 2 sender signs with a non-allowlisted key → gifter refuses auth NEG=2 bash orchestrate.sh
DEPLOYMENT (build-arg) Which on-chain deployment (RLN tree + funded wallet) is baked into the image (docker/testnet/deployments/<name>/, default shared-5ade) docker build -f docker/Dockerfile.testnet-e2e --build-arg DEPLOYMENT=fresh-tree -t lp2p-mix-e2e .
REPO_BASE / LOGOS_REPO_BASE (env, bootstrap only) Clone bases for the sibling repos (mix-stack forks / logos-co repos); override for HTTPS see Happy path

Failure modes and limits

orchestrate.sh auto-detects the two on-chain failures by scanning relay1’s log
and prints the exact fix commands (set LEZ_RLN_DIR=/path/to/logos-lez-rln so
they show real paths). Both fixes provision a fresh deployment and rebuild the
image — the gifter signs with the baked wallet. Build the host tools once
first (cd "$LEZ_RLN_DIR/lez-rln" && PYO3_PYTHON=$(command -v python3) cargo build --release --bin run_setup --bin derive_accounts; the checkout needs a
plain lssa/ sibling clone at v0.2.0-rc6 for host cargo builds).

  1. Payment account out of funds — symptom: registrations stop confirming,
    relay1 logs Insufficient balance. Cause: each registration costs
    price_per_unit * rate RLNTOK. Fix: re-provision on the same tree and rebuild:

    D=docker/testnet/deployments/shared-5ade
    LEZ_RLN_DIR=/path/to/logos-lez-rln bash docker/testnet/provision.sh \
      --name shared-refunded --tree $(jq -r .tree_id "$D/deployment.json") --adopt-wallet "$D/storage.json"
    docker build -f docker/Dockerfile.testnet-e2e --build-arg DEPLOYMENT=shared-refunded -t lp2p-mix-e2e .
    

    (If the log says supply holding may be out of funds, the master supply is
    exhausted → provision a brand-new tree, next item.)

  2. Tree full — symptom: relay1 logs Would exceed max total rate limit
    (~10k members at rate 100 is the practical cap). Fix: provision a brand-new
    tree (no source edits — tree_id is the single knob) and rebuild:

    LEZ_RLN_DIR=/path/to/logos-lez-rln bash docker/testnet/provision.sh --name fresh-tree
    docker build -f docker/Dockerfile.testnet-e2e --build-arg DEPLOYMENT=fresh-tree -t lp2p-mix-e2e .
    
  3. Testnet unreachable — symptom: setup stalls at the first wallet-sync or
    registration barrier. Cause: everything needs https://testnet.lez.logos.co/.
    Fix/workaround: none — there is no offline mode.

GitHub handle

@adklempner

Discord handle

arseniy.eth

Existing docs or specs

Hardware requirements

~30 GB free disk in Docker’s VM (the image is ~17 GB; a cold Nix build needs
headroom)
Recommended 16GB+ RAM for Docker
This was tested on a 2023 Macbook Pro M3 32GB RAM; on a 2018 Intel Macbook Pro 16GB it eventually completed but struggled with swap space and memory usage

Estimated time to complete

~30-45 min first run from clone; ~15 min each subsequent run

Security notes

committed keys that can be used to drain the testnet account(s) of the RLNTOK (payment token), which would make the sim fail for anyone else unless they re-deploy a new set of programs

1 Like