Documentation
An audit against established chains
What is compared is what our code actually does (verified by reading and measuring, not by impression) against how networks that have been running for years do it — and above all against what they really got burned by.
what to verify yourself is the list for somebody considering trusting us. This document is the list for us: where we differ from the rest of the world and whether that is a decision or a hole.
The findings by consequence
| # | finding | consequence | status |
|---|---|---|---|
| 1 | Checkpoints and a “minimum chain work” | without them a new node can be fed a cheap fake history | ✅ the mechanism is in; the values are baked into every release after the first |
| 2 | The coinbase has to carry the height (BIP34) | otherwise duplicate txids after a full spend → an ambiguous index and ambiguous wallets | ✅ in consensus |
| 3 | The node has to know its peers’ time | otherwise a machine with a skewed clock silently cuts itself off from the network | ✅ |
| 4 | The GPU kernel has to be verified in CI | otherwise project rule 3 is not enforced | ✅ (cuda:verify) |
| 5 | The first epoch change on a live network | unknown memory and timing behaviour at a point that comes in every network, at height 5,000 (≈ 7 days) | rehearsed; the boundary itself has to be lived through |
| 6 | The mempool has to survive a restart, orphan transactions must be set aside | otherwise transactions are lost on a restart | ✅ |
| 7 | Signed and reproducible releases | otherwise nobody can verify that a binary came from this source | ✅ |
| 8 | Metrics and an alert on a stalled chain | otherwise a stopped chain is found out by accident | ✅ |
| 9 | The lane rotation relief must not be measured against median time past | against MTP the rule that is the whole point of two lanes is waived almost always — 31 lane C blocks in a row on a test network | ✅ in consensus: measured against the parent’s time |
| 10 | A fresh block has to be handed over, not announced; one missing body must not stall the network silently | a block that does not propagate stops the UTXO set of every node at the hole, so every wallet and the explorer freeze while the chain looks healthy | ✅ push, parallel request, metrics, alert |
The rest of the document is the defence of those findings and a list of what, by contrast, held up.
1. Money: inflation and double spending
What others got burned by: Bitcoin’s CVE-2018-17144 (a block could spend the same input twice and create coins from nothing), the Zcash counterfeiting bug (2018, fixed in secret), Bitcoin 2010 (duplicate coinbase txids, BIP30/BIP34).
What we do (internal/chain/block.go, utxo.go):
| check | where | status |
|---|---|---|
| the same input twice in a block | spent[op] in ValidateBlock |
✅ |
| duplicate txids within one block | seenTx[txid] |
✅ |
| an output whose outpoint already exists in the UTXO set | ErrCoinExists in ApplyBlockTo |
✅ (that is BIP30) |
| the sum of the outputs ≤ the subsidy + the fees | checkCoinbase, the exact dev fee value |
✅ |
| value overflow | MaxMoney on every subtotal |
✅ |
| a single implementation of applying a block, for memory and for disk | ApplyBlockTo over CoinBatch |
✅ |
Finding 2 — the coinbase carries the height. If the uniqueness of a coinbase txid rested only
on a 32 B random extranonce from the miner, ErrCoinExists would prevent the worse outcome
(overwriting an unspent output), but once the predecessor is fully spent a duplicate txid would
be acceptable — and then the transaction index has two answers to the same question. Bitcoin solved
this with BIP34: the height is mandatory in the coinbase, so the txid is unique by construction.
Here the first 8 B of the extranonce are the block height (little-endian), enforced in
checkCoinbase. One rule, no new structure — and a consensus rule, so it is there from genesis.
2. Time and difficulty
What others got burned by: Verge (2018, ~$1.7 M) — forged timestamps manipulated the retarget of one of the algorithms. Bitcoin still has the known “timewarp” weakness in its retarget window (it is only being fixed in a consensus change).
What we do:
| the lower time bound | not before the parent’s time (MTP of 11 blocks until the lane-difficulty fork) ✅ |
| the upper bound | 30 s into the future (120 s until the fork) ✅ |
| the retarget | per lane against the chain’s clock, an aserti3-2d style exponential with a 1 h half-life, recomputed every block (the specification §3.6) ✅ |
| one extreme solvetime | one gap moves a lane at most 16× either way ✅ |
| the difficulty floor | enforced in a single place (clampBitsToFloor) ✅ |
| the conversion between lanes | normalized chainwork, not constants ✅ |
Finding 3 — the node has to know its peers’ time. MaxFutureDrift is measured against the
local clock (hc.now()). Bitcoin and Monero both compute the median offset from the times in
peers’ version messages and, when the machine is off, say so loudly (Bitcoin additionally
limits the influence to ±70 min). A node that receives the peers’ times and throws them away
leaves its operator blind.
The consequence for a stranger: a clock two minutes fast → everybody else rejects their blocks; two minutes slow → they reject everybody’s blocks as “too far in the future” and it looks like a network outage. It is exactly the kind of failure a beginner has no way of diagnosing.
What the node does: the median offset from the handshake times, a warning in the log and a field in
getinfo. Do not touch consensus — the time validation runs on has to stay local, otherwise the
network can be shifted through peers.
3. Reorgs, 51 % and finality
Here we are above the average of established chains, because it was built on their experience:
| us | the comparison | |
|---|---|---|
| a deep reorg | MESS (an hour-deep one requires 2.7× the network’s work) | the Ethereum Classic ECIP-1100 model, after three 51 % attacks; none successful since |
| definitive finality | a quorum of 5 of 9 at a depth of 20 blocks | Komodo’s dPoW solves the same thing with notaries |
| a double signature | a self-contained proof, finality off for that height, exercised on a live network | most networks with a federation have not tested it at all |
| a cap on reorg depth | ReorgLimit 500 blocks |
Bitcoin has nothing like it (and does not need it — it has hashrate) |
Finding 1 — checkpoints and a minimum chain work. This is the most serious thing in this
document for a young chain: its first blocks are at the difficulty floor, so anyone with a single
card can produce a branch from genesis with more chainwork than the real history, and a new node
connecting for the first time would pick theirs. Running nodes are safe (MESS, finality,
ReorgLimit), a newcomer is not.
Bitcoin solves this with two things at once: nMinimumChainWork (headers below it are not taken
seriously) and checkpoints baked in at release time. We do the same: the values are produced by
scripts/bake-checkpoint.sh from a running node, on a block that the finality quorum confirmed
— the least arbitrary choice there is — so baking them is one command, not deciphering hex by hand.
The first release of a network ships with them empty, because there is no block to pin yet; every
release after it carries them.
MinChainWork is taken from the tip, not from the checkpoint: it answers the question “does this
chain have at least as much work as the network had at release time”, not “how much did it have at
height X”. A chain only grows, so the value stays valid later on too; a new node passes it during
its first sync and until then it reports trusted_work=false.
4. P2P and eclipse
What others got burned by: eclipse attacks on Bitcoin (2015, Heilman et al.) → that is where the addrman with buckets, feeler connections and anchor peers come from.
| status | |
|---|---|
| an addrman with buckets by network group, not by number of addresses | ✅ |
| feeler connections (verifying old addresses) | ✅ |
| anchor peers after a restart | ✅ |
| block-relay-only connections (they do not exchange addresses) | ✅ |
| choosing between verified and new addresses by a coin flip | ✅ |
| our own address is not dialled | ✅ |
| ping/pong keepalive | ✅ |
| limits before the handshake (per machine, per network, a rate) | ✅ |
| a ban by machine (an IPv4 address, an IPv6 /64), not by connection | ✅ |
| headers-first sync + a manifest of bodies | ✅ |
| compact blocks | ❌ — not yet; at 30 s, blocks are pushed whole and compact blocks are next (security §5) |
| Tor / onion | ❌ — admitted in security, §5b |
Finding 6: a mempool that is thrown away on a restart, and a transaction whose parent has not arrived yet being discarded instead of set aside.
Neither is as minor as it looks at first glance:
- A mempool dump (
<datadir>/mempool.dat, written throughrename, mode 600). After loading, the file is deleted and every transaction goes throughAcceptagain — that is, the signatures, the fees, the dust limit and the chaining. The file has no privilege over a transaction from the network; it is input from an attacker like any other. The time of acceptance is carried over too, otherwise a restart would make the pool younger and the expiry would never be reached. Expiry goes along with it (MempoolExpiry72 h): without it the dump would turn a once-stuck transaction into permanent garbage carried through every restart. - An orphan pool (100 transactions / 4 MiB / 20 minutes). A child and its parent travel the
network independently, through different peers and paths of different lengths — with a chained
payment the order is a matter of chance, not an exception. The node also requests the parent
for a set-aside child (
getdata), otherwise it would only get it if somebody announced it again. It is the only place in the node where unverified data from strangers sits, hence a cap on both the count and the bytes, a short expiry and evicting a random element — if the oldest were evicted, an attacker could flood the pool with their own and nobody else’s would survive in it.
One more thing belongs here: Close() waits for the maintenance loops and for the connection
goroutines. Those are created for every connection and touch both the storage and the epoch
cache, so a shutdown under load that did not wait for them would crash.
5. DoS: how much work an attacker can force
This is measured, not estimated:
| vector | the cap | note |
|---|---|---|
| verifying the worst-case block | 202 ms (1,184 transactions, 196 kB, 170 µs per input) | 0.17 % of a block’s time; the cap is held by the block size, not by counting sigops |
| a header with bad PoW | 1 verify (1 ms full DAG / 63 ms light) and an immediate ban | the attack does not repeat for free |
| a message with 2,000 headers | the first bad one ends the processing | |
getattst |
at most 64 heights, the answer 640 attestations | |
| a finality attestation | a duplicate is not gossiped | otherwise the gossip cycles forever |
| the mempool | 64 MB / 20,000 tx / a chain of 25, eviction by feerate | ✅ |
| the orphan pool | 100 tx / 4 MiB / 20 min, a random element evicted | ✅ |
| the mempool dump | a cap of 20,000 tx, each goes through Accept |
✅ |
| messages per peer | 100/s and 2 MB/s, exceeding = 50 points | ✅ |
| the rejection log | 1 line / 10 s | ✅ |
6. Storage and crashes
| atomic batches (the UTXOs + undo + the index + the tip in one) | ✅ Pebble, tested with kill -9 |
| recovery after a crash between storing and applying a body | ✅ ApplyStored |
| the height index = the active branch, not the last header | ✅ |
| the UTXO reorg (unwinding through undo) | ✅ |
| pruning | ❌ — at ~90 MB/year it is not urgent |
| an unusable datadir from another network | ✅ a genesis check |
7. The wallet
| us | commonly elsewhere | |
|---|---|---|
| file encryption | Argon2id (256 MiB, 3 passes) + XChaCha20-Poly1305, the header as AAD | Bitcoin Core: AES-256-CBC + 25k SHA512 iterations — we are stricter |
| the backup | 24 BIP39 words | ✅ |
| separation from the node | a separate process, the node holds no keys | like Monero |
| a BIP39 passphrase | ❌ admitted in what to verify yourself | |
| watch-only / hardware | ❌ | |
| fee estimation | ❌ — the fees are flat today |
8. Operations and releases
Finding 7: signed releases and a reproducible build. Bitcoin has Guix builds and signatures from several people; Monero the same.
Here: scripts/release.sh builds in a container pinned by digest (the golang:1.26 tag changes
with every patch release and the binary’s bytes change with it), with -trimpath,
-buildvcs=false and -ldflags="-s -w -buildid="; the modules are fetched separately, so the build
itself needs no network. Signing is done with minisign and the private key is not in CI — a
pipeline that every commit can start has nothing to sign with.
Reproducibility is not an assumption, it is measured: two builds in a row gave bit-identical binaries, cgo RandomX included. What is not reproducible — the GPU plugins (nvcc is not deterministic) and the wallet installers — is written down in releases and signing, not passed over in silence.
Finding 8: metrics and an alert. A chain can stall for ordinary reasons — the rotation rule
waiting for a lane whose only miner has crashed — and without an alert that is found out by
accident. The node exposes /metrics; the PowmTipStalled alert over powm_tip_age_seconds is
exactly the one that catches it. The threshold is 1,200 s +
for: 5m, and not twice the target time: the time between blocks is exponentially distributed, so a
“twice the target” threshold would beep on a healthy network every day.
Finding 4: project rule 3 says the GPU kernel has to be bit-exact against internal/mh at
every commit. A rule like that only holds if CI builds the kernel and runs it against the test
vectors on a machine with a card — otherwise it holds for whatever somebody ran by hand.
9. Testing
| status | |
|---|---|
| 1,000 test vectors of the algorithm | ✅ |
| conformance of the vendored RandomX against the official vectors | ✅ |
| fuzzing (the header, the target, the retarget, attestations, P2P messages) | ✅ 8 functions |
| the race detector in CI | ✅ |
| the tests go the path a block goes on a live network | ✅ |
| a long (soak) run | ❌ — the testnet is young |
| an epoch change on a live network | ❌ finding 5 — it comes at height 5,000 (rehearsed) |
| a reorg on a live network | ❌ — only tested in tests |
Finding 5: the first epoch change (height 5,000, ≈ 7 days) brings the building of a new DAG
alongside the old one — that is, two DAGs at once (keepEpochs = 2).
A node that only started building the new DAG with a block that already belongs to the new epoch
would freeze for seconds exactly when new blocks are arriving. And at an epoch boundary everybody is
in the same position, so it would look like a network outage. The next epoch is therefore built
warmAhead = 20 blocks (≈ 40 min) before the boundary, in the background and once per epoch.
A block the node mined itself and a block from the network end in the same place
(onBlockApplied), so nothing hooked onto “a block was applied” can be missing for one of them.
The rehearsal itself is the test TestEpochBoundaryFullDagRehearsal (skipped in CI, it needs
gigabytes): it builds epoch 0 with the full DAG, then epoch 1 alongside it, and measures the time
and the memory at the point where the node holds two epochs at once. Measured on a 7950X3D:
| value | |
|---|---|
| building an epoch with the full DAG | 1.72 s |
| building the next epoch alongside it | 1.62 s |
| memory with one epoch | 1,056 MiB |
| memory with two epochs at once | 2,101 MiB |
The numbers say two things. First: a node with the full DAG needs a 3 GiB limit and 2 GiB would
not be enough at an epoch boundary — the node would be OOM-killed exactly when it holds two
epochs. Second: building takes seconds, so warmAhead = 20 blocks is three orders of magnitude more
headroom than needed — and that is good, because on a machine weaker than an EPYC/7950X3D it will be
several times slower. It does not concern nodes that run -dag=light (26 MiB).
Finding 9: the lane rotation relief (LaneRotationRelief, 900 s) must not be measured against
median time past. MTP is the median of eleven blocks and therefore trails the tip by roughly six of
them — about 12 minutes at the 120 s target. Against MTP, a perfectly healthy chain satisfies the
relief almost always (an ordinary three-minute gap plus 12 minutes of lag is over the threshold), so
the rule that is the whole point of two lanes is switched off nearly all the time. On a test network
that showed as 31 lane C blocks in a row, with the rule allowing two.
The gap is measured against the parent block’s time (internal/chain/lane.go): both values
come from headers, so every node gets the same answer and historical validation sees the same thing
as a node at the tip. Faking the gap buys an attacker little — a header’s time has to exceed MTP and
must not be more than MaxFutureDrift ahead of the real clock, so a claimed 15-minute gap has to be
mostly real.
What it says about tests: a rule like this can be covered by tests that all pass — because they
build chains whose timestamps are far apart. The test that matters is the ordinary case, a healthy
chain with a few minutes between blocks (TestLaneRuleHoldsOnHealthyChain). The same pattern as
finding 5: a test must go the path a live network goes.
Finding 10: a block that is only announced makes every hop ask for the headers, verify them and only then let its body driver ask for the body — a round trip plus a driver tick per hop, and a request that goes unanswered waits out the full 20 s timeout before anybody else is asked. When a body does not arrive, application being serial, nothing above the hole can be applied: every node sits below it, and since the wallets and the explorer read the UTXO state, they freeze too. From the outside the chain looks perfectly healthy.
Three things, in the order they matter:
- A fresh block is pushed whole (header + body, under 64 kB) at every hop instead of being announced. The request path stays for syncing history, where it belongs.
- The oldest missing body is asked of every peer at once. A body verifies itself against the manifest, so a duplicate costs only bandwidth — whereas one silent peer would cost a whole timeout during which nothing can be applied.
- The path says something about itself:
powm_header_body_gap(and the alert on it),powm_oldest_missing_body_seconds, the driver’s counters, a log line for a body missing longer than 45 s, and a log line for every body that does not match the manifest.
What it says about tests is the same lesson as findings 5 and 9: tests that hand every peer every
body, and mine blocks carrying nothing but a coinbase, never meet the two conditions that matter
(TestSilentPeerDoesNotStallTheChain, TestBlockWithTransactionsReachesPeer). On top of them there
is scripts/soak-tx.sh: a transaction a minute against the live network with a check on the gap,
because this class of bug only shows up under real traffic.
10. What we deliberately did differently
Not everything different is a hole. These differences are decisions and they have their reason written down:
| us | why differently | |
|---|---|---|
| two mining lanes with their own retarget | both CPUs and GPUs have a guaranteed share | the whole point of the project (specification) |
| staking / a PoS hybrid | no | it takes reward from miners = weaker PoW (security, §3) |
| finality through a quorum | yes, 5 of 9 | an exchange should not have to deal with reorgs (finality) |
| a 10 kB script limit instead of 520 B | yes | room for contracts without a hard fork (specification, §2.4) |
| dust in consensus (storage mass) | yes | the Kaspa KIP-9 model (fees, dust and the utxo set) |
| mining without a pool is realistic | yes | lane C on an ordinary CPU |