Skip to content

Signet commitment rewrite matches Core byte for byte; MTP needs a full window - #948

Open
bkeroack wants to merge 10 commits into
release/0.6from
fix/signet-rewrite-mtp-window
Open

bkeroack wants to merge 10 commits into
release/0.6from
fix/signet-rewrite-mtp-window

Conversation

@bkeroack

@bkeroack bkeroack commented Oct 8, 2026

Copy link
Copy Markdown
Contributor

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's FetchAndClearCommitmentSection (src/signet.cpp:32-57):

  • A zero-length OP_PUSHDATA1/2/4 (4c00, 4d0000, 4e00000000) came back as an empty push, written as OP_0 (0x00). Core writes an op with no data back as its own opcode byte (replacement << opcode): 0x4c, 0x4d or 0x4e.
  • An op that does not parse made satd drop the whole scan and check the block as if it had no solution. Core's GetOp loop 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) and ChainState::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's GetMedianTimePast (src/chain.h:233-245) walks pprev and 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 as Invalid.

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's GetBitsProof (src/chain.cpp:121-134) returns 0 when SetCompact (src/arith_uint256.cpp:176-194) flags negative or overflow, or the target is zero. add_u256 wrapped.

Fix

  • fetch_and_clear_signet_section is 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, and AppendDataSize (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.
  • A median-time-past window with a missing block is 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_block and 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 from GetAncestor(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 lenient get_median_time_past (no behaviour change there).
  • work_for_bits decodes nBits exactly as SetCompact and returns zero work where GetBitsProof does. 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_u256 saturates instead of wrapping.

Tests

Expected values come from Core's own code: FetchAndClearCommitmentSection and GetBitsProof copied verbatim and compiled with Core's script.cpp / arith_uint256.cpp (Core 16613c9de9), run over each input.

  • validation::signet::consharden_tests
    • the_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 the OP_0 form → 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 of validation_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 (old accept_block returned Connected); also fails when only accept_block is 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_index checks heights 1..16 against a pprev walk.
  • storage::blockindex::consharden_tests::block_work_is_cores_get_bits_proof (27 nBits values) and bits_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_overflow edited 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.rs is changed by a separate PR in flight.
  • validation/versionbits.rs median_time_past likewise stops early on a missing header; it is not on a connect path and is left as is.
  • TestBlockValidity still reports any error from inside connect_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/4 or 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

bkeroack and others added 10 commits October 7, 2026 14:33
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>
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>
@bkeroack
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

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant