914fa6e5a7427dea785ec35d3884cb4a5f9d8b50
23
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
10d5d71a9f
|
Prevent transaction replay and stop reorgs from destroying transactions
Two gaps in the account model, found while working out how many confirmations
are actually needed for a payment to be safe.
== A reorg silently destroyed transactions ==
chain.c removed transactions from the mempool when they were mined but never
put them back. TxMempool_Insert had exactly two callers -- the `send` command
and inbound broadcasts -- so a transaction that existed only in an orphaned
block was gone from both the chain and the pool, and would never be mined
unless its sender happened to rebroadcast.
That makes the usual reassurance about accidental orphans ("your transaction
just lands in the next block") false for this node. Chain_RollbackToHeightLocked
now returns the discarded blocks' non-coinbase transactions to the mempool
before anything is freed, going through the existing Chain_BorrowBlockTransactions
disk fallback because those blocks are usually header-only by then.
Coinbases are deliberately not restored: they are bound to a specific height and
reward and are invalid anywhere else.
A transaction that also appears in the replacing branch needs no special
handling. Chain_ReplaceBranch rolls back and applies under a single lock
acquisition, and Chain_AddBlockLocked removes every applied transaction from the
pool again -- so it is re-inserted and removed moments later, with no window in
which a miner could pick up a copy of something already back in the chain. That
ordering is what makes this safe in an account model, where re-mining a
transaction would debit the sender twice, so it is spelled out at the site.
== Any historical transaction could be replayed ==
Nothing rejected a transaction whose hash was already in the chain.
Block_AllTransactionsValid checks signatures and that there is exactly one
coinbase; it never looked for repeats, and the mempool's hash-keyed dedup is
mempool-only. So anyone could take a mined, publicly visible signed transaction,
rebroadcast it, and have it mined again -- debiting the sender a second time.
UTXO chains get this for free because the spent inputs no longer exist; an
account model has nothing to stop it.
balance_sheet_entry_t gains lastTxTimestamp, and a non-coinbase transaction is
now valid only if its timestamp is strictly greater than its sender's last
included one. Transaction timestamps are unix MILLISECONDS (the comment in
transaction.h claimed seconds and was stale), so two genuinely distinct
transactions never collide and an exact collision means a byte-identical copy --
precisely what must be refused.
Coinbase is exempt: a block may hold only one, its amount is pinned to the
height, and including someone else's would only donate the miner's own reward.
Three deliberate choices:
* Enforced in Chain_AddBlockLocked, BEFORE the push. That function is the only
insertion point into the chain besides the header-only disk load, so mining,
broadcast, windowed sync, orphan attach and reorg apply are all covered by
one check rather than four copies. Putting it in the ledger pass instead
would be too late -- that runs after the block is in the chain and can only
return false, leaving an invalid block behind.
* Extracted as Chain_BlockRespectsSenderOrdering rather than left inline,
so the multi-sender case can be tested directly. Senders are strictly
independent: one account's timestamps say nothing about another's, and
folding them together would reject ordinary blocks outright.
* DebitAddress takes the timestamp and advances the guard in the same call, so
a spend cannot happen without the guard moving. They cannot drift apart.
Rebuilt for free on reorg: the rollback already destroys and replays the whole
balance sheet, so setting lastTxTimestamp in that same loop means there is no
separate invalidation path to get wrong.
== The miner's fee sort broke the new rule ==
CompareTransactionPriority orders by fee descending, so a sender's later,
higher-fee transaction could sort ahead of their earlier, lower-fee one -- and
Chain_AddBlockLocked walks a block in order, so the node would have built blocks
its own rule rejects.
This cannot be folded into the comparator: "higher fee first, except same sender
by time" is not a strict weak ordering (A beats B on fee, B beats C on fee, C
beats A on time) and qsort with an inconsistent comparator is undefined. Instead
the priority sort is followed by a permutation restricted to each sender's own
slots, so fee-based slot allocation survives untouched and only the order within
one sender's slots changes.
== Mempool timestamp policy (local policy, NOT consensus) ==
Separate concern: keeping junk out of the pool. TX_MAX_FUTURE_DRIFT_MS (2h) and
TX_EXPIRY_MS (4 days, chosen to roughly match what DIFFICULTY_ADJUSTMENT_INTERVAL
spans but in milliseconds so it does not drift with block time), both in
constants.h. Gated at both admission sites via TxMempool_PolicyAccepts, with
TxMempool_PruneExpired on the 1Hz maintenance tick.
Blocks are never rejected for either bound, so a node with a skewed clock cannot
fork itself off the network over an admission rule.
Both bounds are measured against the node's own clock, NOT against the chain
tip. Measuring "future" against the last block assumes blocks keep arriving: on a
quiet chain the tip can be hours old, and an honest transaction created right now
would look hours ahead of it and be refused -- making sending impossible exactly
when the chain is idle.
A too-old timestamp needs no rule here; the replay guard already refuses anything
at or below a sender's last.
== Verification ==
New unit suite, 21 assertions against the real objects, all passing. The ones
that matter:
multi-sender independence
alice(6000) and bob(101) in one block -> accepted
reversed order -> accepted
8 senders sharing one timestamp -> accepted
replay
equal to sender's last (carbon copy) -> rejected
older than sender's last -> rejected
same transaction twice in one block -> rejected
ordering within a block
same sender increasing -> accepted
same sender decreasing -> rejected
policy window
inside/outside both bounds, and pruning -> as specified
Without the sender comparison in the guard, the first three would all fail --
that is the case worth guarding against, because the check reads as if it folds
all senders together.
penalty_test and dag_test suites still pass. AddressSanitizer reported zero
errors on both nodes across the reorg path, which is the signal that matters
given the rollback now does more work.
== Not yet verified ==
End-to-end replay rejection and reorg-restores-mempool against live nodes; both
need two funded wallets, so they are a separate setup. The fork regression
(forksync 5 60) was still climbing toward its penalty threshold when this was
written -- branchLead 40 of the required 42, behaving correctly but not yet
adopted.
== Note ==
balance_sheet_entry_t is written raw to disk, so balance_sheet.data gains a
field and old files will not load. wipechain before running.
Related gap, pre-existing and NOT addressed: the disk load restores headers only
and Chain_RecomputeRuntimeState does not rebuild balances, so lastTxTimestamp
survives a restart purely because it is persisted in balance_sheet.data -- and
that file has no height marker to detect it being stale against the chain. A node
with a missing or out-of-date sheet silently resets every account's guard to 0
and allows one replay per account. Balances are already wrong in that situation
today, but this change turns it from an accounting bug into a security one.
Recording the chain height alongside the sheet and refusing to start on a
mismatch is the natural follow-up.
|
||
|
|
393d26dfcb
|
Fix the difficulty adjustment off-by-one; slight initial diff adjustment; BREAKS CONSENSUS | ||
|
|
01c44731ef
|
Fix windowed sync stalling against a peer on a competing branch
Reported symptom: node A (4656 blocks, last 10 mined on its own fork) syncing
from node B (5646 blocks) crawled for minutes while B re-served the whole chain
several times over. A's log was thousands of "Queued orphan BLOCK_DATA" lines
interleaved with "timed out fetching block N, giving up", sliding forward 8
blocks at a time and never reorging.
== Root cause ==
The sync loop had no channel for FETCH_BLOCK replies. It inferred that a
requested block had arrived purely from the chain growing:
if ((uint64_t)Chain_Size(chain) > h) { /* block h was applied */ }
But the reply is handled on the peer's io thread by Node_ParseAndAcceptBlock,
which correctly identifies a block whose prevHash points at the peer's branch
and files it in the orphan pool -- the chain does not grow. So a block that
ARRIVED AND WAS CORRECTLY ORPHANED was indistinguishable from a lost packet.
Every one of the 990 blocks therefore cost MAX_SYNC_RETRIES re-requests plus a
SYNC_REQUEST_TIMEOUT_MS stall before the window slid on and did it again. That
is both the crawl and the repeated serving on the peer.
Neither existing fork probe could rescue it:
* The divergence check sits downstream of a SUCCESSFUL append -- it only runs
after Chain_GetBlockCopy(chain, h) succeeds. When the fork begins at the
very first requested height nothing ever lands, so RequestForkWindow was
never reached from there.
* The no-progress fallback only runs after the entire window range drains,
i.e. after timing out through all 990 blocks, and is capped at 3 rounds.
== Delivery receipts ==
Node_NoteBlockDelivered / Node_TakeBlockDelivery / Node_ResetBlockDeliveries,
backed by a 512-slot ring with its own mutex. Recorded from the BLOCK_DATA
handler only -- BLOCK_DATA is sent solely in reply to a FETCH_BLOCK, so a
receipt means "the peer answered", independently of whether the block could
join our chain. A receipt for a height already present is refreshed in place,
so a retried request cannot leave a stale one for the next window to consume.
Receipts carry a status rather than a bool:
NODE_DELIVERY_APPENDED joined our chain
NODE_DELIVERY_DUPLICATE we already held this exact block -- common ground
NODE_DELIVERY_ORPHANED competing branch; now pooled
NODE_DELIVERY_REJECTED failed validation
DUPLICATE is what lets a backwards fork walk terminate; a plain "appended"
boolean cannot tell "we already have this" from "this is on their branch",
which is precisely the signal the walk needs.
The sync loop now treats any non-APPENDED delivery as the divergence signal and
probes immediately, instead of retrying something retrying cannot fix. Bounded
by MAX_FORK_PROBE_ROUNDS -- without that the window resets to the same height
and re-probes forever -- and the counter resets on real progress so a branch
that forks again further on can still be followed.
== RequestForkWindow is now an actual walk ==
It previously fired REORG_FETCH_DEPTH (128) requests and slept a flat
SYNC_REQUEST_TIMEOUT_MS. Two problems:
* The sleep was a race against the peer's serving rate. At the ~10 blocks/s
observed in testing, a 128-block window cannot land in 5s, so the
OrphanPool_AttemptAttach that followed ran against a half-filled pool and
reported a failure that was not real.
* It always asked for the full depth. On a shallow fork the overwhelming
majority came back as duplicates that were discarded without even entering
the pool -- a wasted full block send each.
It now descends in batches of MAX_PARALLEL_FETCHES, waits on that batch's
receipts rather than guessing, and stops at the first height the peer returns
that we already hold: that block is the fork point and everything below it is
shared. It returns whether common ground was found, so callers no longer
attempt an attach that cannot possibly link.
Measured on the reported scenario: 16 requests instead of 129, locating the
fork point at height 4645 (fork = blocks 4646..4655, exactly the 10 mined).
== IBD_TIP_AGE_BLOCKS 500 -> 20 ==
The reorg penalty is served by LOCAL chain growth, so a node that does not mine
can never serve it -- its tip does not move, and the only way it could grow is
by adopting the branch the penalty is gating. The IBD exemption is that node's
only route back, so at 500 block times it had to sit stalled for ~12.5 hours.
20 block times is ~30 minutes at a 90s target, far beyond normal Poisson block
spacing (a gap that long has probability ~e^-20), so a node genuinely following
the tip will not trip it.
This is what flipped the live test from "initialSync=no" to "initialSync=yes"
and let the reorg be adopted at all. Noted in the constant's comment: the
exemption is all-or-nothing, so lowering it further widens that hole.
== Reorg failures are now legible ==
Chain_ReplaceBranch broke silently for fork-point-beyond-tip, work computation
failure, insufficient work, snapshot failure and candidate copy failure alike,
so "not adopted" could not be diagnosed from a log. Each now says which. The
not-heavier case reports candidate vs incumbent block counts, because the
common cause is a branch that is still arriving -- a partial branch is
genuinely lighter -- and that reads very differently from a peer actually on a
weaker chain.
The sync loop's "Reorg candidate not adopted (lighter branch, or still serving
its reorg penalty)" is replaced by "Reorg not completed on this pass; branch
stays pooled for retry". The old wording was actively misleading: live testing
showed it firing on reorgs that the 1Hz maintenance thread completed a second
later.
== Verification ==
Against a real peer at 5646 blocks, from a real local chain of 4656 with a
10-block fork:
reported receipts only all changes
wall-clock minutes 123s 116s
timed-out fetches 1 per block 0 0
fork-walk requests n/a 129 16
reorg completed never maintenance first pass
final height 4656 5646 5646
fullverify - Chain OK Chain OK
The 116s is not a meaningful speedup over 123s and should not be read as one:
990 blocks at ~10 blocks/s is ~100s, so both are bound by the peer's serving
throughput rather than by anything in the sync loop. The substantive wins are
the 129 -> 16 fork-walk reduction, the reorg completing deterministically
instead of by luck, and logs that no longer report failure on success.
Also exercised: a synthetic partitioned fork (10 vs 60 blocks) confirming zero
timeouts and bounded probe rounds when the branch is correctly refused by the
penalty.
== Known limits, unchanged ==
Chain_ReplaceBranch adopts whatever is pooled at attach time (18 blocks in the
live run); the remainder arrives through normal windowed sync afterwards. That
bounds a single reorg by MAX_ORPHAN_BLOCKS.
|
||
|
|
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.
|
||
|
|
42b325d57a
|
Fix self-deadlock on chainLock and fix double-delivery of accepted blocks (+ DUPLICATE enum entry) | ||
|
|
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
|
||
|
|
21fe73fb01
|
Add NodeDiscovery: multi-hop peer crawl with UDP-ping preference
- Implement NodeDiscovery engine: known-peer table (DynArr) with a per-tick seed/ping/query/connect state machine driven by the node maintenance thread; bounded multi-hop crawl (FANOUT peers per node, hop-capped) that connects to reachable peers lowest-ping-first - Add GET_PEERS/PEERS TCP opcodes for peer-list exchange, handled on both inbound and outbound connections - Measure UDP round-trip time and pass it to the on_pong callback (previously the send timestamp was only used for retries) - Advertise each node's listen port in HELLO/ACK_HELLO and store it per-connection, so inbound-only peers and non-default ports are discoverable (length-guarded parse; wire-compatible with old peers) - Wire a udp_node_t + node_discovery_t into net_node_t: init/start in Node_Create, tick in the maintenance loop, teardown in Node_Destroy (stop UDP before destroying discovery to avoid callback races) - Add Node_ConnListenEndpoint / Node_GetPeerEndpoints helpers to derive peers' listen endpoints (outbound: dialed port; inbound: advertised), with IPv4-mapped-IPv6 normalization and IP+port dedup - Match ping pong/timeout callbacks by peer address (the UDP layer owns the nonce), fixing discovered peers stuck UNREACHABLE - Ping the peer's listen port instead of the ephemeral TCP source port (fixes the original stub so pongs actually return) - Add `peers` CLI command to dump the discovery table (endpoint/hop/ state/ping) - Add discovery tunables to constants.h (fanout, max hops, target connections, timeouts, caps) |
||
|
|
ce27dafaba | todo update, forward block broadcasts, optional echo connect | ||
|
|
3337ac85ab | reorgs, fetch batching (parallel fetch), orphans | ||
|
|
361ac73e45 | global externs refactor, some tcp methods | ||
|
|
32b9a57366 | tx mempool start, hello packet | ||
|
|
a89a912898 | quality-of-life improvements, lower client slave thread stack to 512KB (maybe still too much), dynamic fullverify - freeing transactions after verification | ||
|
|
d631eb190d | Start doing TCP networking | ||
|
|
ae64bb9dfc | temoporarily changed DAG size for testing, fix TX loading, move some TX logic to Transaction_Init() | ||
|
|
df7787ed2d | balance sheet stuff, added khash hashmaps | ||
|
|
b20ba9802e | Full DAG size, epoch scaling etc. | ||
|
|
406ec95139 | diff | ||
|
|
06e6f02b86 | Figured out the reward scheme | ||
|
|
075793c24c | huge chain test, added 1.5% yearly inflation at 3.5 million blocks | ||
|
|
b47ff30bc7 | difficulty calculation, move from randomx to autolykos2 | ||
|
|
c358115af4 | stuff | ||
|
|
50e357d8a2 | Monero-style emission | ||
|
|
0d7adc39e0 | BigInts, save/load, will make a calculation for block rewards soon |