Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions changelog.d/proposal-bip113-bip68.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 2 additions & 0 deletions crates/rbitcoin-net/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
113 changes: 105 additions & 8 deletions crates/rbitcoin-net/src/chain.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Comment thread
niteshbalusu11 marked this conversation as resolved.
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 {
Expand Down Expand Up @@ -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<Vec<TxOut>>), String> {
if block.txdata.is_empty() {
return Err("bad-blk-length".into());
Expand All @@ -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 {
Expand All @@ -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) {
Expand All @@ -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());
Expand All @@ -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(
Expand All @@ -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>(
Expand Down Expand Up @@ -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]
Expand Down
2 changes: 1 addition & 1 deletion crates/rbitcoin-query/src/block_pipeline.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions crates/rbitcoin-query/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::*;
Expand Down
61 changes: 61 additions & 0 deletions crates/rbitcoin-rpc/src/regtest_chain_ops_journey.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down Expand Up @@ -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!(
Expand Down
2 changes: 1 addition & 1 deletion docs/rpc.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |
Expand Down
Loading