Let a node that is behind adopt the peer's branch, and fix two bugs that only appear once a reorg actually applies

Reported: a node at height N+10 on its own short fork, with the peer at N+500,
could never sync. It located the fork point correctly, pooled the branch, and
then refused it forever. At 90s blocks a node is 960 blocks behind after a day
offline, so this is the normal case, not an edge case.

== Why it could never recover ==

The reorg delay was served by LOCAL chain growth alone:

    elapsed = tipHeight - observedTip

That only makes sense for a node whose tip is advancing. A node that is behind
has a frozen tip precisely because it is rejecting the branch, so elapsed stays
0 forever while penalty(5) = 42. Both escape hatches also fail: mining out of it
means extending a fork nobody accepts, and the IBD exemption never fires for a
node that is mining, because mining keeps its tip fresh.

Being 490 blocks behind is being behind, not a reorg contest. Refusing to adopt
protects nothing while the node falls a further 40 blocks behind every hour.

The delay is now satisfiable by EITHER side making progress:

    localGrowth = tipHeight - observedTip
    branchLead  = candidateTip - tipHeight        (0 if not ahead)
    elapsed     = max(localGrowth, branchLead)

A branch already extending `penalty` blocks past our tip has demonstrated
exactly what the delay asks for, and every one of those blocks carries PoW we
validated ourselves. Requiring us to independently produce the same amount is
demanding the same proof twice. Only VALIDATED blocks count -- a peer's
advertised height is not evidence and never reaches this code.

It degrades correctly in both directions: a mining node's tip advances, so an
attacker must outpace it by penalty blocks; a node that is only observing
follows the heaviest chain, which is what an observer should do.

== The window has to keep pulling the branch ==

Every forked delivery reset nextReq back to our own tip, so the window
re-requested the same eight heights forever and the pool never grew past the
window size -- branchLead could not rise even in principle. The backward walk
now runs ONCE to establish linkage, then the window marches forward pooling the
branch, retrying adoption per window-full with a final attempt when it drains.

== sync force ==

Operator override that skips the delay for one sync, for a node whose chain is
known to be the wrong one. Threaded explicitly (Chain_ReplaceBranch gains
bypassPenalty, OrphanPool_AttemptAttachForced) rather than through a global, so
nothing a peer sends can reach it. Linkage, work comparison and atomicity still
apply -- it waives only the waiting, and says so loudly in the log.

== Bug found in testing: PoW is branch-relative ==

A block's epoch seed is the last block of the previous epoch ON ITS OWN BRANCH.
Validating a competing branch's block against OUR epoch seed does not merely
fail to resolve: when the chains diverge before the boundary it resolves to the
WRONG seed and rejects a perfectly valid block. Any fork spanning an epoch
boundary was therefore impossible to assemble -- the branch could not grow past
the boundary block, so branchLead stalled one short of it.

The receive path now does self-contained checks only (Block_HasValidStructure:
merkle, transactions, vote, non-empty). Proof of work moved to
Chain_AddBlockLocked, at the point a block joins the chain, where the branch
context is real -- the rollback has put its ancestors in place by then. That is
where the invariant belongs and it removes a duplicate check rather than adding
one. Needs Chain_DagParamsForHeightLocked, because Chain_AddBlockLocked already
holds chainLock for writing and the lock is not recursive.

Consequence worth knowing: the orphan pool can now hold blocks whose work has
not been verified, bounded by MAX_ORPHAN_BLOCKS (512). Each still had to pass
merkle and full transaction/signature validation, and none can reach the chain
unverified.

This bug also affected plain forward sync across block 350000; it was masked
because appending keeps the boundary block in the chain.

== Bug found in testing: stale DAG accepted as current ==

Block_PowHashHeavy matched on epoch index and size but not the seed. A DAG's
content is a function of (seed, size); the epoch index is a label for it. A
reorg is exactly the event that changes the seed while leaving index and size
untouched, so mid-apply the miner's context still held a DAG built from the
PRE-reorg seed, the guard passed, and a valid block was hashed against the wrong
lanes. g_dagSeed now records what each DAG was generated from and both
Block_EnsureAutolykos2Dag and Block_PowHashHeavy compare it.

