Documentation
Chain synchronization
Status: implemented — headers, the manifest, parallel bodies, serial application, the UTXO reorg.
This document describes how a node gets from genesis to the network’s tip and how it keeps up once
it is running. The code: cmd/powmachinad/bodysync.go (the driver), internal/blocksync (parallel
downloading), internal/store/restore.go (application and recovery after a restart).
Two chains, not one
A node holds two different kinds of progress and it is important not to mix them up:
| what it is | the mark in storage | who moves it | |
|---|---|---|---|
| the header tip | the highest verified header of the active branch | keyHeaderTip |
SetActiveBranch, ApplyBlockDirect |
| the UTXO tip | up to which block the UTXO set is finished | keyTip |
ApplyBlock* in one batch with the UTXOs |
The header tip can be far above the UTXO tip and that is a normal state, not an error: a header is 89 B, a body up to 352 kB (96 kB of transactions and up to 256 kB of settled shares).
The manifest
A verified chain of headers is a manifest: for every height it says what hash the block at that height has. Which means:
A block’s body can be downloaded from anyone. Even from an enemy.
The source does not have to be trustworthy — either it gives bytes whose hash and Merkle root match
the header, or we throw it away (blocksync.VerifyBody). That is why bodies are pulled in parallel,
out of order and even from a plain HTTP mirror (-blocksource).
The manifest is the height index in storage (keyHeight). There is a single rule about it, but a
strict one: the index describes the active branch. That is why only store.SetActiveBranch
writes it, which the node calls when the tip moves (indexActiveBranch), and not PutHeader: a
header on its own does not say whether it is on the active branch. When every accepted header wrote
the index, one header from a branch that lost was enough for the node to assemble a chain from two
branches after a restart, according to the index, and refuse to start.
A block’s path
peer ──inv(block)──▶ do we lack the header? ──getheaders──▶ headers ──▶ AddHeader (PoW, bits,
lane rotation, MESS)
│
SetActiveBranch │ (the manifest = the active branch)
▼
the body driver: what is missing between the UTXO tip and the header tip ──getdata──▶ bodies
│
VerifyBody + PutBlock │ (stores it, does NOT apply it)
▼
ApplyStored: in height order, with full validation into the UTXOs
A body is never requested for a header we have not verified — we would have nothing to verify it against. A body that arrives anyway is discarded without ban points: between the request and the arrival the header can be lost to a reorg and that is not the peer’s fault.
Propagating a new block
A node announces a new tip onward whenever its active branch moves — not only when it mines a block itself. Without that, relay is one-hop: the block reaches the miner’s direct peers and stops there: the miner’s node and its direct peers grow while the nodes behind them stand two to four heights below the tip.
A fresh block is handed over whole, not announced: one headers message
with its header and the block with its body right behind it, to every peer, at every hop — as
long as the block is under maxBlockPushBytes (64 kB). An announcement alone means the peer has
to ask for the headers, verify them, and only then may the body driver ask for the body: a round
trip plus up to one driver tick per hop, and when one request goes unanswered, the next
attempt is a whole bodyRequestTimeout away.
That chain of waits is not harmless. When a block’s header reaches every node in seconds and its body reaches none of them for minutes, nothing can be applied past the hole: the UTXO set of the whole network stops one block below it — with it every wallet and the explorer, while the tip keeps growing and the chain looks healthy.
The order within the push matters: a body whose header the peer does not know yet is dropped
(storeBody), so the header goes first in the same stream. Above the push limit the peer gets an
inv and pulls what it wants — which is also where compact blocks (BIP152) would belong one day.
A safeguard against a lost announcement: when the driver has nothing to fetch, it asks every peer
for headers once per headerPollInterval (30 s). One message per peer every half minute is cheaper
than a node that stands still until somebody new connects.
The body driver
What is to be downloaded is decided by state, not by the last message received:
- the candidates = the heights from the UTXO tip + 1 up to the header tip that are missing a body
(capped at
bodyWindowBatches × MaxGetDataItemsbodies in flight), - the batch per peer =
MaxGetDataItems(128), - a request nobody answered within
bodyRequestTimeout(20 s) is repeated — preferably to a different peer, and when there is only one, to the same one, - the oldest missing body is asked of every peer at once, the rest are split between them: a body verifies itself against the manifest, so a duplicate costs only bandwidth, while waiting for one silent peer costs a whole timeout — and nothing above that height can be applied meanwhile,
- the driver runs every
bodySyncInterval(2 s) and is additionally woken by receiving headers or aninvwith a new block.
Why doing it “after headers are received” is not enough: one message carries up to
MaxHeadersPerMessage (2,000) headers, but only 128 bodies fit in one getdata. A node that
requests 128 bodies and never asks for the rest stays with its headers ahead and its bodies
behind forever.
A single owner of application
Bodies are applied and the branch index is written exclusively by ApplyReadyBodies, under
applyMu, in this order: the index → reconciling the UTXOs → application. That holds for the bulk
download (-connect), for a body received from the network and for a driver tick alike.
When two places wrote the index (receiving headers and applying), two concurrent header messages overwrote the manifest with each other’s older view of the chain and application stopped with the headers ahead. It only showed up in the reorg test, and only occasionally — exactly the class of bug that is hardest to find on a live network.
Application and the reorg
Application is serial and validating (store.ApplyStored): it takes bodies from disk in height
order, runs Block.ValidateBlock on each and writes it into the UTXO set in one atomic batch with
the undo data. The invariant: blocks up to the UTXO tip are verified, bodies above it are not.
Before application the UTXOs are reconciled to the active branch (reconcileUTXOTip): while the
UTXO tip does not match the block that is at that height on the active branch, it is unwound through
store.RevertTip (the undo data is in storage with every applied block). Without this step a header
reorg would leave the UTXOs on a dead branch and every subsequent application would end with “the
block does not build on the tip” — forever, because nothing would bring it back from there.
RevertTip does not delete the height index: the manifest belongs to the header branch, not to
whatever is currently applied. When it did delete it, a reorg also deleted the entries the new
branch had written there moments earlier, and the driver had nothing left to go by.
The bulk download
-connect <host:port> and -blocksource <url> go through internal/blocksync: the manifest is
split into batches, those are pulled from every source at once, a source that lies is dropped
immediately and one that cannot keep up after MaxFailures. It is an accelerator, not an
authority — every body goes through the same verification against the manifest as a body from a
peer.
What it says about itself
Left alone, this whole path is silent: a body that fails verification is dropped without a word, and a request that is never answered looks exactly like one that was never sent — nothing to look at when it matters. So the node reports:
powm_header_body_gap |
how far the UTXO set is behind the headers — the metric the alert stands on |
powm_oldest_missing_body_seconds, powm_bodies_inflight |
how long the longest wait has been going on, and how many are waiting |
powm_body_requests_total, powm_bodies_delivered_total, powm_body_timeouts_total, powm_body_mismatch_total |
counters of the driver |
| the log | one line per 45 s when a body is missing that long: the height, the age, how many peers were asked; and a line for every body that does not match the manifest |
What the tests hold
| test | what would break otherwise |
|---|---|
TestSyncsChainLongerThanGetDataBatch |
the sync stops at the first batch of bodies |
TestSyncsChainOverRealGetDataWindow (POWM_SLOW_TESTS=1) |
the same with the real 128 |
TestOutOfOrderBodyWaitsForItsTurn |
an out-of-order body is applied or discarded |
TestBodySyncRetriesAfterTimeout |
a silent peer stops the synchronization |
TestMinedBlockReachesPeerWithBody |
relay does not deliver the body (inv → getdata without a header) |
TestBlockRelayCrossesTwoHops |
relay is one-hop — the nodes behind a neighbour stand still |
TestReorgRevertsUTXOToActiveBranch |
the UTXOs stay on a dead branch |
TestRestartAfterReorgLoadsHeavierBranch |
after a reorg and a restart the node comes up on the weaker branch |
TestLosingBranchHeadersDoNotRewriteManifest |
a header from a losing branch overwrites the manifest |
store.TestSetActiveBranchReplacesAndTruncates |
entries of the old branch stay in the index after a reorg |
TestInvalidBodyDoesNotStallNode |
a block with valid PoW and an invalid body stops the node |
TestInvalidBranchSurvivesRestart |
after a restart the invalid branch is accepted again |
TestSilentPeerDoesNotStallTheChain |
one peer that does not hand over one body stops the chain |
TestBlockWithTransactionsReachesPeer |
a body with transactions does not make it into the store |
TestMetricsShowHeaderBodyGap |
the gap between the headers and the UTXO set is invisible |
A node as its own seed
When a node is a DNS seed itself, it gets its own address from the seed. A connection to itself
is only revealed by the handshake (the nonce in version) — a node behind NAT does not know its
public address — and it is revealed by the receiving side, because that side reads version
first; the caller only sees a closed connection. That is why the receiving side remembers the
address (AddrMan.MarkLocal, keyed by IP) and the addrman never accepts it again. Without that a
public node knocks on itself every ten seconds.
What is still missing
- Bodies are requested from every connection equally — with no measurement of which peer keeps up. The oldest missing one is asked of everybody, which covers the case that hurt, but a peer that never delivers still gets its share of the work.
- The bulk download from HTTP mirrors (
-blocksource) is only used by the-connectpath; the background driver pulls bodies from peers exclusively.