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/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 a93f0e5d8..d542b2d3c 100644 --- a/crates/rbitcoin-net/src/chain.rs +++ b/crates/rbitcoin-net/src/chain.rs @@ -3601,21 +3601,27 @@ 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). 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,33 @@ 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, from confirmed headers only. +/// A store fault reading it is the store error, not a reject. +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_store(query, Height(h.saturating_sub(1))) + .map_err(|e| e.to_string())? + } 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>( @@ -8060,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-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..b40ba5237 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,61 @@ 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.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(); + 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. |