Fix reorg penalty scaling direction and rebuild DAG sizing around a miner-signalled band

Two consensus-parameter bugs, both latent today and both guaranteed to become
live splits later. Neither is a regression; both are cheap to fix now and
expensive to fix after launch.

== 1. The reorg penalty scaled with block time the wrong way ==

FetchScheduler_ComputeReorgPenaltyBlocks had TARGET_BLOCK_TIME in the numerator
and REORG_PENALTY_REF_BLOCK_TIME in the denominator. The penalty is counted in
BLOCKS, so the wall-clock protection it actually buys was

    penalty(d) * TARGET_BLOCK_TIME  ~  d^2 * T^2 / REF

i.e. quadratic in block time, when the stated intent in constants.h is
wall-clock equivalence to the reference scheme defined at REF_BLOCK_TIME = 150.
At T = 90 the chain got 54*d^2 seconds of protection instead of 150*d^2 --
2.78x weaker than intended -- and any future block-time reduction would have
weakened it further, silently.

Swapping the two makes T cancel out of the wall-clock figure:

    penalty(d) = ceil(d^2 * REF / T)   ->   protection ~ d^2 * REF seconds

Penalty in blocks, depth -> before/after: 4 -> 10/27, 8 -> 39/107,
10 -> 60/167, 50 -> 1500/4167, 100 -> 6000/16667.

Verified by rebuilding at TARGET_BLOCK_TIME = 60: wall-clock protection is now
identical at both block times (2400s, 9600s, 15000s, ...), ratio 1.0000. Under
the old formula 60s blocks would have been 56% weaker.

Still integer-only: the ceiling division and saturation guards are unchanged,
and numeratorScale only moves 90 -> 150 while `raised` stays bounded by
REORG_PENALTY_MAX_DEPTH^2 = 1e6, nowhere near overflow.

This is fork choice, not block validity, so mixed-version nodes converge once
the longer penalty expires and no block ever becomes invalid.

== 2. DAG sizing was a fail-open consensus split waiting on block 350000 ==

Fixed, in rough order of severity:

  * FAIL-OPEN PoW. Block_CalculateAutolykos2Hash memset the hash to zero on any
    failure, and zero compares below every target -- so a DAG that was missing,
    mis-sized or had failed to build made *every* block pass PoW validation
    instead of rejecting it. PoW checks now fail closed.

  * TWO DIVERGENT IMPLEMENTATIONS of the same rule: CalculateTargetDAGSize in
    constants.h (mining) and ComputeEpochDagBytesForHeightFromChain in main.c
    (verification). They already disagreed at exactly height == EPOCH_LENGTH,
    where the constants.h copy underflowed size_t computing
    `Chain_Size - 1 - EPOCH_LENGTH` and returned 0.

  * A MEANINGLESS DIFFICULTY COUPLING. difficultyTarget is compact-encoded
    [1B exponent][3B coefficient]; subtracting two compact values mixes
    exponent and mantissa. The first real retarget yields a delta of 900649,
    which times DAG_BASE_GROWTH is ~967 TB, so the result was always clamped
    and the "proportional" term degenerated to a binary base+maxUp/base-maxDown
    switch. The size also never accumulated -- it was always recomputed from
    the constant base -- so the documented 1 GB/epoch growth could not happen.

  * A NON-EPOCH-ALIGNED SEED. GetNextDAGSeed returned hash(tip) while the
    verifier correctly used hash(block[epochIndex * EPOCH_LENGTH - 1]). The
    `chainSize % EPOCH_LENGTH == 0` rebuild guard masked this while running,
    but startup called it at an arbitrary height -- so a node restarted
    mid-epoch built a DAG from a different seed than one that had run straight
    through the boundary, and its blocks were rejected.

  * UNSIGNED PROMOTION. `DAG_BASE_GROWTH * difficultyDelta` promoted the signed
    delta to unsigned long long and wrapped before being assigned back to
    int64_t; the main.c copy cast first, so the two also differed in overflow
    behaviour. Both are gone.

  * A LEAK on the `targetSize <= 0` path in main.c (both block copies).

  * A USE-AFTER-FREE at every epoch boundary: Block_RebuildAutolykos2Dag ran
    DagClear -> DagAllocate -> DagGenerate from whichever thread advanced the
    tip, freeing ctx->dag.buf while miners read it.

  * GetAutolykos2Ctx called DagAllocate without DagGenerate, leaving
    dag.len == 0 so every heavy hash failed -- which, combined with the
    fail-open above, meant "everything is valid". It also memset 1 GiB that
    DagGenerate immediately overwrote.

  * TOCTOU / lock reentrancy: CalculateTargetDAGSize called Chain_Size 4x and
    Chain_GetBlockCopy 2x, each taking chainLock for reading, making it unsafe
    to call from a write-locked section.

