Fix the reorg system: make rollback survivable, adopt by cumulative work, and enforce the reorg penalty everywhere
(This is a big one, get ready - I told Claude to write the commit message cause I couldn't be bothered)
Root cause: reorg was broken at every layer and the failures compounded. Verified with the node's own
SKALACOIN_FORCE_ORPHAN_REORG debug mode, which stalled permanently at height 2 on 114 consecutive
coinbase-validation failures. A binary built at HEAD behaves identically, so none of this is a regression —
the reorg path had simply never worked.
Rollback (the keystone):
- Chain_RollbackToHeight always returned false on any node that had ever saved or loaded its chain, and
only after it had already truncated the chain and destroyed the balance sheet. Chain_RecomputeRuntimeState
bails on any header-only block, and Chain_SaveToFile nulls transactions on every in-memory block once
persisted, so the failure was universal in practice. Both callers treated the false as "nothing happened"
- Added Chain_BorrowBlockTransactions / Chain_ReturnBlockTransactions, which fall back to the on-disk copy
when the in-memory block has been compacted to headers
- Supply is now accumulated in the rollback's existing balance-sheet replay pass instead of a second
Chain_RecomputeRuntimeState pass
- Gave Chain_RecomputeRuntimeState the same disk fallback: it had been failing on every restart with an
existing chain, silently leaving currentSupply/currentReward at whatever came out of chain.meta
Fork choice is now cumulative work, not height:
- Added Chain_ComputeBlockWork / Chain_ComputeWorkRange / Chain_ComputeBranchWork, computing
2^256 / (target + 1) per block and summing over a range. Derived on demand from headers — no header,
chain.meta or wire-format change
- uint256 only had add/sub/cmp, so added uint256_divide (restoring binary long division),
uint256_from_be_bytes, uint256_bitwise_not and uint256_is_zero
- Comparison is strictly greater, so tied tips do not cause two nodes to keep swapping
- Height-based choice was wrong now that difficulty actually varies: a long low-difficulty branch beat a
short high-difficulty one
Atomic branch replacement:
- Added Chain_ReplaceBranch: validate linkage, apply the reorg penalty, compare work, snapshot the outgoing
blocks, roll back, apply. On any failure the original chain, balance sheet, supply, reward and difficulty
target are restored. The caller keeps ownership of its blocks in every case — the chain applies copies
- Split Chain_AddBlock and Chain_RollbackToHeight into locked public wrappers over unlocked internals, so a
whole branch swap happens under one lock acquisition and Chain_OnTipAdvanced runs once per reorg rather
than once per block
- Chain_AddBlock now validates header.prevHash against the tip. It never did — that check lived only in
Chain_IsValid and the network path, which is exactly why a rollback-then-reapply could splice blocks from
two different forks into a chain that no longer links up
- Moved the currentSupply/currentReward update into Chain_AddBlock. Each caller used to do it separately, so
the orphan-attach and maintenance-thread paths never did, and the next block's coinbase was then validated
against a stale currentReward and rejected forever. This was the height-2 stall
Reorg penalty (Horizen-style delayed block submission):
- The penalty was only ever reachable from the manual sync command. The P2P broadcast -> orphan pool ->
branch adoption path, which is the path an attacker actually uses, had none at all and picked the winner
by raw height. It is now enforced inside Chain_ReplaceBranch, the single choke point every adoption
passes through
- Removed its application to the sync fetch window. The height gap to a peer is not a reorg depth;
penalising it only throttled honest catch-up, and for gaps of 4-50 it collapsed the window to one block
per pass, defeating MAX_PARALLEL_FETCHES
- The depth is stamped once when a branch is first observed (orphan_entry_t.observedAtTipHeight) and never
recomputed. Re-deriving it from a moving tip never converges: depth and elapsed both grow by one per block
while penalty(depth) grows faster, so a penalized branch could never be adopted at all
- The initial-sync exemption now comes from Chain_IsInitialBlockDownload, which uses the local median time
past over MEDIAN_TIME_SPAN blocks. It used to key off the peer's advertised height, so any peer claiming
localHeight + INITIAL_SYNC_HEIGHT_DIFF could switch reorg handling off for the whole session. A median
rather than the tip alone means one backdated block cannot fake it either
Orphan pool (largely rewritten):
- Added a pool mutex. It had no synchronisation whatsoever while being mutated from the 1 Hz maintenance
thread, every per-peer TCP thread and the REPL thread; a concurrent insert could realloc the array while a
scan held a raw element pointer. The lock is never held across a call into chain.c
- Dedup by block hash, a MAX_ORPHAN_BLOCKS cap with oldest-first eviction, and pruning of entries that can
no longer apply. Nothing was ever reaped before, and orphans are reachable before the chain-derived
difficulty check, so this is also the memory-exhaustion fix
- Candidate branches are now assembled by following prevHash from the fork point. Taking the first orphan
found at each successive height could interleave blocks from two competing forks into one incoherent branch
- Fixed rollbackHeight = forkHeight - 1. Chain_RollbackToHeight is exclusive, so every non-genesis adoption
amputated one block too many and then failed Chain_AddBlock's index check
- Fixed a block_t wrapper leak on every successful attach (free the wrapper, not Block_Destroy — the chain
owns the transactions after a shallow copy)
- Permanently invalid orphans are dropped instead of being retried on every maintenance tick forever
Forks below the tip are now discoverable:
- A block at blockNumber < chainSize was rejected and freed, so the fork point and the lower half of any
competing branch were always thrown away and a sub-tip fork could never be learned. Now the hash is
compared: identical means a duplicate and is ignored, different means it goes to the orphan pool
- The sync loop probes downwards (RequestForkWindow, bounded by REORG_FETCH_DEPTH and MAX_FORK_PROBE_ROUNDS)
when it makes no progress while the peer is ahead. That is the only trigger that fires for a genuine
sub-tip fork, because the old divergence check could only see blocks that had already entered our chain.
FETCH_BLOCK already answers from the peer's own chain, so no protocol change was needed
- Removed the rollback-to-height-0 path. "Could not find the parent" used to wipe the entire local chain,
genesis included, and any peer could trigger it with a single unlinked block
Floating point removed from consensus and network math:
- Chain_ComputeTargetAtHeight (the difficulty retarget) used double ratio arithmetic, and
FetchScheduler_ComputeReorgPenaltyBlocks used double/pow/ceil. Both are consensus-critical and are now
integer only; float results are not reproducible across platforms and compilers, and a single last-digit
difference in a target or a penalty splits the network
- The penalty constants became integer rationals (REORG_PENALTY_FACTOR_NUM/DEN, integer EXPONENT and
REF_BLOCK_TIME) with saturating exponentiation and explicit ceiling division. Output is unchanged:
penalty(4)=10, penalty(8)=39, penalty(10)=60, penalty(50)=1500, penalty(100)=6000
- Removed the unused float macros DAG_MAX_UP/DOWN_SWING_PERCENTAGE and the now-dead math.h include from
constants.h. Both were latent: DAG size feeds PoW verification. Replaced with integer numerator/denominator
constants and used them at both clamp sites (values verified identical)
Other fixes that were blocking fork propagation:
- madeProgressOverall was set but never reset, so after one productive pass the "no progress -> stop" guard
could never fire again and the sync loop could spin forever holding the REPL
- seenBlocks was inserted before/regardless of a successful send, so a block broadcast while no peer was
connected was never offered again. It is now recorded only after the block actually goes out
- Broadcasts relayed to outbound connections only, so in a two-node setup the dialled node never pushed
anything back and the dialer learned of new blocks only via a manual sync. Inbound peers are now relayed to
Verified with two-node harnesses at a shortened adjustment interval:
- forced-orphan regression: was height 2 with 114 coinbase rejections, now reaches the peer's height with
zero rejections and both nodes report Chain OK
- sub-tip fork at depth 3: the node discovers the fork below its own tip, discards its three blocks, adopts
the heavier five, and both nodes converge on an identical tip hash
- deep fork at depth 8: the strictly heavier branch is correctly refused with depth=8 penalty=39 elapsed=0
- uint256 work arithmetic covered by a standalone test (division, big-endian conversion, monotonicity,
halved target doubles work)
One issue found along the way: the chain in build/chain_data does not pass fullverify. Block 7683 reverts to
INITIAL_DIFFICULTY where it should carry 0x1f06df14, i.e. it contains blocks mined before 1288a64 landed. A
binary built at HEAD rejects it too, so this is stale data rather than a regression — it needs a wipechain
and a re-mine.
This commit is contained in:
@@ -28,6 +28,33 @@ void Chain_Wipe(blockchain_t* chain);
|
||||
// Returns true on success.
|
||||
bool Chain_RollbackToHeight(blockchain_t* chain, size_t height);
|
||||
|
||||
/**
|
||||
* Atomically replace the blocks at [forkHeight, tip] with `newBlocks` (ascending, `count` of them).
|
||||
*
|
||||
* The swap happens only if the candidate branch is properly linked, has strictly more cumulative
|
||||
* work, and has served its Horizen delayed-submission penalty. `observedAtTipHeight` is the local
|
||||
* tip height at which the branch was FIRST seen and must not be recomputed as the chain grows --
|
||||
* see the comment in the implementation. The initial-block-download exemption is decided inside,
|
||||
* from local state only, so no caller can switch the penalty off.
|
||||
*
|
||||
* On any failure the original chain, balance sheet, supply and reward are restored and false is
|
||||
* returned. The caller keeps ownership of `newBlocks` in every case: the chain applies copies.
|
||||
**/
|
||||
bool Chain_ReplaceBranch(blockchain_t* chain,
|
||||
size_t forkHeight,
|
||||
block_t** newBlocks,
|
||||
size_t count,
|
||||
uint64_t observedAtTipHeight);
|
||||
|
||||
// True when this node is catching up rather than following the tip (empty chain, or a median
|
||||
// block time far in the past). Used to exempt initial sync from the reorg penalty.
|
||||
bool Chain_IsInitialBlockDownload(blockchain_t* chain);
|
||||
|
||||
// Penalty in blocks of local chain growth before a branch forking `reorgDepth` blocks back may be
|
||||
// adopted. Thin wrapper over FetchScheduler_ComputeReorgPenaltyBlocks, for callers that only
|
||||
// want to report it.
|
||||
uint64_t Chain_ReorgPenaltyForDepth(uint64_t reorgDepth);
|
||||
|
||||
// Recompute `currentSupply` and `currentReward` from the in-memory chain blocks.
|
||||
// Returns true on success and updates runtime state globals.
|
||||
bool Chain_RecomputeRuntimeState(blockchain_t* chain);
|
||||
@@ -53,4 +80,15 @@ uint32_t Chain_GetTargetForHeight(blockchain_t* chain, uint64_t height);
|
||||
// Call after any change to the tip. Must NOT be called while holding `chainLock`.
|
||||
void Chain_OnTipAdvanced(blockchain_t* chain);
|
||||
|
||||
// Work
|
||||
// Expected number of hashes to satisfy `difficultyTargetBits`, i.e. 2^256 / (target + 1).
|
||||
bool Chain_ComputeBlockWork(uint32_t difficultyTargetBits, uint256_t* outWork);
|
||||
|
||||
// Summed work of the chain's blocks over the half-open range [from, to).
|
||||
// Takes no locks; safe to call while holding `chainLock`.
|
||||
bool Chain_ComputeWorkRange(blockchain_t* chain, size_t from, size_t to, uint256_t* outWork);
|
||||
|
||||
// Summed work of a candidate branch that is not (yet) part of the chain.
|
||||
bool Chain_ComputeBranchWork(block_t** blocks, size_t count, uint256_t* outWork);
|
||||
|
||||
#endif
|
||||
|
||||
+44
-12
@@ -43,14 +43,44 @@ static const int MAX_SYNC_RETRIES = 4; // retry attempts per block fetch
|
||||
static const uint64_t SYNC_BACKOFF_BASE_MS = 200ULL; // base backoff in ms (exponential)
|
||||
// Parallelism
|
||||
static const int MAX_PARALLEL_FETCHES = 8; // concurrent block fetches during windowed sync
|
||||
// Heuristic: if peer is this many blocks ahead, treat as initial sync
|
||||
static const uint64_t INITIAL_SYNC_HEIGHT_DIFF = 50ULL;
|
||||
// How far below a detected divergence we ask a peer for blocks, so the orphan pool has enough of
|
||||
// the competing branch to locate the fork point by prevHash linkage.
|
||||
static const uint64_t REORG_FETCH_DEPTH = 128ULL;
|
||||
// How many times one `sync` will probe downwards for a fork point before giving up, so a peer on a
|
||||
// permanently incompatible chain cannot keep us looping.
|
||||
static const int MAX_FORK_PROBE_ROUNDS = 3;
|
||||
|
||||
// Reorg penalty configuration (used to penalize peers reporting higher heights but with delayed work)
|
||||
// Reorg penalty configuration (Horizen-style delayed block submission penalty).
|
||||
// A branch forking B blocks below our tip is held for penalty(B) blocks of local chain growth
|
||||
// before it may be adopted, so a rented-hashrate attacker has to sustain the attack publicly
|
||||
// instead of winning by dumping a privately mined branch.
|
||||
//
|
||||
// penalty(B) = ceil(FACTOR_NUM/FACTOR_DEN * B^EXPONENT * TARGET_BLOCK_TIME / REF_BLOCK_TIME)
|
||||
//
|
||||
// Expressed as integer rationals on purpose: this feeds fork choice, so it must evaluate
|
||||
// identically on every node. Floating point is not acceptable here.
|
||||
static const uint64_t REORG_PENALTY_GRACE_BLOCKS = 3ULL; // allow small reorgs without penalty
|
||||
static const double REORG_PENALTY_FACTOR = 1.0; // base scaling factor (theta)
|
||||
static const double REORG_PENALTY_EXPONENT = 2.0; // exponent p in penalty ~ B^p
|
||||
static const double REORG_PENALTY_REF_BLOCK_TIME = 150.0; // reference block time in seconds used by original scheme
|
||||
static const uint64_t REORG_PENALTY_FACTOR_NUM = 1ULL; // base scaling factor (theta), numerator
|
||||
static const uint64_t REORG_PENALTY_FACTOR_DEN = 1ULL; // base scaling factor (theta), denominator
|
||||
static const uint32_t REORG_PENALTY_EXPONENT = 2U; // exponent p in penalty ~ B^p
|
||||
static const uint64_t REORG_PENALTY_REF_BLOCK_TIME = 150ULL; // reference block time in seconds used by original scheme
|
||||
// Beyond this depth the penalty saturates. At the configured parameters penalty(1000) is already
|
||||
// ~600k blocks (over a year), so this only exists to keep the arithmetic away from overflow.
|
||||
static const uint64_t REORG_PENALTY_MAX_DEPTH = 1000ULL;
|
||||
|
||||
// Upper bound on pooled orphan blocks. Orphans are accepted before the chain-derived difficulty
|
||||
// check (that lives in Chain_AddBlock, which orphans only reach on attach), so without a cap a
|
||||
// peer can push blocks at an arbitrary height until the node runs out of memory.
|
||||
static const size_t MAX_ORPHAN_BLOCKS = 512U;
|
||||
|
||||
// A node whose chain tip is older than this many target block times is catching up rather than
|
||||
// following the tip, and is exempt from the reorg penalty (Horizen does the same via
|
||||
// IsInitialBlockDownload). Determined purely from local state, so an unverified peer cannot
|
||||
// trigger the exemption by claiming a large height.
|
||||
static const uint64_t IBD_TIP_AGE_BLOCKS = 500ULL;
|
||||
// Number of trailing blocks whose median timestamp is used for the age test above. Using a median
|
||||
// rather than the tip alone means a single miner cannot backdate one block to fake being in IBD.
|
||||
static const size_t MEDIAN_TIME_SPAN = 11U;
|
||||
|
||||
// Reward schedule acceleration: 1 means normal-speed progression.
|
||||
#define EMISSION_ACCELERATION_FACTOR 1ULL
|
||||
@@ -70,9 +100,12 @@ static const double REORG_PENALTY_REF_BLOCK_TIME = 150.0; // reference block tim
|
||||
#define DAG_BASE_GROWTH (1ULL << 30) // 1 GB per epoch, adjusted by acceleration
|
||||
//#define DAG_BASE_SIZE (6ULL << 30) // 6 GB, adjusted per cycle based off DAG_BASE_GROWTH
|
||||
#define DAG_BASE_SIZE (1ULL << 30) // TEMPORARY FOR TESTING
|
||||
// Swings - calculated as MIN(percentage, absolute GB) to prevent absurd swings from low hashrate or very large DAG growth
|
||||
#define DAG_MAX_UP_SWING_PERCENTAGE 1.15 // 15%
|
||||
#define DAG_MAX_DOWN_SWING_PERCENTAGE 0.90 // 10%
|
||||
// Swings - calculated as MIN(percentage, absolute GB) to prevent absurd swings from low hashrate or very large DAG growth.
|
||||
// Percentages are integer numerator/denominator pairs, never float literals: DAG size feeds PoW
|
||||
// verification, so it has to evaluate identically on every node.
|
||||
#define DAG_MAX_UP_SWING_PERCENT_NUM 15ULL // +15%
|
||||
#define DAG_MAX_DOWN_SWING_PERCENT_NUM 10ULL // -10%
|
||||
#define DAG_SWING_PERCENT_DEN 100ULL
|
||||
#define DAG_MAX_UP_SWING_GB (2ULL << 30) // 2 GB
|
||||
#define DAG_MAX_DOWN_SWING_GB (1ULL << 30) // 1 GB
|
||||
#define DAG_GENESIS_SEED 0x00 // Genesis seed is zeroes, every epoch's seed is the hash of the previous block, therefore unpredictable until the block is mined
|
||||
@@ -184,7 +217,6 @@ static inline uint64_t CalculateBlockReward(uint256_t currentSupply, blockchain_
|
||||
}
|
||||
|
||||
// Hashing DAG
|
||||
#include <math.h>
|
||||
static inline size_t CalculateTargetDAGSize(blockchain_t* chain) {
|
||||
// Base size plus (base growth * difficulty factor), adjusted by acceleration
|
||||
if (!chain || !chain->blocks) { return 0; } // Invalid
|
||||
@@ -213,12 +245,12 @@ static inline size_t CalculateTargetDAGSize(blockchain_t* chain) {
|
||||
// Clamp
|
||||
if (growth > 0) {
|
||||
// Difficulty increased -> Clamp the UPWARD swing
|
||||
int64_t maxUp = (int64_t)((DAG_BASE_SIZE * 15) / 100); // 15%
|
||||
int64_t maxUp = (int64_t)((DAG_BASE_SIZE * DAG_MAX_UP_SWING_PERCENT_NUM) / DAG_SWING_PERCENT_DEN);
|
||||
if (growth > maxUp) growth = maxUp;
|
||||
if (growth > (int64_t)DAG_MAX_UP_SWING_GB) growth = DAG_MAX_UP_SWING_GB;
|
||||
} else {
|
||||
// Difficulty decreased -> Clamp the DOWNWARD swing
|
||||
int64_t maxDown = (int64_t)((DAG_BASE_SIZE * 10) / 100); // 10%
|
||||
int64_t maxDown = (int64_t)((DAG_BASE_SIZE * DAG_MAX_DOWN_SWING_PERCENT_NUM) / DAG_SWING_PERCENT_DEN);
|
||||
if (-growth > maxDown) growth = -maxDown;
|
||||
if (-growth > (int64_t)DAG_MAX_DOWN_SWING_GB) growth = -(int64_t)DAG_MAX_DOWN_SWING_GB;
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
#define ORPHAN_POOL_H
|
||||
|
||||
#include <stdint.h>
|
||||
#include <stdbool.h>
|
||||
#include <block/block.h>
|
||||
#include <block/chain.h>
|
||||
|
||||
@@ -10,11 +11,21 @@ void OrphanPool_Init(void);
|
||||
void OrphanPool_Destroy(void);
|
||||
|
||||
// Insert an orphan block into the pool. Ownership of `block` is transferred to the pool.
|
||||
// `height` is the block number from the header.
|
||||
void OrphanPool_Insert(block_t* block, uint64_t height);
|
||||
// `height` is the block number from the header. `observedAtTipHeight` is the local chain tip
|
||||
// height at the moment the block arrived; it is stamped once and drives the Horizen reorg
|
||||
// penalty, so it must never be re-derived from a later tip.
|
||||
// Duplicates (same block hash) are rejected and the block is destroyed.
|
||||
void OrphanPool_Insert(block_t* block, uint64_t height, uint64_t observedAtTipHeight);
|
||||
|
||||
// Attempt to attach any orphans whose parents now exist in `chain`.
|
||||
// Attempt to attach any orphans whose parents now exist in `chain`, and to adopt a competing
|
||||
// branch when one is heavier and has served its reorg penalty.
|
||||
// Returns the number of blocks successfully attached.
|
||||
size_t OrphanPool_AttemptAttach(blockchain_t* chain);
|
||||
|
||||
// True if a block with this hash is already pooled.
|
||||
bool OrphanPool_Contains(const uint8_t blockHash[32]);
|
||||
|
||||
// Number of pooled orphans (diagnostics).
|
||||
size_t OrphanPool_Size(void);
|
||||
|
||||
#endif
|
||||
|
||||
@@ -114,6 +114,72 @@ static inline int uint256_cmp(const uint256_t* a, const uint256_t* b) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
static inline bool uint256_is_zero(const uint256_t* a) {
|
||||
return a && a->limbs[0] == 0 && a->limbs[1] == 0 && a->limbs[2] == 0 && a->limbs[3] == 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a uint256 from 32 big-endian bytes, the layout used by hashes and by decoded
|
||||
* difficulty targets (see DecodeCompactTarget).
|
||||
**/
|
||||
static inline uint256_t uint256_from_be_bytes(const uint8_t bytes[32]) {
|
||||
uint256_t res = {{0, 0, 0, 0}};
|
||||
if (!bytes) {
|
||||
return res;
|
||||
}
|
||||
|
||||
for (int limb = 0; limb < 4; ++limb) {
|
||||
// limbs[0] is the least significant, so it holds the LAST eight bytes.
|
||||
const uint8_t* src = bytes + (3 - limb) * 8;
|
||||
uint64_t value = 0;
|
||||
for (int b = 0; b < 8; ++b) {
|
||||
value = (value << 8) | (uint64_t)src[b];
|
||||
}
|
||||
res.limbs[limb] = value;
|
||||
}
|
||||
|
||||
return res;
|
||||
}
|
||||
|
||||
static inline void uint256_bitwise_not(uint256_t* a) {
|
||||
if (!a) {
|
||||
return;
|
||||
}
|
||||
for (int i = 0; i < 4; ++i) {
|
||||
a->limbs[i] = ~a->limbs[i];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Unsigned 256-bit division by restoring binary long division.
|
||||
* Returns false (leaving *outQuotient untouched) when dividing by zero.
|
||||
**/
|
||||
static inline bool uint256_divide(const uint256_t* numerator, const uint256_t* denominator, uint256_t* outQuotient) {
|
||||
if (!numerator || !denominator || !outQuotient || uint256_is_zero(denominator)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
uint256_t quotient = uint256_from_u64(0);
|
||||
uint256_t remainder = uint256_from_u64(0);
|
||||
|
||||
for (int bit = 255; bit >= 0; --bit) {
|
||||
// remainder = (remainder << 1) | bit_of_numerator
|
||||
for (int i = 3; i > 0; --i) {
|
||||
remainder.limbs[i] = (remainder.limbs[i] << 1) | (remainder.limbs[i - 1] >> 63);
|
||||
}
|
||||
remainder.limbs[0] <<= 1;
|
||||
remainder.limbs[0] |= (numerator->limbs[bit / 64] >> (bit % 64)) & 1ULL;
|
||||
|
||||
if (uint256_cmp(&remainder, denominator) >= 0) {
|
||||
(void)uint256_subtract(&remainder, denominator);
|
||||
quotient.limbs[bit / 64] |= (1ULL << (bit % 64));
|
||||
}
|
||||
}
|
||||
|
||||
*outQuotient = quotient;
|
||||
return true;
|
||||
}
|
||||
|
||||
static inline void uint256_serialize(const uint256_t* value, char* out) {
|
||||
if (!value || !out) {
|
||||
return;
|
||||
|
||||
Reference in New Issue
Block a user