== Bug found in testing: double free on the failed-apply path ==

SIGABRT in the allocator: free_tiny_botch -> DynArr_destroy -> Block_Destroy ->
Chain_FreeBlockArray -> Chain_ReplaceBranch.

DynArr_push_back stores the struct BY VALUE, so the chain's element and the
caller's block_t share one transactions pointer. Three places free that array
through the chain's copy -- Chain_ClearBlocks, Chain_RollbackToHeightLocked and
Chain_SaveToFile -- and each NULLs only the chain's side, leaving any caller
wrapper dangling. Whether a caller then had to use free() or Block_Destroy() was
a convention carried in comments at every call site plus a consumed-count passed
into Chain_FreeBlockArray. Chain_ReplaceBranch reset that count to 0 after
rolling back a failed apply, which told the cleanup to Block_Destroy exactly the
blocks whose arrays the rollback had just freed.

Rather than fix the count, the aliasing is now safe by construction:
Chain_AddBlockLocked clears the CALLER's transactions pointer immediately after
the push. Since DynArr_destroy(NULL) is a no-op, free(wrapper) and
Block_Destroy(wrapper) become equivalent and both safe regardless of what later
frees the chain's copy. The consumed-count parameter and both counters are gone
-- the thing that could be got wrong no longer exists -- and all call sites are
unified on Block_Destroy.

Placement is deliberate: immediately after the push, not at the end on success.
The ledger pass can fail with the block already in the chain, returning false to
a caller that destroys its wrapper on failure -- OrphanPool_ExtendTip does
exactly that, a third live instance not yet triggered.

Two follow-ons the refactor forced, both improvements anyway: MineAndAppendBlock
read the coinbase for its log line after the add (hoisted above it), and the
success log printed the caller's block rather than the chain's copy.

== Also ==

The deferral line is rate-limited. The maintenance thread retries pooled
branches once a second and elapsed only changes when something moves, so it
printed an identical line every second -- forever, on a node that is not mining.
It now reports each distinct situation once.

== Verification ==

Synthetic fork, node A 5 deep, node B ~60 ahead, EPOCH_LENGTH=8 so the branch
crosses three epoch boundaries:

  branchLead climbs 8 -> 16 -> 28 -> 34, crosses penalty(5)=42
  Adopted competing branch of 50 block(s) at fork height 20
  sync complete: localHeight=70
  Chain OK

Repeated under AddressSanitizer: adopted 47 blocks, 0 ASan errors on both nodes.
This matters because the refactor rewrites the exact cleanup path the SIGABRT
came from, and a double free that no longer aborts would otherwise pass silently.

Unit suites pass, including a new assertion "heavy path refuses a DAG built from
a different seed" -- the direct regression for the stale-DAG bug.

Harness note: each node needs its OWN wallet. With a shared one both pay the
same coinbase address, produce identical merkle roots, and at easy difficulty
mine byte-identical blocks -- the fork test silently became a catch-up test.

== Still untested ==