--- New sizing rule: default-grow inside a hard band ---

DAG size now follows a recurrence gated by a miner signal in the block header,
clamped to [DAG_MIN_SIZE, DAG_MAX_SIZE]:

    brake  = (hold + down) * DAG_BRAKE_DEN > EPOCH_LENGTH * DAG_BRAKE_NUM
    downQ  =          down * DAG_DOWN_DEN  > EPOCH_LENGTH * DAG_DOWN_NUM

    downQ(k) && downQ(k-1) -> size -= DAG_EPOCH_STEP   (floored at DAG_MIN_SIZE)
    brake                  -> size unchanged
    otherwise              -> size += DAG_EPOCH_STEP   (capped at DAG_MAX_SIZE)

Growth is the default and there is deliberately NO up-vote: every signal a
miner can express only slows the walk or reverses it. Under stratum-style
pooled mining the pool builds the header and therefore controls its share of
the vote, so the mechanism has to be safe under pool capture -- and it is,
because the lever a pool would want (grow the DAG to price out smaller miners)
does not exist. This is NOT because upward capture would be self-defeating: it
would in fact be profitable, since the fixed block reward redistributes to
whoever survives and difficulty retargets down. The protection is the absence
of the lever. The whole upward trajectory is therefore governance
(DAG_EPOCH_STEP, DAG_MAX_SIZE), not signalling.

Braking keeps the DAG small, which helps old hardware and only costs ASIC
resistance -- bounded by DAG_MIN_SIZE, which is the constant that actually
secures the property. Shrinking needs a supermajority sustained across two
consecutive epochs; that gates the onset only, so miners genuinely being
squeezed get relief every epoch rather than every other one.

Thresholds are cross-multiplied rather than divided, so there is no rounding
for nodes to disagree on, and the denominator is the constant epoch length
rather than blocks-observed, so a partial epoch cannot read as a stronger
signal than it is. No floating point anywhere on this path.

--- Header vote field ---

reserved[0] carries the vote: 0 = grow (default), 1 = hold, 2 = down.
reserved[1..2] must be zero. All three already sat inside the packed, hashed
header, so the vote is committed to by both the canonical hash and the PoW hash
and cannot be altered after mining -- no wire-format or hash-layout change.

0 must mean grow, because the point of this shape is that inaction produces
growth; it also means a miner that knows nothing about the vote contributes to
the intended default rather than silently freezing the schedule.

Rejecting unrecognised vote values and non-zero spare bytes is a new validity
rule. It closes 24 bits of undefined-meaning malleable header space.

--- Validation moves to the light path ---

Autolykos2_DagGenerate fills lane i with exactly what ReadDagLaneFromSeed
recomputes for lane i -- Blake2b(seed || (i/2)_LE64), half i&1 -- so the DAG is
a pure cache and the two hashing paths are bit-for-bit equivalent. Validation
therefore uses the light path: no allocation, correct for any epoch rather than
only whichever one the global DAG happens to hold, and the DAG band becomes a
miner requirement rather than a full-node memory requirement.

Chain_OnTipAdvanced no longer rebuilds the DAG at all. MineBlock builds it on
demand for the height it is working on, so a node that does not mine never
allocates one, generation stays off the tip-advance path, and the buffer is
only ever touched by the miner (the ctx mutex remains as a backstop).

--- API changes ---

  + Chain_DagParamsForHeight(chain, height, &dagBytes, seed) -- single source
    of truth for both the size and the epoch seed, backed by a memoised
    per-epoch table on blockchain_t. The table is a pure cache of a function of
    the headers, extended lazily and dropped whenever anything at or below the
    tip changes; it lives on the chain rather than in a global because a
    second, header-only chain is built to re-verify historical PoW. Guarded by
    a per-chain mutex, always taken after chainLock.
  + Block_EnsureAutolykos2Dag / Block_PowHashHeavy / Block_PowHashLight.
    The heavy variant verifies the epoch and size itself, so it can never
    answer from a DAG built for another epoch.
  + Block_HasValidProofOfWorkWithParams -- resolve-once form. MineBlock called
    Block_HasValidProofOfWork inside its nonce loop, so resolving from the
    chain per attempt would have taken chainLock millions of times per block.
  + Block_HasValidVote.
  ~ Block_HasValidProofOfWork / Block_IsFullyValid now take the chain, because
    PoW validity genuinely is chain-relative. blockchain_t gained a struct tag
    so block.h can forward-declare it.
  - CalculateTargetDAGSize, GetNextDAGSeed, ComputeEpochDagBytesForHeightFromChain,
    ComputeEpochSeedForHeightFromChain, Block_CalculateAutolykos2Hash,
    Block_RebuildAutolykos2Dag, Autolykos2_LightHash (dead).
  - DAG_BASE_GROWTH and the five DAG_MAX_*_SWING_* / DAG_SWING_PERCENT_DEN
    macros, all now unreachable.

