Skip to content

Commit 63b88df

Browse files
committed
feat(chain,node): node_at_height_from + BIP68 time-based sequence-lock enforcement
Add BlockTree height backtracking for MTP lookup and enforce time-based BIP68 sequence locks using the prevout block's median time past. Integration coverage for time-based BIP68 is intentionally out of scope because exercising it requires a CSV-active chain height with controlled timestamps. Op: extend
1 parent 01eec40 commit 63b88df

2 files changed

Lines changed: 106 additions & 4 deletions

File tree

crates/chain/src/tree.rs

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,37 @@ impl BlockTree {
127127
}
128128
locator
129129
}
130+
/// Walks backward from `start_id` via parent pointers to the node at
131+
/// `target_height`. Returns the `NodeId` at that height, or None if
132+
/// `target_height > start_id.height` or the chain is broken.
133+
#[must_use]
134+
pub fn node_at_height_from(&self, start_id: NodeId, target_height: u32) -> Option<NodeId> {
135+
let Ok(start_node) = self.node(start_id) else {
136+
return None;
137+
};
138+
if target_height > start_node.height {
139+
return None;
140+
}
141+
if target_height == start_node.height {
142+
return Some(start_id);
143+
}
144+
145+
let mut cursor = start_id;
146+
loop {
147+
let Ok(node) = self.node(cursor) else {
148+
return None;
149+
};
150+
if node.height == target_height {
151+
return Some(cursor);
152+
}
153+
if node.height < target_height {
154+
return None;
155+
}
156+
let parent = node.parent?;
157+
cursor = parent;
158+
}
159+
}
160+
130161
/// Returns the median time of the most recent `window` blocks, inclusive
131162
/// of `start_id`, walking backward via parent pointers.
132163
///
@@ -376,6 +407,44 @@ mod tests {
376407
Ok(())
377408
}
378409

410+
#[test]
411+
fn node_at_height_from_walks_back_to_requested_height() -> Result<(), Box<dyn std::error::Error>>
412+
{
413+
let mut tree = BlockTree::new();
414+
let mut prev_hash = BlockHash::all_zeros();
415+
let mut genesis_id = None;
416+
let mut tip_id = None;
417+
418+
for height in 0..5_u32 {
419+
let header = BlockHeader {
420+
version: Version::ONE,
421+
prev_blockhash: prev_hash,
422+
merkle_root: TxMerkleNode::all_zeros(),
423+
time: 1_000_000 + height * 600,
424+
bits: CompactTarget::from_consensus(0x207f_ffff),
425+
nonce: height,
426+
};
427+
prev_hash = header.block_hash();
428+
let node_id = tree.insert_header(header, NodeStatus::HeaderValid)?;
429+
if height == 0 {
430+
genesis_id = Some(node_id);
431+
}
432+
tip_id = Some(node_id);
433+
}
434+
435+
let Some(genesis_id) = genesis_id else {
436+
panic!("chain has 5 blocks should yield a genesis node");
437+
};
438+
let Some(tip_id) = tip_id else {
439+
panic!("chain has 5 blocks should yield a tip");
440+
};
441+
442+
assert_eq!(tree.node_at_height_from(tip_id, 0), Some(genesis_id));
443+
assert_eq!(tree.node_at_height_from(tip_id, 4), Some(tip_id));
444+
assert_eq!(tree.node_at_height_from(tip_id, 99), None);
445+
Ok(())
446+
}
447+
379448
fn test_header(prev_blockhash: BlockHash, height: u32) -> BlockHeader {
380449
let mut merkle = [0_u8; 32];
381450
merkle[..4].copy_from_slice(&height.to_le_bytes());

crates/node/src/apply.rs

Lines changed: 37 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ const COINBASE_MATURITY: u32 = 100;
2121
const BIP68_DISABLE_FLAG: u32 = 0x8000_0000;
2222
const BIP68_TYPE_FLAG: u32 = 0x0040_0000;
2323
const BIP68_MASK: u32 = 0x0000_ffff;
24+
const BIP68_TIME_GRANULARITY_SECONDS: u32 = 512;
2425

2526
/// Owned shared handle set needed by `apply_block` to perform a block apply.
2627
pub struct ApplyHandles {
@@ -107,7 +108,7 @@ pub fn apply_block(
107108
verify_block_transactions(handles, block, height, prev_tip_state.median_time_past)?;
108109

109110
check_coinbase_maturity(handles, block, height)?;
110-
check_bip68_sequence_locks(handles, block, height)?;
111+
check_bip68_sequence_locks(handles, block, height, prev_tip_state.median_time_past)?;
111112

112113
let changes = build_utxo_changes(block, height)?;
113114
handles
@@ -235,6 +236,7 @@ fn check_bip68_sequence_locks(
235236
handles: &ApplyHandles,
236237
block: &bitcoin::Block,
237238
height: u32,
239+
mtp: u32,
238240
) -> core::result::Result<(), ApplyError> {
239241
use bitcoin::hashes::Hash as _;
240242

@@ -256,10 +258,41 @@ fn check_bip68_sequence_locks(
256258
}
257259
let is_time_based = sequence & BIP68_TYPE_FLAG != 0;
258260
if is_time_based {
259-
tracing::warn!(
260-
sequence = %format_args!("0x{sequence:08x}"),
261-
"BIP68 time-based sequence lock observed; enforcement deferred"
261+
let relative_intervals = sequence & BIP68_MASK;
262+
let prev_outpoint = OutPoint::new(
263+
bitcoin_rs_primitives::Hash256::from_le_bytes(
264+
tx_input.previous_output.txid.as_byte_array(),
265+
),
266+
tx_input.previous_output.vout,
267+
);
268+
let Some(entry) = handles.utxo.get_entry(&prev_outpoint) else {
269+
continue;
270+
};
271+
let prevout_mtp = {
272+
let tree = handles.block_tree.read();
273+
let Some(chain_tip) = handles.chain_tip.load_full() else {
274+
continue;
275+
};
276+
let Some(prev_block_node) =
277+
tree.node_at_height_from(chain_tip.tip_id, entry.height)
278+
else {
279+
continue;
280+
};
281+
tree.median_time_past_at(prev_block_node, 11).unwrap_or(0)
282+
};
283+
let earliest_time = prevout_mtp.saturating_add(
284+
relative_intervals.saturating_mul(BIP68_TIME_GRANULARITY_SECONDS),
262285
);
286+
if mtp < earliest_time {
287+
return Err(ApplyError::Consensus(
288+
bitcoin_rs_consensus::ConsensusError::Bip {
289+
bip: "BIP68",
290+
reason: format!(
291+
"input sequence time-based lock unmet: prevout mtp {prevout_mtp} + {relative_intervals}*512s = {earliest_time} > current mtp {mtp}",
292+
),
293+
},
294+
));
295+
}
263296
continue;
264297
}
265298

0 commit comments

Comments
 (0)