The Kleo signet
Kleo runs on a signet of our own: kleoapp.xyz. This is the record of how it was brought up and, more usefully, of the four things that will waste a day if you do not already know them.
A custom signet is not a convenience here. BitNames has to be activated on a sidechain slot, and activation is a vote cast by whoever mines the coinbase. On the public signet we do not control the coinbase, so we could never activate anything. On our own we control it, and the enforcer's non-mainnet thresholds mean activation takes six blocks instead of 13,150.
Parameters
| Challenge (scriptPubKey) | 00142de41067e8cb93aae3ed7016d0003c1eb3c904a7 |
| Signing key | /opt/kleo/data/signer-wif.txt, in Core wallet kleosigner |
| Mining address | tb1q9hjpqelgewf64cldwqtdqqpur6eujp98vamy9j |
| Data directory | /opt/kleo/data/signet |
| RPC / ZMQ | 127.0.0.1:38332 / tcp://127.0.0.1:29000 |
| Enforcer gRPC | 127.0.0.1:50051 |
| BitNames node | 127.0.0.1:6002 |
| Sidechain slot | 2, activated at height 163 |
The four things that cost a day
1. Fix the challenge before the data directory is ever used. The signet challenge determines the network magic, and the magic is written into the block files. Change the challenge afterwards and the node cannot read its own genesis. It does not tell you that: it says Block not found on disk, from whatever component happened to ask for a block, hours away from the cause. The underlying message is Block magic mismatch d2f13cc7 versus expected 552cf352. Wallets are bound to the chain too, so generate the key offline, before the chain exists — Core's test_framework will do it — and only then create the datadir. Importing it later needs the private descriptor, wpkh(WIF)#checksum;
getdescriptorinfo hands back the public one, which silently gives you a watch-only wallet that cannot sign.
2. Stock Bitcoin Core will not relay a deposit. BIP301 deposits pay to
OP_DRIVECHAIN (OP_NOP5), which stock Core rejects as non-standard:
failed to broadcast tx: ServerError(-26), message: "scriptpubkey"Add acceptnonstdtxn=1 under [signet]. Consensus is untouched — to stock Core a NOP is a no-op, and the enforcer is what actually reads the output. The alternative is LayerTwo-Labs' bitcoin-patched, which publishes no binaries.
3. Do not point the enforcer at Core's signet miner. The enforcer passes
--block-interval, which only LayerTwo-Labs' patched miner understands; Core's rejects the invocation. Leave --signet-miner-script-path unset and the enforcer clones the patched miner itself, at a pinned commit. Setting it to
contrib/signet/miner from a Core release looks right and fails.
3b. The block interval is a constant in the enforcer, not a setting.
target_block_interval returns 600 seconds for every challenge but LayerTwo-Labs' own — and it compares the challenge's script bytes against the ASCII of a hex string, which can never match, so even theirs gets 600. On a chain you mine yourself that number is yours to choose, so
scripts/hub/patches/enforcer-block-interval.patch makes it read
ENFORCER_TARGET_BLOCK_INTERVAL, leaving upstream behaviour when unset. Ours is 60, set in the enforcer's unit. Whatever drives mining has to pace to the same number.
4. Never let the tip run ahead of the wall clock. This is the one that bites hardest, because it is not self-correcting.
Each block is stamped a fixed interval after the last, whatever the clock says, so mining faster than that interval walks the tip into the future. Bitcoin rejects a block more than two hours ahead, so a chain that drifts far enough refuses its own next block and stops. Ours reached 112 minutes.
It cannot be undone by mining: a block timestamped earlier than its parent connects and is then marked invalid, and the chain rolls back to where it was. The median-time-past rule allows a block below the previous one but the node will not keep it, so there is no clever way down. The only cure is to stop mining and let the clock overtake the tip — after which the enforcer's catch-up keeps block times near real time by itself, which is where a chain should sit.
So whatever drives mining must pace itself in real seconds, one block per target interval, rather than mining as fast as blocks will come.
scripts/hub/kleo-mine does that; nothing else will do it for you.
The original symptom: The enforcer mines on demand only while the tip is at least its target interval behind real time — 600 seconds for any challenge but LayerTwo-Labs' own, and there is no flag to change it. Ahead of the clock, it hands the miner no --set-block-time, the miner sleeps until its next scheduled slot, and the enforcer kills it:
signet miner subprocess timed out. Its catch-up step advances each block by half the tip's age, so bulk mining through the enforcer walks the tip forward until it overshoots. Mine in bulk with Core's stock miner and
--set-block-time instead, which has no pacing at all, and keep the timestamps behind real time.
Bringing it up
# 1. The key and the challenge, before any datadir exists.
# (Core's test_framework: ECKey -> WIF + wpkh scriptPubKey.)
# 2. bitcoin.conf: signet=1, signetchallenge=..., acceptnonstdtxn=1, txindex=1,
# server=1, zmqpubsequence, rpcuser/rpcpassword.
# 3. Import the signing key and mine past coinbase maturity:
miner --cli="bitcoin-cli -datadir=$D -signet -rpcwallet=kleosigner" generate \
--address "$MINER_ADDR" --grind-cmd="bitcoin-util grind" \
--min-nbits --set-block-time=$T # one block per call; advance T by 1s
# 4. The enforcer. No --signet-miner-script-path.
bip300301_enforcer --data-dir=$E \
--node-rpc-addr=127.0.0.1:38332 --node-rpc-user=kleo --node-rpc-pass=$PASS \
--node-zmq-addr-sequence=tcp://127.0.0.1:29000 \
--serve-grpc-addr=127.0.0.1:50051 --serve-rpc-addr=127.0.0.1:8122 \
--enable-wallet --wallet-auto-create --wallet-sync-source=disabled \
--enable-mempool --enable-block-template-server \
--signet-miner-bitcoin-util-path=/opt/kleo/bin/bitcoin-util \
--signet-miner-bitcoin-cli-path=/opt/kleo/bin/bitcoin-cli
# 5. Propose BitNames on slot 2, then mine until it activates (~6 blocks).
grpc BlockProducerService/SubmitSidechainProposal \
'{sidechainId:2, declaration:{v0:{title:"BitNames", ...}}}'
until sidechain_active; do enforcer_mine 1; doneDeposits come from the enforcer's wallet, not Core's, so its own coinbases have to mature first — mine to an address from WalletService/CreateNewAddress and wait out the hundred blocks. Then
WalletService/CreateDepositTransaction, confirm it, and BMM until the coins appear on the sidechain.
Mining the mainchain is only half of it
A BitNames block exists only because somebody paid, on Bitcoin, for the right to publish it. Mine the mainchain and nothing else, and the sidechain stays at whatever height it reached while the mainchain runs away from it — ours sat at height 3 for a day while the mainchain passed 400.
Nothing reports this. The node is healthy, the enforcer is healthy, blocks arrive on schedule, and every registration, profile and message simply stays unconfirmed. scripts/hub/kleo-mine is what avoids it: each round asks the node to bid and then mines the mainchain block that carries the bid, which is
regtest.sh's bmm_one on a timer.
This belongs to whoever runs the chain. A hub that has joined one does not mine, which is why deploy-hub.sh does not install it.
Day-to-day
Blocks come every sixty seconds: ENFORCER_TARGET_BLOCK_INTERVAL=60 in the enforcer's unit and KLEO_BLOCK_INTERVAL=60 in the miner's, which have to agree or the tip drifts. Upstream's default is 600, which is right for a chain meant to feel like Bitcoin and wrong for one meant to be tested against. For a hundred blocks in a hurry, the stock miner is still faster.
A node that wants the chain without carrying it can borrow ours:
KLEO_MAINCHAIN=grpc.kleoapp.xyz:50051 scripts/signet.sh lightOne daemon instead of three. The mainchain view is borrowed; the keys are not.
Caddy holds the public IP on port 50051 while the enforcer keeps loopback on the same port, and it forwards exactly one path prefix:
http://grpc.kleoapp.xyz:50051 {
bind 62.238.101.76
handle /grpc.health.v1.Health/* {
reverse_proxy h2c://127.0.0.1:50051
}
handle /cusf.mainchain.v1.ValidatorService/* {
reverse_proxy h2c://127.0.0.1:50051
}
handle {
respond "only cusf.mainchain.v1.ValidatorService is public here" 403
}
}The health probe is in that list because a BitNames node checks the service is alive before it will use it: refuse it and the whole endpoint is refused, with an error about compression flags that says nothing about what happened.
Two details cost a round each. Caddy matches a site on the address's host, so
http://62.238.101.76:50051 never matches a request whose Host: is
grpc.kleoapp.xyz — the hostname belongs in the address and the IP in bind. And
respond runs before reverse_proxy in Caddy's directive order, so a bare catch-all refuses everything, including the prefix meant to pass; handle blocks are what make first-match-wins hold.
Keeping it up
signet.sh light starts a node and walks away, so a long sleep, a logout or a restart leaves Kleo pointing at nothing until somebody notices. On macOS:
scripts/signet-agent.sh install # two launchd agents: the node, and the dialler
scripts/signet-agent.sh status
scripts/signet-agent.sh uninstallKeepAlive restarts either if it stops, throttled so a node that cannot start does not spin. The arguments are the ones signet.sh light uses; that script stays the place they are explained. Installing stops whatever
signet.sh started first — one node per datadir, or they fight over the same files and neither wins.
Our BitNames network is not the public one
Same software, a different mainchain underneath. On the default magic the node dials public signet peers and spends every tenth minute failing to fetch mainchain ancestors our enforcer has never heard of, because they belong to their chain:
ERROR Error fetching mainchain ancestors: Requested block was not available: 00000063...--network-magic=6b6c656f ("kleo") isolates it. Peers drop to none and the errors stop.