Also: Block_CalculateAutolykos2Hash truncated the height to uint32 while the
light path takes uint64, so the two would have diverged above block 2^32; the
full width is now passed. Added a `dagvote <grow|hold|down>` REPL command and a
progress line during DAG generation, which is tens of seconds at production
sizes.

static_asserts now enforce DAG_MIN_SIZE <= DAG_BASE_SIZE <= DAG_MAX_SIZE and
32-byte alignment, so a misconfigured band fails the build instead of being
silently clamped.

NOTE: DAG_MIN_SIZE (2 GiB), DAG_BASE_SIZE (2 GiB), DAG_MAX_SIZE (8 GiB) and
DAG_EPOCH_STEP (1 GiB) are economic judgements, not derivations, and since the
vote cannot accelerate growth they are the entire upward story. Sanity-check
them before launch.

== Verification ==

  * Penalty: exact table match at d = 4/8/10/50/100/1000, zero within grace,
    saturation at MAX_DEPTH, and identical wall-clock protection when rebuilt
    at TARGET_BLOCK_TIME = 60.
  * DAG recurrence (30 assertions against the real objects): default growth,
    legacy all-zero headers read as grow, strict inequality at exactly 1/2 and
    exactly 7/8, one qualifying epoch freezes but does not shrink, two
    consecutive shrink, sustained shrink repeats, both clamps saturate.
  * Epoch seed: constant across an epoch, equal to hash(block[k*EL - 1]),
    genesis seed in epoch 0, and resolvable at exactly height == EPOCH_LENGTH.
  * Fail-closed: unresolvable params and a zero-byte DAG both reject.
  * Heavy/light equivalence across 3 epochs; heavy refuses a DAG built for the
    wrong epoch or size.
  * Two nodes across 3 epoch boundaries: B reached height 30 purely by
    receiving, then mined blocks A accepted; both fullverify Chain OK, zero
    rejections.
  * Restart mid-epoch: B restarted at height 28 derived epoch 3's seed as
    hash(block[23]) -- the boundary block, not the tip -- and kept producing
    blocks A accepted. This fails before the change.
  * Divergent votes: A voting hold and B voting grow computed identical size
    and seed, confirming the tally is chain-derived, not config-derived.
  * ThreadSanitizer across epoch rollovers under load: no new races.
This commit is contained in:
2026-07-30 19:32:10 +02:00
parent 4d39614cb5
commit 0e721ca389
10 changed files with 612 additions and 272 deletions
+230 -5
View File
@@ -181,6 +181,27 @@ bool Chain_RecomputeRuntimeState(blockchain_t* chain) {
return true;
}
/**
* Drop the memoised DAG size recurrence.
*
* Every epoch's size is folded from the votes of every epoch before it, so any change at or below
* the tip invalidates the whole table. It is only a cache -- rebuilding it costs one pass over the
* headers reading a single byte each -- so blowing it away wholesale is both correct and cheap,
* and far safer than trying to work out which suffix a reorg actually disturbed.
*
* Takes `dagCacheLock`. Safe to call while holding `chainLock` (that is the required lock order);
* must NOT be called while already holding `dagCacheLock`.
**/
static void Chain_InvalidateDagEpochs(blockchain_t* chain) {
if (!chain) {
return;
}
pthread_mutex_lock(&chain->dagCacheLock);
chain->dagEpochsComputed = 0;
pthread_mutex_unlock(&chain->dagCacheLock);
}
static void Chain_ClearBlocks(blockchain_t* chain) {
if (!chain || !chain->blocks) {
return;
@@ -196,6 +217,7 @@ static void Chain_ClearBlocks(blockchain_t* chain) {
DynArr_erase(chain->blocks);
chain->size = 0;
Chain_InvalidateDagEpochs(chain);
}
blockchain_t* Chain_Create() {
@@ -207,6 +229,15 @@ blockchain_t* Chain_Create() {
ptr->blocks = DYNARR_CREATE(block_t, 1);
ptr->size = 0;
ptr->dagEpochs = NULL;
ptr->dagEpochsComputed = 0;
ptr->dagEpochsCapacity = 0;
if (pthread_mutex_init(&ptr->dagCacheLock, NULL) != 0) {
DynArr_destroy(ptr->blocks);
free(ptr);
return NULL;
}
return ptr;
}
@@ -216,6 +247,8 @@ void Chain_Destroy(blockchain_t* chain) {
Chain_ClearBlocks(chain);
DynArr_destroy(chain->blocks);
}
free(chain->dagEpochs);
pthread_mutex_destroy(&chain->dagCacheLock);
free(chain);
}
}
@@ -557,6 +590,9 @@ static bool Chain_RollbackToHeightLocked(blockchain_t* chain, size_t height) {
chain->size = DynArr_size(chain->blocks);
currentBlockHeight = chain->size ? (uint64_t)(chain->size - 1) : 0ULL;
// Blocks below the old tip are gone, so the DAG recurrence folded from their votes is stale.
Chain_InvalidateDagEpochs(chain);
// Rebuild balance sheet from scratch up to current chain size
BalanceSheet_Destroy();
BalanceSheet_Init();
@@ -1678,6 +1714,195 @@ bool Chain_ComputeBranchWork(block_t** blocks, size_t count, uint256_t* outWork)
return true;
}
/**
* Grow the memoised epoch table to hold at least `needed` entries.
* Caller must hold `chain->dagCacheLock`.
**/
static bool Chain_ReserveDagEpochsLocked(blockchain_t* chain, size_t needed) {
if (needed <= chain->dagEpochsCapacity) {
return true;
}
size_t capacity = chain->dagEpochsCapacity ? chain->dagEpochsCapacity : 8u;
while (capacity < needed) {
if (capacity > SIZE_MAX / 2u) {
return false;
}
capacity *= 2u;
}
dag_epoch_state_t* grown =
(dag_epoch_state_t*)realloc(chain->dagEpochs, capacity * sizeof(dag_epoch_state_t));
if (!grown) {
return false;
}
chain->dagEpochs = grown;
chain->dagEpochsCapacity = capacity;
return true;
}
/**
* Fold the DAG size recurrence forward until entry `epochIndex` is valid.
*
* Entry k's size comes from the votes cast during epoch k-1, so extending to k requires blocks
* [0, k * EPOCH_LENGTH) to all be present. Growth is the default: the size only stays put or falls
* when miners actively say so, and there is no vote that makes it climb faster.
*
* Caller must hold `chainLock` (read) and `chain->dagCacheLock`.
**/
static bool Chain_ExtendDagEpochsLocked(blockchain_t* chain, size_t epochIndex) {
if (!chain || !chain->blocks) {
return false;
}
if (!Chain_ReserveDagEpochsLocked(chain, epochIndex + 1u)) {
return false;
}
if (chain->dagEpochsComputed == 0) {
chain->dagEpochs[0].sizeBytes = DAG_BASE_SIZE;
chain->dagEpochs[0].downQualified = false;
chain->dagEpochsComputed = 1;
}
const size_t chainSize = DynArr_size(chain->blocks);
const size_t epochLength = (size_t)EPOCH_LENGTH;
for (size_t k = chain->dagEpochsComputed; k <= epochIndex; ++k) {
const size_t prev = k - 1u; // the epoch whose votes decide entry k
const size_t from = prev * epochLength;
const size_t to = from + epochLength; // exclusive
if (to > chainSize) {
// The epoch that would decide this entry has not been fully mined yet.
return false;
}
uint64_t holdVotes = 0;
uint64_t downVotes = 0;
for (size_t i = from; i < to; ++i) {
const block_t* blk = (const block_t*)DynArr_at(chain->blocks, i);
if (!blk) {
return false;
}
// Anything other than HOLD or DOWN counts as GROW. The accept path rejects values
// above DAG_VOTE_MAX, so in practice the only other value that reaches here is GROW.
const uint8_t vote = blk->header.reserved[0];
if (vote == DAG_VOTE_HOLD) {
holdVotes++;
} else if (vote == DAG_VOTE_DOWN) {
downVotes++;
}
}
// Cross-multiplied rather than divided, so there is no rounding for nodes to disagree on.
// The denominator is the constant epoch length, not the number of blocks looked at, so a
// partial epoch can never be read as a stronger signal than it is.
const uint64_t epochBlocks = (uint64_t)EPOCH_LENGTH;
const bool brake = (holdVotes + downVotes) * DAG_BRAKE_DEN > epochBlocks * DAG_BRAKE_NUM;
const bool downQualified = downVotes * DAG_DOWN_DEN > epochBlocks * DAG_DOWN_NUM;
chain->dagEpochs[prev].downQualified = downQualified;
const bool priorDownQualified = (prev >= 1u) ? chain->dagEpochs[prev - 1u].downQualified : false;
const uint64_t prevSize = chain->dagEpochs[prev].sizeBytes;
uint64_t next;
// Order matters: a qualifying down vote also satisfies the brake condition (down > 7/8
// implies hold+down > 1/2), so testing the brake first would make shrinking impossible.
if (downQualified && priorDownQualified) {
// Shrinking needs the supermajority sustained across two consecutive epochs. That gates
// the *onset* only -- it is deliberately not reset afterwards, so miners who are
// genuinely being squeezed keep getting relief every epoch rather than every other one.
next = (prevSize > DAG_MIN_SIZE + DAG_EPOCH_STEP) ? (prevSize - DAG_EPOCH_STEP)
: (uint64_t)DAG_MIN_SIZE;
} else if (brake) {
next = prevSize;
} else {
next = (prevSize + DAG_EPOCH_STEP < DAG_MAX_SIZE) ? (prevSize + DAG_EPOCH_STEP)
: (uint64_t)DAG_MAX_SIZE;
}
chain->dagEpochs[k].sizeBytes = next;
chain->dagEpochs[k].downQualified = false; // filled in when entry k+1 is folded
chain->dagEpochsComputed = k + 1u;
}
return true;
}
/**
* Epoch-aligned DAG seed for `blockHeight`: the genesis seed in epoch 0, otherwise the hash of the
* last block of the previous epoch. Constant for a whole epoch, which is what lets the DAG be
* generated once per epoch instead of once per block.
*
* Caller must hold `chainLock`.
**/
static bool Chain_EpochDagSeedForHeightLocked(blockchain_t* chain, uint64_t blockHeight, uint8_t outSeed[32]) {
const uint64_t epochIndex = blockHeight / (uint64_t)EPOCH_LENGTH;
if (epochIndex == 0) {
memset(outSeed, DAG_GENESIS_SEED, 32);
return true;
}
const uint64_t seedBlockNumber = (epochIndex * (uint64_t)EPOCH_LENGTH) - 1ULL;
if (seedBlockNumber >= (uint64_t)DynArr_size(chain->blocks)) {
return false;
}
const block_t* seedBlock = (const block_t*)DynArr_at(chain->blocks, (size_t)seedBlockNumber);
if (!seedBlock) {
return false;
}
Block_CalculateHash(seedBlock, outSeed);
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;
}
const size_t epochIndex = (size_t)(blockHeight / (uint64_t)EPOCH_LENGTH);
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);
if (Chain_ExtendDagEpochsLocked(chain, epochIndex)) {
bytes = chain->dagEpochs[epochIndex].sizeBytes;
ok = true;
}
pthread_mutex_unlock(&chain->dagCacheLock);
if (ok) {
ok = Chain_EpochDagSeedForHeightLocked(chain, blockHeight, outSeed);
}
pthread_rwlock_unlock(&chainLock);
if (!ok) {
return false;
}
// Autolykos2 addresses the DAG in 32-byte lanes. A size that is zero or not a multiple of 32
// would make hashing fail, and a proof that cannot be computed must never read as valid.
if (bytes < 32ULL || (bytes % 32ULL) != 0ULL) {
return false;
}
*outDagBytes = (size_t)bytes;
return true;
}
void Chain_OnTipAdvanced(blockchain_t* chain) {
if (!chain || !chain->blocks) {
return;
@@ -1689,9 +1914,9 @@ void Chain_OnTipAdvanced(blockchain_t* chain) {
// (mining, P2P accept, sync, orphan attach, reorg) stays on the same difficulty as its peers.
difficultyTarget = Chain_GetTargetForHeight(chain, (uint64_t)chainSize);
if (chainSize % EPOCH_LENGTH == 0 && chainSize > 0) {
uint8_t dagSeed[32];
GetNextDAGSeed(chain, dagSeed);
(void)Block_RebuildAutolykos2Dag(CalculateTargetDAGSize(chain), dagSeed);
}
// The epoch DAG is deliberately NOT rebuilt here. It is a mining accelerator only -- validation
// derives its lanes from the epoch seed, so a node that does not mine never allocates one --
// and MineBlock builds it on demand for the height it is working on. Keeping generation off
// this path matters twice over: it is seconds of work per epoch, and it used to run from
// whichever thread happened to advance the tip, freeing the buffer under any miner mid-hash.
}