Documentation

Security: the 51 % attack and the network layer — the design

Status: a draft. Part of it is already in the code (§2), part has to be completed in F4 (§3, §5).

Where to report a bug

[email protected] — not a public issue or PR until the fix is out; until then a description of a hole is an attack manual. /.well-known/security.txt on powmachina.com says the same. Write what can be done and how it reproduces; we will get back to you and agree when to publish it.

1. What an attack on PowMachina costs

There are two lanes, each produces half the blocks and, after normalization, every block weighs the same.

The naive view — and why it misleads

If only the sum of the shares mattered, an attacker would need to exceed 1.0 out of 2:

What the attacker controls Sum The naive result
51 % of both lanes 1.02 works
100 % of lane C, nothing else 1.00 does not work (narrowly)
100 % of lane C, 5 % of lane G 1.05 works

That looks bad, and with lane C on RandomX (rentable, §3b) alarming. But it is the wrong model — it ignores the rotation rule.

The real bound: the rotation rule constrains the shape of the secret chain

Three blocks of the same lane in a row are invalid. With two lanes the sparsest possible pattern is therefore C, C, G — lane G has to make up at least a third of every valid chain, the secret one included.

Let a_G be the attacker’s share of the honest lane G hashrate. Their chain cannot grow faster than their lane G blocks allow:

R_attacker ≤ 3 × (their lane G blocks) = 3 × a_G × R_network/2 = 1.5 × a_G × R_network
they get ahead only if   1.5 × a_G > 1   →   a_G > 2/3

An attacker needs more than 2/3 of the honest lane G hashrate — that is, ~40 % of the total lane G — regardless of how much lane C they have. Even with 100 % rented CPUs.

That is a harder condition than a design with three lanes would give: there the pattern C,C,G,G,C,C,G,G is valid, so the attacker does not need the third lane at all — controlling two of three was enough.

how many domains are needed the weakest link
3 lanes 2 of 3 the third lane can be bypassed entirely
2 lanes both > 2/3 of the honest lane G

Liveness: whoever controls one lane cannot rewrite the chain, but they can slow it down by not delivering their lane. LaneRotationRelief (900 s) is the answer to that — once the chain stalls, the rotation rule is waived and the other lane can continue on its own. Without it the chain would never get going again. The retarget (internal/chain/asert.go, specification §3.6) runs on the clock of the whole chain, so while the other lane keeps mining through the relief, the silent lane’s difficulty comes down by itself — by at most 16× per gap.

The relief is measured against the parent block, and it releases exactly one block. The condition is block.Time − parent.Time > LaneRotationRelief: both values come from block headers, so every node gets the same answer forever, and historical validation sees the same thing as a node at the tip. As soon as the stalled lane delivers one block, the gap to the parent is short again and MaxSameLaneRun = 2 is back in force — the surviving lane therefore gets a single block out of the relief, not a burst (TestLaneReliefReleasesOneBlockOnly). If the other lane still does not deliver, another 900 s of silence releases another one.

Why the parent’s time and not median time past. MTP is the median of the last 11 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 900 s condition almost always (a three-minute gap between blocks plus 12 minutes of lag is over the threshold), so the rule that is the whole point of two lanes would be switched off nearly all the time: a test network measuring against MTP produced 31 lane C blocks in a row. The rule is guarded by TestLaneRuleHoldsOnHealthyChain.

Why faking the gap does not help an attacker. A header’s time is not below its parent’s and not more than 30 s ahead of the real clock (specification §3.6), so of a claimed 15-minute gap to the parent at least 870 s have to be real — the attacker would have to actually stop producing blocks for a quarter of an hour to buy one extra block of their own lane. The same reasoning is why Bitcoin ties its consensus time rules to MTP (BIP113): the local clock must never decide validity, or the same block is valid for one node and invalid for another.

