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.comis 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:
| Terminal | Runs |
|---|---|
| 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.
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:
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 hereIMAGE 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
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 --versionNow, create an alias for the Octez client:
mkdir -p client
echo "alias tzc='bin/octez-client --base-dir client --endpoint http://127.0.0.1:8732'" >> env
. ./envKeep 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:
cd ~/pqnet-tezos
. ./env
bin/octez-node config init --data-dir l1 \
--network https://$NET.$BASE/network.json --rpc-addr 127.0.0.1:8732Then import the network's rolling snapshot instead of replaying the chain from genesis:
curl -fO https://$NET.$BASE/snapshots/latest.rolling
bin/octez-node snapshot import latest.rolling --data-dir l1Now start the node — this is the process that stays up:
bin/octez-node run --data-dir l1 \
--expected-pow=26 --synchronisation-threshold=0run occupies T2 from here on — leave it there and go back to T1.
- Import between
config initand the firstrun. The import needs the config (it reads the custom genesis from it) and an empty store, so it fails both beforeconfig initand oncerunhas created a store — with a node already running you would have to stop it and clearl1/storeandl1/contextfirst. curl -freturning 404 is not a blocker. It means the network was deployed without--snapshots, or the node that exports them has not produced its first one yet (a minute or so on a fresh network). Skip both lines and go straight torun: you then sync from genesis, which is seconds on a young network and only gets long on an old one. And because that node keeps the full history, there is no insufficient history to work around either way.--synchronisation-threshold=0is required. You will have exactly one peer, which cannot meet the default quorum — the node would never declare itself bootstrapped, and the baker would never start.-
The network has to be open to the outside in the first place. If it isn't, there is nothing public for you to dial, no flag below fixes it, and it cannot be changed on a live network. Check it before debugging anything else:
bashcurl -s https://$NET.$BASE/network.json | jq '.default_bootstrap_peers, .dal_config.bootstrap_peers'Both lists must be present and non-empty. Empty or absent means the network was launched closed — ask whoever launched it for one you can join.
- Proof of work 26. The in-cluster nodes check your identity stamp against
expected-proof-of-work: 26, so a weaker identity is refused at authentication. Nothing to do about it: 26 is Octez's own default, andrungenerates a conforming identity on first start (a few seconds to ~2 min).--expected-pow=26restates that default; it does not re-check an identity file that already exists —octez-node identity checkis what does. - One connection is the expected steady state. Only the bootstrap nodes are public; peer
exchange hands you unroutable
10.xpod addresses for the rest. The bootstrap relays blocks and mempool, and your node reconnects to it by itself.
Checkpoint, back in T1 · commands (tzc and the variables are already there):
tzc rpc get /chains/main/blocks/head/header
tzc rpc get /chains/main/is_bootstrapped
tzc rpc get /network/connectionsRead 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:
Start from a fresh identity.
rungenerates a conforming one whenever the file is absent:bashrm l1/identity.json- 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
tzc gen keys mykey --sig mldsa44
tzc gen keys consensus-1 --sig xmss --xmss-slot-start 0 --xmss-slot-end 131071
tzc list known addressesThe 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:
cat client/xmss_slots # "slot": 0 on a fresh keyNever 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
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/sendfails 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:
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_keyThe RPC prints what the protocol recorded:
{ "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:
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:
[{"kind":"permanent","id":"proto.alpha.seed.unknown_seed",
"oldest":832,"requested":836,"latest":835}] // HTTP 500latest 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:
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:
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:
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 $PKHWe 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.
--peersis not optional.--expected-pow=0is correct here, unlike L1: the DAL default is 0 and the cluster's DAL nodes run at 0.--attester-profiles $PKHsubscribes to exactly the topics the protocol assigned your delegate. Other profiles:--observer-profiles <slot>for a whole slot, and--operator-profiles/--publish-slots-regularlyto produce a slot instead.- It is the delegate here, not the consensus key —
$PKHis your tz5. DAL topics follow the delegate, so the tz6 is of no concern to this node, and rotating the tz6 later needs no change here and no restart.
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 thedal_config.bootstrap_peerslist 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.
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 passThe 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:
Baker will run with the following keys:
'consensus-1' (tz6A3iihbRym3fZfM3ALPVfkN1fbhDMHapFy)
'mykey' (tz5RjfCCXq51Udp5ZegArR9Vpm2XT7gmmKs2)- Startup is slower than you expect: ~9 s before the first signature, spent rebuilding the
tz6's Merkle tree. The baker does this off the consensus critical path, on purpose — it is
also why you run the tz6 through this daemon rather than through one-shot
octez-clientcalls, each of which would pay those 9 s again. - Until the activation cycle, the baker reports the tz6 as having no rights
(
The following delegates have no attesting rights at level …, naming the tz6). Expected: §4 announced it for cycleTARGET, and that is also the cycle your own rights start.
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:
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:
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/90And 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:
cat client/xmss_slots # "slot" grows; remaining = 131071 - slot + 1Rotate 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:
cd ~/pqnet-tezos
. ./env
jq -r '.[] | "next \(.slot) — remaining \(131071 - .slot + 1)"' client/xmss_slotsRotate 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.
bashtzc gen keys consensus-2 --sig xmss --xmss-slot-start 0 --xmss-slot-end 131071 tzc register key mykey as delegate --consensus-key consensus-2Restart 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.
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_keyThe 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:
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 passBoth 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:
tzc rpc get /chains/main/blocks/head/context/delegates/$PKH/consensus_key
jq -r '.[] | "next \(.slot) — remaining \(131071 - .slot + 1)"' client/xmss_slotsconsensus-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.comis 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:
cd ~ && rm -rf ~/pqnet-tezos # binaries, wallet, L1 and DAL data — nothing lives outside