Commit Graph
26 Commits
Author SHA1 Message Date
dcrubro 5eaf0b699c 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.
2026-08-02 19:36:09 +02:00
dcrubro 0e721ca389 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.
2026-07-30 19:32:10 +02:00
dcrubro 1ff2890c0f 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.
2026-07-28 00:24:45 +02:00
dcrubro 1288a64977 Fixed the target computation to trigger on syncs - old system would cause difficulty mismatches on syncs, reorgs, etc. 2026-07-27 19:21:39 +02:00
dcrubro f9c785b316 Add timestamp field to transactions - allows multiple transactions with the same Ins/Outs 2026-07-27 14:30:06 +02:00
dcrubro 39293029c5 recompute state bug fixed 2026-05-29 13:51:34 +02:00
dcrubro 763aeb648f add fee-aware mining, coinbase validation, and reorg-safe orphan handling
Mining: blocks now include mempool txs, select spendable txs by fee, and pay coinbase as base reward + fees in main.c.
 - Consensus: block validation now enforces coinbase accounting and rejects invalid coinbase placement, including coinbase on amount2, in block.c and transaction.c.
 - Chain state: rollback now rebuilds currentSupply/currentReward, and block addition preflights spendability before mutating balances in chain.c.
 - Orphans/reorgs: orphan retry is safer, rollback-triggered sync reattaches orphans immediately, and transient orphan failures no longer drop blocks in orphan_pool.c and main.c.
 - Networking/mempool: node lifecycle now initializes the mempool, broadcasts can exclude one peer, and mempool snapshotting supports mining selection in net_node.c and txmempool.c.
 - Ledger simulation: added non-mutating spendable-transaction selection for block assembly in balance_sheet.c.
2026-05-29 13:44:15 +02:00
dcrubro 0fb2615d4c sync errors 2026-05-15 19:01:51 +02:00
dcrubro 3337ac85ab reorgs, fetch batching (parallel fetch), orphans 2026-05-15 13:01:27 +02:00
dcrubro 361ac73e45 global externs refactor, some tcp methods 2026-05-14 17:36:40 +02:00
dcrubro d4ec88426a blockdetail command, fullverify checks difficulty (needs optimizing), move general functions to utils.h 2026-04-30 00:09:40 +02:00
dcrubro 9c99eec3a8 TCP Node boilerplate; CLI interface 2026-04-23 16:24:26 +02:00
dcrubro 258ca9474f shorten transaction 2026-04-15 18:46:00 +02:00
dcrubro eb7c29abb1 pragma packing 2026-04-10 16:36:37 +02:00
dcrubro 7aafaa4196 note 2026-04-03 16:08:33 +02:00
dcrubro ae64bb9dfc temoporarily changed DAG size for testing, fix TX loading, move some TX logic to Transaction_Init() 2026-04-03 15:20:13 +02:00
dcrubro df7787ed2d balance sheet stuff, added khash hashmaps 2026-04-02 21:21:12 +02:00
dcrubro b20ba9802e Full DAG size, epoch scaling etc. 2026-04-01 18:47:59 +02:00
dcrubro dfea98aee2 update storage 2026-04-01 15:01:25 +02:00
dcrubro 06e6f02b86 Figured out the reward scheme 2026-04-01 11:56:09 +02:00
dcrubro 075793c24c huge chain test, added 1.5% yearly inflation at 3.5 million blocks 2026-03-30 23:46:22 +02:00
dcrubro b47ff30bc7 difficulty calculation, move from randomx to autolykos2 2026-03-30 15:42:28 +02:00
dcrubro c358115af4 stuff 2026-03-30 09:06:17 +02:00
dcrubro 50e357d8a2 Monero-style emission 2026-03-29 23:30:31 +02:00
dcrubro 0d7adc39e0 BigInts, save/load, will make a calculation for block rewards soon 2026-03-29 22:18:57 +02:00
dcrubro 57bfe61c13 Copied TCP impl from other project, basic Block implementation, randomx pow, signing via secp256k1 2026-03-29 17:18:23 +02:00