Skip to content

Open network: remove the node whitelist, harden consensus behind a fork; 0.9.0 - #363

Open
xdagx wants to merge 18 commits into
XDagger:developfrom
xdagx:feature/node2-open-network
Open

xdagx wants to merge 18 commits into
XDagger:developfrom
xdagx:feature/node2-open-network

Conversation

@xdagx

@xdagx xdagx commented Oct 1, 2026

Copy link
Copy Markdown

Summary

Anybody can run a node: the node whitelist (node.whiteIPs) is removed. What replaces it, and why it could not simply be deleted, is in docs/OPEN_NETWORK.md; what was found and fixed on the way is in docs/SECURITY_AUDIT_OPEN_NETWORK.md. Version 0.9.0.

Depends on xdagj-p2p 0.1.8: XDagger/xdagj-p2p#31. Until that version is published to Maven Central, CI on this pull request cannot resolve the dependency and will fail.

What changes

Network

  • The 0.8.x transport (io.xdag.net frames, handshake, whitelist) is replaced by xdagj-p2p 0.1.8: signed handshake, signed discovery with endpoint proof, admission control, backpressure.
  • node.seeds (entry points, no privilege) and node.trustedPeers (kept connected) replace node.whiteIPs, which is still read and mapped to both with a warning. The node has its own identity key (node.keyFile) instead of using the wallet key.
  • XdagP2pHandler: request spans bounded, per-peer rate limits, misbehaviour scores and bans, history only accepted in answer to a request.

Consensus - behind a fork, not active on mainnet

  • The whitelist was hiding consensus and protocol weaknesses that only matter once peers are untrusted. They are fixed by "hardened" rules that apply from consensus.opennet.forkEpoch on: devnet from genesis, testnet and mainnet not scheduled. On testnet the epoch comes from the configuration file; on mainnet it is a constant in MainnetConfig that the maintainers set, and the configuration file is ignored.
  • Before the fork is in force on its chain a node runs closed - configured peers only, which is what the whitelist did - and executes the 0.8.x rules unchanged for the history before the fork. Once a fork-era main block is confirmed it opens up by itself.

Synchronisation

  • Whether a node is synchronised is no longer decided by what peers claim or by the node's own tip, but by whether the newest blocks of its peers attach to its own blocks. Comparison with peers goes on after the node is synchronised, and the state is revoked if older blocks with more work turn up. In a closed network the 0.8.x rule is unchanged.

Other fixes (from a review done while porting the node to Rust): single-account spend with its own nonce in xfer, rewards of blocks found without a pool, pending pool awards surviving restarts, exact amount parsing, history paging without a global static, snapshot block records over RPC. Remaining Chinese comments translated to English.

Compatibility and rollout

  • The wire protocol is not compatible with 0.8.x (network version 1, XDAG messages at codes 0x20-0x2A): upgrading a network is a flag day.
  • Nothing changes in consensus on mainnet until the maintainers set the fork epoch. Until then an upgraded mainnet behaves like 0.8.x with node.seeds / node.trustedPeers in place of the whitelist.
  • No change to the block format, transaction encoding, signatures or the format of stored records.

Decisions this needs from the maintainers

  • Whether and when to schedule the fork on testnet and mainnet (OPEN_NET_FORK_EPOCH).
  • The deferred items listed in docs/OPEN_NETWORK.md (section 2.3): fee on failed transactions, atomic main-chain updates, the Kryo storage format.

Testing

  • mvn test: 263 tests, 0 failures.
  • OpenNetForkTest: legacy against hardened behaviour, the latch, execution rules following the main block's epoch.
  • DeterminismTest: random histories delivered in different orders must give the same state (600 seeds under the hardened rules).
  • TwoNodeNetworkTest, SyncUnderAttackTest: real nodes on the loopback interface - seed, gossip, request, ban, closed mode; a node that is behind and a peer that tries to make it believe it is synchronised.
  • XdagP2pHandlerTest, SyncDoneDecisionTest, XdagSyncCycleTest, ChannelManagerTest, NodeKeyStoreTest, PeerSettingsTest.
  • mvn license:check passes.

No test connects to a real network.

Notes for reviewers

  • The branch is based on master. develop is currently 13 commits behind master (dependency updates), so until develop is brought up to master this pull request also lists those commits.
  • The two documents describe weaknesses of 0.8.x in detail. On mainnet they are behind the whitelist today and stay behind the closed mode until the fork is scheduled.
  • Suggested reading order: docs/OPEN_NETWORK.md, then BlockchainImpl (search for hardened), XdagSync / SyncManager, XdagP2pHandler, net/ChannelManager.

🤖 Generated with Claude Code

