|
3 | 3 | pub mod utils; |
4 | 4 |
|
5 | 5 | use anyhow::Context; |
| 6 | +use bdk_chain::bitcoin::{ |
| 7 | + block::Header, hash_types::TxMerkleNode, hex::FromHex, script::PushBytesBuf, transaction, |
| 8 | + Address, Amount, Block, BlockHash, CompactTarget, ScriptBuf, Transaction, TxIn, TxOut, Txid, |
| 9 | +}; |
6 | 10 | use bdk_chain::CheckPoint; |
7 | | -use bitcoin::{address::NetworkChecked, Address, Amount, BlockHash, Txid}; |
8 | | -use std::time::Duration; |
| 11 | +use bitcoin::address::NetworkChecked; |
| 12 | +use bitcoin::consensus::Decodable; |
| 13 | +use bitcoin::hex::HexToBytesError; |
| 14 | +use core::str::FromStr; |
| 15 | +use core::time::Duration; |
| 16 | +use electrsd::corepc_node::vtype::GetBlockTemplate; |
| 17 | +use electrsd::corepc_node::{TemplateRequest, TemplateRules}; |
9 | 18 |
|
10 | 19 | pub use electrsd; |
11 | 20 | pub use electrsd::corepc_client; |
@@ -45,6 +54,27 @@ impl Default for Config<'_> { |
45 | 54 | } |
46 | 55 | } |
47 | 56 |
|
| 57 | +/// Parameters for [`TestEnv::mine_block`]. |
| 58 | +#[non_exhaustive] |
| 59 | +#[derive(Default)] |
| 60 | +pub struct MineParams { |
| 61 | + /// If `true`, the block will be empty (no mempool transactions). |
| 62 | + pub empty: bool, |
| 63 | + /// Set a custom block timestamp. Defaults to `max(min_time, now)`. |
| 64 | + pub time: Option<u32>, |
| 65 | + /// Set a custom coinbase output script. Defaults to `OP_TRUE`. |
| 66 | + pub coinbase_address: Option<ScriptBuf>, |
| 67 | +} |
| 68 | + |
| 69 | +impl MineParams { |
| 70 | + fn address_or_anyone_can_spend(&self) -> ScriptBuf { |
| 71 | + self.coinbase_address |
| 72 | + .clone() |
| 73 | + // OP_TRUE (anyone can spend) |
| 74 | + .unwrap_or(ScriptBuf::from_bytes(vec![0x51])) |
| 75 | + } |
| 76 | +} |
| 77 | + |
48 | 78 | impl TestEnv { |
49 | 79 | /// Construct a new [`TestEnv`] instance with the default configuration used by BDK. |
50 | 80 | pub fn new() -> anyhow::Result<Self> { |
@@ -119,70 +149,157 @@ impl TestEnv { |
119 | 149 | Ok(block_hashes) |
120 | 150 | } |
121 | 151 |
|
| 152 | + /// Get a block template from the node. |
| 153 | + pub fn get_block_template(&self) -> anyhow::Result<GetBlockTemplate> { |
| 154 | + use corepc_node::TemplateRequest; |
| 155 | + Ok(self.bitcoind.client.get_block_template(&TemplateRequest { |
| 156 | + rules: vec![ |
| 157 | + TemplateRules::Segwit, |
| 158 | + TemplateRules::Taproot, |
| 159 | + TemplateRules::Csv, |
| 160 | + ], |
| 161 | + })?) |
| 162 | + } |
| 163 | + |
122 | 164 | /// Mine a block that is guaranteed to be empty even with transactions in the mempool. |
123 | 165 | #[cfg(feature = "std")] |
124 | 166 | pub fn mine_empty_block(&self) -> anyhow::Result<(usize, BlockHash)> { |
125 | | - use bitcoin::secp256k1::rand::random; |
126 | | - use bitcoin::{ |
127 | | - block::Header, hashes::Hash, transaction, Block, ScriptBuf, ScriptHash, Transaction, |
128 | | - TxIn, TxMerkleNode, TxOut, |
129 | | - }; |
130 | | - use corepc_node::{TemplateRequest, TemplateRules}; |
131 | | - let request = TemplateRequest { |
132 | | - rules: vec![TemplateRules::Segwit], |
133 | | - }; |
134 | | - let bt = self |
| 167 | + self.mine_block(MineParams { |
| 168 | + empty: true, |
| 169 | + ..Default::default() |
| 170 | + }) |
| 171 | + } |
| 172 | + |
| 173 | + /// Get the minimum valid timestamp for the next block. |
| 174 | + pub fn min_time_for_next_block(&self) -> anyhow::Result<u32> { |
| 175 | + Ok(self |
135 | 176 | .bitcoind |
136 | 177 | .client |
137 | | - .get_block_template(&request)? |
138 | | - .into_model()?; |
| 178 | + .get_block_template(&TemplateRequest { |
| 179 | + rules: vec![ |
| 180 | + TemplateRules::Segwit, |
| 181 | + TemplateRules::Taproot, |
| 182 | + TemplateRules::Csv, |
| 183 | + ], |
| 184 | + })? |
| 185 | + .min_time) |
| 186 | + } |
| 187 | + |
| 188 | + /// Mine a single block with the given [`MineParams`]. |
| 189 | + pub fn mine_block(&self, params: MineParams) -> anyhow::Result<(usize, BlockHash)> { |
| 190 | + let bt = self.bitcoind.client.get_block_template(&TemplateRequest { |
| 191 | + rules: vec![ |
| 192 | + TemplateRules::Segwit, |
| 193 | + TemplateRules::Taproot, |
| 194 | + TemplateRules::Csv, |
| 195 | + ], |
| 196 | + })?; |
| 197 | + let coinbase_scriptsig = { |
| 198 | + // BIP34 requires the height to be the first item in coinbase scriptSig. |
| 199 | + // Bitcoin Core validates by checking if scriptSig STARTS with the expected |
| 200 | + // encoding (using minimal opcodes like OP_1 for height 1). |
| 201 | + // The scriptSig must also be 2-100 bytes total. |
| 202 | + let mut builder = bdk_chain::bitcoin::script::Builder::new().push_int(bt.height); |
| 203 | + for v in bt.coinbase_aux.values() { |
| 204 | + let bytes = Vec::<u8>::from_hex(v).expect("must be valid hex"); |
| 205 | + let bytes_buf = PushBytesBuf::try_from(bytes).expect("must be valid bytes"); |
| 206 | + builder = builder.push_slice(bytes_buf); |
| 207 | + } |
| 208 | + let script = builder.into_script(); |
| 209 | + // Ensure scriptSig is at least 2 bytes (pad with OP_0 if needed) |
| 210 | + if script.len() < 2 { |
| 211 | + bdk_chain::bitcoin::script::Builder::new() |
| 212 | + .push_int(bt.height) |
| 213 | + .push_opcode(bdk_chain::bitcoin::opcodes::OP_0) |
| 214 | + .into_script() |
| 215 | + } else { |
| 216 | + script |
| 217 | + } |
| 218 | + }; |
139 | 219 |
|
140 | | - let txdata = vec![Transaction { |
| 220 | + let coinbase_outputs = if params.empty { |
| 221 | + let value: Amount = Amount::from_sat( |
| 222 | + (bt.coinbase_value - bt.transactions.iter().map(|tx| tx.fee).sum::<i64>()) as u64, |
| 223 | + ); |
| 224 | + vec![TxOut { |
| 225 | + value, |
| 226 | + script_pubkey: params.address_or_anyone_can_spend(), |
| 227 | + }] |
| 228 | + } else { |
| 229 | + core::iter::once(TxOut { |
| 230 | + value: Amount::from_sat(bt.coinbase_value.try_into().expect("must fit into u64")), |
| 231 | + script_pubkey: params.address_or_anyone_can_spend(), |
| 232 | + }) |
| 233 | + .chain( |
| 234 | + bt.default_witness_commitment |
| 235 | + .map(|s| -> Result<_, HexToBytesError> { |
| 236 | + Ok(TxOut { |
| 237 | + value: Amount::ZERO, |
| 238 | + script_pubkey: ScriptBuf::from_hex(&s)?, |
| 239 | + }) |
| 240 | + }) |
| 241 | + .transpose()?, |
| 242 | + ) |
| 243 | + .collect() |
| 244 | + }; |
| 245 | + |
| 246 | + let coinbase_tx = Transaction { |
141 | 247 | version: transaction::Version::ONE, |
142 | 248 | lock_time: bdk_chain::bitcoin::absolute::LockTime::from_height(0)?, |
143 | 249 | input: vec![TxIn { |
144 | 250 | previous_output: bdk_chain::bitcoin::OutPoint::default(), |
145 | | - script_sig: ScriptBuf::builder() |
146 | | - .push_int(bt.height as _) |
147 | | - // random number so that re-mining creates unique block |
148 | | - .push_int(random()) |
149 | | - .into_script(), |
| 251 | + script_sig: coinbase_scriptsig, |
150 | 252 | sequence: bdk_chain::bitcoin::Sequence::default(), |
151 | 253 | witness: bdk_chain::bitcoin::Witness::new(), |
152 | 254 | }], |
153 | | - output: vec![TxOut { |
154 | | - value: Amount::ZERO, |
155 | | - script_pubkey: ScriptBuf::new_p2sh(&ScriptHash::all_zeros()), |
156 | | - }], |
157 | | - }]; |
| 255 | + output: coinbase_outputs, |
| 256 | + }; |
| 257 | + |
| 258 | + let txdata = if params.empty { |
| 259 | + vec![coinbase_tx] |
| 260 | + } else { |
| 261 | + core::iter::once(coinbase_tx) |
| 262 | + .chain(bt.transactions.iter().map(|tx| { |
| 263 | + let raw = Vec::<u8>::from_hex(&tx.data).expect("must be valid hex"); |
| 264 | + Transaction::consensus_decode(&mut raw.as_slice()).expect("must decode tx") |
| 265 | + })) |
| 266 | + .collect() |
| 267 | + }; |
158 | 268 |
|
159 | 269 | let mut block = Block { |
160 | 270 | header: Header { |
161 | | - version: bt.version, |
162 | | - prev_blockhash: bt.previous_block_hash, |
163 | | - merkle_root: TxMerkleNode::all_zeros(), |
164 | | - time: Ord::max( |
| 271 | + version: bdk_chain::bitcoin::blockdata::block::Version::from_consensus(bt.version), |
| 272 | + prev_blockhash: BlockHash::from_str(&bt.previous_block_hash)?, |
| 273 | + merkle_root: TxMerkleNode::from_raw_hash( |
| 274 | + bdk_chain::bitcoin::merkle_tree::calculate_root( |
| 275 | + txdata.iter().map(|tx| tx.compute_txid().to_raw_hash()), |
| 276 | + ) |
| 277 | + .expect("must have atleast one tx"), |
| 278 | + ), |
| 279 | + time: params.time.unwrap_or(Ord::max( |
165 | 280 | bt.min_time, |
166 | 281 | std::time::UNIX_EPOCH.elapsed()?.as_secs() as u32, |
167 | | - ), |
168 | | - bits: bt.bits, |
| 282 | + )), |
| 283 | + bits: CompactTarget::from_unprefixed_hex(&bt.bits)?, |
169 | 284 | nonce: 0, |
170 | 285 | }, |
171 | 286 | txdata, |
172 | 287 | }; |
173 | 288 |
|
174 | 289 | block.header.merkle_root = block.compute_merkle_root().expect("must compute"); |
175 | 290 |
|
| 291 | + // Mine! |
| 292 | + let target = block.header.target(); |
176 | 293 | for nonce in 0..=u32::MAX { |
177 | 294 | block.header.nonce = nonce; |
178 | | - if block.header.target().is_met_by(block.block_hash()) { |
179 | | - break; |
| 295 | + let blockhash = block.block_hash(); |
| 296 | + if target.is_met_by(blockhash) { |
| 297 | + self.rpc_client().submit_block(&block)?; |
| 298 | + return Ok((bt.height as usize, blockhash)); |
180 | 299 | } |
181 | 300 | } |
182 | 301 |
|
183 | | - self.bitcoind.client.submit_block(&block)?; |
184 | | - |
185 | | - Ok((bt.height as usize, block.block_hash())) |
| 302 | + Err(anyhow::anyhow!("Cannot find nonce that meets the target")) |
186 | 303 | } |
187 | 304 |
|
188 | 305 | /// This method waits for the Electrum notification indicating that a new block has been mined. |
@@ -318,9 +435,11 @@ impl TestEnv { |
318 | 435 | #[cfg(test)] |
319 | 436 | #[cfg_attr(coverage_nightly, coverage(off))] |
320 | 437 | mod test { |
321 | | - use crate::TestEnv; |
| 438 | + use crate::{MineParams, TestEnv}; |
| 439 | + use bdk_chain::bitcoin::{Amount, ScriptBuf}; |
322 | 440 | use core::time::Duration; |
323 | 441 | use electrsd::corepc_node::anyhow::Result; |
| 442 | + use std::collections::BTreeSet; |
324 | 443 |
|
325 | 444 | /// This checks that reorgs initiated by `bitcoind` is detected by our `electrsd` instance. |
326 | 445 | #[test] |
@@ -355,4 +474,75 @@ mod test { |
355 | 474 |
|
356 | 475 | Ok(()) |
357 | 476 | } |
| 477 | + |
| 478 | + #[test] |
| 479 | + fn test_mine_block() -> Result<()> { |
| 480 | + let anyone_can_spend = ScriptBuf::from_bytes(vec![0x51]); |
| 481 | + |
| 482 | + let env = TestEnv::new()?; |
| 483 | + |
| 484 | + // So we can spend. |
| 485 | + let addr = env |
| 486 | + .rpc_client() |
| 487 | + .get_new_address(None, None)? |
| 488 | + .address()? |
| 489 | + .assume_checked(); |
| 490 | + env.mine_blocks(100, Some(addr.clone()))?; |
| 491 | + |
| 492 | + // Try mining a block with custom time. |
| 493 | + let custom_time = env.min_time_for_next_block()? + 100; |
| 494 | + let (_a_height, a_hash) = env.mine_block(MineParams { |
| 495 | + empty: false, |
| 496 | + time: Some(custom_time), |
| 497 | + coinbase_address: None, |
| 498 | + })?; |
| 499 | + let a_block = env.rpc_client().get_block(a_hash)?; |
| 500 | + assert_eq!(a_block.header.time, custom_time); |
| 501 | + assert_eq!( |
| 502 | + a_block.txdata[0].output[0].script_pubkey, anyone_can_spend, |
| 503 | + "Subsidy address must be anyone_can_spend" |
| 504 | + ); |
| 505 | + |
| 506 | + // Now try mining with min time & some txs. |
| 507 | + let txid1 = env.send(&addr, Amount::from_sat(100_000))?; |
| 508 | + let txid2 = env.send(&addr, Amount::from_sat(200_000))?; |
| 509 | + let txid3 = env.send(&addr, Amount::from_sat(300_000))?; |
| 510 | + let min_time = env.min_time_for_next_block()?; |
| 511 | + let (_b_height, b_hash) = env.mine_block(MineParams { |
| 512 | + empty: false, |
| 513 | + time: Some(min_time), |
| 514 | + coinbase_address: None, |
| 515 | + })?; |
| 516 | + let b_block = env.rpc_client().get_block(b_hash)?; |
| 517 | + assert_eq!(b_block.header.time, min_time); |
| 518 | + assert_eq!( |
| 519 | + a_block.txdata[0].output[0].script_pubkey, anyone_can_spend, |
| 520 | + "Subsidy address must be anyone_can_spend" |
| 521 | + ); |
| 522 | + assert_eq!( |
| 523 | + b_block |
| 524 | + .txdata |
| 525 | + .iter() |
| 526 | + .skip(1) // ignore coinbase |
| 527 | + .map(|tx| tx.compute_txid()) |
| 528 | + .collect::<BTreeSet<_>>(), |
| 529 | + [txid1, txid2, txid3].into_iter().collect(), |
| 530 | + "Must have all txs" |
| 531 | + ); |
| 532 | + |
| 533 | + // Custom subsidy address. |
| 534 | + let (_c_height, c_hash) = env.mine_block(MineParams { |
| 535 | + empty: false, |
| 536 | + time: None, |
| 537 | + coinbase_address: Some(addr.script_pubkey()), |
| 538 | + })?; |
| 539 | + let c_block = env.rpc_client().get_block(c_hash)?; |
| 540 | + assert_eq!( |
| 541 | + c_block.txdata[0].output[0].script_pubkey, |
| 542 | + addr.script_pubkey(), |
| 543 | + "Custom address works" |
| 544 | + ); |
| 545 | + |
| 546 | + Ok(()) |
| 547 | + } |
358 | 548 | } |
0 commit comments