tz6 Key Rotation#
A baker runbook for XMSS consensus keys on the pqpark demo network: how to size one, how to read what is left of it, when to rotate, and how to rotate without destroying the key.
A tz6 key can only ever produce a finite number of signatures. Everything below follows from that.
Validated live on 2026-08-19. The rotation procedure below was executed twice end-to-end on an external baker joined to the shownet (delegate
tz5RjfCC…,attesting_power120/7000): tz5 → tz6[0,131071]at cycle 244, then tz6 → tz6[0,16383]at cycle 247. Both activated at n+3 as predicted, both left the delegate active withsufficient_dal_participation. A further A-B-A rotation (16 383 → 131 071 → 16 383, cycles 255–262) settled the key-size question. Figures below marked measured come from those runs.
Why you are being asked to run a tz6#
Standard Tezos aggregates consensus signatures. A post-quantum Tezos that could not would be a downgrade, so the demo network aggregates too — and that is what forces tz6.
Only Bls and Xmss attestations are aggregation-eligible. ML-DSA (tz5) is
post-quantum but not aggregatable, and with a consensus committee of 7 000
slots against a validation pass capped at 4 MiB / 2 048 operations, individual
post-quantum attestations simply do not fit. So the network runs tz5 manager
key + tz6 consensus key, which is what the in-cluster bakers already do.
The cost of aggregation is the mutable state described below. It is real, and the UX around it is not good yet — that is on the roadmap. For now, running a tz6 means running this runbook.
Where the join tutorial stops
tutorial.pqpark.dal.nomadic-labs.comjoins the network with a tz5 manager key and a tz6 consensus key sized atslot_end = 131071, announced in the same operation that registers the delegate. Its §7 carries the rotation commands alone — six of them, for the one key size it generates. Everything behind those commands is this document: sizing, where the margins come from, monitoring, and what breaks a key.
The network you are joining#
Every number in this document is computed from the demo network's parameters
(internal/genesis/mainnet-parameters.json). They are not mainnet numbers,
and the difference matters more than you would expect.
| Parameter | Value | Consequence |
|---|---|---|
minimal_block_delay | 6 s | |
blocks_per_cycle | 100 | a cycle is 10 minutes |
consensus_rights_delay | 2 | a key announced at cycle n activates at n + 3 — ~30 minutes |
tolerated_inactivity_period | 2 | an exhausted key gets you deactivated in ~20–30 min |
consensus_committee_size | 7 000 | why aggregation is mandatory |
Ten-minute cycles are the headline. On a mainnet-like chain the activation window is measured in days and dominates every sizing decision; here it is half an hour, and it is the deactivation deadline that bites first.
The network is a shownet: it is relaunched every couple of weeks. A key does not need to outlive the network.
tz5 and tz6 do not need the same care#
Both are post-quantum. That is not what separates them.
| tz5 | tz6 | |
|---|---|---|
| Scheme | ML-DSA-44 (FIPS 204) | XMSS (hash-based) |
| State | Stateless | Stateful |
| Signature budget | Unlimited | slot_end - slot_start + 1 |
| Aggregatable | No | Yes (recursive zkVM proof) |
| Rotation | Not required | Required |
Each signature of a tz6 key consumes one slot, and every slot is a one-time
signing key. The key is only valid over the range [slot_start, slot_end] fixed
at creation time.
The rule that governs everything else
Signing twice with the same slot permanently compromises the key — a third party can then forge your signatures, and therefore your double-signatures. The slot counter is mutable state living in the wallet: it needs protecting just as much as the secret key does.
The counter: where it lives, what it means#
The counter is client-side, in <base_dir>/xmss_slots, keyed by the secret key
URI. On every signature Octez reads the current slot, writes slot + 1, and
then signs — wasting a slot is safe, reusing one is not.
The join tutorial works inside ~/pqnet-tezos, with the wallet in client and
the binaries in bin; those are the paths used throughout:
cat client/xmss_slots[ { "uri":
"unencrypted:xmsk2cLjZfvwyhZxrq4XXuhYCBZ8sWwdpduUVjUm8N2bqF9RWb75NGZDn1UhVjHrKGpxCzvk9ExuL32krZKGmptzhAc",
"slot": 0 } ]slot is the next slot to be used, so for a key of range [start, end]:
remaining slots = slot_end - slot + 1Direct consequence
A signature that fails (node unreachable, application error) has already burnt its slot. A burst of failures consumes real budget.
What a baker actually consumes#
An attester signs twice per level: preattestation + attestation. The DAL attestation is embedded in the attestation and costs nothing extra. Add one signature per block proposed, and one extra signature per additional round during round changes.
Two measurements, and they differ by design:
- An external baker with a small share consumes ~2.0. Measured on this
network over a full cycle (cycle 244, 178 slots across 88 attested levels,
attesting_power120/7000): 2.02 signatures per level — essentially preattestation + attestation, since at that share block proposals are rare (~1.7 per 100-level cycle) and round changes were absent. - In-cluster bakers consume ~2.6, measured on a live 5-baker net where each baker proposes a fifth of the blocks.
Consumption therefore scales with your baking power, and 2.0 is a floor, not
a budget. For sizing, this document and the deployment code both use 3
(pqComputeBaseMargin in internal/cmd/tezosk8s.go) — headroom for proposals,
round changes, and bursts of failed signatures. Use 2.0 only to interpret a
counter you are watching, never to decide how big a key to generate.
| At 3 sig/level, 6 s blocks | |
|---|---|
| per level | 3 slots |
| per cycle (100 levels) | 300 slots |
| per hour (6 cycles) | 1 800 slots |
| per day | 43 200 slots |
The octez default is not viable here
gen keys --sig xmsswith no options produces the range[0, 1023]— 34 minutes of attesting. That is shorter than the rotation margin below, meaning such a key cannot even survive one activation window. The deploy path rejects this configuration outright (feasibility guard,internal/cmd/tezosk8s.go:174). A production key must be sized explicitly.
Sizing the range#
The Merkle tree is not stored on disk — it is rebuilt on every load of the key. That cost grows linearly with the range, and it is paid at generation time as well as at baker startup (the baker warms the key up off the consensus critical path).
Generation times measured on the branch binary bin/octez-client
(46c784de), 16-core i7-13620H, net of the ~1.4 s client startup. Autonomy at
43 200 slots/day; "usable" subtracts the 1 200-slot rotation margin derived in
the next section.
slot_end | Signatures | Generation / load | Autonomy | Usable before rotating |
|---|---|---|---|---|
| 1 023 (octez default) | 1 024 | 0.04 s | 34 min | infeasible |
| 8 191 | 8 192 | 0.5 s | 4.6 h | 3.9 h |
| 16 383 (in-cluster default) | 16 384 | 1.2 s | 9.1 h | 8.4 h |
| 65 535 | 65 536 | 4.4 s | 1.5 d | 1.5 d |
| 131 071 | 131 072 | 8.8 s | 3.0 d | 3.0 d |
| 524 287 | 524 288 | 38 s | 12.1 d | 12.1 d |
Memory is ~345 MB per key and does not grow with the range — but it is additive per key the baker holds: a baker measured mid-rotation, holding one 131 071 tree and one 16 383 tree, sat at 690 MB RSS. Warm-up is additive the same way: 9.2 s from daemon start to first signature with one tree, ~11.8 s with two. Budget for both during the rotation window, when you deliberately hold two keys.
Recommended for a baker rotating by hand: --xmss-slot-end 131071 — about
three days between rotations, for ~9 s of warm-up at baker startup, and (measured,
see below) no penalty at all on per-signature latency.
Why this differs from the in-cluster default of 16 383
internal/genesis/pqkeys.gosets 16 383 for the bakers pqpark deploys, on the grounds that XMSS signing latency grows with tree height (cf. the tezosxmss_large_slot_range_no_signing_stallregression test) — "keep keys this size and ROTATE rather than use giant, slow-to-sign keys".That is the right call there, because those bakers rotate automatically:
rpc-baker.shruns a background loop that regenerates and rotates without a human. At 16 383 they rotate roughly every 8 hours and nobody notices. A small tree also keeps their startup warm-up near a second, which matters when a rotation ends in a pod restart.You are doing it by hand, and the latency argument does not transfer: measured on this network, a 131 071 tree signs no slower than a 16 383 one (A-B-A experiment below). So the trade is simply three manual rotations a day at 16 383 versus one every three days at 131 071, paid for with ~8 s more warm-up and ~345 MB of RAM. For a human-operated baker that is not a close call.
Tree height does not cost you signing speed. This was settled by an A-B-A experiment on a live external baker (2026-08-19): the same baker process, never restarted, holding the same three keys throughout, rotated 16 383 → 131 071 → 16 383 across cycles 255–262. Latencies were referenced to an external clock — a poller on the node's
monitor/heads/mainstream, outside the baker process, so it cannot be skewed by the baker's single-threaded event loop (which XMSS signing loads, cf.docs/public-p2p-exposure.md). Levels are then matched on baker lag < 80 ms so every phase is compared under equivalent conditions.
phase key n sign median 95% CI A XMSS @16 383 104 435 ms [317, 555] B XMSS @131 071 128 368 ms [303, 416] A′ XMSS @16 383 81 371 ms [282, 480] The return leg is the whole point: A′ came back to 371 ms, matching B (368 ms) rather than A (435 ms) — with the same key as A. A difference that does not reproduce when you restore the original condition is drift, not a treatment effect. Formally: 131 071 − 16 383 = −41 ms, 95% CI [−135, +56], while the same-key A→A′ drift is +53 ms, CI [−94, +205] — the noise is as large as the signal (Mann-Whitney p = 0.12 / 0.49 / 0.44 across the three pairings).
So: no per-signature latency difference between a 16 k and a 131 k tree, and the experiment can exclude anything larger than about ±135 ms. The earlier impression that 16 383 was slower was entirely an artefact of an unstable link and a control that ran through the baker's own event loop.
What a bigger tree does still cost is generation and warm-up time (0.04 s → 38 s across the table above) and RAM, not signing. That is what the in-cluster default of 16 383 is really buying, and it matters much less to a baker who rotates by hand than the rotation interval does.
XMSS is nevertheless far slower than ML-DSA, and that gap is real: under the same matching, a tz5 signs in 72 ms with a p90 of 80 ms — near-constant-time — against ~370–435 ms median and a p90 near one second for any XMSS key. On a 6 s block the median is comfortable; the tail is what will eventually cost you an attestation. Monitor the tail, not the average.
When to rotate#
A new consensus key only becomes active at cycle
n + consensus_rights_delay + 1 = n + 3: between 2 and 3 cycles, so
20–30 minutes, and the old key has to cover that whole window.
The margin used by the deployment code, and the one this runbook adopts:
margin = (consensus_rights_delay + 2) × blocks_per_cycle × 3
= (2 + 2) × 100 × 3
= 1200 slots ≈ 40 minutesThat is the activation window plus one full cycle of buffer. Rotate when
remaining < 1200.
With the recommended slot_end = 131071 that threshold is reached after ~3
days of baking, and it leaves ~40 minutes of headroom — comfortable for a
process that needs 30 minutes to take effect, but not comfortable enough to
discover it by accident. Check the counter at least daily; see the monitoring
section.
The deadline is deactivation, not missed rights
With
tolerated_inactivity_period = 2on 10-minute cycles, a baker that stops attesting is deactivated in ~20–30 minutes. Exhausting a key does not just cost you rights until you fix it: it drops your delegate to inactive, and you then have to re-register and wait for rights all over again. Missing the rotation window is expensive here in a way it is not on mainnet.
Rotating#
Rotation goes through the consensus key: the manager key is untouched and delegators do not move. The old key keeps signing until the new one activates.
Commands assume the tutorial's alias, its delegate mykey, and its current
consensus key consensus-1:
alias tzc='bin/octez-client --base-dir client --endpoint http://127.0.0.1:8732'
PKH=$(tzc show address mykey | awk '/^Hash:/ {print $2}')1. Generate the new key, sized
tzc gen keys consensus-2 \
--sig xmss --xmss-slot-start 0 --xmss-slot-end 131071The flag is --sig (not --sig-alg). The counter is initialised to
slot_start at generation.
On
--encryptedAn encrypted key (
xmesk…) cannot be decoded, soxmss_status.pycan no longer read its range — the monitoring below goes blind on exactly the key it most needs to watch, and it will tell you so (RANGE UNKNOWN).On a shownet that is relaunched every couple of weeks, with a key that is deliberately short-lived, encryption buys little. Leave it unencrypted. If you do encrypt it, write the range down and pass
--warn-slotsagainst a range you track yourself.
2. Check the key before announcing it
tzc show address consensus-2
# Hash: tz6ENM6…
# Public Key: xmpk…
python3 ~/pqpark/docs/xmss_status.py clientThe counter must equal slot_start, and the range must be the one you asked
for.
3. Announce the new consensus key
tzc set consensus key for mykey to consensus-24. Confirm the announcement is recorded
tzc rpc get /chains/main/blocks/head/context/delegates/$PKH/consensus_keyThe response carries active (the key signing today) and pendings (the
announcements, with their activation cycle). The new key must appear under
pendings.
5. Restart the baker with both keys — without removing the old one
The baker fixes its key set at startup, and must know both keys for the whole window: it signs with the old key until the activation cycle, and with the new one afterwards. That restart is also what pays the new key's warm-up.
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:10732 \
--liquidity-baking-toggle-vote passThe baker confirms its key set on startup — check this line before walking away:
Baker will run with the following keys:
'consensus-1' (tz6A3iihbRym3fZfM3ALPVfkN1fbhDMHapFy)
'consensus-2' (tz6ENM6…)
'mykey' (tz5RjfCCXq51Udp5ZegArR9Vpm2XT7gmmKs2)Until the new key activates, the baker reports it as having no rights
(The following delegates have no attesting rights at level …, naming the tz6).
That is expected, not a misconfiguration.
You have ~30 minutes before activation, so the restart is not urgent — but do not postpone it past the activation cycle, or the baker will not hold the key it is supposed to be signing with. The restart itself costs attestations: measured at 8 levels (~55 s) between the last signature of the old process and the first of the new one, warm-up included. Do it early in the window, not at the boundary.
6. Wait for activation, then verify
At cycle n + 3 (~30 minutes), the same RPC must show the new key as active
and pendings empty. Then check that the new key's counter is advancing and
that the old key's counter has stopped:
python3 ~/pqpark/docs/xmss_status.py clientalias next slot_end remaining autonomy status
consensus-1 130204 131071 868 29 min ROTATE NOW <- frozen
consensus-2 130 131071 130942 3.0 d ok <- advancingThe retired key still reads ROTATE NOW — expected: the status column knows
nothing about which key the baker signs with. What matters is that its next
has stopped moving, and the new key's has not.
Once the new key is active the attestation log gains a with consensus key
clause, which is the ground-truth confirmation that the tz6 is doing the signing:
injected attestation (attesting DAL slots at published level(s): 24418 -> [0])
for level 24421, round 0 for delegate
'mykey' (tz5RjfCC…) with consensus key
'consensus-2' (tz6ENM6…)The new key burns nothing before it activates
Verified on a live rotation: the incoming key's counter stayed at
0for the entire ~30-minute window while the outgoing key kept signing, and took its first slot at the first level of the activation cycle. You do not need to size in any budget for the waiting period.
Your DAL node needs no change
The DAL attester profile follows the delegate, not the consensus key. A node running
--attester-profiles <tz5-manager>kept attesting DAL slots across a consensus-key rotation without a restart. Do not rotate it.
7. Retire the old key — without deleting its counter
Take consensus-1 out of the baker command line at your next restart. Keep its
xmss_slots entry and the key in the wallet: if you delete them and one day
re-import the same key, you restart from a zeroed counter, i.e. straight into
slot reuse.
Monitoring#
There is no exhaustion warning from Octez: no baker event tells you the budget is running low. The first signal is the hard failure, mid-baking:
Error:
XMSS signing slot 64 is out of the key's valid range [0, 63]: the key is exhausted.From that point the baker signs nothing, misses every right, and is deactivated within ~30 minutes — while a rotation needs ~30 minutes to activate. There is no recovering from this gracefully; you have to not reach it.
docs/xmss_status.py is built for a cron. It exits 0 when every key is
fine, 1 when one is below the margin or unreadable, 2 when one is
exhausted, and with --quiet it prints nothing in the healthy case:
python3 ~/pqpark/docs/xmss_status.py clientalias next slot_end remaining autonomy status
consensus-1 129998 131071 1074 36 min ROTATE NOW
consensus-2 41203 131071 89869 2.1 d okAn hourly cron is enough at slot_end = 131071 (three days of budget):
0 * * * * python3 ~/pqpark/docs/xmss_status.py ~/pqnet-tezos/client --quiet || \
echo "tz6 rotation needed" | mail -s "pqpark baker" you@example.orgDefaults are this network's: --sigs-per-day 43200, --warn-slots 1200.
Override both if you deploy against different parameters.
Not every missed attestation is the key's fault
An external baker holds a single peer over an off-cluster link, and that link is the more common culprit. Observed on the reference run: an 18-level gap (~108 s) with no signing involved at all, announced by
textWARN │ No data received from monitor_operations RPC for 9. seconds. │ Assuming the stream is stalled and refreshing it.followed by several levels delivered in one burst once the stream recovered. Key exhaustion looks nothing like this — it is the explicit
slot … is out of the key's valid rangeerror, and it does not recover on its own. Checkxmss_status.pybefore blaming the tz6: ifremainingis healthy, the problem is upstream of the key.
Also
Every one-shot
octez-clientinvocation that signs rebuilds the key (≈ 9 s atslot_end = 131071). For a production key, go through theoctez-bakerdaemon, which warms it once at startup and keeps it cached.
What breaks a tz6 key#
Every situation below produces the same fault: two signatures on one slot.
- Two bakers, one key. Two machines, a copied wallet, or a remote signer shared between two clients: each copy has its own counter and they diverge immediately.
- Restoring
xmss_slotsfrom a backup. That rewinds the counter. A wallet backup containing a tz6 key is not restorable as-is. -
Re-importing a key without its counter. This is why import requires an explicit slot:
bashtzc import secret key consensus-2 \ "unencrypted:xmsk...?xmss_slot=41203"The slot you pass must be strictly greater than the last slot used on the original machine. When in doubt, round up: losing slots is free.
- A VM snapshot or clone resumed twice from the same point: both instances replay the same slots.
The good news
The error is asymmetric and Octez plays the safe side: the increment is written before the signature, so a crash loses a slot instead of replaying one.
Appendix · xmss_status.py#
Decodes the range of every xmsk key in the wallet and cross-references the
counter. No dependencies. Kept in sync with docs/xmss_status.py — run that
file, this listing is for reading.
#!/usr/bin/env python3
"""État des clés XMSS (tz6) d'un wallet octez-client.
Reads <BASE_DIR>/xmss_slots (the counter) and decodes the slot range stored in
each xmsk secret key (the capacity), then reports how much signing budget is
left and whether it is time to rotate.
Exit codes, so this can be crontab'd directly:
0 every key has more than --warn-slots remaining
1 at least one key is below the margin (rotate now) or cannot be read
2 at least one key is exhausted (the baker is already failing)
Defaults are those of the pqpark demo network: 6 s blocks, blocks_per_cycle=100,
consensus_rights_delay=2, ~3 signatures per level. Override for another network.
"""
import argparse
import json
import os
import sys
ALPHABET = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz"
XMSK_PREFIX = bytes([32, 113, 56, 113]) # "xmsk", unencrypted XMSS secret key
SEED_LEN = 20
# 3 signatures per level x 14400 levels/day at minimal_block_delay=6. Measured
# rates: ~2.0 for a small external baker, ~2.6 in-cluster; 3 is sizing headroom.
DEFAULT_SIGS_PER_DAY = 43200
# (consensus_rights_delay + 2) * blocks_per_cycle * 3 = (2+2) * 100 * 3.
# Mirrors pqComputeBaseMargin() in internal/cmd/tezosk8s.go.
DEFAULT_WARN_SLOTS = 1200
OK, ROTATE, EXHAUSTED = 0, 1, 2
def b58decode(s):
n = 0
for c in s:
n = n * 58 + ALPHABET.index(c)
raw = n.to_bytes((n.bit_length() + 7) // 8, "big")
return b"\x00" * (len(s) - len(s.lstrip("1"))) + raw
def strip_uri(uri):
"""Drop the scheme prefix and any ?xmss_slot=N query."""
return uri.split(":")[-1].split("?")[0]
def slot_range(sk_uri):
"""(slot_start, slot_end), or None if the key cannot be decoded.
Encrypted keys (xmesk...) are opaque: their range is only recoverable from
whatever you recorded at generation time.
"""
key = strip_uri(sk_uri)
if not key.startswith("xmsk"):
return None
try:
raw = b58decode(key)
except ValueError:
return None
if raw[:4] != XMSK_PREFIX:
return None
body = raw[4:-4] # without prefix or checksum
if len(body) < SEED_LEN + 8:
return None
return (
int.from_bytes(body[SEED_LEN:SEED_LEN + 4], "little"),
int.from_bytes(body[SEED_LEN + 4:SEED_LEN + 8], "little"),
)
def load_aliases(base_dir):
"""{normalised secret-key URI: alias} from <base_dir>/secret_keys."""
path = os.path.join(base_dir, "secret_keys")
if not os.path.exists(path):
return {}
with open(path) as fh:
return {strip_uri(e["value"]): e["name"] for e in json.load(fh)}
def collect(base_dir, sigs_per_day, warn_slots):
"""[(alias, next_slot, slot_end, remaining, days, state)], worst state first."""
path = os.path.join(base_dir, "xmss_slots")
if not os.path.exists(path):
sys.exit(f"no {path}: no tz6 key in this wallet")
with open(path) as fh:
entries = json.load(fh)
aliases = load_aliases(base_dir)
rows = []
for e in entries:
alias = aliases.get(strip_uri(e["uri"]), "?")
nxt = e["slot"]
rng = slot_range(e["uri"])
if rng is None:
# Range unknown => monitoring is blind on this key. That is itself
# worth alerting on, so it counts as ROTATE rather than OK.
rows.append((alias, nxt, None, None, None, ROTATE))
continue
_, slot_end = rng
remaining = slot_end - nxt + 1
state = (EXHAUSTED if remaining <= 0
else ROTATE if remaining < warn_slots
else OK)
rows.append((alias, nxt, slot_end, remaining, remaining / sigs_per_day,
state))
rows.sort(key=lambda r: (-r[5], r[0]))
return rows
def human(days):
"""Days as the largest sensible unit — cycles here are 10 minutes long."""
if days >= 1:
return f"{days:.1f} d"
if days * 24 >= 1:
return f"{days * 24:.1f} h"
return f"{days * 1440:.0f} min"
def main():
p = argparse.ArgumentParser(
description="Report the remaining signing budget of tz6 (XMSS) keys.")
p.add_argument("base_dir", nargs="?",
default=os.environ.get("TEZOS_CLIENT_DIR",
os.path.expanduser("~/.tezos-client")),
help="octez-client base directory "
"(default: $TEZOS_CLIENT_DIR or ~/.tezos-client)")
p.add_argument("--sigs-per-day", type=int, default=DEFAULT_SIGS_PER_DAY,
help=f"assumed consumption, for the autonomy column "
f"(default: {DEFAULT_SIGS_PER_DAY})")
p.add_argument("--warn-slots", type=int, default=DEFAULT_WARN_SLOTS,
help=f"rotate-now threshold, in slots "
f"(default: {DEFAULT_WARN_SLOTS})")
p.add_argument("--quiet", action="store_true",
help="print nothing when every key is fine (for cron)")
args = p.parse_args()
rows = collect(args.base_dir, args.sigs_per_day, args.warn_slots)
worst = max((r[5] for r in rows), default=OK)
if not (args.quiet and worst == OK):
width = max([len("alias")] + [len(r[0]) for r in rows])
print(f"{'alias':<{width}} {'next':>10} {'slot_end':>10} "
f"{'remaining':>10} {'autonomy':>10} status")
for alias, nxt, slot_end, remaining, days, state in rows:
if slot_end is None:
print(f"{alias:<{width}} {nxt:>10} {'?':>10} {'?':>10} "
f"{'?':>10} RANGE UNKNOWN (encrypted key: "
f"check the range you recorded at generation)")
continue
status = {OK: "ok",
ROTATE: "ROTATE NOW",
EXHAUSTED: "EXHAUSTED"}[state]
print(f"{alias:<{width}} {nxt:>10} {slot_end:>10} "
f"{remaining:>10} {human(days):>10} {status}")
return worst
if __name__ == "__main__":
sys.exit(main())Why tz6 at all, when tz5 is already post-quantum#
Because tz5 signatures cannot be aggregated. Individually the two schemes cost
the same (XMSS 2 329 bytes measured, ML-DSA-44 2 420 bytes), but N XMSS
signatures fold into a single recursive zkVM proof, and ML-DSA has no such
path — only Bls and Xmss attestations are eligible for aggregation.
Measured aggregate proof sizes: ~97 KB of fixed base plus roughly 0.4–1 KB per signer (96 967 bytes at 1 signer, 112 928 at 32), independent of the key's slot range. Break-even against individual ML-DSA signatures sits around 50 signers, and the gap widens from there. With a consensus committee of 7 000 slots and a consensus validation pass capped at 4 MiB / 2 048 operations, individual post-quantum attestations simply do not fit.
So: tz5 for accounts, transactions and the manager key — stateless, unlimited, no rotation. tz6 only where aggregation is mandatory, which is the consensus key. The mutable state is the price paid for that.
References#
Octez tree:
| Path | What it pins down |
|---|---|
src/lib_crypto/xmss.mli | Signing contract, one-time-slot warning, aggregation API |
src/lib_client_base/client_keys.ml | Slot counter, range validation, exhaustion message |
src/lib_client_commands/client_keys_commands.ml | Generation and import options |
src/proto_alpha/lib_delegate/baking_commands.ml | Key warm-up at baker startup |
src/proto_alpha/lib_delegate/block_forge.ml | Which signatures are aggregation-eligible |
src/proto_alpha/lib_protocol/constants_repr.ml | Consensus key activation delay |
src/proto_alpha/lib_protocol/main.ml | Consensus validation pass quotas |
This repository:
| Path | What it pins down |
|---|---|
internal/genesis/mainnet-parameters.json | The network parameters every number here derives from |
internal/genesis/pqkeys.go | In-cluster slot_end = 16383 and its rationale |
internal/cmd/tezosk8s.go | pqComputeBaseMargin, the 1 200-slot margin, the feasibility guard |
helm/tezos-k8s/scripts/rpc-baker.sh | The automatic rotation loop in-cluster bakers run |
docs/pq-default-bootstrap-plan.md | tz5 manager + tz6 consensus from genesis |
docs/join-network-tutorial.md | Joining the network: tz5 manager + tz6 consensus, and the bare rotation commands (§7) |