LucasMLK and others added 18 commits July 1, 2024 17:10
Firefly v0.8.0
Bumps [com.fasterxml.jackson.core:jackson-databind](https://github.com/FasterXML/jackson) from 2.18.6 to 2.18.9.
- [Commits](https://github.com/FasterXML/jackson/commits)

---
updated-dependencies:
- dependency-name: com.fasterxml.jackson.core:jackson-databind
  dependency-version: 2.18.9
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Bumps [com.fasterxml.jackson.core:jackson-core](https://github.com/FasterXML/jackson-core) from 2.18.6 to 2.18.8.
- [Commits](FasterXML/jackson-core@jackson-core-2.18.6...jackson-core-2.18.8)

---
updated-dependencies:
- dependency-name: com.fasterxml.jackson.core:jackson-core
  dependency-version: 2.18.8
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Bumps org.apache.logging.log4j:log4j-api from 2.25.4 to 2.25.5.

---
updated-dependencies:
- dependency-name: org.apache.logging.log4j:log4j-api
  dependency-version: 2.25.5
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
…he.logging.log4j-log4j-api-2.25.5

Bump org.apache.logging.log4j:log4j-api from 2.25.4 to 2.25.5
…erxml.jackson.core-jackson-core-2.18.8

Bump com.fasterxml.jackson.core:jackson-core from 2.18.6 to 2.18.8
…erxml.jackson.core-jackson-databind-2.18.9

Bump com.fasterxml.jackson.core:jackson-databind from 2.18.6 to 2.18.9
…rk; version 0.9.0

Anybody can run a node. The whitelist (node.whiteIPs) is gone; what
replaces it, and why it could not simply be removed, is in
docs/OPEN_NETWORK.md and docs/SECURITY_AUDIT_OPEN_NETWORK.md.

Network
- The 0.8.x transport (io.xdag.net frames, handshake, whitelist) is
  replaced by xdagj-p2p 0.1.7: signed handshake, signed Kademlia
  discovery with endpoint proof, admission control, backpressure.
  The wire protocol is not compatible with 0.8.x (networkVersion 1,
  XDAG messages at codes 0x20-0x2A): upgrading a network is a flag day.
- node.seeds (entry points, no privilege) and node.trustedPeers (kept
  connected, never banned) replace node.whiteIPs, which is still read
  and mapped to both with a warning. The node has its own identity key
  (node.keyFile) instead of using the wallet key.
- The node runs closed (configured peers only - what the whitelist did)
  until the open-network hardening fork is in force on its chain, then
  opens up by itself (ChannelManager.applyOpenness).
- XdagP2pHandler: request spans bounded to what the protocol produces,
  per-peer rate limits, misbehaviour scores and bans, impossible network
  statistics ignored, relay TTL capped, one peer asked for a missing
  parent, outbound requests throttled, silent peers scored.
- BlockStoreImpl.loadSum refuses non-positive spans (a span of 2^63
  looped forever).

Consensus (gated behind consensus.opennet.forkEpoch; devnet from
genesis, testnet/mainnet not scheduled - MainnetConfig.OPEN_NET_FORK_EPOCH
is set in code by the maintainers, the config file is ignored on mainnet)
- Only end-of-epoch candidates carry difficulty (C1); duplicate inputs
  of one block are added up (C2); peer-supplied execution status is
  ignored (C3); overflow-safe execution, nothing throws out of setMain
  (C4); no "input address must exist" import rule (C6); exact address
  total (C7); crash mark for interrupted main-chain updates (C8,
  detection only); RandomX fork time no longer set to -1 on unwind (C9).
- Also fixed, found in this work: block without an output signature
  (NPE after state mutation), amount-bearing coinbase field in a
  non-transaction block (IndexOutOfBounds in setMain), zero-output
  account transaction (division by zero), unApplyBlock on snapshot
  blocks (NPE), a skipped transaction un-skipped by an unrelated unwind,
  removeOrphan overwriting the recorded fee, and confirmation depending
  on BI_REF, which rollTx clears - so the order of arrival decided when
  a main block was confirmed.
- The 0.8.x rules are kept bug for bug for the history before the fork.
  Once a fork-era main block is confirmed the node latches (persisted)
  and checks every block with the hardened rules.
- Transaction history (optional MySQL store) is written when a
  transaction executes and removed when that is undone (S2).

Other bug fixes from the Rust port's review (docs/BUGS.md there)
- xfer / doXfer spend from exactly one account with its own nonce (O1);
  rewards of blocks found without a pool go to the node (O2); pending
  pool awards survive restarts (O7); node payments de-duplicated (the
  same block could be spent twice in one transaction); amounts parsed
  exactly instead of rounded to 0.01 XDAG (A2); history paging without
  a global static (P3); snapshot-inherited blocks report their record
  over RPC (O9).
- Remaining Chinese comments translated to English.

Tests: 244 pass, including OpenNetForkTest (legacy vs hardened rules),
DeterminismTest (random histories delivered in four orders must give
the same state; 600 seeds pass under the hardened rules) and
TwoNodeNetworkTest (two nodes on the loopback interface: seed,
gossip, request, ban, closed mode).

Deferred, documented: C5 (fee on failed transactions; mitigated by a
local pool admission policy), C8 atomicity, the Kryo storage format
(S1), the swapped BLOCK/TIME directory names (O6).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…t by its tip, claims or sums

A node that was behind could be made to call itself synchronised, and to
stop fetching history for good, by any peer and without any work
(docs/SECURITY_AUDIT_OPEN_NETWORK.md, X12 and X13):

- Three blocks without work but with current timestamps are the heaviest
  thing a stale node knows and become its latest main blocks. That moved
  the frontier of XdagSync (the time of the latest main block) to the
  present and satisfied the "latest main block is recent" rule; SYNC_DONE
  then stopped the comparison with the peers.
- The sums of that comparison are plain sums of block words. Blocks
  nobody needs, dated into the spans a node is missing and with one field
  chosen for the purpose, bring its sums to those of an honest peer, and
  nothing is fetched there any more - found while fixing the first point,
  and it defeated a first fix that decided by "the comparison found
  nothing to fetch".

What cannot be had for free: a block only becomes part of a node's blocks
when everything it refers to is there.

XdagSync
- Rounds with one peer at a time, in cycles in which every connected peer
  gets exactly one round (a peer under a new name waits for the next
  cycle). Rounds never stop, also not after the node called itself
  synchronised.
- Compare: the whole sums tree, wherever the tip is, down to the snapshot
  the node was booted from; spans in which the peer has more are fetched,
  oldest first. A span fetched from a peer is settled for that peer while
  the peer's sums stay the same; spans in which this node holds at least
  as much are looked into a few per round; a synchronised node fetches at
  most 16 spans per round.
- Verify (not synchronised): the two request spans that are being
  written to are fetched whatever the sums say. The node is in sync with
  the peer only if those blocks attach to its own (PeerEvidence).
- Recover: if they do not attach although the sums show nothing to
  fetch, the node looks at spans of the peer without importing them
  until it has found where its own blocks end, and fetches everything
  from there on regardless of the sums.
- The time limit of a round is checked before every request; a peer that
  did not answer, or whose sums promised blocks for a whole round of
  which none was new, gets a short round and short waits.

SyncManager
- Open network: synchronised when a cycle of rounds (with at least half
  of the current peers) was held since the node last caught up, most of
  the peers that showed blocks from the present (or did not answer) had
  none the node could not attach, and the best chain has not advanced
  through old blocks for 30 s. Revoked when older blocks with more work
  arrive; XdagPow pauses and resumes with the state.
- waitEpoch only starts a node that has no peers.
- Closed network: the 0.8.x rule, unchanged.

XdagP2pHandler
- SYNC_BLOCKs are only taken in answer to a request of this node (for
  the span or for the block), and so is news dated more than about nine
  hours ago: nobody writes into a node's past unasked.

ChannelManager no longer tells a P2P service that is not running yet to
open (a NullPointerException warning from its connect loop at start).

Tests: 263 pass. SyncUnderAttackTest (three nodes on 127.0.0.1: blocks
without work, eclipse and revocation, sums made equal, blocks nobody
needs at an honest peer, the node's own loops), SyncDoneDecisionTest,
XdagSyncCycleTest, XdagP2pHandlerTest (history and old news only when
asked for). Network tests no longer depend on the first dial winning the
race against the listener's bind (NetNode.awaitConnectedTo).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
xdagj-p2p (branch security/open-network-hardening, d97265d) returns from
start() when the TCP listener and the discovery socket are bound, and
setPermissionless() before start() only changes the setting. Recorded as
P16 in docs/SECURITY_AUDIT_OPEN_NETWORK.md (xdagj-p2p: 923 tests); two
comments that described the old behaviour are corrected. No change in
behaviour here: 263 tests pass against that build.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
0.1.8 is 0.1.7 plus the two start-up fixes (start() returns with the
sockets bound; setPermissionless() before start() only changes the
setting) and is the same on the wire. The design and audit documents name
the version accordingly (P16 is fixed in 0.1.8).

Tests: 263 pass against xdagj-p2p 0.1.8.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The design and audit documents pointed at a local path and at documents
of the Rust port that are not published. Reworded; nothing else changes.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants