Repository navigation
rpc: proposal mode applies BIP113, BIP68, and the version floors - #1093
Conversation
There was a problem hiding this comment.
Verdict
Requesting changes. The three rules are wired through the connect helpers, and the journey pins the happy path (BIP113 equality, BIP68 height at the exact depth and one short, same-block depth 0 vs 1, version 3, u32::MAX → time-too-new). Two median reads can still accept a lock that submitblock rejects. That is the failure mode this PR is closing, and it is the verdict #976 will trust for Stratum v2 custom jobs.
Required checks on 6fb9c764 are green (qc, test, windows, macos, coverage).
What matches connect
- Absolute finality uses
assemble_lock_time_cutoff: header time before CSV and at height 0, parent MTP once CSV is active.is_final_txkeeps the strict<. - Relative locks run only under
csv_active_at, thenbip68_active_for_tx(unsigned version ≥ 2). - A same-block coin is recorded at the proposal height. A confirmed coin uses the tip-index create height. The coin time is the median at
create_height.saturating_sub(1), which is also whatstructural_bip68loads. Height 0 stays a lookup of block 0. Sequence::is_time_lockedis the disable bit clear and the type bit set, same as the mask instructural_bip68.- Height math matches Core
EvaluateSequenceLocks:coin_height + rel - 1 >= block_heightrejects. The age / age+1 pair and the same-block 0 / 1 pair pin that, including the off-by-one. - Version floors and the two-hour cap are
check_header_version_and_future_timeunder the caller clock. The predicate is stilltime > now + 2h.BadVersionformats asbad-version(0x{:08x}), same as submit. Future-time is still checked before the version floor, so a far-future version-1 block staystime-too-new. - The old inline future-time check is gone, so there is one clock path.
Changes
mtpis stillmedian_time_past(tip).unwrap_or(tip_rec.timestamp). That value is now the BIP113 cutoff and the BIP68 block median. On a chain whose timestamps move forward, the tip time sits above the median, so a failed median read acceptsnLockTimein(real MTP, tip time]. That is the hole the success path just closed. Fail the proposal when the median read fails. Do not substitute the tip timestamp once this value is a consensus cutoff.- Coin medians go through
median_time_past, which will fill a missing header from a confirm header plan. Connect usesmedian_time_past_storeand will not connect that block. A lower plan timestamp shrinksmin_timeand can accept a relative time lock.map_err(|_| "bad-txns-nonfinal")also turns a store fault into a consensus needle.sequence_locks_satisfiedalready rejects a zero median. Use the store helper. Leave a store error as an RPC error.
Not blocking
time < mtpstill accepts a header time equal to the parent median. The comment and the PR body say this is deliberate for the harness cache. It is still a false accept against Core and againstsubmitblock(time <= mtpthere). A cached template at exactly the new median is not connectable. #976 should not treat a null proposal as submit-equivalent while this stands. Matching<=, or confining the exception to the harness, can be a follow-up.- The 0-interval relative time lock only proves the coin median is non-zero. A raw median of 0 fails closed inside
sequence_locks_satisfied, so "never read the median" does trip that assert. Any other non-zero stamp also passes, andu16::MAXintervals (~388 days) fails for every plausible median. Neither case pins which block's median was read. A lock that is final for the real previous-block median and non-final for1, or for this block's own median, would. - BIP113 never asserts the accept side (
tip_mtp - 1→ null). The equality reject is what catches the oldmax(mtp, header.time)cutoff. A cutoff of 0 would still reject equality and hide on the rest of this journey, because the other spends use lock time 0. - The coinbase-maturity fence is re-read before the height is pushed, so a memo/fence mismatch becomes
bad-txns-inputs-missingorspentand does not reach BIP68. A reorg that moves both together is still a race. Not worth a separate path.
A proposal compared time locks against its own header time, so a lock between the parent's median time and that stamp passed. It never checked relative locks or the BIP34/66/65 nVersion floors. Core's TestBlockValidity rejects all three, and so does submitblock. Use the connect path's lock time cutoff, check sequence locks on the inputs the proposal already resolves, and run the shared header version and future-time check under the proposal's clock. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The parent median is now the BIP113 cutoff and the BIP68 block median, so a failed read no longer falls back to the tip's own timestamp. Coin medians use median_time_past_store, as structural_bip68 does, so a header plan cannot stand in for a confirmed header. A store fault on either read is the store error, not bad-txns-nonfinal. Pin which median a relative time lock reads, and the BIP113 accept side. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
6fb9c76 to
9360b91
Compare
|
Thanks. Both changes are in 1. Tip median fallback: fixed. 2. Coin medians: fixed. They go through On "stay an RPC error": the proposal path has one Not blocking:
The new test needs |
|
CI note: on |
|
haha, already reran :) |
There was a problem hiding this comment.
Verdict
Approving 9360b913. The two changes from the last review are in, and they close the false-accept paths. A separate security review of this diff found no medium, high, or critical issue.
qc, test, windows, and macos are green on this head. coverage was still running.
Previous requests
- The parent median is
median_time_past_store(tip)?. A failed read is the store error. The tip timestamp is no longer the BIP113 cutoff or the BIP68 block median. - Coin medians use
median_time_past_storeas well, the same helperstructural_bip68uses throughmtp_at. A missing confirmed header stays a store error, notbad-txns-nonfinal.
check_block_proposal_reads_medians_from_confirmed_headers pins which median. Blocks are 600 s apart. A relative lock of 118×512 s is final for the median of the block before the creating block, and 119 is not. 118 would fail if the creating block's own median were used. Blanking height 11 fails the coin read and still lets an empty proposal through. Blanking height 110 fails the tip read. The journey now also accepts nLockTime == tip MTP − 1.
What still matches connect
- Cutoff is
assemble_lock_time_cutoff: header time before CSV and at height 0, parent MTP after CSV.is_final_txis strict<. - BIP68 runs only when
csv_active_at, then the unsigned version ≥ 2 gate. - Same-block coins are at the proposal height. Confirmed coins use the tip-index create height. Coin time is the median at
create_height.saturating_sub(1). Sequence::is_time_lockedis the disable bit clear and the type bit set.- Height locks match
EvaluateSequenceLocks. The journey's age / age+1 pair and the same-block 0 / 1 pair pin the off-by-one. - Version floors and the two-hour cap are
check_header_version_and_future_timeunder the proposal clock. The predicate is stilltime > now + 2h.bad-version(0x{:08x})matches submit. A far-future low version is stilltime-too-new, because the future check runs first. test-hooksonrbitcoin-storeis a dev-dependency.set_raw_for_teststays behind that feature.
Security review
No medium or higher finding. The old proposal false accepts (BIP113 cutoff max(parent MTP, header time), no BIP68, no version floor) are gone. Store and median failures return Err and do not accept the block. SubmitSolution still goes through accept_block, not this preflight.
Left as-is
time < mtpstill accepts a header time equal to the parent median. Connect and Core use<=. The comment says this is for the harness cache. It is a preflight lie: that block is not connectable. GBTmintimeismtp + 1, so an honest template does not hit it. Not a chain-accept bypass.expected_next_bits(...).unwrap_or(tip bits)is unchanged and still fail-open on a store error. Out of this diff.
Summary
getblocktemplateproposal mode (check_block_proposal_with, also behindChainHub::check_block_proposal) passed three kinds of block that Core'sTestBlockValidityrejects, and that rbitcoin's ownsubmitblockalso rejects.The rules come from the helpers the connect path already uses.
assemble_lock_time_cutoffis now exported fromrbitcoin-query. The coin heights come from the inputs the proposal already resolves: the block height for a coin created in the same block, and the create height for a confirmed coin. Every median comes from confirmed headers throughmedian_time_past_store, the helper connect'sstructural_bip68uses. A store fault reading one is reported as that store error. It is not a reject reason, and the tip's own timestamp is never used in its place.This matters more once #976 lands, because that PR validates declared Stratum v2 custom jobs through
check_block_proposal.Evidence
Both nodes were given the same proposals: bitcoind v31.1 and rbitcoin regtest, with recent block times and the proposal built on the tip.
nLockTime= tip MTP + 100, header time MTP + 1000bad-txns-nonfinalnullbad-txns-nonfinalnLockTime= tip MTPbad-txns-nonfinalnullbad-txns-nonfinalnLockTime= tip MTP − 1nullnullnullnSequence= 10 blocksbad-txns-nonfinalnullbad-txns-nonfinalnSequence= 1 blocknullnullnullnSequence= 5 × 512 sbad-txns-nonfinalnullbad-txns-nonfinalnVersion= 1bad-version(0x00000001)nullbad-version(0x00000001)nVersion= 3bad-version(0x00000003)nullbad-version(0x00000003)Tests, extending the existing proposal journeys:
rpc_regtest_from_genesis(chain_ops_empty_template_and_proposals): version 3 isbad-version(0x00000003). Timeu32::MAXistime-too-new, which pins the check this PR replaced.rpc_regtest_mature_chain_ops(chain_ops_proposal_spends):bad-txns-nonfinal; one second below is valid.bad-txns-nonfinal.u16::MAXunits isbad-txns-nonfinal.bad-txns-nonfinal.check_block_proposal_reads_medians_from_confirmed_headers(rbitcoin-net): blocks are 600 s apart and the coin is the height-12 coinbase.bad-txns-nonfinal. 118 would fail if the median of the coin's own block were used instead of the block before it.bad-txns-nonfinal.Before:
rpc_regtest_mature_chain_opsfails withBIP113: left: Null, right: "bad-txns-nonfinal".rpc_regtest_from_genesisfails withleft: Null, right: "bad-version(0x00000003)".After: both pass.
Before the review fix:
check_block_proposal_reads_medians_from_confirmed_headersfails withcoin median: bad-txns-nonfinal. With only the coin fix applied, it fails on the tip median withunwrap_err()onOk(0).Hand mutations (no
cargo mutants): each of these fails the journey on its own assert:BIP68 height lock one block shorta same-block coin is zero blocks deepa met time lock reads the coin's median timebad-txns-nonfinalBIP113: one second below the median is finalLocal: fmt, clippy
-D warnings, ast-grep,cargo test --workspace --exclude rbitcoin-bench,ci-os-smoke.sh.cargo denyis not installed here; this PR changes no dependencies.Not changed: proposal mode still accepts a header time equal to the parent MTP (
time < mtp). The comment there says this is deliberate, for the harness cache. Core returnstime-too-old.Merge Danger
Door: two-way
RPC verdict only. No consensus, store, or schema change; block connect is untouched.
rbitcoin-netturns on therbitcoin-storetest-hooksfeature for its own tests only (dev-dependency).Blast Radius: proposals
A proposal that used to return
nullcan now returnbad-txns-nonfinalorbad-version(…). In each case,submitblockwould have rejected that block anyway.🤖 Generated with Claude Code