Derived by us, not verified by a third party. The model assumes both lanes’ difficulty has settled at half the blocks and that the attacker mines in secret from a single point. It does not cover selfish mining or the transient states after a hashrate jump. Before genesis this should be looked over by somebody outside — it is the most important claim in the whole document and it rests on a three-line inequality.

2. What is already in the code

Defence Where
The lane rotation rule + the liveness relief internal/chain/lane.go
Chainwork normalization by a geometric mean, in integers internal/chain/work.go
The per-lane retarget on the chain’s clock, 16× per gap at most, a difficulty floor internal/chain/asert.go (before the lane-difficulty fork: lwma.go)
A block’s time: not before its parent, at most 30 s into the future internal/chain/headerchain.go

3. What to finish — the priorities

Priority 0: timestamps and difficulty

Verge (2018, ~$1.7 M) did not fall through hashrate but through forged timestamps — the attacker manipulated one algorithm’s retarget. This has to be enforced: MTP(11) as the lower bound, a block not dated before its parent and at most 30 s into the future, and a retarget that follows the chain’s clock, not a declared time it cannot check (the specification §3.6).

On multi-algo, zawy adds: the conversion between lanes must not be static, otherwise an attacker buys cheap chainwork in the weakest lane. Our moving average (1,440 blocks) + the geometric mean is the right model (DigiByte MultiShield) and must not be simplified into constants.

Priority 1: MESS — scoring deep reorgs

Implemented in internal/chain/mess.go. On top of it runs the finality layer (finality), which rules a reorg out entirely after ~40 minutes. The model: Ethereum Classic’s ECIP-1100 (2020), deployed after three 51 % attacks; after it, no successful deep reorg. The reference implementation is in Go (core-geth).

A node rejects a competing branch if it does not have enough work given how old the fork point is:

x    = min(Δt, 25132 s)
req  = 1 + (3x² − 2x³/25132) · 3840 / (128 · 25132²)
accept the branch only if  normalized_chainwork(branch) > req · normalized_chainwork(ours)

With our 30 s blocks it comes out like this:

age of the fork point blocks required work multiple
2 min 4 1.00×
6 min 12 1.02×
30 min 60 1.4×
1 h 120 2.7×
2 h 240 7.0×
4 h 480 19.3×
7 h and more 836+ 31.0× (the cap)

Ordinary operation (a 1–3 block reorg) therefore passes with practically no penalty, but rewriting half a day of history is impossible without 31× the work.

The cost: it is a subjective rule — every node decides by what it saw first. ECIP-1100 itself admits the risk of a permanent bifurcation if two partitions were held in almost perfect balance. ETC turned it off in 2026, but only once it had 85 % of its algorithm’s hashrate. This has to be documented for exchanges, so they know why their node may see a different branch.

Priority 2: signed launch checkpoints with a hard expiry

The first months are the biggest risk — with a small hashrate a few hundred graphics cards are enough.

  • signed with the developer’s key, not automatic from a depth of N,
  • valid only for blocks older than a few hours,
  • the expiry written into the code (height ~400,000 ≈ 18 months), after which the mechanism switches off,
  • in the release notes explicitly as a temporary measure.

What will NOT be done

Mechanism Why not
Rolling automatic checkpoints (BCH ABC, Komodo Auto Checkpoints) BitMEX showed that causing a permanent split is cheaper than a reorg — the attacker publishes a shadow chain exactly when the honest one has N blocks
dPoW / notarization into BTC or LTC fees, 64 notaries, governance — and Komodo itself cancelled it in January 2026
Avalanche pre-consensus (eCash) it requires a stake proof → the end of “PoW-only”
Changing the algorithm in response to an attack both Vertcoin and Bitcoin Gold had the attack repeated after changing their algorithm
Merge mining it lets in miners of a foreign algorithm, which kills fairness
Staking / a PoW+PoS hybrid (Decred) it takes reward away from miners, that is, it weakens the PoW itself — see below

Staking — considered, rejected

