join-network-tutorial.md

Quantumnet Tutorial: joining a PQPark network#

Join a running PQPark network from your own machine — your own L1 node, your own post-quantum keys (a tz5 manager key, funded and staked, plus the tz6 consensus key that does the signing), and your own DAL node + baker attesting the producer's slots.

On a Mac? quantumnet-tuto-docker.pqpark.dal.nomadic-labs.com is the same tutorial with every process running as a container: the binaries below are Linux ELF and macOS will not execute them, whatever the architecture. Stay here on Linux, and on Windows too — from inside WSL2, which is a real Linux. The binaries are statically linked, so no distribution is too old for them.

Everything starts from the network's URL — the dashboard that whoever launched the network sent you:

https://pq-shownet-09-10.pqpark.dal.nomadic-labs.com
       └─ network name ─┴─────── base domain ───────┘

Those two halves, plus the image tag the dashboard itself displays, are the only network-specific values in this tutorial. §0 extracts all three with one command.

Budget ~45 min, almost all of it protocol-imposed waiting: rights arrive consensus_rights_delay + 1 (= 3) cycles after you stake, which on a 10-minute cycle is ~30 min. Your baker logging no rights for the first half hour is the protocol, not a bug.

You will need four terminals, and every code block below is labelled with the one it belongs in:

TerminalRuns
T1 · commands§0, §1, §3, §4, §7, the checkpoints, §8 — short commands, one after another
T2 · L1 node§2 — one process, left running
T3 · DAL node§5 — one process, left running
T4 · baker§6, §7 — one process, left running

An unlabelled block continues in the same terminal as the one before it.


0. Set up#

T1 · commands

What you need installed: docker (§1 copies the binaries out of the image), plus curl, jq for the checks along the way.

Your machine first: make yourself a directory to work in.

bash
mkdir -p ~/pqnet-tezos     # a directory of your own, wherever suits you — we picked ~
cd ~/pqnet-tezos           # every path below is relative to here — stay in this directory

~/pqnet-tezos exists for this tutorial and nothing else. Every file the tutorial creates lands inside it. We have chosen to put it in ~. Feel free to put it anywhere.

Then the network. Put the URL you were given on the first line (in practice, only NETWORK_NAME changes) — the rest derives from it and lands in env, which the other terminals read back:

bash
URL=https://NETWORK_NAME.pqpark.dal.nomadic-labs.com    # ← the one line to edit

HOST=${URL#https://}
NET=${HOST%%.*}            # for instance pq-shownet-1708 — yours will differ
BASE=${HOST#*.}            # for instance pqpark.dal.nomadic-labs.com
IMAGE=$(curl -s "$URL/" | grep -o 'node image: <code>[^<]*' | sed 's/.*<code>//')

cat > env <<EOF
NET=$NET
BASE=$BASE
IMAGE=$IMAGE
EOF

cat env                    # three non-empty values, or stop here

IMAGE is the dashboard's Parameters → node image: line, which echoes the deploy's --image verbatim, registry included.

An empty IMAGE is the one thing to react to: the dashboard did not answer, because NETWORK_NAME is still there or the URL is wrong. Fix it before going any further.


1. Get the network's own binaries#

Use the binaries the network runs, not a stock Octez. The ZODA protocol and its DAL encodings differ from upstream: a stock octez-node cannot validate these blocks, and a stock octez-dal-node speaks a different DAL. Extract them from the exact image that was deployed:

T1 · commands

bash
mkdir -p bin
CID=$(docker create $IMAGE)                                 # makes a container without starting it
for b in octez-node octez-client octez-dal-node octez-baker; do
  docker cp "$CID:/usr/local/bin/$b" bin/$b
done
docker rm -f "$CID"
chmod +x bin/*
bin/octez-node --version

Now, create an alias for the Octez client:

bash
mkdir -p client
echo "alias tzc='bin/octez-client --base-dir client --endpoint http://127.0.0.1:8732'" >> env
. ./env

Keep the ./. . env does not read the file: . searches your $PATH first, finds the env binary there, and fails with .: /usr/bin/env: cannot execute binary file. Every terminal below opens with . ./env for that reason.


2. Run your own L1 node#

T2 · L1 node — a new terminal, so pick the variables back up first:

bash
cd ~/pqnet-tezos
. ./env

bin/octez-node config init --data-dir l1 \
  --network https://$NET.$BASE/network.json --rpc-addr 127.0.0.1:8732

Then import the network's rolling snapshot instead of replaying the chain from genesis:

bash
curl -fO https://$NET.$BASE/snapshots/latest.rolling
bin/octez-node snapshot import latest.rolling --data-dir l1

Now start the node — this is the process that stays up:

bash
bin/octez-node run --data-dir l1 \
  --expected-pow=26 --synchronisation-threshold=0

run occupies T2 from here on — leave it there and go back to T1.

Checkpoint, back in T1 · commands (tzc and the variables are already there):

bash
tzc rpc get /chains/main/blocks/head/header
tzc rpc get /chains/main/is_bootstrapped
tzc rpc get /network/connections

Read the header's protocol to tell success from failure. Level 0 with ProtoGenesis… means you are not connected; a level tracking the cluster with ProtoALphaALpha… means you are.

Authentication keeps failing and the node stays at 0 connections? If the log repeats something like:

authentication error for 35.195.57.101:9732:
  IO error: connection with a peer is closed.

you have most likely been greylisted by the bootstrap node, after a node dialling from your address presented an identity below the network's proof of work. Not necessarily yours: off-cluster joiners reach the bootstrap under a shared source address, so someone else's bad attempt can catch you too. It lapses on its own, but only after ~22 h — so don't wait it out:

  1. Start from a fresh identity. run generates a conforming one whenever the file is absent:

    bash
    rm l1/identity.json
  2. Ask whoever launched the network to clear the greylist. Until they do, a correct identity fails exactly like the bad one. It is immediate on their side, and your node retries every 5 s — nothing to restart here.

3. Create your post-quantum keys#

Two keys, two jobs. A tz5 manager key owns the funds and is what you register as a delegate; a tz6 consensus key does the actual consensus signing.

T1 · commands

bash
tzc gen keys mykey --sig mldsa44
tzc gen keys consensus-1 --sig xmss --xmss-slot-start 0 --xmss-slot-end 131071
tzc list known addresses

The second command takes ~9 s and looks stuck while it runs: an XMSS key is a Merkle tree over its whole slot range, and the tree is built at generation time (and rebuilt at every baker start — §6).

Two of the schemes --sig accepts are post-quantum: mldsa44 → tz5 (ML-DSA-44, lattice-based) and xmss → tz6 (hash-based). The faucet accepts tz5 destinations; the tz6 never needs funding: a consensus key holds no balance.

Why not just bake with the tz5. A tz5 can be registered as a delegate on this branch and act as its own consensus key — but ML-DSA signatures cannot be aggregated. Only XMSS signatures are aggregation-eligible, so a block carries one xmss_attestations_aggregate for all XMSS attesters. Baking with a tz6 consensus key is what the network is actually built around.

Why the manager key stays tz5. A tz6 is stateful: every signature consumes one one-time signing slot out of a finite range, and signing twice with the same slot destroys the key. You want that key doing one thing only — consensus — not paying for transfers and staking operations too. The tz5 is stateless and unlimited, which is what a manager key should be.

The counter is state you have to respect. It lives in client/xmss_slots and holds the next slot to use, so remaining = 131071 - slot + 1:

bash
cat client/xmss_slots        # "slot": 0 on a fresh key

Never run two bakers off this wallet, never copy it to a second machine, and never restore it from a backup — each of those rewinds or forks the counter into slot reuse. The full list is tz6-key-rotation.pqpark.dal.nomadic-labs.com § What breaks a tz6 key, and it is worth reading once before §6 rather than after.

Nothing else to create. A BLS consensus key would additionally need a tz4 companion key to attest DAL slots; XMSS attesters sign their own DAL content directly, so the two keys above are the whole set.

This key will need rotating. 131 071 slots run out after ~3 days of baking, with no warning from Octez and ~30 minutes needed for a replacement to activate. If your baker is going to outlive the afternoon, §7 is the procedure.


4. Get funded, register with the tz6, stake#

We ask the faucet for 400,000 tez and stake 300,000 of it, keeping the rest liquid for fees. That is far above the protocol's floors and deliberately so: those floors buy you some rights, whereas what you want is a weight that is not negligible next to the bakers already running, so you hold rights at nearly every level and the DAL side is exercised continuously.

T1 · commands

bash
PKH=$(tzc show address mykey | awk '/^Hash:/ {print $2}')
echo "PKH=$PKH" >> env    # §5 reads it back in T3
echo $PKH

curl -s https://$NET-faucet.$BASE/api/info | jq .
curl -s -X POST https://$NET-faucet.$BASE/api/send \
  -H 'Content-Type: application/json' \
  -d "{\"to\":\"$PKH\",\"amount\":400000}" | jq .

tzc get balance for mykey        # expect 400000 ꜩ

/api/send simulates, forges on the node, signs, injects and polls until inclusion — a 200 means it is on-chain, and a protocol rejection comes back as the node's own error message.

No tez arrived? If /api/send fails or the balance stays at 0, the likeliest cause is a faucet that has run dry. It is funded once at genesis and never topped up, so a long-lived network that has served many joiners can exhaust it. Unlikely — it starts with 100 M tez, around 250 joiners at this size — but it is the one failure in this section you cannot fix from your side: ask whoever launched the network to refill it, or to send you the tez directly.

Then register and stake — in this order, because stake requires the account to already be a delegator, which self-delegation makes it:

bash
tzc register key mykey as delegate --consensus-key consensus-1
tzc stake 300000 for mykey
tzc get staked balance for mykey
tzc get delegate for mykey            # should be mykey itself

tzc rpc get /chains/main/blocks/head/context/delegates/$PKH/consensus_key

The RPC prints what the protocol recorded:

json
{ "active":   { "pkh": "tz5RjfCC…", "pk": "…" },
  "pendings": [ { "cycle": 836, "pkh": "tz6A3iih…", "pk": "…" } ] }

active being your tz5 is correct and not a mistake: registering a delegate makes it its own consensus key, and the tz6 you just announced is the one under pendings, with the cycle it activates at. What matters is that the tz6 appears there — if pendings is empty, the --consensus-key argument did not take, and you are about to bake with the tz5 instead.

Keep some liquid balance (100,000 here) for fees. set delegate parameters is not needed — that only governs whether third parties may stake with you. The tz6 needs no funding and no reveal: its public key travels inside the announcement.

Now wait for rights. Stake is snapshotted per cycle and rights are computed consensus_rights_delay cycles ahead, so yours open at cycle current + consensus_rights_delay + 1 — with consensus_rights_delay = 2, three cycles out:

bash
CYCLE=$(tzc rpc get /chains/main/blocks/head/helpers/current_level | jq -r .cycle)
DELAY=$(tzc rpc get /chains/main/blocks/head/context/constants | jq -r .consensus_rights_delay)
TARGET=$((CYCLE + DELAY + 1))

echo "cycle $CYCLE — your rights open at cycle $TARGET"

There is nothing useful to ask right now, which is worth knowing before you try. Rights exist only consensus_rights_delay cycles ahead, so the horizon is cycle CYCLE + DELAY — one short of TARGET. Asking beyond it fails outright rather than returning an empty list:

json
[{"kind":"permanent","id":"proto.alpha.seed.unknown_seed",
  "oldest":832,"requested":836,"latest":835}]        // HTTP 500

latest is the horizon. Once the chain enters the next cycle — ten minutes at most — TARGET comes into range, and this single query answers the question:

bash
tzc rpc get "/chains/main/blocks/head/helpers/attestation_rights?delegate=$PKH&cycle=$TARGET" \
  | jq -r 'if length == 0 then "no rights — the stake did not land"
           else "\(length) levels, first at \(.[0].level) around \(.[0].estimated_time)"
                + ", power \(.[0].delegates[0].attesting_power)"
                + ", signed by \(.[0].delegates[0].consensus_key)" end'
# 100 levels, first at 83401 around 2026-08-27T12:20:03Z, power 560, signed by tz6A3iih…

The query filters on the delegate, so it is still your tz5 you ask about — but each entry names the consensus_key that will sign for it, and by TARGET that is your tz6. Seeing a tz5 there means the announcement in the previous step did not land.

estimated_time is the wall-clock at which your rights start — 20 to 30 minutes after you staked, depending on where in a cycle you were when you did. length is how many levels of the cycle you hold rights in: blocks_per_cycle of them means every level, which is what the 300,000 was for.

Do §5 while you wait — the DAL node needs no rights to start.


5. Run your own DAL node#

First find the peer to dial.

T3 · DAL node:

bash
cd ~/pqnet-tezos
. ./env
curl -s https://$NET.$BASE/network.json | jq '.dal_config.bootstrap_peers'

The list is ordered: [0] is bootstrap-dal, [1] and on are producer-0, producer-1… Take any entry.

The only thing left to paste is the DAL node you just picked:

bash
DAL_PEER=34.76.143.72:11733       # remote DAL-node on this network, from the list above
MYIP=$(curl -s https://ifconfig.me)

bin/octez-dal-node run --data-dir dal \
  --endpoint http://127.0.0.1:8732 \
  --rpc-addr 127.0.0.1:10733 \
  --net-addr "[::]:11733" \
  --public-addr "[$MYIP]:11733" \
  --peers $DAL_PEER \
  --expected-pow=0 \
  --attester-profiles $PKH

We spell the ports out so that nothing collides with what may already be running on this machine. The defaults are ...32, so we used ...33 here; if those are taken too, use any free pair.

run occupies T3 from here on.

Success looks like the ZODA DAL node is ready, following Layer 1 at …, then joined gossip topics: attesters … and Process New_connection ….

If it refuses to start, or starts and never receives a shard, two things are worth trying before anything else. A port already in use: restart on any other free pair — the RPC port is the one §6 hands to the baker as --dal-node, so change it in both places. A peer that is down or unreachable: take a different entry from the dal_config.bootstrap_peers list above and restart.


6. Run your own baker#

T4 · baker — no variables needed here, only the right directory. Both keys go on the command line: the tz6 is what signs, the tz5 is the delegate it signs for.

bash
cd ~/pqnet-tezos

bin/octez-baker --base-dir client --endpoint http://127.0.0.1:8732 \
  run with local node l1 mykey consensus-1 \
  --dal-node http://127.0.0.1:10733 \
  --liquidity-baking-toggle-vote pass

The DAL node port has to be the one you selected when you launched it.

The baker fixes its key set at startup, so check the line it prints before walking away:

text
Baker will run with the following keys:
       'consensus-1' (tz6A3iihbRym3fZfM3ALPVfkN1fbhDMHapFy)
       'mykey' (tz5RjfCCXq51Udp5ZegArR9Vpm2XT7gmmKs2)

What success looks like, once your rights are active — the shape of the line, from the tz6 run recorded in tz6-key-rotation.pqpark.dal.nomadic-labs.com; your aliases and hashes will differ:

text
injected attestation (attesting DAL slots at published level(s): 24418 -> [0])
  for level 24421, round 0 for delegate
  'mykey' (tz5RjfCC…) with consensus key
  'consensus-1' (tz6A3iih…)

Two things to read in it. The with consensus key clause is the ground truth that the tz6 is doing the signing — if it is absent, the tz5 is still the active consensus key and the announcement in §4 did not take effect (or has not reached its cycle yet). And the parenthetical is the DAL half: published level(s): … -> [slot indices] means your local DAL node received and validated shards from the cluster's producer and your baker attested them. An attestation carrying no DAL content — the parenthetical absent, or no DAL slots — means it did not.

T1 · commands — Confirm on-chain rather than by eye: dal_participation counts what the protocol credited you with, and the two numbers should track each other:

bash
tzc rpc get /chains/main/blocks/head/context/delegates/$PKH | jq '.dal_participation, .participation'
# "delegate_attested_dal_slots": 90, "delegate_attestable_dal_slots": 90   <- 90/90

And watch the tz6's budget. Nothing in Octez warns you before a key runs out; the first signal is a hard the key is exhausted failure mid-baking, after which the delegate is deactivated within ~30 minutes. The counter now advances by ~2 slots per level:

bash
cat client/xmss_slots        # "slot" grows; remaining = 131071 - slot + 1

Rotate every ~3 days, well before it runs out — §7.


7. Rotate the tz6 before it runs out#

The tz6 has a finite budget: 131 071 slots, and an attester burns at least two per level (preattestation + attestation), plus one per block it proposes and one per extra round. Count on three and the key lasts ~3 days of the baking you started in §6. Either way nothing in Octez warns you as it drains — the first signal is a hard the key is exhausted failure mid-baking, after which the delegate is deactivated within ~30 minutes, while a replacement needs ~30 minutes to activate. So the whole point is to rotate well before the end — which is easy, because rotating early is cheap.

T1 · commands — read the budget. Both keys in that file have the same 131 071 range here:

bash
cd ~/pqnet-tezos
. ./env
jq -r '.[] | "next \(.slot) — remaining \(131071 - .slot + 1)"' client/xmss_slots

Rotate whenever it suits you — at most every three days. Inside that ceiling nothing is delicate: a new key costs 9 seconds to generate and one baker restart, and the slots left unused on the old one cost nothing — you are throwing away a key that was never meant to outlive the network. Once a day, or whenever you next sit down at the terminal, is a perfectly good rule.

And if you run out anyway, you lose half an hour, not your baker. The tz6 stops signing, and 20 to 30 minutes later the protocol deactivates your delegate — it keeps its funds and its stake, it simply stops holding rights. Getting back is the rotation below with one change: a deactivated delegate has to be registered again, as in §4, rather than just handed a new consensus key.

bash
tzc gen keys consensus-2 --sig xmss --xmss-slot-start 0 --xmss-slot-end 131071
tzc register key mykey as delegate --consensus-key consensus-2

Restart the baker on the new key, and your rights reopen three cycles later — the same ~30 minutes you waited in §4.

1 · Generate and announce a new key. The manager key does not move and your delegators do not move — only the consensus key does, and the old one keeps signing until the new one activates.

bash
tzc gen keys consensus-2 --sig xmss --xmss-slot-start 0 --xmss-slot-end 131071   # ~9 s
tzc set consensus key for mykey to consensus-2

tzc rpc get /chains/main/blocks/head/context/delegates/$PKH/consensus_key

The new tz6 must appear under pendings with its activation cycle — the same shape as in §4, except active is now consensus-1 rather than your tz5. Activation is at cycle n + 3: 20 to 30 minutes away.

2 · Restart the baker with both keys. In T4, stop the currently running baker (Ctrl-C), then:

bash
cd ~/pqnet-tezos

bin/octez-baker --base-dir client --endpoint http://127.0.0.1:8732 \
  run with local node l1 mykey consensus-1 consensus-2 \
  --dal-node http://127.0.0.1:10733 \
  --liquidity-baking-toggle-vote pass

Both tz6 keys stay on the command line, and this is the step to get right. The baker fixes its key set at startup and needs each key for one half of the window: consensus-1 signs until the activation cycle, consensus-2 after it. Until activation the baker reports consensus-2 as having no rights — expected, exactly as in §6.

Restart early in the window rather than near the boundary: the restart itself costs ~8 levels (~55 s) of attestations, tree warm-up included. The DAL node in T3 needs no change and no restart — its attester profile follows the delegate, not the consensus key (§5).

3 · Confirm the handover, ~30 minutes later, back in T1:

bash
tzc rpc get /chains/main/blocks/head/context/delegates/$PKH/consensus_key
jq -r '.[] | "next \(.slot) — remaining \(131071 - .slot + 1)"' client/xmss_slots

consensus-2 must now be active with pendings empty, its counter advancing while consensus-1's has frozen. In T4, the attestation lines name the new key in their with consensus key clause — that is the ground truth (§6).

4 · Retire the old key at your next restart — take consensus-1 off the command line, and stop there. Keep the key in the wallet and keep its client/xmss_slots entry. Delete either, re-import that key one day, and it resumes from a zeroed counter — signing slots it has already signed, which is precisely what destroys an XMSS key and lets a third party forge your signatures.

Then repeat every ~3 days, for as long as your baker runs.

The rest of the story. tz6-key-rotation.pqpark.dal.nomadic-labs.com is the full runbook behind these six commands: how to size a key for a different rotation interval (and why the in-cluster bakers use a much smaller one), monitoring from cron, the complete list of what breaks a tz6 key, and how to tell exhaustion apart from a flaky link.


8. Teardown#

Stop the processes in T2, T3 and T4 (Ctrl-C in each), then in T1:

bash
cd ~ && rm -rf ~/pqnet-tezos    # binaries, wallet, L1 and DAL data — nothing lives outside