Running a node
The operator's guide to zycordd. The protocol it speaks is the wire specification; this page is about running it.
The one-line version#
zycordd --devnet --dir ./devnet
That is a full node: it validates every block from genesis, serves peers, and answers a
read-only RPC on 127.0.0.1:9420. To join the network that is actually running, see
Public testnet — and note that it needs the
-randomx binary.
What the node does not have#
There is no key material in the node process, ever. It accepts no seed, no
passphrase and no key file. Signing happens in zcd, and the node's only write
endpoint — /submit — grants no authority: a submitted certificate is
validated exactly as one arriving from a stranger would be.
There is also no privileged endpoint, because there is nothing to privilege. No key can pause, upgrade, censor or mint, so there is no call to expose.
The limiter keys on the transport peer and never on X-Forwarded-For: a limiter
that trusts a header the client sets is a limiter the client turns off. Behind a proxy every
request therefore arrives with the proxy's address, so the per-client limit stops being per-client
and becomes one shared bucket of 600 a minute for everybody at once — and a single caller
can empty it for all of them. That is arguably worse than no limit at all, and nothing in the
response says so: callers get a plain 429 whether they caused it or someone else did.
The read-only surface#
Anything that watches the chain from outside — an explorer, a monitor, an indexer — reads it here. The node hands over bytes and never interpretation.
| Endpoint | Answers |
|---|---|
/status, /head | Tip identity, height, state root |
/block?height=N or ?id=0x… | One block as JSON, with canonical, orphaned and confirmations |
/block?…&format=ssz | The same block as canonical SSZ bytes, application/octet-stream |
/params | The active parameter set and its consensus root |
/cell, /balance | Tip state |
/fees | Base fees, and the elastic ceilings in force |
/mempool | Pool counters; ?limit=N adds up to 1000 pending ids, lowest first |
/network, /metrics | Connection shape and counters |
/submit | The only write, and it grants nothing |
Whitepaper §8.1 makes the block's byte, gas and certificate ceilings functions of
T, the sequential target, and T is consensus state the epoch controller moves.
/params therefore carries only the genesis values —
block_byte_limit_genesis and friends — which are where T starts and the
floor it can decay back to, never the limit in force. Those numbers stay real
forever, so reading one for "the block size limit" is wrong silently, and by up to the distance
between the 2.5 MB genesis value and the 8 MB capacity wall. /fees serves the live
T and the four ceilings derived from it, so the derivation can be checked rather than
trusted.
Why the bytes matter#
blake3("zcd/block/v1" ‖ header_bytes) is the block id, so an observer that
re-derives ids from what it was served either agrees with the network or finds out immediately. It
is also how per-certificate outcomes are obtained: whether a certificate was applied, skipped and
billed, or dropped is computed inside the fold and never persisted, because one
row per certificate forever, in a store that lives in memory, is not something a node can be asked
to carry. An observer that wants the outcomes folds the block itself.
What is not here, and will not be#
- No arbitrary state iteration. No address list, no prefix scan, no rich list.
- No historical state.
/celland/balanceread the tip; "balance at height H" implies retained history the node does not keep. - No admin, reindex or debug call, because there is nothing to privilege.
Aggregation belongs to whatever is watching, computed once at ingest into its own database.
Status codes#
Reads answer GET and HEAD and refuse every other verb with
405. A well-formed question with a negative answer — a height the chain has not
reached, an unknown id, the body of a block that lost a reorg — is 404; only a
malformed request is 400. A record the node wrote and can no longer read back is
500, not 400: the fault is the node's disk, and telling a caller its
request was malformed invites it to stop retrying a request that was always well-formed. A poller
should never have to read prose to tell absence from error, or its own bug from the node's.
Reorgs#
Height lookup answers for the canonical chain only. A block that loses a reorg keeps its header
and loses its body, so its id still resolves, comes back marked orphaned, and still
carries the parent link back to the fork point — which makes id lookup the only
reorg-safe path.
Reachability, and why it matters more than it looks#
Zycord does not do NAT traversal. A node with --listen on a reachable port is
somewhere others can bootstrap from, and the share of such nodes is the falsifiable
condition the whole no-traversal decision rests on.
# periphery: nothing to configure
zycordd --testnet --dir ./testnet
# core: bind a port, and make sure it is actually reachable
zycordd --testnet --dir ./testnet \
--listen 0.0.0.0:9421 --advertise <public-address>:9421
--advertise falls back to --listen, so --listen
0.0.0.0:9421 with no --advertise publishes 0.0.0.0:9421, which
nobody can dial. A wrong address propagates through peer exchange and costs every node that tries
it a dial. If you cannot forward a port, leave --listen off and be periphery.
Checking whether it worked#
curl -s localhost:9420/network
{"enabled":true,"peers":8,"listening":true,"inbound":5,"outbound":3,"reachable":true}
listening: true with inbound: 0 after the node has
been up for a few minutes means the port is not actually reachable: the process is bound and
waiting, and nothing is arriving. That looks healthy in every other view, which is why this
endpoint exists.
Bootstrap addresses from a file#
zycordd --testnet --dir ./testnet --peers-file peers.txt
One address per line; # comments and blank lines are ignored, and the list is
merged with the network's built-in seeds. --peers takes the same thing as a
comma-separated argument, and --no-seeds drops the built-ins while keeping both.
The wallet interface, over an ssh tunnel#
A node holds no key and never will. The wallet is a separate process, and on a server it is
zcd ui:
zcd ui --key wallet.json --no-open
That prints a URL and serves it on 127.0.0.1:9430. It binds loopback and
refuses anything else, with no flag to override. The process behind that listener holds
an unlocked private key and authenticates with a bearer token in a URL, which is adequate for a
socket only the local machine can open and is not adequate for anything else. Reaching it from
elsewhere is ssh's job, and ssh is better at it than this would be:
# on your own machine
ssh -L 9430:127.0.0.1:9430 <host>
Then open the URL the server printed. The token rides in the URL fragment,
so it never reaches the server, a log, or a Referer header — treat the URL as
the secret it is. It is new on every run, and Ctrl-C wipes the key and stops serving.
The local end of the forward does not have to be port 9430: the interface checks the
hostname in the Host header and deliberately not the port. The hostname is
what matters — it is what blocks DNS rebinding, which is the real attack against a server on
loopback, since any page in your browser can make requests to 127.0.0.1 and the one
thing it cannot forge is the name it was navigated to.
zcd uiThe advice above about the node's RPC is about a surface that holds no key and grants no authority. This one holds a key. There is no version of exposing it that is a good idea, and the tunnel costs one flag.
Two other shapes worth knowing:
zcd ui --lockedstarts without asking for a passphrase in the terminal; the browser asks instead. Useful when the terminal is shared or logged.zcd ui --lock-after 5mshortens the idle lock, which wipes the key in place — the seed is overwritten, not merely dereferenced.
Peer identity and anonymity#
The node generates a fresh Ed25519 peer key on every start and never writes it to disk. It is derived from nothing — not from your wallet, not from a seed, not from anything else on the machine. Restarting rotates it.
If you are running a node in a way that matters to you, the thing that deanonymises you is not the key. It is the address. A bootstrap node you run on infrastructure traceable to you is a deanonymisation vector that no amount of key hygiene fixes.
Data directory#
data/
chain/ blocks, state, and the write-ahead log
peers.json the persisted peer store
The peer store is persisted on purpose: a node that starts from a blank slate after every restart hands an attacker a fresh chance to fill it. Deleting it is safe but throws that away.
The node survives being killed at any moment — that is what the write-ahead log is for, and it is tested by killing nodes at random under network chaos. It does not survive its data directory being edited underneath it: on start it recomputes the state root and refuses to run if the stored one disagrees.
Choosing a network#
| Flag | Network | Chain id | Engine |
|---|---|---|---|
| (none) | mainnet | 1 | randomx-v1 |
--testnet | public testnet | 2 | randomx-v1 |
--devnet | local devnet | 1337 | development engine |
The parameters are embedded in the binary, not read from a path. Every
participant of a public network has to carry the same bytes or they are not on one network, and a
file passed by --params is a file that drifts. A different network is a different
network: the two disconnect at the handshake, and a data directory holding the wrong chain is
refused at startup rather than silently mixed.
Recovering a damaged data directory#
zycordd carries a repair path for a data directory that a node refuses to start
against. Stop the node first — zycordd repair --dir ./data takes the directory
lock and refuses while a node holds it — then ask what happened with --dry-run
before changing anything. Only one damage class is repairable in place; for the others the remedy
is to resync, and the dry run is what tells you which one you have.