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:
+89
-83
@@ -289,7 +289,7 @@ static bool ComputeEpochDagBytesForHeightFromChain(const blockchain_t* chain, ui
|
||||
int64_t growth = (int64_t)((int64_t)DAG_BASE_GROWTH * difficultyDelta);
|
||||
|
||||
if (growth > 0) {
|
||||
int64_t maxUp = (int64_t)((DAG_BASE_SIZE * 15ULL) / 100ULL);
|
||||
int64_t maxUp = (int64_t)((DAG_BASE_SIZE * DAG_MAX_UP_SWING_PERCENT_NUM) / DAG_SWING_PERCENT_DEN);
|
||||
if (growth > maxUp) {
|
||||
growth = maxUp;
|
||||
}
|
||||
@@ -297,7 +297,7 @@ static bool ComputeEpochDagBytesForHeightFromChain(const blockchain_t* chain, ui
|
||||
growth = (int64_t)DAG_MAX_UP_SWING_GB;
|
||||
}
|
||||
} else {
|
||||
int64_t maxDown = (int64_t)((DAG_BASE_SIZE * 10ULL) / 100ULL);
|
||||
int64_t maxDown = (int64_t)((DAG_BASE_SIZE * DAG_MAX_DOWN_SWING_PERCENT_NUM) / DAG_SWING_PERCENT_DEN);
|
||||
if (-growth > maxDown) {
|
||||
growth = -maxDown;
|
||||
}
|
||||
@@ -403,6 +403,36 @@ static bool Block_GetCoinbaseAndFeeTotals(const block_t* block, uint64_t* outCoi
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ask `peerConn` for its blocks in [topHeight - REORG_FETCH_DEPTH, topHeight], newest first.
|
||||
*
|
||||
* Replies arrive asynchronously as BLOCK_DATA and go through Node_ParseAndAcceptBlock, which routes
|
||||
* blocks below our tip into the orphan pool rather than dropping them. The pool then locates the
|
||||
* common ancestor by prevHash linkage and Chain_ReplaceBranch decides whether to adopt. FETCH_BLOCK
|
||||
* already answers from the peer's own chain, so finding a fork needs no new packet type.
|
||||
**/
|
||||
static void RequestForkWindow(net_node_t* node, tcp_connection_t* peerConn, uint64_t topHeight) {
|
||||
if (!node || !peerConn) {
|
||||
return;
|
||||
}
|
||||
|
||||
const uint64_t from = (topHeight > REORG_FETCH_DEPTH) ? (topHeight - REORG_FETCH_DEPTH) : 0ULL;
|
||||
printf("Requesting peer blocks %" PRIu64 "..%" PRIu64 " to locate the fork point\n", from, topHeight);
|
||||
|
||||
for (uint64_t hh = topHeight + 1; hh-- > from; ) {
|
||||
uint64_t req = hh;
|
||||
if (Node_SendPacket(node, peerConn, PACKET_TYPE_FETCH_BLOCK, &req, sizeof(req)) != 0) {
|
||||
break;
|
||||
}
|
||||
if (hh == 0) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Give the asynchronous replies time to land in the pool.
|
||||
sleep_for_milliseconds(SYNC_REQUEST_TIMEOUT_MS);
|
||||
}
|
||||
|
||||
static bool MineAndAppendBlock(blockchain_t* chain,
|
||||
block_t* block,
|
||||
uint256_t* currentSupply,
|
||||
@@ -444,8 +474,6 @@ static bool MineAndAppendBlock(blockchain_t* chain,
|
||||
BalanceSheet_SaveToFile(chainDataDir);
|
||||
}
|
||||
|
||||
(void)uint256_add_u64(currentSupply, coinbaseAmount);
|
||||
|
||||
uint8_t canonicalHash[32];
|
||||
uint8_t powHash[32];
|
||||
Block_CalculateHash(block, canonicalHash);
|
||||
@@ -462,10 +490,8 @@ static bool MineAndAppendBlock(blockchain_t* chain,
|
||||
powHash[0], powHash[1], powHash[2], powHash[3],
|
||||
canonicalHash[0], canonicalHash[1], canonicalHash[2], canonicalHash[3]);
|
||||
|
||||
*currentReward = CalculateBlockReward(*currentSupply, chain);
|
||||
|
||||
// The difficulty retarget and epoch DAG rebuild happen in Chain_AddBlock, so that blocks we
|
||||
// receive from peers advance them exactly like blocks we mine ourselves.
|
||||
// Supply, reward, the difficulty retarget and the epoch DAG rebuild all happen inside
|
||||
// Chain_AddBlock, so that blocks we receive from peers advance them exactly like ones we mine.
|
||||
|
||||
return true;
|
||||
}
|
||||
@@ -1072,36 +1098,25 @@ int main(int argc, char* argv[]) {
|
||||
|
||||
// Continue syncing in a loop until we've caught up to the peer or no progress is made.
|
||||
bool madeProgressOverall = false;
|
||||
int forkProbes = 0;
|
||||
while (true) {
|
||||
uint64_t localHeight = (uint64_t)Chain_Size(chain);
|
||||
|
||||
// Only penalize small near-tip gaps. Large gaps are treated as normal catch-up,
|
||||
// because a much taller peer on the same chain is not evidence of a reorg. TODO: Maybe look at this again some other day.
|
||||
bool isInitialSync = (localHeight == 0) || ((peerHeight > localHeight) && ((peerHeight - localHeight) > INITIAL_SYNC_HEIGHT_DIFF));
|
||||
// Whether we are catching up rather than following the tip. Derived from our own chain
|
||||
// only: this used to key off the peer's advertised height, which let any peer claiming
|
||||
// localHeight + INITIAL_SYNC_HEIGHT_DIFF switch off reorg handling for the session.
|
||||
bool isInitialSync = Chain_IsInitialBlockDownload(chain);
|
||||
|
||||
// Compute penalty and adjusted peer height.
|
||||
uint64_t delay = (peerHeight > localHeight) ? (peerHeight - localHeight) : 0ULL;
|
||||
uint64_t penalty = isInitialSync ? 0ULL : FetchScheduler_ComputeReorgPenaltyBlocks(delay);
|
||||
uint64_t adjustedPeerHeight = (peerHeight > penalty) ? (peerHeight - penalty) : 0ULL;
|
||||
|
||||
// Ensure we always make forward progress: if the penalty would reduce the
|
||||
// target below our current height, fetch at least the next block. This
|
||||
// lets us apply penalties for near-tip reorg risk while still allowing
|
||||
// normal syncing when the peer is ahead by a small amount.
|
||||
if (adjustedPeerHeight <= localHeight) {
|
||||
adjustedPeerHeight = localHeight + 1;
|
||||
}
|
||||
|
||||
if (adjustedPeerHeight > peerHeight) {
|
||||
adjustedPeerHeight = peerHeight;
|
||||
}
|
||||
|
||||
printf("syncing: peerHeight=%" PRIu64 " adjusted=%" PRIu64 " local=%" PRIu64 " penalty=%" PRIu64 "\n",
|
||||
peerHeight, adjustedPeerHeight, localHeight, penalty);
|
||||
// The reorg penalty is NOT applied to the fetch window. It is a delay on adopting a
|
||||
// competing branch (enforced in Chain_ReplaceBranch), not on catching up: penalizing
|
||||
// the height gap to a peer only throttled honest sync, and for gaps of 4-50 it
|
||||
// collapsed the window to a single block per pass.
|
||||
printf("syncing: peerHeight=%" PRIu64 " local=%" PRIu64 " initialSync=%s\n",
|
||||
peerHeight, localHeight, isInitialSync ? "yes" : "no");
|
||||
|
||||
// Windowed parallel fetch
|
||||
uint64_t start = localHeight;
|
||||
uint64_t end = adjustedPeerHeight; // exclusive target height
|
||||
uint64_t end = peerHeight; // exclusive target height
|
||||
uint64_t nextReq = start;
|
||||
|
||||
const int maxInFlight = MAX_PARALLEL_FETCHES;
|
||||
@@ -1176,59 +1191,29 @@ int main(int argc, char* argv[]) {
|
||||
|
||||
// Check whether this block builds on our expected tip. If not, it's a reorg.
|
||||
if (memcmp(fetched->header.prevHash, expectedPrevHash, sizeof(expectedPrevHash)) != 0) {
|
||||
// Find matching ancestor in our current chain (if any)
|
||||
ssize_t matchIndex = -1;
|
||||
size_t chainSz = Chain_Size(chain);
|
||||
uint8_t tmpHash[32];
|
||||
for (size_t bi = 0; bi < chainSz; ++bi) {
|
||||
block_t* b = NULL;
|
||||
if (!Chain_GetBlockCopy(chain, bi, &b) || !b) continue;
|
||||
Block_CalculateHash(b, tmpHash);
|
||||
if (memcmp(tmpHash, fetched->header.prevHash, sizeof(tmpHash)) == 0) {
|
||||
matchIndex = (ssize_t)bi;
|
||||
Block_Destroy(b);
|
||||
break;
|
||||
}
|
||||
Block_Destroy(b);
|
||||
}
|
||||
// Ask the peer for a window of blocks below the divergence so the orphan
|
||||
// pool can assemble its branch and find the true common ancestor by
|
||||
// prevHash linkage. FETCH_BLOCK answers from the peer's own chain, and
|
||||
// Node_ParseAndAcceptBlock now routes sub-tip blocks into the pool
|
||||
// instead of dropping them, so no protocol change is needed.
|
||||
//
|
||||
// We deliberately do NOT roll back here. The swap happens in
|
||||
// Chain_ReplaceBranch, which compares cumulative work, enforces the
|
||||
// Horizen reorg penalty, and restores our chain if the branch fails to
|
||||
// apply. The old code rolled back to height 0 whenever it could not find
|
||||
// the parent -- a full chain wipe, genesis included, that any peer could
|
||||
// trigger with a single unlinked block.
|
||||
printf("Divergence at height %" PRIu64 "; probing for the fork point\n", h);
|
||||
RequestForkWindow(node, peerConn, h);
|
||||
|
||||
uint64_t reorgDepth = 0ULL;
|
||||
if (matchIndex >= 0) {
|
||||
reorgDepth = (uint64_t)localHeight - ((uint64_t)matchIndex + 1ULL);
|
||||
size_t reattached = OrphanPool_AttemptAttach(chain);
|
||||
if (reattached > 0) {
|
||||
printf("Reorg attached %zu block(s) from the peer's branch\n", reattached);
|
||||
} else {
|
||||
// No match found: treat as full reorg depth equal to localHeight
|
||||
reorgDepth = localHeight;
|
||||
printf("Reorg candidate not adopted (lighter branch, or still serving its reorg penalty)\n");
|
||||
}
|
||||
|
||||
if (!isInitialSync) {
|
||||
uint64_t reorgPenalty = FetchScheduler_ComputeReorgPenaltyBlocks(reorgDepth);
|
||||
printf("Reorg detected at height %" PRIu64 ": depth=%" PRIu64 " penalty=%" PRIu64 "\n",
|
||||
h, reorgDepth, reorgPenalty);
|
||||
|
||||
// Rollback our chain to the matching ancestor (or to 0 if none)
|
||||
size_t rollbackTo = (matchIndex >= 0) ? (size_t)(matchIndex + 1) : 0;
|
||||
if (!Chain_RollbackToHeight(chain, rollbackTo)) {
|
||||
printf("Failed to rollback to height %zu during reorg handling\n", rollbackTo);
|
||||
inFlight = 0; // abort sync
|
||||
break;
|
||||
}
|
||||
|
||||
size_t reattached = OrphanPool_AttemptAttach(chain);
|
||||
if (reattached > 0) {
|
||||
printf("Reorg rollback attached %zu orphan(s)\n", reattached);
|
||||
}
|
||||
|
||||
// Apply additional penalty by shrinking end and restart window from current Chain_Size
|
||||
if (peerHeight > reorgPenalty) {
|
||||
end = peerHeight - reorgPenalty;
|
||||
} else {
|
||||
end = start;
|
||||
}
|
||||
} else {
|
||||
printf("Initial sync: reorg-like divergence ignored (height=%" PRIu64 ")\n", h);
|
||||
}
|
||||
|
||||
// Free fetched block and reset window to pick up new adjusted end and expectedPrevHash
|
||||
// Free fetched block and reset the window against whatever our tip is now
|
||||
Block_Destroy(fetched);
|
||||
nextReq = Chain_Size(chain);
|
||||
inFlight = 0;
|
||||
@@ -1291,9 +1276,12 @@ int main(int argc, char* argv[]) {
|
||||
}
|
||||
}
|
||||
|
||||
// After the window completes, check progress and possibly refresh peer height
|
||||
// After the window completes, check progress and possibly refresh peer height.
|
||||
// This flag tracks THIS iteration only: it used to be set once and never cleared, so
|
||||
// after a single productive pass the "no progress -> stop" guard below could never fire
|
||||
// again and the outer loop could spin forever holding the REPL.
|
||||
uint64_t newLocal = (uint64_t)Chain_Size(chain);
|
||||
if (newLocal > localHeight) madeProgressOverall = true;
|
||||
madeProgressOverall = (newLocal > localHeight);
|
||||
printf("sync complete: localHeight=%" PRIu64 "\n", newLocal);
|
||||
|
||||
// If we've caught up to the peer, stop. Otherwise refresh peerHeight and loop again.
|
||||
@@ -1309,8 +1297,26 @@ int main(int argc, char* argv[]) {
|
||||
}
|
||||
pthread_mutex_unlock(&node->outboundLock);
|
||||
|
||||
// If no progress was made in this iteration, stop to avoid tight loop
|
||||
// If no progress was made in this iteration, stop to avoid a tight loop -- but first
|
||||
// consider that the peer may be ahead on a branch that forks BELOW our tip. In that
|
||||
// case every block we asked for is unappendable and lands in the orphan pool, so the
|
||||
// window completes having achieved nothing. Probe downwards for the fork point before
|
||||
// giving up; this is the only trigger that fires for a genuine sub-tip fork, because
|
||||
// the divergence check above can only see blocks that made it into our chain.
|
||||
if (!madeProgressOverall) {
|
||||
if (peerHeight > newLocal && forkProbes < MAX_FORK_PROBE_ROUNDS) {
|
||||
forkProbes++;
|
||||
printf("No progress but peer is ahead (%" PRIu64 " > %" PRIu64 "); probing for a fork point\n",
|
||||
peerHeight, newLocal);
|
||||
RequestForkWindow(node, peerConn, newLocal);
|
||||
|
||||
size_t attached = OrphanPool_AttemptAttach(chain);
|
||||
if (attached > 0) {
|
||||
printf("Fork probe adopted %zu block(s) from the peer's branch\n", attached);
|
||||
continue;
|
||||
}
|
||||
printf("Fork probe found nothing adoptable (lighter branch, or still serving its reorg penalty)\n");
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user