From 6926619f468a08c0e9b32dc336a464ed886edab4 Mon Sep 17 00:00:00 2001 From: Nitesh Balusu Date: Mon, 19 Oct 2026 19:22:37 -0400 Subject: [PATCH 1/2] rpc: proposal mode applies BIP113, BIP68, and the version floors 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 --- changelog.d/proposal-bip113-bip68.md | 8 +++ crates/rbitcoin-net/src/chain.rs | 62 ++++++++++++++++--- crates/rbitcoin-query/src/block_pipeline.rs | 2 +- crates/rbitcoin-query/src/lib.rs | 1 + .../src/regtest_chain_ops_journey.rs | 56 +++++++++++++++++ docs/rpc.md | 2 +- 6 files changed, 122 insertions(+), 9 deletions(-) create mode 100644 changelog.d/proposal-bip113-bip68.md diff --git a/changelog.d/proposal-bip113-bip68.md b/changelog.d/proposal-bip113-bip68.md new file mode 100644 index 000000000..604fe0713 --- /dev/null +++ b/changelog.d/proposal-bip113-bip68.md @@ -0,0 +1,8 @@ +Fixed + +- **`getblocktemplate` proposal mode applies BIP113, BIP68, and the + version floors.** A time lock is final only below the parent's median + time, as at connect, not below the proposal's own timestamp. A spend + whose relative lock is not met is `bad-txns-nonfinal`. A block below the + BIP34/66/65 `nVersion` floor is `bad-version(0x…)`. Each of these blocks + used to pass as a proposal, while Core and `submitblock` rejected it. diff --git a/crates/rbitcoin-net/src/chain.rs b/crates/rbitcoin-net/src/chain.rs index a93f0e5d8..c936b61fe 100644 --- a/crates/rbitcoin-net/src/chain.rs +++ b/crates/rbitcoin-net/src/chain.rs @@ -3608,14 +3608,20 @@ pub fn check_block_proposal_with( if block.header.time < mtp { return Err("time-too-old".into()); } - if u64::from(block.header.time) > u64::from(now).saturating_add(2 * 60 * 60) { - return Err("time-too-new".into()); - } + rbitcoin_consensus::with_now(u64::from(now), || { + rbitcoin_consensus::header::check_header_version_and_future_time( + params, + Height(height), + &block.header, + ) + }) + .map_err(|e| rbitcoin_consensus::block_reject_reason(&e))?; let vctx = rbitcoin_consensus::ValidationContext::at(params, Height(height), milestone); if let Err(e) = rbitcoin_consensus::validate_block_structure(block, &vctx) { return Err(rbitcoin_consensus::block_reject_reason(&e)); } - let (fees, prevouts) = proposal_connect(query, block, height, mtp, params.coinbase_maturity())?; + let lock_time_cutoff = rbitcoin_query::assemble_lock_time_cutoff(&vctx, block, mtp); + let (fees, prevouts) = proposal_connect(query, params, block, height, mtp, lock_time_cutoff)?; let subsidy = rbitcoin_consensus::block_subsidy(height, params) as u64; let mut coinbase_out = 0u64; for o in &block.txdata[0].output { @@ -3671,12 +3677,16 @@ pub fn check_block_proposal_with( /// to cache the create height; each confirmed spend reads that fence again, /// and a missing or moved height is `bad-txns-inputs-missingorspent`. /// Spentness is still probed per input, never served from that map. +/// +/// `mtp` is the parent's median time; `lock_time_cutoff` is the connect +/// path's BIP113 cutoff for this block. fn proposal_connect( query: &Query, + params: &ChainParams, block: &Block, height: u32, mtp: u32, - maturity: u32, + lock_time_cutoff: u32, ) -> Result<(u64, Vec>), String> { if block.txdata.is_empty() { return Err("bad-blk-length".into()); @@ -3692,7 +3702,7 @@ fn proposal_connect( let mut fees = 0u64; let mut prevouts = Vec::with_capacity(block.txdata.len().saturating_sub(1)); for (i, (tx, &txid)) in block.txdata.iter().zip(&txids).enumerate() { - if !rbitcoin_consensus::is_final_tx(tx, height, mtp.max(block.header.time)) { + if !rbitcoin_consensus::is_final_tx(tx, height, lock_time_cutoff) { return Err("bad-txns-nonfinal".into()); } if i == 0 { @@ -3709,6 +3719,7 @@ fn proposal_connect( } let mut in_val = 0u64; let mut tx_prevouts = Vec::with_capacity(tx.input.len()); + let mut coin_heights = Vec::with_capacity(tx.input.len()); for inp in &tx.input { let op = inp.previous_output; if !spent.insert(op) { @@ -3720,11 +3731,19 @@ fn proposal_connect( return Err("bad-txns-premature-spend-of-coinbase".into()); } let txout = if let Some(o) = created.get(&op) { + coin_heights.push(height); o.clone() } else if let Some((fk, created_h, o)) = chain_txout(query, &parents, &op) { - if coinbase_spend_is_immature(query, *fk, *created_h, height, maturity)? { + if coinbase_spend_is_immature( + query, + *fk, + *created_h, + height, + params.coinbase_maturity(), + )? { return Err("bad-txns-premature-spend-of-coinbase".into()); } + coin_heights.push(*created_h); o.clone() } else { return Err("bad-txns-inputs-missingorspent".into()); @@ -3751,6 +3770,9 @@ fn proposal_connect( fees = fees .checked_add(in_val - out_val) .ok_or("bad-txns-fee-outofrange")?; + if params.csv_active_at(height) { + proposal_sequence_locks(query, tx, &coin_heights, height, mtp)?; + } prevouts.push(tx_prevouts); for (vout, o) in tx.output.iter().enumerate() { created.insert( @@ -3765,6 +3787,32 @@ fn proposal_connect( Ok((fees, prevouts)) } +/// BIP68 on a proposal, as at connect: a coin's time is the median time of +/// the block before the one that created it. An unknown median fails closed. +fn proposal_sequence_locks( + query: &Query, + tx: &Transaction, + coin_heights: &[u32], + height: u32, + mtp: u32, +) -> Result<(), String> { + if !rbitcoin_consensus::block::bip68_active_for_tx(tx) { + return Ok(()); + } + let mut coin_mtps = Vec::with_capacity(coin_heights.len()); + for (inp, &h) in tx.input.iter().zip(coin_heights) { + let coin_mtp = if inp.sequence.is_time_locked() { + rbitcoin_query::median_time_past(query, Height(h.saturating_sub(1))) + .map_err(|_| "bad-txns-nonfinal")? + } else { + 0 + }; + coin_mtps.push(coin_mtp); + } + rbitcoin_consensus::block::reject_sequence_locks(tx, coin_heights, &coin_mtps, height, mtp) + .map_err(|e| rbitcoin_consensus::block_reject_reason(&e)) +} + /// `op`'s unspent confirmed output: a per-input spentness probe, then the /// pre-decoded entry in `parents`. fn chain_txout<'a>( diff --git a/crates/rbitcoin-query/src/block_pipeline.rs b/crates/rbitcoin-query/src/block_pipeline.rs index 21de9fe68..964e54ed0 100644 --- a/crates/rbitcoin-query/src/block_pipeline.rs +++ b/crates/rbitcoin-query/src/block_pipeline.rs @@ -262,7 +262,7 @@ fn assemble_prevout_guards( Ok(()) } -pub(crate) fn assemble_lock_time_cutoff( +pub fn assemble_lock_time_cutoff( ctx: &rbitcoin_consensus::ValidationContext<'_>, block: &Block, prev_mtp: u32, diff --git a/crates/rbitcoin-query/src/lib.rs b/crates/rbitcoin-query/src/lib.rs index bb976bbfb..7f27b7591 100644 --- a/crates/rbitcoin-query/src/lib.rs +++ b/crates/rbitcoin-query/src/lib.rs @@ -50,6 +50,7 @@ mod structure_rule_tests; #[cfg(test)] mod wire_agree_tests; +pub use block_pipeline::assemble_lock_time_cutoff; pub use combined_stage::{load_creates_once, CombinedCreate}; pub use confirm_error::ConfirmError; pub use connect_facade::*; diff --git a/crates/rbitcoin-rpc/src/regtest_chain_ops_journey.rs b/crates/rbitcoin-rpc/src/regtest_chain_ops_journey.rs index 65c9c7a60..120eab1e7 100644 --- a/crates/rbitcoin-rpc/src/regtest_chain_ops_journey.rs +++ b/crates/rbitcoin-rpc/src/regtest_chain_ops_journey.rs @@ -1184,6 +1184,12 @@ fn chain_ops_empty_template_and_proposals(ctx: &RpcContext) { let mut bits_bad = next.clone(); bits_bad.header.bits = bitcoin::CompactTarget::from_consensus(469762303); assert_eq!(propose(ctx, &bits_bad), "bad-diffbits"); + let mut v3 = next.clone(); + v3.header.version = bitcoin::block::Version::from_consensus(3); + assert_eq!(propose(ctx, &v3), "bad-version(0x00000003)", "BIP65 floor"); + let mut far = next.clone(); + far.header.time = u32::MAX; + assert_eq!(propose(ctx, &far), "time-too-new"); let mut old = next; old.header.time = 0; assert_eq!(propose(ctx, &old), "time-too-old"); @@ -2356,6 +2362,56 @@ fn chain_ops_proposal_spends(ctx: &RpcContext, cbs: &mut TrueCoinbases) { propose(ctx, &proposal_on_tip(ctx, vec![locked])), "bad-txns-nonfinal" ); + // BIP113: a time lock is final only below the parent's median time, + // not below this block's own stamp. + let tip_mtp = dispatch(ctx, "getblockheader", vec![best_hash(ctx), json!(true)]).unwrap() + ["mediantime"] + .as_u64() + .unwrap() as u32; + let mut time_locked = spend.clone(); + time_locked.lock_time = LockTime::from_time(tip_mtp).unwrap(); + time_locked.input[0].sequence = bitcoin::Sequence::ZERO; + let block = proposal_on_tip(ctx, vec![time_locked]); + assert!(block.header.time > tip_mtp); + assert_eq!(propose(ctx, &block), "bad-txns-nonfinal", "BIP113"); + // BIP68: the coin at height `f` is `age` deep in the proposed block. + let age = tip_count(ctx) as u32 + 1 - f; + let mut rel = spend.clone(); + rel.input[0].sequence = bitcoin::Sequence::from_height(age as u16); + assert!(propose(ctx, &proposal_on_tip(ctx, vec![rel.clone()])).is_null()); + rel.input[0].sequence = bitcoin::Sequence::from_height(age as u16 + 1); + assert_eq!( + propose(ctx, &proposal_on_tip(ctx, vec![rel])), + "bad-txns-nonfinal", + "BIP68 height lock one block short" + ); + let mut rel_time = spend.clone(); + rel_time.input[0].sequence = bitcoin::Sequence::from_512_second_intervals(0); + assert!( + propose(ctx, &proposal_on_tip(ctx, vec![rel_time.clone()])).is_null(), + "a met time lock reads the coin's median time" + ); + rel_time.input[0].sequence = bitcoin::Sequence::from_512_second_intervals(u16::MAX); + assert_eq!( + propose(ctx, &proposal_on_tip(ctx, vec![rel_time])), + "bad-txns-nonfinal", + "BIP68 time lock" + ); + // A coin created in the proposed block is at that block's height. + let mut child = spend.clone(); + child.input[0].previous_output = bitcoin::OutPoint { + txid: spend.compute_txid(), + vout: 0, + }; + child.input[0].sequence = bitcoin::Sequence::from_height(0); + child.output[0].value = spend.output[0].value - Amount::from_sat(1_000); + assert!(propose(ctx, &proposal_on_tip(ctx, vec![spend.clone(), child.clone()])).is_null()); + child.input[0].sequence = bitcoin::Sequence::from_height(1); + assert_eq!( + propose(ctx, &proposal_on_tip(ctx, vec![spend.clone(), child])), + "bad-txns-nonfinal", + "a same-block coin is zero blocks deep" + ); let mut ghost = spend.clone(); ghost.input[0].previous_output.txid = Txid::from_byte_array([0xab; 32]); assert_eq!( diff --git a/docs/rpc.md b/docs/rpc.md index 2b9039729..70f80e53e 100644 --- a/docs/rpc.md +++ b/docs/rpc.md @@ -149,7 +149,7 @@ still wait for durable SH when shindex is on. | `estimaterawfee` | Same 10-minute frontier product as `estimatesmartfee` (Core RPC name for harness scripts). Not Core historical `estimaterawfee` buckets. | | `getnetworkhashps` | Core `GetNetworkHashPS`: `chainwork(end) − chainwork(start)` over `(maxTime − minTime)` in the lookup window. Default `nblocks` 120; `nblocks<=0` uses `height % difficulty_adjustment_interval + 1` (capped to height). `height<0` or past tip → tip. Genesis / zero dt → `0.0`. | | `generatetoaddress` / `generatetodescriptor` / `generateblock` / `generate` | **Regtest, and signet whose challenge is exactly `OP_TRUE`.** Mine through `ChainHub::accept_block` (same confirm as P2P). The `OP_TRUE` signet path uses signet bits and an empty BIP325 solution. First generated block includes `select_block_template`, then `remove_for_block`. `generatetodescriptor` accepts `raw(HEX)`, `addr(ADDRESS)`, or a bare address. | -| `getblocktemplate` / `getmininginfo` | All networks. Template from `select_block_template`; `-blockmintxfee` skips whole chunks under the floor (Core chunk feerate). `rules` must include `segwit`. Proposal validates without connecting and returns Core reject needles (`bad-cb-missing`, `bad-cb-amount`, `bad-txns-premature-spend-of-coinbase`, `bad-diffbits`, `time-too-old`, …). Version is `VERSIONBITS_TOP_BITS` only (no testdummy). `longpollid` waits until the tip or mempool update counter changes. `getmininginfo.blockmintxfee` is 8-decimal BTC/kvB (`sat_btc_json`, same helper as mempool fees). | +| `getblocktemplate` / `getmininginfo` | All networks. Template from `select_block_template`; `-blockmintxfee` skips whole chunks under the floor (Core chunk feerate). `rules` must include `segwit`. Proposal validates without connecting and returns Core reject needles (`bad-cb-missing`, `bad-cb-amount`, `bad-txns-premature-spend-of-coinbase`, `bad-txns-nonfinal` for BIP113 and BIP68, `bad-version`, `bad-diffbits`, `time-too-old`, …). Version is `VERSIONBITS_TOP_BITS` only (no testdummy). `longpollid` waits until the tip or mempool update counter changes. `getmininginfo.blockmintxfee` is 8-decimal BTC/kvB (`sat_btc_json`, same helper as mempool fees). | | `prioritisetransaction` / `getprioritisedtransactions` | All networks. Local mining fee delta (sat). Dummy must be 0. Selector honors modified fee. The feerate announced to peers is base fee plus this delta. | | `getmempoolcluster` | All networks. Cluster weight / chunks from the live graph (modified fees). Same prefix-maximal chunks as mining selection. `clusterweight` and `chunkweight` are sigop-adjusted (Core); the cluster size limit itself counts raw weight (`COMPAT.md`). | | `getmempoolancestors` / `getmempooldescendants` | All networks. Exclusive walks of the live cluster graph. `verbose` reuses `getmempoolentry` fields. | From 9360b91395fc3b6faa595b9fe21b47d62e2be9dd Mon Sep 17 00:00:00 2001 From: Nitesh Balusu Date: Tue, 20 Oct 2026 11:02:43 -0400 Subject: [PATCH 2/2] rpc: read proposal medians from confirmed headers and keep store faults 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 --- crates/rbitcoin-net/Cargo.toml | 2 + crates/rbitcoin-net/src/chain.rs | 57 +++++++++++++++++-- .../src/regtest_chain_ops_journey.rs | 7 ++- 3 files changed, 61 insertions(+), 5 deletions(-) diff --git a/crates/rbitcoin-net/Cargo.toml b/crates/rbitcoin-net/Cargo.toml index 6be5bbf5f..e2f80b8a7 100644 --- a/crates/rbitcoin-net/Cargo.toml +++ b/crates/rbitcoin-net/Cargo.toml @@ -38,6 +38,8 @@ sha3 = "0.12" rbitcoin-mempool = { workspace = true, features = ["test-hooks"] } # Side-block accept arms one cancelled header read. Dependents do not. rbitcoin-query = { workspace = true, features = ["test-hooks"] } +# A proposal test blanks a confirmed height. Dependents do not. +rbitcoin-store = { workspace = true, features = ["test-hooks"] } [target.'cfg(target_os = "linux")'.dependencies] # POLLRDHUP: peer FIN with unread bytes still in the receive buffer. diff --git a/crates/rbitcoin-net/src/chain.rs b/crates/rbitcoin-net/src/chain.rs index c936b61fe..d542b2d3c 100644 --- a/crates/rbitcoin-net/src/chain.rs +++ b/crates/rbitcoin-net/src/chain.rs @@ -3601,7 +3601,7 @@ pub fn check_block_proposal_with( if block.header.bits.to_consensus() != expected { return Err("bad-diffbits".into()); } - let mtp = rbitcoin_query::median_time_past(query, tip_h).unwrap_or(tip_rec.timestamp); + let mtp = rbitcoin_query::median_time_past_store(query, tip_h).map_err(|e| e.to_string())?; // Core is `<=` MTP. Proposal uses `<` so a template stamped at the // parent's mediantime+1 still validates after that parent is submitted // (new MTP often equals that stamp on an incrementing cache). @@ -3788,7 +3788,8 @@ fn proposal_connect( } /// BIP68 on a proposal, as at connect: a coin's time is the median time of -/// the block before the one that created it. An unknown median fails closed. +/// the block before the one that created it, from confirmed headers only. +/// A store fault reading it is the store error, not a reject. fn proposal_sequence_locks( query: &Query, tx: &Transaction, @@ -3802,8 +3803,8 @@ fn proposal_sequence_locks( let mut coin_mtps = Vec::with_capacity(coin_heights.len()); for (inp, &h) in tx.input.iter().zip(coin_heights) { let coin_mtp = if inp.sequence.is_time_locked() { - rbitcoin_query::median_time_past(query, Height(h.saturating_sub(1))) - .map_err(|_| "bad-txns-nonfinal")? + rbitcoin_query::median_time_past_store(query, Height(h.saturating_sub(1))) + .map_err(|e| e.to_string())? } else { 0 }; @@ -8108,6 +8109,54 @@ mod tests { } } + /// A proposal reads its medians from confirmed headers, as connect does. + /// With blocks 600 s apart, a coin's time is the median of the block + /// before the one that created it: 118 intervals pass and 119 do not, + /// and 118 would fail on the creating block's own median. A median the + /// store cannot read is that store error. The tip median does not fall + /// back to the tip's own time, and a coin median is not + /// `bad-txns-nonfinal`. + #[test] + fn check_block_proposal_reads_medians_from_confirmed_headers() { + let (dir, hub) = tmp_hub(); + hub.ensure_genesis().unwrap(); + let t0 = hub.tip_header().unwrap().time; + let mut tip = hub.tip_hash().unwrap(); + for h in 1..=112u32 { + let b = mine(tip, t0 + 600 * h, h); + tip = b.block_hash(); + hub.accept_block(b).unwrap(); + } + let op_true = ScriptBuf::from_bytes(vec![0x51]); + let time_locked = |intervals: u16| { + let mut tx = mature_spend_tx(&hub, 12); + tx.input[0].sequence = Sequence::from_512_second_intervals(intervals); + hub.assemble_block_to_script(op_true.clone(), vec![tx]) + .unwrap() + }; + let met = time_locked(118); + assert_eq!(hub.check_block_proposal(&met), Ok(10_000)); + assert_eq!( + hub.check_block_proposal(&time_locked(119)).unwrap_err(), + "bad-txns-nonfinal" + ); + let empty = hub + .assemble_block_to_script(op_true.clone(), vec![]) + .unwrap(); + + let missing = "missing confirmed header"; + let confirmed = &hub.query.store().confirmed; + confirmed.set_raw_for_test(Height(11), 0).unwrap(); + let err = hub.check_block_proposal(&met).unwrap_err(); + assert!(err.contains(missing), "coin median: {err}"); + assert_eq!(hub.check_block_proposal(&empty), Ok(0)); + confirmed.set_raw_for_test(Height(110), 0).unwrap(); + hub.query.store().rebuild_height_fence().unwrap(); + let err = hub.check_block_proposal(&empty).unwrap_err(); + assert!(err.contains(missing), "tip median: {err}"); + let _ = std::fs::remove_dir_all(dir); + } + /// Witness sigops are not in the structure walk. Nine P2WSH inputs of /// 10_000 `OP_CHECKSIG` each are over the 80_000 block limit. #[test] diff --git a/crates/rbitcoin-rpc/src/regtest_chain_ops_journey.rs b/crates/rbitcoin-rpc/src/regtest_chain_ops_journey.rs index 120eab1e7..b40ba5237 100644 --- a/crates/rbitcoin-rpc/src/regtest_chain_ops_journey.rs +++ b/crates/rbitcoin-rpc/src/regtest_chain_ops_journey.rs @@ -2371,9 +2371,14 @@ fn chain_ops_proposal_spends(ctx: &RpcContext, cbs: &mut TrueCoinbases) { let mut time_locked = spend.clone(); time_locked.lock_time = LockTime::from_time(tip_mtp).unwrap(); time_locked.input[0].sequence = bitcoin::Sequence::ZERO; - let block = proposal_on_tip(ctx, vec![time_locked]); + let block = proposal_on_tip(ctx, vec![time_locked.clone()]); assert!(block.header.time > tip_mtp); assert_eq!(propose(ctx, &block), "bad-txns-nonfinal", "BIP113"); + time_locked.lock_time = LockTime::from_time(tip_mtp - 1).unwrap(); + assert!( + propose(ctx, &proposal_on_tip(ctx, vec![time_locked])).is_null(), + "BIP113: one second below the median is final" + ); // BIP68: the coin at height `f` is `age` deep in the proposed block. let age = tip_count(ctx) as u32 + 1 - f; let mut rel = spend.clone();