Quantumnet Tutorial: joining a PQPark network (macOS/Docker)#
Same goal as quantumnet-tutorial.pqpark.dal.nomadic-labs.com: 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. Here every process runs as a container
from the pq-octez image instead of as a binary on your host.
On Linux, use
quantumnet-tutorial.pqpark.dal.nomadic-labs.cominstead. So should you on Windows, from inside WSL2 — that is a real Linux, and the only thing to watch is that~/pqnet-tezoslives in the WSL filesystem and not under/mnt/c/, which would put an irmin store on a 9p mount. This tutorial buys those two nothing.
Why macOS needs its own version. The binaries inside the image are Linux ELF
executables, statically linked. docker cp would happily copy them onto your Mac and macOS
would refuse to execute them — not for want of a library, and not because of the architecture,
but because Darwin does not run ELF at all. There is no host-side path here. The container is
the only way.
What is not a problem, contrary to what you may expect:
- Apple Silicon.
pq-octeztags are published as multi-arch OCI indexes, so an M-series Mac pulls the nativelinux/arm64variant and runs it in the Docker Desktop VM with no emulation. §1 has the one-line check for your own tag. - Your distribution's glibc, the usual worry when moving octez binaries around. The
binaries come from the tezos CI
static-*jobs and carry no interpreter at all.
What it does cost you. The DAL node has to be dialable from the internet, and containers add a hop: the port must be published from the container and forwarded to your Mac. That is §5, and it is the one step this version makes harder rather than easier.
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 | Container |
|---|---|---|
| T1 · commands | §0, §1, §3, §4, §7, the checkpoints, §8 — short commands, one after another | one-shot, --rm |
| T2 · L1 node | §2 — one process, left running | pq-l1 |
| T3 · DAL node | §5 — one process, left running | pq-dal |
| T4 · baker | §6, §7 — one process, left running | pq-baker |
Each is a fresh shell that knows nothing of the others, so each opens the same way:
cd ~/pqnet-tezos && . ./env§0 writes that env file, and the blocks below repeat the line wherever it is needed. Don't
skip it — it carries not just $NET and $BASE but the $DR docker prefix and the tzc
helper that every other block is written in terms of. Keep the ./ as well: . env would
source the env binary from your $PATH and fail with cannot execute binary file.
An unlabelled block continues in the same terminal as the one before it.
0. Set up#
T1 · commands
What you need installed: Docker Desktop, plus curl and jq for the checks along the
way (brew install jq — curl ships with macOS).
Nothing octez-related, and no build tools.
Give the Docker Desktop VM room first. Everything here runs inside it, and its defaults are sized for a web app, not for three octez daemons. Settings → Resources:
| Give it at least | Why | |
|---|---|---|
| CPU | 4 vCPU | the chart caps the DAL node and the baker at 1 CPU each (helm/pqpark-testnet/values.yaml), and the L1 node runs alongside them |
| Memory | 8 GiB | same source: 4 GiB for the DAL node, 2 GiB for the baker, plus the L1 node |
| Disk | 40 GiB free | the image alone is several GB (it carries the DAL trusted setup), before any chain data |
Under-provisioning does not fail loudly — you get a baker that misses attestation deadlines, which reads as "the network is flaky".
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
mkdir -p client # the wallet; §3 fills itThen 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.
0b. Where the data lives — the one macOS-specific decision#
Three kinds of state, and they do not all go to the same place:
| State | Lives in | Why |
|---|---|---|
env, the wallet client/, the snapshot file | a bind mount of ~/pqnet-tezos | you need to read the wallet from the Mac (cat client/xmss_slots, §6) and it must outlive every container |
the L1 store l1/, the DAL store dal/ | named volumes | heavy random I/O, and SQLite |
That second row is the point. Docker Desktop reaches your Mac's filesystem through VirtioFS, a
translation layer to the Linux VM — fine for a wallet file, poor for an irmin store, and
actively risky for the DAL node's SQLite store, whose locking depends on filesystem semantics
that shared mounts have historically got wrong. A named volume is ext4 inside the VM, so
neither problem arises. The price is that l1/ and dal/ are not visible from the Finder, and
that §8 has to delete them explicitly.
Create the two volumes, and hand them to the image's own user — a fresh volume belongs to
root, and the image runs as tezos (uid 1000), which could not write to them otherwise:
docker network create pqnet # once; "already exists" is fine
docker volume create pqnet-l1
docker volume create pqnet-dal
docker run --rm --user 0 -v pqnet-l1:/v1 -v pqnet-dal:/v2 $IMAGE chown 1000:1000 /v1 /v2That last line is the only time anything here runs as root, and it touches nothing but the two
empty volumes. Remember it: if you ever delete and recreate a volume (§2 does, to redo a
snapshot import), you have to chown it again.
Now the plumbing every block below is written in terms of:
cat >> env <<'EOF'
WORK=$HOME/pqnet-tezos
DR="docker run --rm --network pqnet \
-v $WORK:/home/tezos/pqnet \
-v pqnet-l1:/home/tezos/pqnet/l1 \
-v pqnet-dal:/home/tezos/pqnet/dal \
-w /home/tezos/pqnet"
tzc() { $DR -i $IMAGE octez-client --base-dir client --endpoint http://pq-l1:8732 "$@"; }
EOF
. ./env
echo "$DR" # one long line, with your own path substitutedRead $DR once and you have read every command in this tutorial:
--rm— the container is the process. Stop it and nothing is left behind; all state is in the bind mount and the two volumes.--network pqnet— the containers reach each other aspq-l1,pq-dal,pq-baker, which is what replaces the other tutorial's127.0.0.1. Docker's embedded DNS resolves those names only while the container in question is up.- the three
-v— your directory at/home/tezos/pqnet, with the two volumes mounted over itsl1anddalsubpaths. Docker applies the deeper mounts last, so this composes exactly as the table above describes. -w /home/tezos/pqnet— the working directory, sol1,dalandclientmean the same thing in every command as they would on a Linux host. Every relative path below is relative to~/pqnet-tezos.
There is no --user flag here, unlike the Linux equivalent. Docker Desktop's file sharing
maps ownership for you: the container writes as tezos, and the files show up on your Mac
owned by you. /home/tezos is that user's real home in the image, so $HOME needs no override
either.
tzc is a function, not an alias as in the other tutorial — an alias is not expanded when
a shell runs non-interactively, and this one has to survive being sourced from env in four
terminals.
1. Get the network's own image#
Use the image 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. $IMAGE came from the dashboard, so it is the tag
the cluster is running.
T1 · commands — first, check it carries your architecture. This reads the manifest from the registry without downloading the image:
docker buildx imagetools inspect $IMAGE | grep -A1 '^ Name'You want a Platform: line matching your Mac — linux/arm64 on Apple Silicon, linux/amd64
on an Intel one. The unknown/unknown entries are buildx attestations, one per architecture;
ignore them.
Only
linux/amd64, on an Apple Silicon Mac? Then that tag was built for one architecture only. Look for a sibling tag first — the convention is to build$TAG-amd64and$TAG-arm64and merge them, so the arm64 half may exist under its own name:bashdocker buildx imagetools inspect ${IMAGE%:*}:${IMAGE##*:}-arm64 # same tag, -arm64 suffixA
-arm64tag at the same octez commit is a drop-in: put it inenvasIMAGEand carry on.
Then pull, and check the binaries run:
docker pull $IMAGE # several GB, mostly the DAL trusted setup — once
$DR $IMAGE octez-node --versionThat second line is the whole smoke test: if it prints a version, every command in this
tutorial can run. There is nothing to extract, nothing to chmod, and no bin/ directory.
2. Run your own L1 node#
T2 · L1 node — a new terminal, so pick the variables back up first:
cd ~/pqnet-tezos
. ./env
$DR $IMAGE octez-node config init --data-dir l1 \
--network https://$NET.$BASE/network.json --rpc-addr 0.0.0.0:8732--rpc-addr 0.0.0.0:8732, not 127.0.0.1:8732. That is the container's own loopback, and
binding to it would make the RPC unreachable from the baker and the client containers. It is
not an exposure: the port is published to your Mac's loopback only, in the run below.
Then import the network's rolling snapshot instead of replaying the chain from genesis. The
download runs on the Mac, into the bind mount; the import runs in a container, reads it back as
latest.rolling and writes the store into the pqnet-l1 volume:
curl -fO https://$NET.$BASE/snapshots/latest.rolling
$DR $IMAGE octez-node snapshot import latest.rolling --data-dir l1The snapshot is the one big file that crosses VirtioFS, and it is read once, sequentially — that is the case the shared mount handles fine. Everything the import writes goes to the volume.
Now start the node — this is the process that stays up, and the first one that gets a --name:
$DR --name pq-l1 -p 127.0.0.1:8732:8732 $IMAGE \
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. Ctrl-C stops it, and
--rm then removes the container and frees the name; if a crash ever leaves it behind,
docker rm -f pq-l1.
- Extra docker flags go between
$DRand$IMAGE. Everything after the image name is the octez command line, identical to what you would type on a Linux host. -p 127.0.0.1:8732:8732is a convenience, not a requirement. The other containers reach the RPC ashttp://pq-l1:8732overpqnetregardless; publishing it just lets you pointcurl/jqon the Mac athttp://127.0.0.1:8732too. The127.0.0.1:prefix keeps it off your other interfaces — drop it and you publish your node's RPC to the café's wifi.- The P2P port 9732 is deliberately not published. L1 only ever dials out here, so the VM's NAT is fine; you will get one outbound connection to the bootstrap, and that is the expected steady state.
-
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. Starting over means throwing the volume away and redoing thechownfrom §0b:bashdocker volume rm pqnet-l1 && docker volume create pqnet-l1 docker run --rm --user 0 -v pqnet-l1:/v1 $IMAGE chown 1000:1000 /v1 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.--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).
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.
tzchangs or reports it cannot reachpq-l1? That name only resolves while the container is up and onpqnet.docker ps --filter name=pq-l1answers it in one line; if the container is running but the name does not resolve, it joined the default bridge instead — check that$DRreally carries--network pqnet(echo "$DR").
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. Stop the node in T2, then — the file is in the volume, so this needs a container:bash$DR $IMAGE rm -f l1/identity.jsonand start it again with the same
runcommand.- 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.
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 wallet lands in client/ — in the bind mount, so on your Mac, readable from the Finder
and from any terminal. That is deliberate: §6 and §7 have you read the tz6's slot counter
straight off it, and it must outlive every container. It is also the one thing here worth
keeping off Time Machine and off any sync folder — see the warning below.
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. Read it straight from the Mac:
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. Two traps are
specific to this setup. A second docker run against the same bind mount is a second baker
on the same wallet, with nothing to warn you: one pq-baker at a time. And ~/pqnet-tezos is
an ordinary directory in your home — if it sits inside iCloud Drive, Dropbox or a Time Machine
selection, a restore is the backup that destroys the key. Keep it out of all of them. 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 ꜩThe faucet calls go out from the Mac, not from a container — they only need the public
internet, and doing them here keeps jq where you can read the output.
/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#
⚠️ This is the step containers make harder, and the one that fails for reasons outside the software. L1 only dials out, so the VM's NAT is fine there. The DAL gossip mesh is not: peers must be able to dial back at the
--public-addryou advertise, so TCP 11733 has to reach the octez process. On a Mac that is three hops to get right:
-p 11733:11733on thedocker run— the Mac → container hop, below.- macOS's own firewall (System Settings → Network → Firewall) must let the connection in. If it is on and set to block incoming connections, add an exception or turn it off for the duration.
- Internet → Mac: a port-forward on your home or office gateway, or a firewall rule on the network you are on. On a corporate wifi this is often simply not available — that is the honest failure mode of joining from a laptop.
Miss any of the three and the node starts, connects out, looks perfectly healthy — and never receives a shard.
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)
$DR --name pq-dal -p 11733:11733 -p 127.0.0.1:10733:10733 $IMAGE \
octez-dal-node run --data-dir dal \
--endpoint http://pq-l1:8732 \
--rpc-addr 0.0.0.0:10733 \
--net-addr "[::]:11733" \
--public-addr "[$MYIP]:11733" \
--peers $DAL_PEER \
--expected-pow=0 \
--attester-profiles $PKHrun occupies T3 from here on.
$MYIPis your Mac's public address, asked ofifconfig.mefrom the host — which is the right place to ask it. From inside a container you would get the VM's view, not yours. It is what the gossip mesh will dial back, so it must be the address your port-forward points at. On a laptop it changes every time you move networks, and the node has to be restarted when it does.- Two published ports, two different jobs.
11733is the gossip port and must be open to the world (see the warning above).10733is the RPC, published to the Mac's loopback only for your own debugging — the baker reaches it ashttp://pq-dal:10733overpqnetand does not need it published at all. --rpc-addr 0.0.0.0:10733for the same reason as the L1 node: the container's loopback is not reachable from the baker container.- We spell the ports out so nothing collides with what may already be running. The octez
defaults are
…32, so we used…33; if those are taken too, use any free pair — and change the host side of-pwith them. --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. - An attester needs no DAL trusted setup. The image carries one and nothing here uses it: going from the encoded world back to the original data is a producer's job, not yours.
Success looks like the ZODA DAL node is ready, following Layer 1 at …, then
joined gossip topics: attesters … and Process New_connection ….
Untested on macOS, and worth knowing. Docker Desktop routes published ports through a userland proxy, so inbound connections reach the container with a rewritten source address. Whether ZODA's gossip minds has not been checked from a Mac. If the node connects out normally but no peer ever connects in, this is the first thing to suspect and to report.
If it refuses to start, or starts and never receives a shard, three things to try before anything else. A port already in use — on the Mac side now, so
docker: Error … bind: address already in usenames it plainly: restart on any other free pair, remembering that the DAL RPC port is the one §6 hands to the baker. A peer that is down or unreachable: take a different entry from thedal_config.bootstrap_peerslist and restart. And the inbound path, which is the one that produces a healthy-looking node with no shards: from another machine,nc -vz $MYIP 11733.
6. Run your own baker#
T4 · baker — a new terminal, so . ./env first: $DR and $IMAGE are what the command is
built from. Both keys go on the command line: the tz6 is what signs, the tz5 is the
delegate it signs for.
cd ~/pqnet-tezos
. ./env
$DR --name pq-baker $IMAGE \
octez-baker --base-dir client --endpoint http://pq-l1:8732 \
run with local node l1 mykey consensus-1 \
--dal-node http://pq-dal:10733 \
--liquidity-baking-toggle-vote passrun with local node l1is a path, not a hostname — the node's data directory, which the baker reads directly. It works because$DRmounts thepqnet-l1volume into every container, sol1is the same store for the baker as for the node.--endpoint http://pq-l1:8732is the hostname half, and the two are not interchangeable.- The DAL node URL has to be the port you selected when you launched it, as
http://pq-dal:10733— the container name, not127.0.0.1. - No ports are published here. The baker only ever dials out, to the two other containers.
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. - Don't let the Mac sleep. A baker that suspends misses its deadlines, and the Docker
Desktop VM goes down with the machine.
caffeinate -isin a spare terminal, or the equivalent in Energy Saver, for as long as you intend to bake.
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.
Reading the logs from T1. Each of the three long-running containers has a name, so
docker logs -f pq-baker(orpq-l1,pq-dal) gives you the same stream from any terminal, anddocker logs --since 10m pq-baker | grep 'consensus key'is the quickest way to answer "is the tz6 signing?" without watching T4.
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. 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.
Nothing in this section is container-specific except the restart in step 2. The wallet and its counter live in the bind mount, on your Mac, so they are the same files the other tutorial describes.
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 running baker with Ctrl-C —
--rm removes the container and frees the name, which the next run needs. Then:
cd ~/pqnet-tezos
. ./env
$DR --name pq-baker $IMAGE \
octez-baker --base-dir client --endpoint http://pq-l1:8732 \
run with local node l1 mykey consensus-1 consensus-2 \
--dal-node http://pq-dal: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).
docker: Error response from daemon: Conflict. The container name "/pq-baker" is already in usemeans the old container did not go away —Ctrl-Cdid not reach it, or it was started without--rm.docker rm -f pq-bakerand run again. Never work around it by giving the new baker a different name and leaving the old one up: two bakers on the same wallet is slot reuse, which destroys the tz6.
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. The containers are --rm and the volumes are disposable, but client/ is neither:
it is on your Mac, and that is what keeps this true here.
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) — --rm means each container removes
itself. Then clear the volumes, which rm -rf cannot reach: they are inside the Docker
Desktop VM, and this is where the chain and DAL data actually are.
T1 · commands:
docker rm -f pq-l1 pq-dal pq-baker 2>/dev/null # only if a Ctrl-C didn't take
docker volume rm pqnet-l1 pqnet-dal # the stores — the bulk of the disk
docker network rm pqnet
rm -rf ~/pqnet-tezos # env, wallet, snapshot file
docker rmi $IMAGE # optional: several GB, keep it if you'll rejoinrm -rf ~/pqnet-tezos alone frees almost nothing — that directory holds a wallet and a
config. Skip the docker volume rm and the stores sit in the VM's disk image indefinitely,
which on a Mac is the kind of thing you rediscover months later wondering where the space went.
Check nothing is left:
docker ps -a --filter name=pq- # empty
docker volume ls | grep pqnet # empty
docker network ls | grep pqnet # emptyDocker Desktop → Settings → Resources → Advanced reports the VM's disk usage if you want to confirm the space came back.