Decred’s model (a block needs PoW and votes from randomly selected ticket holders) works, and Decred never took a 51 % attack. An attacker needs hashrate and at the same time a large share of the coins. That is why it is not a stupid idea and why it is documented here rather than passed over in silence.

Rejected for these reasons, in order of weight:

  1. It takes reward away from miners. Decred gives stakers 60 % of the emission. Every percent taken from miners means less hashrate — that is, weaker PoW, exactly the layer PowMachina stands on. It does not add security, it just moves it onto other people.
  2. It overlaps with what we already have. The finality quorum (finality) gives definitive finality at a depth of 20 blocks. Staker voting is probabilistic against that. The gain over the existing state is small.
  3. New attack surface. The ticket pool, a lottery with a random source, vote validation, revocations, a separate ticket difficulty. That is more consensus code than the whole of today’s internal/chain.
  4. The wallet stops being simple. Buying tickets, locked funds, a voting key online, missed votes and expirations. Today a wallet backup is one seed phrase.
  5. In the first months it would give a false sense of security. The coins are not distributed yet, so the stake would be concentrated with whoever mines first — and an attacker can buy it cheaply.

What stays open from all this: a bond for the members of the finality quorum — the design is in the quorum bond, the reasoning in finality, §10. It takes only the useful part of staking — skin in the game — and touches neither the emission nor block production.

3b. Rented RandomX hashrate on lane C

Ever since lane C became RandomX, its hashrate has stopped being exotic. Monero has on the order of 5–6 GH/s, NiceHash sells RandomX and a Monero miner rebuilds our dataset (a different salt) in seconds. Our lane C will have a fraction of that at the start.

This is the price of not having invented our own VM (ALGO §7.6). An unknown algorithm of our own would make renting impossible — but it would not have four audits and seven years of production behind it either. It is a conscious trade, not an oversight.

What stands against it

layer what it does
the lane rotation rule (ALGO §4) an attacker with rented CPUs makes at most two blocks in a row, then has to deliver lane G — and GPU hashrate for MachinaHash-G cannot be rented, because nobody sells it
MESS a deep reorg costs multiples of the network’s work
the finality quorum what is confirmed is never rewritten

With two lanes the rotation rule is harder than with three: after two identical blocks a single option is left, not two.

Why that is not enough to rewrite the chain

The cost of an attack is derived in §1: the rotation rule requires lane G to make up at least a third of the secret chain too, so the attacker needs > 2/3 of the honest lane G hashrate — even when they have the whole of lane C. Rented CPUs therefore do not help them on their own.

What does help them is slowing the network down: whoever controls lane C can stop delivering it. LaneRotationRelief is the answer to that — at the cost of the other lane then getting one block per 900 s of silence (§1, Liveness).

Not verified by a third party — see the warning at the end of §1.

4. The eclipse attack — cheaper than 51 %

An eclipsed exchange node accepts a fake branch without the attacker having a majority of the hashrate. Heilman et al. (USENIX 2015) showed it on Bitcoin, “False Friends” (2019) on geth from two hosts. Our P2P layer has to have this from day one, not afterwards:

Measure Status
an addrman with deterministic placement by a secret 256-bit key (1,024 new / 256 tried) done (internal/p2p/addrman.go)
bucketing by group, /16 for IPv4, /32 for IPv6, onion separately; ASN optionally done (netgroup.go)
test-before-evict (an evicted address returns to new, it is not thrown away) done
hard caps on message size and item counts done (message.go)
outbound peer selection spread across groups done (manager.go)
anchor peers (block-relay connections are saved and used after a restart) done
≥2 block-relay-only slots, separate from full connections done
inbound connection eviction that protects the spread done
a ban score with levels by severity done (peer.go)
a rate limit on addr messages (1,000 at once, refilling ~360/h) done
a deadline on every read and write (a peer must not hold a slot by staying silent) done (transport.go)
feeler connections — once every 2 min it tries one unverified address and hangs up done (cmd/powmachinad/connect.go)
anchor peers are saved and restored after a restart done
a limit on addresses from one DNS seed (64) + its own source group for every seed done (internal/p2p/seeds.go)
nMinimumChainWork + anti-DoS during the headers sync missing

