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
overlogoscore 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:
up+config=… holding(funder)=…— compose force-recreates 5 fresh
daemons; the entrypoint installs the freshly-built.lgxand sets the libp2p
listen address to the container IP (not0.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 — onlyrelay1ever spends from it.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 equalleaf_actual
(on-chain),confirmed=trueis 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.mesh— every nodemixNodepoolAdds the other four (pubkeys derived
host-side bykeys.py).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 pollsget_valid_rootsuntil all 5 nodes agree —
otherwise a hop can reject the first proof (Proof rejected: invalid Merkle root).register dest-read-behavior— the SURB exit is random, so every node is
taught to echo/ipfs/ping.exchange— 3 round-trips per direction (mixDialWithReply→
streamWrite→streamReadExactly, reply over the SURB path); RLN is
generated and verified at every hop on both legs.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).
-
Payment account out of funds — symptom: registrations stop confirming,
relay1 logsInsufficient balance. Cause: each registration costs
price_per_unit * rateRLNTOK. 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.) -
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_idis 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 . -
Testnet unreachable — symptom: setup stalls at the first wallet-sync or
registration barrier. Cause: everything needshttps://testnet.lez.logos.co/.
Fix/workaround: none — there is no offline mode.
GitHub handle
@adklempner
Discord handle
arseniy.eth
Existing docs or specs
- RLN Membership Allocation spec (LIP-158): RLN-MEMBERSHIP-SERVICE - Logos LIP
- RLN DoS Protection for Mixnet spec (LIP-144): RLN DoS Protection for Mixnet - Logos LIP
- LIBP2P-MIX spec (LIP-99): MIX - Logos LIP
- Repo README (overview + quick start): GitHub - logos-co/logos-rln-mix-sim: Simulation of a DoS-protected Mix network using RLN on LEZ and the RLN membership allocation protocol · GitHub
- Gifter protocol README: GitHub - logos-co/logos-rln-gifter: Nim implementation of the RLN membership allocation protocol with basic auth · GitHub
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