join-network-tuto-in-docker.md

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.com instead. So should you on Windows, from inside WSL2 — that is a real Linux, and the only thing to watch is that ~/pqnet-tezos lives 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:

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:

TerminalRunsContainer
T1 · commands§0, §1, §3, §4, §7, the checkpoints, §8 — short commands, one after anotherone-shot, --rm
T2 · L1 node§2 — one process, left runningpq-l1
T3 · DAL node§5 — one process, left runningpq-dal
T4 · baker§6, §7 — one process, left runningpq-baker

Each is a fresh shell that knows nothing of the others, so each opens the same way:

bash
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 leastWhy
CPU4 vCPUthe chart caps the DAL node and the baker at 1 CPU each (helm/pqpark-testnet/values.yaml), and the L1 node runs alongside them
Memory8 GiBsame source: 4 GiB for the DAL node, 2 GiB for the baker, plus the L1 node
Disk40 GiB freethe 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.

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
mkdir -p client            # the wallet; §3 fills it

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.

0b. Where the data lives — the one macOS-specific decision#

Three kinds of state, and they do not all go to the same place:

StateLives inWhy
env, the wallet client/, the snapshot filea bind mount of ~/pqnet-tezosyou 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 volumesheavy 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:

bash
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 /v2

That 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:

bash
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 substituted

Read $DR once and you have read every command in this tutorial:

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:

bash
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-amd64 and $TAG-arm64 and merge them, so the arm64 half may exist under its own name:

bash
docker buildx imagetools inspect ${IMAGE%:*}:${IMAGE##*:}-arm64   # same tag, -arm64 suffix

A -arm64 tag at the same octez commit is a drop-in: put it in env as IMAGE and carry on.

Then pull, and check the binaries run:

bash
docker pull $IMAGE         # several GB, mostly the DAL trusted setup — once
$DR $IMAGE octez-node --version

That 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:

bash
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:

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

The 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:

bash
$DR --name pq-l1 -p 127.0.0.1:8732:8732 $IMAGE \
  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. 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.

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.

tzc hangs or reports it cannot reach pq-l1? That name only resolves while the container is up and on pqnet. docker ps --filter name=pq-l1 answers it in one line; if the container is running but the name does not resolve, it joined the default bridge instead — check that $DR really 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:

  1. Start from a fresh identity. run generates 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.json

    and start it again with the same run command.

  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.

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 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:

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. 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

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 ꜩ

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/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#

⚠️ 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-addr you advertise, so TCP 11733 has to reach the octez process. On a Mac that is three hops to get right:

  1. -p 11733:11733 on the docker run — the Mac → container hop, below.
  2. 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.
  3. 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:

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)

$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 $PKH

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 ….

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 use names 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 the dal_config.bootstrap_peers list 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.

bash
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 pass

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.

Reading the logs from T1. Each of the three long-running containers has a name, so docker logs -f pq-baker (or pq-l1, pq-dal) gives you the same stream from any terminal, and docker 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:

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 running baker with Ctrl-C — --rm removes the container and frees the name, which the next run needs. Then:

bash
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 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).

docker: Error response from daemon: Conflict. The container name "/pq-baker" is already in use means the old container did not go away — Ctrl-C did not reach it, or it was started without --rm. docker rm -f pq-baker and 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:

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. 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.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) — --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:

bash
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 rejoin

rm -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:

bash
docker ps -a --filter name=pq-      # empty
docker volume ls | grep pqnet       # empty
docker network ls | grep pqnet      # empty

Docker Desktop → Settings → Resources → Advanced reports the VM's disk usage if you want to confirm the space came back.