Measured properties (the internal/p2p/ tests):

  • an attacker with one /16 range occupies 1 bucket out of 1024; with 5,000 addresses across many ranges from one source, 18 buckets (1.8 %). The distribution depends on the node’s secret key.
  • address selection goes through a random bucket, not in proportion to the number of addresses — an honest address with a single entry wins in ~48 % of cases against the attacker’s 2,000 addresses.
  • an attacker with hundreds of addresses in one network occupies 1 outbound slot out of 8.
  • with the inbound slots full, the eviction takes from the most numerous group and within it the youngest connection — so an attacker only pushes out themselves.
  • the feeler moves working addresses to verified and leaves dead ones at the back, so flooding the addrman with invalid addresses loses its point; verified by a test in both directions.
  • anchor peers protect the most vulnerable moment — a restart. Without them an attacker who flooded the addrman would take every slot after a restart.

5. The network topology

The layout below is what we recommend to anyone running PowMachina at any scale — a pool, an exchange, or a single miner who does not want their node found. It is written as a recommendation rather than as a description of one deployment: who sits where is the operator’s business, and a map of a particular installation is of use only to someone attacking it.

The principle in one line: nothing that holds value or hashrate is reachable from the internet. What is reachable is expendable and can be replaced in minutes.

Layer Exposure
Sentries / public P2P (4–6, ≥3 different ASNs, ≥2 continents) a public IP, full peer exchange, the DNS seeds point here. Expendable.
The mining node no public IP, a private link to the operator’s own sentries only, peer exchange off
The stratum gateway behind a scrubbing provider — the only place one belongs
The exchange node private, outbound only to hand-picked peers across ASNs, with the operator’s own sentries as anchors. No inbound.
The DNS seeds 3–5 domains, different registrars and operators + a hardcoded list in the binary

The bootstrap is finished in the code (p2p.Bootstrap): at most 64 addresses are taken from one seed, every seed has its own source group (so it cannot occupy another’s buckets), on a DNS failure the list baked into the binary is used, and once the addrman has enough addresses the seeds are not asked at all — after a restart the addrman is a better source than any seed. All that is missing is the domains themselves and their operators.

A scrubber in front of P2P makes no sense

  • A node makes outbound connections from its real IP — every peer it dials sees the origin.
  • The scrubber sees all the traffic in the clear and is a single point of failure.
  • Its ToS gives it the right to cut you off without a refund.
  • Practically: a scrubber adds +15 to +40 ms.
  • Careful: a scrubber connects to the origin from its own egress range, not from the anycast edge IP. Without allowing that range in the firewall the SYN packets silently disappear.

Hence: the scrubber holds the stratum, not consensus. When it goes down, the chain keeps running and only miners connecting goes down.

A clarification: the above holds for a scrubber as a reverse proxy. With a GRE tunnel the outbound traffic goes through the scrubber’s IP too and the origin stays hidden — for one or two sentries that makes sense as a supplement, not as the only path (the other sentries have to be at different operators, otherwise the scrubber is a single point of failure again).

There is no Tor yet

In the code .onion addresses exist only in the data model: Address.Onion, its own group in the netgroup (they must not be mixed with IPv6), its own key for bans. The node cannot transport over Tor — p2p.Dial opens plain TCP, there is no SOCKS5 client.

Inbound through a hidden service would work (Tor terminates the circuit and connects to our listener), but the node would see every Tor peer as 127.0.0.1: loopback is exempt from the Gate limits, so no caps would apply to it, and such an address is not stored in the addrman. Until that is finished, Tor must not be switched on.

