Repository navigation
Conversation
check_block tested for an empty transaction list before comparing the merkle root, so an empty body sent for a real header failed bad-blk-length rather than Core's bad-txnmrklroot. accept_block then marked the header's block Invalid for that and for every other non-mutation check_block failure. Core runs CheckMerkleRoot first (validation.cpp CheckBlock), and ProcessNewBlock never marks a block for a CheckBlock failure; of the rules in satd's check_block, Core marks only bad-blk-weight, from ContextualCheckBlock. A block that extends the tip and arrived without its header first had no index entry until it connected, so a failed connect marked nothing and each copy sent again was validated and written again. accept_block now writes the HeaderOnly row a header announcement would leave before connecting, as Core's AcceptBlockHeader creates the index entry before ConnectTip. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A block whose parent had no index entry was parked in the block processor's buffer keyed by its prev hash: up to 8192 entries with no byte bound, no per-peer bound and no expiry, whether or not this node had asked for the block, before any proof-of-work or size check. Each entry kept its sender's in-flight guard, so that peer's pongs waited for the 60-second bound until the block's parent connected, and the block went through the per-input fee pass before accept_block refused it. Bitcoin Core keeps no such block: AcceptBlockHeader runs CheckBlockHeader (validation.cpp:4206) and then refuses an unknown parent as prev-blk-not-found (validation.cpp:4216). satd answers a block inv with a getdata, so a block it asked for can arrive before the parent it is still learning about, and keeping that one saves a second download. Now: - Only a block this node asked for (Core's IsBlockRequested, read in handle_block before the request record is cleared) may wait. - A waiting block must pass check_proof_of_work_bounded and be at most MAX_BLOCK_SERIALIZED_SIZE (4,000,000) bytes. - At most 4 per peer, 64 blocks and 32 MB in all; the sender's oldest goes first when it is at its share, then the oldest of all. Each block expires after 5 minutes. - The in-flight guard is counted out when the block is set aside, as Core is done with it once refused. - A block whose parent is unknown skips the fee pass and accept_block. - The sender is asked for headers, as satd already does for a cmpctblock on an unknown parent. - A block on a parent marked invalid is not kept. - Two blocks on the same parent can both wait; when one fails to connect the next is tried. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…d block The comment on the HeaderOnly row written before connecting said every outcome matched the headers-first path. A failure that is not a verdict on the block does not move the best-header pointer, which an announced header would have done. Say so, and say what reaches the block then. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…orphan-block-buffer-bounds
RocksDbStore turned a failed coin read into "no such coin": get_coin did `.ok()??`, get_coins_batch `.ok().flatten()` and has_coin matched only `Ok(Some(_))`. A row that did not decode was logged and returned as `None` too. connect_block takes a missing coin as a verdict on the block that spends it (bad-txns-inputs-missingorspent), and accept_block and the reorg path then record that block as invalid, a record that survives a restart. Bitcoin Core reads coins through CCoinsViewErrorCatcher, which logs "Error reading from database" and calls std::abort() on any read error (coins.cpp:398-412), because returning would be "interpreted as 'entry not found'". Each coin read now goes through one helper: a missing row is `None`, a read error or an undecodable row stops the node with abort(), as Core does and as satd's other fail-stops do. A test build panics instead so the path can be tested. The Operator Manual says what the stop looks like and what to do after it. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A signet solution signs the block with the solution cut out of the coinbase witness commitment, so the bytes left behind are part of the signed message. satd rebuilt that script from rust-bitcoin's `instructions()`, which differs from Core's FetchAndClearCommitmentSection (signet.cpp:32-57) in two places: - A zero-length OP_PUSHDATA1/2/4 comes back as an empty push, which satd wrote as OP_0 (0x00). Core writes an op with no data back as its own opcode byte (`CScript << opcode`), 0x4c, 0x4d or 0x4e. - An op that does not parse made satd give up on the whole script and check the block as if it carried no solution. Core's GetOp loop stops at that op, keeps the section it found before it, and drops the rest from the rebuilt script. Either way the merkle root the solution signs differed from Core's, so satd refused blocks Core accepts and could accept, under a challenge an empty solution meets, a block whose solution Core refuses. The rewrite is now a port of Core's: GetScriptOp to read each op, the opcode byte for an op with no data, and AppendDataSize (the shortest length prefix) for a push with data, which Core also applies to pushes that used a longer prefix. The expected rewrites in the tests are Core's own output: the function from signet.cpp, compiled with Core's script.cpp, run over each input. Core's signet_parse_tests are ported too. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The connect paths read the median time past through the height index (or a replay plan) and skipped any height whose block they could not resolve, taking the median of the rest; with nothing resolved they used 0. Core's GetMedianTimePast (chain.h) walks the block and up to ten ancestors through pprev and has fewer only near genesis, so it never takes a median over a window with a hole. A shorter window moved BIP 113 and BIP 68 decisions, and an empty one met every BIP 68 time lock. A refusal it caused was then recorded against the block as invalid. A window with a missing block is now ConnectError::MedianTimeWindowGap, which is not a verdict on the block: the connect fails, nothing is marked, and the block connects once the index is whole. Every connect path uses it: accept_block and both reorg loops, the IBD connector, the reindex paths, the background chainstate and TestBlockValidity (through ChainState::median_time_past_for_connect, which still answers from the MTP cache first). The prefetcher hands such a block to the direct read. The mempool, templates and RPCs keep the old lenient get_median_time_past. BIP 68 now takes the coin's median as Core's CalculateSequenceLocks does (consensus/tx_verify.cpp:74), from the block before the coin's, GetAncestor(max(nCoinHeight - 1, 0)). That only differs for a coin at height 0, which the genesis coinbase never creates. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
work_for_bits decoded nBits with target_from_compact, which ignores the sign bit and has no notion of overflow, and gave a zero target 2^256 - 1 work. Core's GetBitsProof (chain.cpp:121-134) gives no work to bits that SetCompact (arith_uint256.cpp:176-194) flags as negative or overflowing, or that decode to zero, and decodes exponents 33 and 34 differently from target_from_compact. work_for_bits now decodes as SetCompact does and returns zero work in those cases. For every target a header's proof of work can meet the value is unchanged, so stored chainwork stays valid; a test checks that over exponents 1 to 32. add_u256 wrapped on overflow. Chainwork is a sum of block work, and a sum that wrapped would rank the chain with the most work below every other, so it now saturates. Core's arith_uint256 wraps, but no reachable chain gets near 2^256. The expected values in the tests are Core's own output: GetBitsProof from chain.cpp, compiled with Core's arith_uint256.cpp, run over each nBits. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
satd-chainstate-audit looks coins up through the same store, so a coin the database cannot read now stops it with the outpoint and the RocksDB error instead of listing the coin as missing. Say so beside its exit statuses, and say where the stop applies. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…nto fix/signet-rewrite-mtp-window
bkeroack
changed the base branch from
fix/coin-read-error-fail-stop
to
release/0.6
October 8, 2026 18:11
This branch has not been deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Stacked on #945 → #941 → #936 (merge those first).
Problem
Signet commitment rewrite. A signet solution signs the block with the solution cut out of the coinbase witness commitment, so the bytes left behind are part of the signed message. satd rebuilt that script from rust-bitcoin's
instructions(), which differs from Core'sFetchAndClearCommitmentSection(src/signet.cpp:32-57):OP_PUSHDATA1/2/4(4c00,4d0000,4e00000000) came back as an empty push, written asOP_0(0x00). Core writes an op with no data back as its own opcode byte (replacement << opcode):0x4c,0x4dor0x4e.GetOploop stops at that op, keeps the section it already found, and drops the rest of the script.In both cases satd's signed merkle root differed from Core's.
Median time past.
connect::median_time_past_with_plan(chain/connect.rs) andChainState::get_median_time_past(chain/state.rs) skipped any height in the 11-block window they could not resolve and took the median of the rest, or returned 0 for an empty window. Core'sGetMedianTimePast(src/chain.h:233-245) walkspprevand has fewer than 11 entries only near genesis. A short window moved BIP 113 / BIP 68 decisions; an empty coin window met every BIP 68 time lock; a refusal it caused was recorded asInvalid.Block work.
work_for_bits(storage/blockindex.rs) gave a zero target 2^256-1 work and ignored the sign and overflow of nBits. Core'sGetBitsProof(src/chain.cpp:121-134) returns 0 whenSetCompact(src/arith_uint256.cpp:176-194) flags negative or overflow, or the target is zero.add_u256wrapped.Fix
fetch_and_clear_signet_sectionis now a port of Core's function:GetScriptOp(script.cpp:312-362) to read each op and stop at the first that does not parse, the opcode byte for an op with no data, andAppendDataSize(script.h:407-425) for a push with data. Note: copying the original bytes would not match Core, which also rewrites pushes that used a longer length prefix (4c0101→0101); the port does what Core does.ConnectError::MedianTimeWindowGap, which is not a verdict on the block (is_verdict_on_block() == false): the connect fails, nothing is marked, the block connects once the index is whole. Every connect path uses the strict form (ChainState::median_time_past_for_connect, MTP cache first):accept_blockand both reorg loops, the IBD connector, reindex, the background chainstate,TestBlockValidity. The prefetcher hands such a block to the direct read. BIP 68 takes the coin's median fromGetAncestor(max(nCoinHeight - 1, 0))as Core does (consensus/tx_verify.cpp:74); this differs only for a coin at height 0, which cannot exist. The mempool, templates and RPCs keep the lenientget_median_time_past(no behaviour change there).work_for_bitsdecodes nBits exactly asSetCompactand returns zero work whereGetBitsProofdoes. For every exponent 1..32 with a positive mantissa (every target a header can meet) the target is unchanged, so stored chainwork stays valid.add_u256saturates instead of wrapping.Tests
Expected values come from Core's own code:
FetchAndClearCommitmentSectionandGetBitsProofcopied verbatim and compiled with Core'sscript.cpp/arith_uint256.cpp(Core 16613c9de9), run over each input.validation::signet::consharden_teststhe_commitment_rewrite_is_cores_byte_for_byte(18 vectors: zero-length PUSHDATA1/2/4 before/after, five truncated tails, long prefixes, OP_0, two sections, an 80-byte section) — fails without the fix: yes (zero-length PUSHDATA1 before: rewritten commitment).a_zero_length_pushdata_is_signed_as_its_own_opcode(P2WPKH challenge; signed over Core's rewrite → accepted; signed over theOP_0form → refused) — fails without the fix: yes (a block signed over Core's rewrite 4c04ecc7daa2 must verify).a_section_before_an_unparseable_push_is_checked— fails without: yes (section then 4c: Core checks the section and accepts the block).a_bad_section_before_an_unparseable_push_is_refused(OP_TRUE challenge, paired with an accepted case) — fails without: yes.cores_signet_parse_tests(port ofvalidation_tests.cpp:69-128),a_commitment_without_a_section_is_left_alone— parity, pass before and after.chain::connect::consharden_tests:a_missing_height_row_is_an_error_not_a_shorter_median,a_height_row_without_its_index_entry_is_an_error,a_replay_plan_that_does_not_reach_the_window_is_an_error,bip68_with_a_hole_in_the_coins_window_is_not_a_verdict(old code:SequenceLockNotMet),an_empty_coin_window_no_longer_meets_every_time_lock(old code:Ok) — all fail without the fix: yes.the_median_is_cores_over_a_full_window_and_near_genesis,bip68_takes_the_coins_median_over_its_whole_window(lock met at +512 s, not at +511 s) — must-succeed pairs.chain::state::consharden_tests::a_hole_in_the_mtp_window_stops_the_connect_without_marking_the_block— fails without: yes (oldaccept_blockreturnedConnected); also fails when onlyaccept_blockis switched back to the lenient helper, and when the gap is classed as a verdict (a window this node cannot read is not a verdict on the block). After the row is restored the same block connects.the_connect_median_is_cores_from_the_cache_and_from_the_indexchecks heights 1..16 against apprevwalk.storage::blockindex::consharden_tests::block_work_is_cores_get_bits_proof(27 nBits values) andbits_without_work_add_nothing_to_chainwork— fail without: yes (bits 00000000).work_is_unchanged_for_every_target_a_header_can_meet.tests::test_add_u256_max_overflowedited in place to assert saturation (fails on the old wrapping add).Notes
validation/pow.rs::check_timestamp(time-too-old) also takes a median over a window cut short by a missing parent. Not touched here:pow.rsis changed by a separate PR in flight.validation/versionbits.rsmedian_time_pastlikewise stops early on a missing header; it is not on a connect path and is left as is.TestBlockValiditystill reports any error from insideconnect_block(including this one, for a BIP 68 coin window) as a BIP 22 reason string; that mapping is out of scope here.Release notes
On signet, satd now rebuilds the signed coinbase commitment exactly as Bitcoin Core does, so blocks whose witness commitment contains an empty
OP_PUSHDATA1/2/4or ends in an unparseable push are judged the same way Core judges them. If the block index is missing a block inside a median-time-past window, satd now stops connecting with a storage error instead of computing the median from fewer blocks, and does not mark the block invalid. Block work for malformed nBits is now zero, as in Core.🤖 Generated with Claude Code