The restore-after-failed-apply path is no longer naturally reachable now that
the two bugs above are fixed, so it needs deliberate corruption to exercise.
Test B (branch only slightly ahead must still DEFER), test C (sync force), and a
TSan pass over the changed paths are outstanding.
This commit is contained in:
2026-08-02 19:36:09 +02:00
parent 01c44731ef
commit 5eaf0b699c
8 changed files with 349 additions and 115 deletions
+37 -17
View File
@@ -16,6 +16,10 @@
static Autolykos2Context* g_autolykos2Ctx = NULL;
static pthread_mutex_t g_powCtxLock = PTHREAD_MUTEX_INITIALIZER;
static uint64_t g_dagEpoch = 0;
// The seed the current DAG was generated from. Matching on epoch index and size is NOT enough: a
// reorg replaces the block an epoch's seed is derived from while leaving the epoch index and size
// unchanged, so a stale DAG would still look current and silently hash against the wrong lanes.
static uint8_t g_dagSeed[32];
static bool g_dagReady = false;
// Caller must hold `g_powCtxLock`.
@@ -50,9 +54,11 @@ bool Block_EnsureAutolykos2Dag(uint64_t epochIndex, size_t dagBytes, const uint8
pthread_mutex_lock(&g_powCtxLock);
// Already built for this epoch at this size: generation is seconds of work, so never redo it.
// Already built from exactly this seed at this size: generation is seconds of work, never redo
// it. The seed has to be part of the test -- see g_dagSeed.
if (g_dagReady && g_autolykos2Ctx && g_dagEpoch == epochIndex &&
Autolykos2_DagSize(g_autolykos2Ctx) == dagBytes) {
Autolykos2_DagSize(g_autolykos2Ctx) == dagBytes &&
memcmp(g_dagSeed, seed32, 32) == 0) {
pthread_mutex_unlock(&g_powCtxLock);
return true;
}
@@ -70,6 +76,7 @@ bool Block_EnsureAutolykos2Dag(uint64_t epochIndex, size_t dagBytes, const uint8
const bool ok = Autolykos2_DagAllocate(ctx, dagBytes) && Autolykos2_DagGenerate(ctx, seed32);
if (ok) {
g_dagEpoch = epochIndex;
memcpy(g_dagSeed, seed32, 32);
g_dagReady = true;
}
@@ -77,17 +84,22 @@ bool Block_EnsureAutolykos2Dag(uint64_t epochIndex, size_t dagBytes, const uint8
return ok;
}
bool Block_PowHashHeavy(const block_t* block, uint64_t epochIndex, size_t dagBytes, uint8_t outHash[32]) {
if (!block || !outHash) {
bool Block_PowHashHeavy(const block_t* block, uint64_t epochIndex, size_t dagBytes,
const uint8_t seed32[32], uint8_t outHash[32]) {
if (!block || !seed32 || !outHash) {
return false;
}
pthread_mutex_lock(&g_powCtxLock);
// Checking the epoch and size here, rather than trusting the caller to have built the right
// DAG, is what makes this impossible to misuse: an unbuilt or stale DAG yields false and the
// caller falls back to deriving the lanes from the seed.
// Verifying the SEED here, not just the epoch and size, is what makes this impossible to
// misuse. A reorg changes the block an epoch's seed is derived from while the epoch index and
// size stay put, so an epoch+size check alone happily accepts a DAG built from the pre-reorg
// seed and returns a hash for the wrong lanes -- which shows up as a valid block failing PoW
// while a branch is being applied. A mismatch yields false and the caller derives the lanes
// from the seed instead.
const bool usable = g_dagReady && g_autolykos2Ctx && g_dagEpoch == epochIndex &&
Autolykos2_DagSize(g_autolykos2Ctx) == dagBytes;
Autolykos2_DagSize(g_autolykos2Ctx) == dagBytes &&
memcmp(g_dagSeed, seed32, 32) == 0;
const bool ok = usable &&
Autolykos2_Hash(
g_autolykos2Ctx,
@@ -254,7 +266,7 @@ bool Block_HasValidProofOfWorkWithParams(const block_t* block, uint64_t epochInd
// from the epoch seed. The two produce identical hashes, so which one runs is invisible to
// consensus; only speed differs.
uint8_t hash[32];
if (!Block_PowHashHeavy(block, epochIndex, dagBytes, hash) &&
if (!Block_PowHashHeavy(block, epochIndex, dagBytes, seed32, hash) &&
!Block_PowHashLight(block, dagBytes, seed32, hash)) {
// Fail CLOSED. This used to hand back a zeroed hash on any failure and compare that to the
// target -- and zero is below every target, so a DAG that was missing, mis-sized or failed
@@ -383,16 +395,24 @@ bool Block_ValidateCoinbaseAndFees(const block_t* block, uint64_t expectedCoinba
return true;
}
bool Block_IsFullyValid(const block_t* block, blockchain_t* chain) {
bool merkleValid = false;
uint8_t calculatedMerkleRoot[32];
if (block && block->transactions) {
Block_CalculateMerkleRoot(block, calculatedMerkleRoot);
merkleValid = (memcmp(calculatedMerkleRoot, block->header.merkleRoot, 32) == 0);
bool Block_HasValidStructure(const block_t* block) {
if (!block || !block->transactions) {
return false;
}
return Block_HasValidVote(block) && Block_HasValidProofOfWork(block, chain) &&
Block_AllTransactionsValid(block) && DynArr_size(block->transactions) > 0 && merkleValid;
uint8_t calculatedMerkleRoot[32];
Block_CalculateMerkleRoot(block, calculatedMerkleRoot);
if (memcmp(calculatedMerkleRoot, block->header.merkleRoot, 32) != 0) {
return false;
}
return Block_HasValidVote(block) &&
Block_AllTransactionsValid(block) &&
DynArr_size(block->transactions) > 0;
}
bool Block_IsFullyValid(const block_t* block, blockchain_t* chain) {
return Block_HasValidStructure(block) && Block_HasValidProofOfWork(block, chain);
}
void Block_Destroy(block_t* block) {
+146 -30
View File
@@ -10,6 +10,11 @@
uint64_t currentBlockHeight = 0;
// Defined near the DAG helpers at the bottom; needed early by Chain_AddBlockLocked, which verifies
// proof of work while already holding chainLock for writing.
static bool Chain_DagParamsForHeightLocked(blockchain_t* chain, uint64_t blockHeight,
size_t* outDagBytes, uint8_t outSeed[32]);
static bool EnsureDirectoryExists(const char* dirpath) {
if (!dirpath || dirpath[0] == '\0') {
return false;
@@ -263,6 +268,7 @@ void Chain_Destroy(blockchain_t* chain) {
**/
static bool Chain_AddBlockLocked(blockchain_t* chain, block_t* block) {
bool ok = true;
block_t* stored = NULL; // the chain's own copy; the caller's `block` loses its transactions below
if (!chain || !block || !chain->blocks || !block->transactions) {
return false;
@@ -295,6 +301,34 @@ static bool Chain_AddBlockLocked(blockchain_t* chain, block_t* block) {
// Ensure the block was mined at the difficulty this chain requires at that height. Without this
// a peer whose difficulty went stale (or a malicious one) can hand us a block mined at an
// easier target, which Block_HasValidProofOfWork accepts because it checks the header's own value.
/**
* Proof of work is verified HERE, at the moment a block joins the chain, and not only on the
* receive path.
*
* A block on a competing branch cannot always be PoW-checked when it arrives: its epoch seed is
* the last block of the previous epoch ON ITS OWN BRANCH, which may still be sitting in the
* orphan pool rather than in our chain. Those blocks are pooled with PoW deferred, so this is
* the checkpoint that keeps the real invariant -- nothing enters the chain without verified
* work. By the time a branch is applied the rollback has put its ancestors in place, so the
* epoch parameters always resolve here.
**/
{
size_t dagBytes = 0;
uint8_t dagSeed[32];
if (!Chain_DagParamsForHeightLocked(chain, block->header.blockNumber, &dagBytes, dagSeed)) {
printf("Chain_AddBlock: validation failed: blockIndex=%zu cannot resolve epoch DAG parameters\n",
expectedIndex);
return false;
}
const uint64_t epochIndex = block->header.blockNumber / (uint64_t)EPOCH_LENGTH;
if (!Block_HasValidProofOfWorkWithParams(block, epochIndex, dagBytes, dagSeed)) {
printf("Chain_AddBlock: validation failed: blockIndex=%zu proof of work is invalid\n",
expectedIndex);
return false;
}
}
uint32_t expectedTarget = Chain_GetTargetForHeight(chain, (uint64_t)expectedIndex);
if (block->header.difficultyTarget != expectedTarget) {
printf("Chain_AddBlock: validation failed: blockIndex=%zu expectedDifficulty=%#x observedDifficulty=%#x\n",
@@ -393,9 +427,30 @@ static bool Chain_AddBlockLocked(blockchain_t* chain, block_t* block) {
// Push the block only after validation succeeds.
block_t* blk = (block_t*)DynArr_push_back(chain->blocks, block);
if (!blk) { ok = false; break; }
stored = blk;
chain->size++;
currentBlockHeight = (uint64_t)(chain->size - 1);
/**
* The chain now owns the transaction array, so clear the CALLER's pointer to it.
*
* DynArr_push_back stores the struct by value, which leaves the chain's element and the
* caller's block_t sharing one `transactions` pointer. Three separate places later free
* that array through the chain's copy -- Chain_ClearBlocks, Chain_RollbackToHeightLocked
* and Chain_SaveToFile -- and each of them NULLs only the chain's side, leaving the
* caller holding a dangling pointer. Whether a caller must then use free() or
* Block_Destroy() was a convention carried in comments at every call site plus a
* consumed-count passed around; getting it wrong aborted in the allocator.
*
* Clearing it here makes the rule structural: free(wrapper) and Block_Destroy(wrapper)
* are now equivalent and both safe, because DynArr_destroy(NULL) is a no-op.
*
* This runs right after the push and NOT at the end on success, deliberately. If the
* ledger pass below fails we return false with the block still in the chain, so a caller
* that destroys its wrapper on failure would otherwise free the chain's array.
**/
block->transactions = NULL;
// Second pass: apply the ledger changes.
if (blk->transactions) {
txCount = DynArr_size(blk->transactions);
@@ -464,7 +519,7 @@ static bool Chain_AddBlockLocked(blockchain_t* chain, block_t* block) {
if (ok) {
printf("Added new block to chain:\n");
Block_ShortPrint(block);
Block_ShortPrint(stored ? stored : block); // stored still has the transactions; `block` no longer does
}
return ok;
@@ -795,20 +850,21 @@ static bool Chain_BranchIsLinkedLocked(blockchain_t* chain, size_t forkHeight, b
return true;
}
static void Chain_FreeBlockArray(block_t** blocks, size_t count, size_t consumedByChain) {
/**
* Free an array of blocks and the array itself.
*
* No consumed-count is needed: Chain_AddBlockLocked clears the caller's `transactions` pointer when
* it takes ownership, so Block_Destroy is correct whether or not a given block reached the chain.
* The count used to be threaded through here, and getting it wrong -- resetting it after a rollback
* had already freed the aliased arrays -- is what aborted in the allocator.
**/
static void Chain_FreeBlockArray(block_t** blocks, size_t count) {
if (!blocks) {
return;
}
for (size_t i = 0; i < count; ++i) {
if (!blocks[i]) {
continue;
}
if (i < consumedByChain) {
// Chain_AddBlockLocked shallow-copies the struct, so the chain owns the transactions
// now. Free only our wrapper -- Block_Destroy would take the chain's array with it.
free(blocks[i]);
} else {
if (blocks[i]) {
Block_Destroy(blocks[i]);
}
}
@@ -824,7 +880,8 @@ bool Chain_ReplaceBranch(blockchain_t* chain,
size_t forkHeight,
block_t** newBlocks,
size_t count,
uint64_t observedAtTipHeight) {
uint64_t observedAtTipHeight,
bool bypassPenalty) {
if (!chain || !chain->blocks || !newBlocks || count == 0 || forkHeight == 0) {
return false;
}
@@ -835,9 +892,8 @@ bool Chain_ReplaceBranch(blockchain_t* chain,
bool ok = false;
block_t** snapshot = NULL; // deep copies of the blocks we are replacing
size_t snapshotCount = 0;
size_t snapshotConsumed = 0;
block_t** candidate = NULL; // deep copies of the branch we are applying
size_t candidateConsumed = 0;
size_t candidateApplied = 0; // how far the apply got, for the log line below
do {
const size_t tipCount = DynArr_size(chain->blocks);
@@ -862,18 +918,64 @@ bool Chain_ReplaceBranch(blockchain_t* chain,
// in by a caller: a peer claiming a huge height must not be able to switch the penalty off.
const bool inInitialBlockDownload = Chain_IsInitialBlockDownload(chain);
const uint64_t tipHeight = tipCount > 0 ? (uint64_t)(tipCount - 1) : 0ULL;
if (!inInitialBlockDownload && tipCount > forkHeight) {
if (bypassPenalty && tipCount > forkHeight) {
// Operator override, never reachable from anything a peer sends. Announced loudly
// because it is the one place the delay protection is deliberately not applied.
printf("Chain_ReplaceBranch: reorg penalty BYPASSED at height %zu by operator request\n",
forkHeight);
}
if (!bypassPenalty && !inInitialBlockDownload && tipCount > forkHeight) {
const uint64_t observedTip = observedAtTipHeight > tipHeight ? tipHeight : observedAtTipHeight;
const uint64_t depth = observedTip >= (uint64_t)forkHeight
? (observedTip - (uint64_t)forkHeight + 1ULL)
: 1ULL;
const uint64_t penalty = FetchScheduler_ComputeReorgPenaltyBlocks(depth);
const uint64_t elapsed = tipHeight >= observedTip ? (tipHeight - observedTip) : 0ULL;
/**
* The delay can be served by EITHER side making progress.
*
* Measuring it against our own chain growth alone assumed we are an active participant
* whose tip advances. A node that is behind is not: its tip is frozen precisely because
* it is rejecting the branch, so `elapsed` stays 0 forever and it can never rejoin. That
* is not a reorg contest at all -- being 500 blocks behind is being behind, and
* refusing to adopt protects nothing while the node falls further back every hour.
*
* A branch that already extends `penalty` blocks past our tip has demonstrated exactly
* what the delay asks for -- that much sustained public work -- and every one of those
* blocks carries PoW we validated ourselves. Requiring us to independently produce the
* same amount is demanding the same proof twice. Counting only VALIDATED blocks is what
* keeps this safe: a peer's advertised height is not evidence and never reaches here.
*
* The rule degrades correctly in both directions. A mining node's tip advances, so an
* attacker has to outpace it by `penalty` blocks. A node that is merely observing
* follows the heaviest chain, which is what an observer should do.
**/
const uint64_t localGrowth = tipHeight >= observedTip ? (tipHeight - observedTip) : 0ULL;
const uint64_t candidateTip = (uint64_t)forkHeight + (uint64_t)count - 1ULL;
const uint64_t branchLead = candidateTip > tipHeight ? (candidateTip - tipHeight) : 0ULL;
const uint64_t elapsed = localGrowth > branchLead ? localGrowth : branchLead;
if (elapsed < penalty) {
printf("Chain_ReplaceBranch: deferring reorg at height %zu: depth=%" PRIu64
" penalty=%" PRIu64 " elapsed=%" PRIu64 "\n",
forkHeight, depth, penalty, elapsed);
// The maintenance thread retries pooled branches once a second, and `elapsed` can
// only change when our own tip moves -- so without this the same line is printed
// every second for as long as the branch stays deferred, which on a node that is
// not mining is forever. Report each distinct deferral once, and again whenever the
// situation actually changes. Guarded by chainLock (held for writing here).
static size_t lastDeferredFork = SIZE_MAX;
static uint64_t lastDeferredTip = UINT64_MAX;
static uint64_t lastDeferredPenalty = UINT64_MAX;
static uint64_t lastDeferredElapsed = UINT64_MAX;
if (forkHeight != lastDeferredFork || tipHeight != lastDeferredTip ||
penalty != lastDeferredPenalty || elapsed != lastDeferredElapsed) {
printf("Chain_ReplaceBranch: deferring reorg at height %zu: depth=%" PRIu64
" penalty=%" PRIu64 " elapsed=%" PRIu64
" (localGrowth=%" PRIu64 " branchLead=%" PRIu64 ")\n",
forkHeight, depth, penalty, elapsed, localGrowth, branchLead);
lastDeferredFork = forkHeight;
lastDeferredTip = tipHeight;
lastDeferredPenalty = penalty;
lastDeferredElapsed = elapsed;
}
break;
}
}
@@ -966,7 +1068,7 @@ bool Chain_ReplaceBranch(blockchain_t* chain,
applied = false;
break;
}
candidateConsumed++;
candidateApplied++;
}
if (applied) {
@@ -976,14 +1078,13 @@ bool Chain_ReplaceBranch(blockchain_t* chain,
// Undo: drop whatever of the candidate branch made it in and put the original blocks back.
printf("Chain_ReplaceBranch: candidate branch failed to apply at height %zu; restoring previous chain\n",
forkHeight + candidateConsumed);
forkHeight + candidateApplied);
if (!Chain_RollbackToHeightLocked(chain, forkHeight)) {
fprintf(stderr, "Chain_ReplaceBranch: FAILED to roll back after a failed apply; chain is truncated to %zu\n",
forkHeight);
break;
}
candidateConsumed = 0; // the rollback freed the copies' transactions
for (size_t i = 0; i < snapshotCount; ++i) {
if (!Chain_AddBlockLocked(chain, snapshot[i])) {
@@ -991,12 +1092,11 @@ bool Chain_ReplaceBranch(blockchain_t* chain,
forkHeight + i, forkHeight + i);
break;
}
snapshotConsumed++;
}
} while (0);
Chain_FreeBlockArray(snapshot, snapshotCount, snapshotConsumed);
Chain_FreeBlockArray(candidate, candidate ? count : 0, candidateConsumed);
Chain_FreeBlockArray(snapshot, snapshotCount);
Chain_FreeBlockArray(candidate, candidate ? count : 0);
pthread_mutex_unlock(&balanceSheetLock);
pthread_rwlock_unlock(&chainLock);
@@ -1879,8 +1979,15 @@ static bool Chain_EpochDagSeedForHeightLocked(blockchain_t* chain, uint64_t bloc
return true;
}
bool Chain_DagParamsForHeight(blockchain_t* chain, uint64_t blockHeight,
size_t* outDagBytes, uint8_t outSeed[32]) {
/**
* As Chain_DagParamsForHeight, but assumes `chainLock` is already held (read OR write).
*
* Needed because Chain_AddBlockLocked verifies proof of work while holding the write lock, and
* chainLock is not recursive -- taking it for reading there deadlocks the moment another thread
* queues for the write lock.
**/
static bool Chain_DagParamsForHeightLocked(blockchain_t* chain, uint64_t blockHeight,
size_t* outDagBytes, uint8_t outSeed[32]) {
if (!chain || !chain->blocks || !outDagBytes || !outSeed) {
return false;
}
@@ -1890,8 +1997,6 @@ bool Chain_DagParamsForHeight(blockchain_t* chain, uint64_t blockHeight,
uint64_t bytes = 0;
bool ok = false;
pthread_rwlock_rdlock(&chainLock);
// Lock order is chainLock -> dagCacheLock, everywhere. Nothing under dagCacheLock calls back
// into chain.c, so this pair cannot deadlock.
pthread_mutex_lock(&chain->dagCacheLock);
@@ -1905,8 +2010,6 @@ bool Chain_DagParamsForHeight(blockchain_t* chain, uint64_t blockHeight,
ok = Chain_EpochDagSeedForHeightLocked(chain, blockHeight, outSeed);
}
pthread_rwlock_unlock(&chainLock);
if (!ok) {
return false;
}
@@ -1921,6 +2024,19 @@ bool Chain_DagParamsForHeight(blockchain_t* chain, uint64_t blockHeight,
return true;
}
bool Chain_DagParamsForHeight(blockchain_t* chain, uint64_t blockHeight,
size_t* outDagBytes, uint8_t outSeed[32]) {
if (!chain || !chain->blocks || !outDagBytes || !outSeed) {
return false;
}
pthread_rwlock_rdlock(&chainLock);
const bool ok = Chain_DagParamsForHeightLocked(chain, blockHeight, outDagBytes, outSeed);
pthread_rwlock_unlock(&chainLock);
return ok;
}
void Chain_OnTipAdvanced(blockchain_t* chain) {
if (!chain || !chain->blocks) {
return;