What has to be finished: a SOCKS5 dialer, an onion address to listen on, and removing the loopback exemption for connections from the Tor proxy. As a supplementary inbound path (censorship, availability) it makes sense; as a path for blocks it does not — a circuit latency of 200–800 ms means more orphaned blocks.

Cloudflare and nginx in front of P2P — what yes and what no

The question everybody asks: could the nodes not be hidden behind Cloudflare or behind proxy_pass?

layer behind Cloudflare / nginx? why
P2P (x696) no the CF proxy speaks HTTP/HTTPS, our P2P is our own binary protocol. Arbitrary TCP is only handled by Spectrum (Enterprise).
The HTTP block mirror (x698) yes it is ordinary HTTP and an accelerator of the sync, not an authority — the bodies are verified anyway. An ideal CDN candidate.
The explorer, the website yes pure HTTP, no consensus.
RPC (x697) never localhost, or WireGuard.
Stratum (x699) a scrubber yes, CF no see above.

proxy_pass in nginx is HTTP; our protocol would need stream {} (L4). Technically that works, but:

  • The peer’s real IP disappears. The node needs it for the addrman buckets, the netgroup spread, the Gate limits and bans. Behind a proxy all the peers are suddenly in one group — which is exactly the state the anti-eclipse design is built against. (The node does not read the PROXY protocol.)
  • It only helps inbound. The node opens outbound connections itself from its own IP, so the origin is visible anyway — the same objection as for a scrubber as a reverse proxy.
  • It adds latency, that is, more orphaned blocks.

Validation does not change — it is always local and would run the same way behind a proxy. The risk is not “an invalid block gets through” but a blinded view of the network and slower propagation.

A trap: the DNS seeds have to stay DNS-only (the grey cloud). If a seed’s A record were switched to the orange cloud, peers would get a Cloudflare IP and would try to open a P2P connection to it. The seed would stop working and at first glance it would look like a network outage.

DDoS without third parties

The setup: sentries at a cloud provider with an API (a new sentry in minutes), publicly only the P2P port, RPC on localhost, the mining node behind the sentries with no public IP. A volumetric attack (Gbit/s) is not solved by any code — it is solved by the number of sentries across ASNs and by replacing them quickly. The code solves what comes through TCP:

Defence Value Where
The key is a machine, not a connection: the IPv4 address, an IPv6 /64, onion a ban is not bypassed from a new port or an address in the same /64 p2p/guard.go HostKey
Bad PoW after the cheap checks = an immediate ban (100 points) an honest node cannot send it node.go, headers
A deep fork is rejected before the PoW (beyond ReorgLimit or beyond finality) the search for a common ancestor is bounded by O(ReorgLimit) chain/headerchain.go
Checkpoints in the network parameters a fake chain from genesis does not pass chain/netparams.go
Before the handshake: ≤2 connections/machine, ≤8/network (/16, /32 for IPv6), ≤16 unfinished a flood from one network does not exhaust the global limit p2p.Gate
The rate of new connections: 10/s (burst 40) overall, 0.5/s (burst 4) per network the network tracking has a cap of 4,096 and networks with live connections are not evicted p2p.Gate
Per-peer limits: 100 messages/s, 2 MB/s (inbound); exceeding them = 50 points an honest peer is two orders of magnitude below p2p.PeerMeter
The ban list has a cap of 65,536; a full list does not reject a new ban an attacker with thousands of IPs cannot inflate it p2p.BanList
The rejection log is at most 1 line / 10 s with a count a flood does not write the disk full node.reject
A ping every 30 s, a pong within 90 s or a disconnect; ping/pong is exactly 8 B a connection nobody answers on does not block a slot; the echoed payload cannot be inflated p2p.Conn.Ping, node.PingPeers
-banlist <file>: the active bans (an IP or a /64 per line), written atomically the firewall (ipset ban4/ban6) drops them before they reach the node — the sentry's ban script-bans.sh node.writeBanListLoop

The numbers are public and the attacker knows them — deliberately: no rule rests on the attacker not knowing a limit. Every limit is per machine or per network, not per connection, and every structure has a cap. Loopback is exempt from the connection limits (local tools), from bans it is not.

A live test (regtest): 30 connections from one machine → the node kept 2, the log got 1 line.

Light verification and DoS: a light node pays ~63 ms instead of ~1 ms for a header with bad PoW. The cap: the light path runs behind a lock one at a time (at most one core), bad PoW is an immediate ban of the machine, and new connections are rate-limited to 10/s overall and 0.5/s per network. The auto mode decides by the age of its own tip (> 24 h full, < 1 h light) — a peer cannot flip it; withholding blocks (an eclipse) causes at most a one-off build of the full DAG. A machine without ~6 GiB of memory available stays light from the start; -dag=light forces it.

Tor

  • It helps: hiding the mining node from correlation tracking (who announces a block first), giving censored users a way in, one dual-stack sentry as insurance against a network split.
  • It hurts: as the only path for blocks. A measurement on Ethereum (Nimbus) gives +614 ms per hop, which with a 30 s block is ~2 % extra orphans per hop.
  • An onion-only node is worse, not better: it is undialable from the clearnet, it has a small peer set and is therefore easier to eclipse. Biryukov & Pustogarov (IEEE S&P 2015) also showed how to use the anti-DoS ban to push users onto the attacker’s onion nodes.

The orphan rate by propagation delay (1 − e^(−d/30 s)):

delay orphans
1 s 3.3 %
2 s 6.5 %
5 s 15 %
10 s 28 %

Four times what a 120 s block had. In the Sharepool an orphan costs its miner little — the reward goes to the shares, which carry over to the next block, and a share on the block both siblings share is paid either way (the Sharepool §3–4) — but every second of propagation is still a second of split votes. Hence: headers-first and blocks pushed whole (a block with its settlement is ~50 kB), compact blocks next (the shares a peer already has, by id); FIBRE, Falcon and Erlay no (Erlay saves tx-relay bandwidth, which does not exist at 96 kB/30 s).

5b. What a node MUST NOT dial (and why servers get locked for it)

A node never attempts to connect to an address that is not routable on the internet — RFC1918 (10/8, 172.16/12, 192.168/16), CGNAT 100.64/10, loopback, link-local (169.254.169.254 included, which is cloud metadata), IPv6 ULA fc00::/7, multicast and the reserved ranges. The check is in one place (internal/p2p/routable.go) and runs when an address is inserted into the address table, not only when dialling: an address that does not get in there is never selected.

Why this is not cosmetic: a node on a cloud server that knocks on private ranges looks to the provider like scanning the internal network. Providers send abuse reports and lock servers for it — and one poisoned DNS seed or one address from gossip is enough.

The exception is explicit: -allow-private (and regtest, which always has it). That is a mode for your own closed deployment — sentry nodes in a VPN, a testnet on a LAN. It is not switched on on a public server.

The list is the same one Bitcoin Core has in CNetAddr::IsRoutable() (netaddress.cpp): RFC1918, RFC2544, RFC3927, RFC4862, RFC5737, RFC6598, RFC4193 and RFC4843. We stick to it deliberately — it is proven by fifteen years of operation and originality has nothing to add here. So the documentation ranges are blocked too (192.0.2/24, 198.51.100/24, 203.0.113/24, IPv6 2001:db8::/32), even though they lead nowhere by themselves: an address from them in the table means somebody slipped in nonsense, and such a peer has no business in the addrman. The tests therefore use real public addresses, not documentation ones.

6. On the exchange’s side

  • The number of confirmations by amount.
  • Require blocks from at least two different lanes between confirmations — an attacker with one domain cannot even produce a valid sequence of confirmations. This one is specific to PowMachina.
  • Cross-check the tip against two independent nodes before a deposit is credited.
  • Watch for a sudden hashrate jump in one lane and report reorgs deeper than 20 blocks.

7. Sources