diff --git a/smite-ir/src/generators.rs b/smite-ir/src/generators.rs index 58d1b2df..e3fc8781 100644 --- a/smite-ir/src/generators.rs +++ b/smite-ir/src/generators.rs @@ -12,6 +12,7 @@ mod funding_created; mod funding_flow; mod node_announcement; mod open_channel; +mod reorg_chain; pub use channel_announcement::ChannelAnnouncementGenerator; pub use channel_ready::ChannelReadyGenerator; @@ -20,6 +21,7 @@ pub use funding_created::FundingCreatedGenerator; pub use funding_flow::FundingFlowGenerator; pub use node_announcement::NodeAnnouncementGenerator; pub use open_channel::OpenChannelGenerator; +pub use reorg_chain::ReorgChainGenerator; use rand::Rng; @@ -42,6 +44,7 @@ pub enum AnyGenerator { FundingCreated(FundingCreatedGenerator), ChannelReady(ChannelReadyGenerator), FundingFlow(FundingFlowGenerator), + ReorgChain(ReorgChainGenerator), } impl AnyGenerator { @@ -54,6 +57,7 @@ impl AnyGenerator { Self::FundingCreated(FundingCreatedGenerator), Self::ChannelReady(ChannelReadyGenerator), Self::FundingFlow(FundingFlowGenerator), + Self::ReorgChain(ReorgChainGenerator), ]; } @@ -67,6 +71,7 @@ impl Generator for AnyGenerator { Self::FundingCreated(generator) => generator.generate(builder, rng), Self::ChannelReady(generator) => generator.generate(builder, rng), Self::FundingFlow(generator) => generator.generate(builder, rng), + Self::ReorgChain(generator) => generator.generate(builder, rng), } } } diff --git a/smite-ir/src/generators/reorg_chain.rs b/smite-ir/src/generators/reorg_chain.rs new file mode 100644 index 00000000..2e5feeb5 --- /dev/null +++ b/smite-ir/src/generators/reorg_chain.rs @@ -0,0 +1,26 @@ +//! Generator for chain reorganizations. + +use rand::{Rng, RngExt}; + +use super::Generator; +use crate::Operation; +use crate::builder::ProgramBuilder; + +/// Generates a shallow chain reorganization. +/// +/// Emits instructions to: +/// 1. Mine blocks to confirm any broadcast transaction +/// 2. Reorg the chain +#[derive(Clone, Copy)] +pub struct ReorgChainGenerator; + +impl Generator for ReorgChainGenerator { + fn generate(&self, builder: &mut ProgramBuilder, rng: &mut impl Rng) { + // Mine blocks to confirm any broadcast transaction. + builder.append(Operation::MineBlocks(rng.random_range(1..=16)), &[]); + + // One or two block reorgs occur naturally on mainnet and are therefore + // the shallow reorgs a Lightning node is expected to handle. + builder.append(Operation::ReorgChain(rng.random_range(1..=2)), &[]); + } +} diff --git a/smite-ir/src/mutators/operation_param.rs b/smite-ir/src/mutators/operation_param.rs index 4f01d95f..7839b38a 100644 --- a/smite-ir/src/mutators/operation_param.rs +++ b/smite-ir/src/mutators/operation_param.rs @@ -82,6 +82,13 @@ fn mutate_operation(op: &mut Operation, rng: &mut impl Rng) -> bool { *v = rng.random_range(1..=16); true } + Operation::ReorgChain(v) => { + // One or two block reorgs occur naturally on mainnet and are + // therefore the shallow reorgs a Lightning node is expected to + // handle. + *v = rng.random_range(1..=2); + true + } Operation::ExtractAcceptChannel(field) => mutate_extract_field(field, rng), Operation::BuildNodeAnnouncement { rgb_color, alias } => { // Randomly mutate rgb_color or alias bytes in place; never change diff --git a/smite-ir/src/operation.rs b/smite-ir/src/operation.rs index 20b1f64b..f4159add 100644 --- a/smite-ir/src/operation.rs +++ b/smite-ir/src/operation.rs @@ -230,6 +230,8 @@ pub enum Operation { RecvChannelReady, /// Mines the given number of blocks on the Bitcoin network. MineBlocks(u8), + /// Reorganizes the given number of blocks at the tip of the Bitcoin chain. + ReorgChain(u8), /// Sign wallet inputs of the transaction and broadcast it via `bitcoin-cli`. /// Input: `FundingTransaction`. BroadcastTransaction, @@ -689,6 +691,7 @@ impl fmt::Display for Operation { Self::LoadTargetPubkeyFromContext => write!(f, "LoadTargetPubkeyFromContext()"), Self::LoadChainHashFromContext => write!(f, "LoadChainHashFromContext()"), Self::MineBlocks(v) => write!(f, "MineBlocks({v})"), + Self::ReorgChain(v) => write!(f, "ReorgChain({v})"), // Operations with inputs: parens added by Program::Display. Self::DerivePoint => write!(f, "DerivePoint"), Self::ExtractAcceptChannel(field) => write!(f, "Extract{field}"), @@ -752,6 +755,7 @@ impl Operation { | Self::SendChannelReady { .. } | Self::RecvChannelReady | Self::MineBlocks(_) + | Self::ReorgChain(_) | Self::BroadcastTransaction => None, Self::SendOpenChannel => Some(VariableType::SentOpenChannel), Self::SendFundingCreated => Some(VariableType::SentFundingCreated), @@ -782,7 +786,8 @@ impl Operation { | Self::LoadTargetPubkeyFromContext | Self::LoadChainHashFromContext | Self::RecvChannelReady - | Self::MineBlocks(_) => vec![], + | Self::MineBlocks(_) + | Self::ReorgChain(_) => vec![], Self::DerivePoint => vec![VariableType::PrivateKey], Self::ExtractAcceptChannel(_) => vec![VariableType::AcceptChannel], @@ -921,6 +926,7 @@ impl Operation { | Self::RecvFundingSigned | Self::RecvChannelReady | Self::MineBlocks(_) + | Self::ReorgChain(_) | Self::BroadcastTransaction | Self::LookupShortChannelId => vec![], @@ -945,6 +951,7 @@ impl Operation { | Self::RecvFundingSigned | Self::RecvChannelReady | Self::MineBlocks(_) + | Self::ReorgChain(_) | Self::CreateFundingTransaction | Self::BroadcastTransaction | Self::LookupShortChannelId => true, @@ -996,7 +1003,8 @@ impl Operation { | Self::ExtractAcceptChannel(_) | Self::BuildNodeAnnouncement { .. } | Self::SendChannelReady { .. } - | Self::MineBlocks(_) => true, + | Self::MineBlocks(_) + | Self::ReorgChain(_) => true, Self::LoadTargetPubkeyFromContext | Self::LoadChainHashFromContext diff --git a/smite-ir/src/tests.rs b/smite-ir/src/tests.rs index bb092617..02d8f39b 100644 --- a/smite-ir/src/tests.rs +++ b/smite-ir/src/tests.rs @@ -9,6 +9,7 @@ use super::*; use generators::{ AnyGenerator, ChannelAnnouncementGenerator, ChannelReadyGenerator, ChannelUpdateGenerator, FundingCreatedGenerator, FundingFlowGenerator, NodeAnnouncementGenerator, OpenChannelGenerator, + ReorgChainGenerator, }; use minimizers::{CommonSubexpressionEliminator, DeadCodeEliminator, Minimizer}; use mutators::{ @@ -670,16 +671,22 @@ fn mine_blocks_operation() { } #[test] -fn displays_mine_blocks_program() { +fn displays_mine_and_reorg_blocks_program() { let program = Program { - instructions: vec![Instruction { - operation: Operation::MineBlocks(6), - inputs: vec![], - }], + instructions: vec![ + Instruction { + operation: Operation::MineBlocks(6), + inputs: vec![], + }, + Instruction { + operation: Operation::ReorgChain(2), + inputs: vec![], + }, + ], }; let text = program.to_string(); let lines: Vec<&str> = text.lines().collect(); - assert_eq!(lines, vec!["MineBlocks(6)"]); + assert_eq!(lines, vec!["MineBlocks(6)", "ReorgChain(2)"]); } #[test] @@ -886,7 +893,8 @@ fn any_generator_all_is_complete() { | AnyGenerator::OpenChannel(_) | AnyGenerator::FundingCreated(_) | AnyGenerator::ChannelReady(_) - | AnyGenerator::FundingFlow(_) => 7, + | AnyGenerator::FundingFlow(_) + | AnyGenerator::ReorgChain(_) => 8, } }; assert_eq!(AnyGenerator::ALL.len(), variant_count(AnyGenerator::ALL[0])); @@ -1351,6 +1359,65 @@ fn generated_funding_flow_program_structure() { ); } +fn generate_reorg_chain_program(seed: u64) -> Program { + let mut rng = SmallRng::seed_from_u64(seed); + let mut builder = ProgramBuilder::new(); + ReorgChainGenerator.generate(&mut builder, &mut rng); + builder.build() +} + +// If ReorgChainGenerator completes without panicking, every instruction has +// correct input types (enforced by ProgramBuilder::append). +#[test] +fn generated_reorg_chain_program_is_type_correct() { + for seed in 0..100 { + generate_reorg_chain_program(seed); + } +} + +#[test] +fn generated_reorg_chain_program_structure() { + let program = generate_reorg_chain_program(0); + let ops: Vec<_> = program.instructions.iter().map(|i| &i.operation).collect(); + + // Must be MineBlocks followed by ReorgChain, and nothing else. + assert_eq!( + ops.len(), + 2, + "expected exactly two instructions, got {ops:?}" + ); + assert!( + matches!(ops[0], Operation::MineBlocks(_)), + "first instruction should be MineBlocks", + ); + assert!( + matches!(ops[1], Operation::ReorgChain(_)), + "last instruction should be ReorgChain", + ); +} + +// The reorg depth must stay shallow: deeper reorgs do not occur naturally on +// mainnet and would only cost execution time. +#[test] +fn generated_reorg_chain_program_respects_bounds() { + for seed in 0..100 { + let program = generate_reorg_chain_program(seed); + for instruction in &program.instructions { + match instruction.operation { + Operation::MineBlocks(blocks) => assert!( + (1..=16).contains(&blocks), + "seed {seed}: MineBlocks({blocks}) out of range", + ), + Operation::ReorgChain(depth) => assert!( + (1..=2).contains(&depth), + "seed {seed}: ReorgChain({depth}) out of range", + ), + ref op => panic!("seed {seed}: unexpected operation {op}"), + } + } + } +} + fn generate_channel_announcement_program(seed: u64) -> Program { let mut rng = SmallRng::seed_from_u64(seed); let mut builder = ProgramBuilder::new(); @@ -1498,6 +1565,14 @@ fn generated_funding_flow_program_postcard_roundtrip() { assert_eq!(program, decoded); } +#[test] +fn generated_reorg_chain_program_postcard_roundtrip() { + let program = generate_reorg_chain_program(42); + let bytes = postcard::to_allocvec(&program).expect("postcard serialization"); + let decoded: Program = postcard::from_bytes(&bytes).expect("postcard deserialization"); + assert_eq!(program, decoded); +} + #[test] fn generated_channel_announcement_program_postcard_roundtrip() { let program = generate_channel_announcement_program(42); @@ -1752,7 +1827,7 @@ fn param_mutator_changes_values() { fn param_mutator_changes_mined_num_blocks() { let original = Program { instructions: vec![Instruction { - operation: Operation::MineBlocks(42), + operation: Operation::MineBlocks(8), inputs: vec![], }], }; @@ -1779,6 +1854,37 @@ fn param_mutator_changes_mined_num_blocks() { ); } +#[test] +fn param_mutator_changes_reorg_num_blocks() { + let original = Program { + instructions: vec![Instruction { + operation: Operation::ReorgChain(2), + inputs: vec![], + }], + }; + let mut program = original.clone(); + let mutator = OperationParamMutator; + let mut rng = SmallRng::seed_from_u64(0); + + let mut diff_count = 0; + for _ in 0..100 { + mutator.mutate(&mut program, &mut rng); + // Make sure that ReorgChain contains a value within the clamped range of + // blocks to be reorged. + let Operation::ReorgChain(depth) = program.instructions[0].operation else { + panic!("OperationParamMutator changed the operation type"); + }; + assert!((1..=2).contains(&depth)); + if program != original { + diff_count += 1; + } + } + assert!( + diff_count > 35, + "OperationParamMutator has an unexpected bias" + ); +} + #[test] fn param_mutator_changes_short_channel_id() { let original = Program { diff --git a/smite-scenarios/src/executor.rs b/smite-scenarios/src/executor.rs index 1c01b145..507d8ab6 100644 --- a/smite-scenarios/src/executor.rs +++ b/smite-scenarios/src/executor.rs @@ -6,7 +6,7 @@ use bitcoin::secp256k1::ecdsa::Signature; use bitcoin::secp256k1::{PublicKey, Secp256k1, SecretKey}; use bitcoin::{OutPoint, ScriptBuf, Txid}; -use smite::bitcoin::{BitcoinCli, TxBlockPosition, Utxo}; +use smite::bitcoin::{BitcoinCli, ReorgedTxs, TxBlockPosition, Utxo}; use smite::bolt::{ AcceptChannel, AnnouncementSignatures, ChannelAnnouncement, ChannelId, ChannelReady, ChannelReadyTlvs, ChannelUpdate, FundingCreated, FundingSigned, Message, NodeAnnouncement, @@ -63,6 +63,11 @@ pub trait BitcoinRpc { /// `private_mempool` in the first block. fn mine_blocks(&mut self, num_blocks: u8, private_mempool: &[String]); + /// Disconnects the top `depth` blocks and mines `depth + 1` empty blocks in + /// their place. Returns what the reorg left unconfirmed. + #[must_use] + fn reorg_chain(&mut self, depth: u8) -> ReorgedTxs; + /// Returns the wallet's spendable UTXOs. #[must_use] fn get_utxos(&mut self) -> Vec; @@ -98,6 +103,10 @@ impl BitcoinRpc for BitcoinCli { BitcoinCli::mine_blocks(self, num_blocks, private_mempool); } + fn reorg_chain(&mut self, depth: u8) -> ReorgedTxs { + BitcoinCli::reorg_chain(self, depth) + } + fn get_utxos(&mut self) -> Vec { BitcoinCli::get_utxos(self) } @@ -517,6 +526,31 @@ impl Executor { None } + Operation::ReorgChain(depth) => { + let reorg = self.bitcoin_cli.reorg_chain(*depth); + + // The replacement blocks were mined empty, so every + // disconnected transaction is unconfirmed again. + for txid in &reorg.disconnected_txids { + if self.mined_txids.remove(txid) { + self.unmined_txids.insert(*txid); + } + } + + // Queue transactions rejected by the mempool for direct + // mining, dropping any stale copies and prioritizing them + // since they may be parents of queued transactions. + self.private_mempool + .retain(|(txid, _)| !reorg.rejected_txs.iter().any(|(t, _)| t == txid)); + self.private_mempool.splice(0..0, reorg.rejected_txs); + + log::debug!( + "[{:?}] ReorgChain: reorged {depth} block(s)", + start.elapsed() + ); + None + } + Operation::BroadcastTransaction => { let ft = resolve_funding_transaction(&variables, instr.inputs[0]); let txid = ft.tx.compute_txid(); @@ -1431,6 +1465,7 @@ mod tests { use std::str::FromStr; use super::*; + use bitcoin::consensus::encode::{deserialize_hex, serialize_hex}; use bitcoin::secp256k1::{Secp256k1, SecretKey}; use bitcoin::{Amount, Transaction}; use smite::bolt::{AcceptChannelTlvs, GossipTimestampFilter, Init, Ping}; @@ -1483,7 +1518,7 @@ mod tests { #[derive(Default)] struct MockBitcoinCli { mine_blocks_calls: Vec, - mined_private_mempool: Vec, + mined_private_txs: Vec<(Txid, String)>, broadcast_calls: Vec, block_position_lookups: Vec, utxos: Vec, @@ -1494,10 +1529,28 @@ mod tests { impl BitcoinRpc for MockBitcoinCli { fn mine_blocks(&mut self, num_blocks: u8, private_mempool: &[String]) { self.mine_blocks_calls.push(num_blocks); - self.mined_private_mempool = private_mempool.to_vec(); + self.mined_private_txs + .extend(private_mempool.iter().map(|hex| { + let tx: Transaction = deserialize_hex(hex).unwrap(); + (tx.compute_txid(), hex.clone()) + })); self.confirmations += u32::from(num_blocks); } + fn reorg_chain(&mut self, _depth: u8) -> ReorgedTxs { + let rejected_txs = std::mem::take(&mut self.mined_private_txs); + + // Add more tx that might also have been disconnected but were not + // created by us. + let mut disconnected_txids = vec![sample_utxo().outpoint.txid]; + disconnected_txids.extend(rejected_txs.iter().map(|(txid, _)| *txid)); + + ReorgedTxs { + disconnected_txids, + rejected_txs, + } + } + fn get_utxos(&mut self) -> Vec { self.utxos.clone() } @@ -1517,7 +1570,7 @@ mod tests { .iter() .any(|o| o.value < o.script_pubkey.minimal_non_dust()); if has_dust { - Some(bitcoin::consensus::encode::serialize_hex(tx)) + Some(serialize_hex(tx)) } else { None } @@ -3032,7 +3085,7 @@ mod tests { // Verify that mine_blocks was called with the correct number assert_eq!(executor.bitcoin_cli.mine_blocks_calls, vec![6]); - assert!(executor.bitcoin_cli.mined_private_mempool.is_empty()); + assert!(executor.bitcoin_cli.mined_private_txs.is_empty()); } #[test] @@ -3244,12 +3297,105 @@ mod tests { executor.bitcoin_cli.broadcast_calls[1].compute_txid(), ); - let rejected_hex = - bitcoin::consensus::encode::serialize_hex(&executor.bitcoin_cli.broadcast_calls[0]); + let rejected_txid = executor.bitcoin_cli.broadcast_calls[0].compute_txid(); + let rejected_hex = serialize_hex(&executor.bitcoin_cli.broadcast_calls[0]); assert!(executor.private_mempool.is_empty()); assert_eq!( - executor.bitcoin_cli.mined_private_mempool, - vec![rejected_hex] + executor.bitcoin_cli.mined_private_txs, + vec![(rejected_txid, rejected_hex)] + ); + } + + #[test] + fn execute_reorg_chain_unconfirms_and_requeues_disconnected_txs() { + // A second UTXO so the program can build a second funding transaction. + let second_utxo = Utxo { + outpoint: OutPoint { + vout: 1, + ..sample_utxo().outpoint + }, + ..sample_utxo() + }; + let mock_cli = MockBitcoinCli { + utxos: vec![sample_utxo(), second_utxo], + change_spk: sample_change_spk(), + ..Default::default() + }; + + // Use dust amounts so both funding transactions are rejected by mempool + // policy and must be mined from the private mempool. + let mut instrs = create_and_broadcast_tx_instructions(); + instrs[4] = Instruction { + operation: Operation::LoadAmount(200), + inputs: vec![], + }; + instrs.extend([ + // Confirm the first funding transaction. + Instruction { + operation: Operation::MineBlocks(1), + inputs: vec![], + }, + // Build and broadcast a second transaction that remains unconfirmed + // in the private mempool. + Instruction { + operation: Operation::LoadAmount(300), + inputs: vec![], + }, + Instruction { + operation: Operation::CreateFundingTransaction, + inputs: vec![1, 3, 9, 5], + }, + Instruction { + operation: Operation::BroadcastTransaction, + inputs: vec![10], + }, + // Re-broadcast the confirmed transaction; it is rejected again and + // queues behind the second transaction. + Instruction { + operation: Operation::BroadcastTransaction, + inputs: vec![6], + }, + Instruction { + operation: Operation::ReorgChain(1), + inputs: vec![], + }, + ]); + + let mut executor = Executor::new(MockConnection::new(), mock_cli, sample_context()); + executor + .execute( + &Program { + instructions: instrs, + }, + std::time::Instant::now(), + ) + .unwrap(); + + // The reorg disconnects the first transaction; the second was never mined. + let disconnected = executor.bitcoin_cli.broadcast_calls[0].compute_txid(); + let disconnected_hex = serialize_hex(&executor.bitcoin_cli.broadcast_calls[0]); + let waiting = executor.bitcoin_cli.broadcast_calls[1].compute_txid(); + let waiting_hex = serialize_hex(&executor.bitcoin_cli.broadcast_calls[1]); + + // The third broadcast is the re-broadcast of the disconnected transaction. + assert_eq!( + disconnected, + executor.bitcoin_cli.broadcast_calls[2].compute_txid() + ); + + // Both transactions are unconfirmed and queued for mining. The other + // disconnected tx are not tracked because they were not created by us. + assert!(executor.mined_txids.is_empty()); + assert_eq!( + executor.unmined_txids, + HashSet::from([disconnected, waiting]) + ); + + // The disconnected transaction is moved ahead of the waiting one, without + // leaving a stale duplicate in the queue. + assert_eq!( + executor.private_mempool, + vec![(disconnected, disconnected_hex), (waiting, waiting_hex)], ); } @@ -3937,7 +4083,7 @@ mod tests { std::time::Instant::now(), ) .unwrap(); - assert!(executor.bitcoin_cli.mined_private_mempool.is_empty()); + assert!(executor.bitcoin_cli.mined_private_txs.is_empty()); // The target's next per-commitment point is still unknown and the queued // `channel_ready` remains untouched. @@ -3974,7 +4120,7 @@ mod tests { std::time::Instant::now(), ) .unwrap(); - assert!(executor.bitcoin_cli.mined_private_mempool.is_empty()); + assert!(executor.bitcoin_cli.mined_private_txs.is_empty()); // The `channel_ready` was consumed and the target's next per-commitment // point is now recorded. diff --git a/smite/src/bitcoin.rs b/smite/src/bitcoin.rs index 250409aa..fb49e01f 100644 --- a/smite/src/bitcoin.rs +++ b/smite/src/bitcoin.rs @@ -6,8 +6,8 @@ use std::path::PathBuf; use std::process::Command; use std::str::FromStr; -use bitcoin::consensus::encode::serialize_hex; -use bitcoin::{Address, Amount, Network, OutPoint, ScriptBuf, Transaction, Txid}; +use bitcoin::consensus::encode::{deserialize_hex, serialize_hex}; +use bitcoin::{Address, Amount, Block, Network, OutPoint, ScriptBuf, Transaction, Txid}; use serde::{Deserialize, Serialize}; /// A spendable UTXO used as a transaction input. @@ -49,6 +49,24 @@ pub struct TxBlockPosition { pub tx_index: u32, } +/// The transactions [`BitcoinCli::reorg_chain`] left unconfirmed. +/// +/// Both fields list transactions in the order their blocks confirmed them, and +/// within a block in the order it held them, so a transaction always precedes +/// any transaction that spends it. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct ReorgedTxs { + /// Txids of every non-coinbase transaction in the disconnected blocks. All + /// of them are unconfirmed again, whether or not they made it back into + /// the mempool. + pub disconnected_txids: Vec, + /// The disconnected transactions the mempool refused to take back, as + /// `(txid, raw_hex)`. Typically transactions mined straight out of a + /// private mempool because they violate mempool policy, so nothing will + /// re-confirm them unless they are mined directly again. + pub rejected_txs: Vec<(Txid, String)>, +} + /// Parsed response from `getrawtransaction 1`. #[derive(Deserialize)] struct RawTransactionInfo { @@ -134,19 +152,31 @@ impl BitcoinCli { /// /// # Panics /// - /// - If `bitcoin-cli getrawmempool`, `getnewaddress`, or `generateblock` - /// fails to execute or exits non-zero. + /// - If `bitcoin-cli getrawmempool` or `getnewaddress` fails to execute or + /// exits non-zero. /// - If `getrawmempool` does not return valid JSON. /// - If `getnewaddress` does not return a valid regtest address. - /// - If any transaction in `private_mempool` is consensus-invalid. - /// - If the combined transaction list contains a duplicate rawtx/txid or is - /// not topologically ordered. + /// - For anything [`generate_block_to`](Self::generate_block_to) panics on, + /// the block's transactions. fn mine_block_including(&self, private_mempool: &[String]) { let mut txs = self.get_raw_mempool(); txs.extend_from_slice(private_mempool); + + self.generate_block_to(&self.get_new_address(), &txs); + } + + /// Mines a single block carrying exactly `txs`, in the given order, paying + /// newly generated bitcoin to `address`. + /// + /// # Panics + /// + /// - If `bitcoin-cli generateblock` fails to execute or exits non-zero. + /// - If any transaction in `txs` is consensus-invalid. + /// - If `txs` contains a duplicate rawtx/txid or is not topologically + /// ordered. + fn generate_block_to(&self, address: &Address, txs: &[String]) { let txs_json = serde_json::to_string(&txs).expect("tx list serializes to valid JSON"); - let address = self.get_new_address(); let gen_out = self .run() .arg("generateblock") @@ -181,6 +211,118 @@ impl BitcoinCli { serde_json::from_slice(&out.stdout).expect("getrawmempool should return valid JSON") } + /// Disconnects the top `depth` blocks with `invalidateblock` and mines + /// `depth + 1` empty blocks in their place, so the chain is only ever + /// rewritten by a branch that is longer than the one it replaces. + /// + /// The replacement blocks carry no transactions, so everything the + /// disconnected blocks confirmed is unconfirmed again. Bitcoin Core takes + /// back into the mempool the transactions that pass mempool policy; the + /// ones it rejects come back in [`ReorgedTxs::rejected_txs`] for the + /// caller to queue for direct mining. The coinbases are discarded by + /// consensus and are not returned. + /// + /// # Panics + /// + /// - If `bitcoin-cli getbestblockhash`, `getblock`, `invalidateblock`, or + /// `generateblock` fails to execute or exits non-zero. + /// - If `getbestblockhash` or `getblock` does not return valid UTF-8. + /// - If `getblock` does not return a deserializable block. + #[must_use] + pub fn reorg_chain(&self, depth: u8) -> ReorgedTxs { + // Get the tip block hash of the fully validated chain. + let tip_hash_out = self + .run() + .arg("getbestblockhash") + .output() + .expect("bitcoin-cli getbestblockhash should not fail"); + assert!( + tip_hash_out.status.success(), + "bitcoin-cli getbestblockhash failed: {}", + String::from_utf8_lossy(&tip_hash_out.stderr) + ); + + // Walk backwards from the tip to collect transactions from the blocks + // that will be disconnected. + let mut current_hash = String::from_utf8(tip_hash_out.stdout) + .expect("getbestblockhash should return valid UTF-8") + .trim() + .to_string(); + let mut base_block_hash = current_hash.clone(); + let mut disconnected_txs = Vec::new(); + + for _ in 0..depth { + let block = self.get_block(¤t_hash); + base_block_hash = current_hash; + current_hash = block.header.prev_blockhash.to_string(); + + // Prepend non-coinbase transactions to restore block order, while + // preserving transaction order within each block. + disconnected_txs.splice(0..0, block.txdata.into_iter().skip(1)); + } + + // Invalidate the lowest block to disconnect it and all blocks above it. + let invalidate_out = self + .run() + .arg("invalidateblock") + .arg(base_block_hash) + .output() + .expect("bitcoin-cli invalidateblock should not fail"); + assert!( + invalidate_out.status.success(), + "bitcoin-cli invalidateblock failed: {}", + String::from_utf8_lossy(&invalidate_out.stderr) + ); + + // Mine a new branch one block longer than the disconnected branch. Each + // block contains only its coinbase, leaving the mempool untouched. + let address = self.get_new_address(); + for _ in 0..=depth { + self.generate_block_to(&address, &[]); + } + + // Collect disconnected transactions that were not restored to the mempool. + let mempool = self.get_raw_mempool(); + let mut disconnected_txids = Vec::with_capacity(disconnected_txs.len()); + let mut rejected_txs = Vec::new(); + for tx in disconnected_txs { + let txid = tx.compute_txid(); + if !mempool.contains(&txid.to_string()) { + rejected_txs.push((txid, serialize_hex(&tx))); + } + disconnected_txids.push(txid); + } + + ReorgedTxs { + disconnected_txids, + rejected_txs, + } + } + + /// Returns the block with the given hash. + /// + /// # Panics + /// + /// - If `bitcoin-cli getblock` fails to execute or exits non-zero. + /// - If the output is not valid UTF-8 containing hex-encoded block data. + fn get_block(&self, blockhash: &str) -> Block { + let out = self + .run() + .arg("getblock") + .arg(blockhash) + .arg("0") // return the serialized, hex-encoded data for blockhash. + .output() + .expect("bitcoin-cli getblock should not fail"); + assert!( + out.status.success(), + "bitcoin-cli getblock failed: {}", + String::from_utf8_lossy(&out.stderr) + ); + + let block_hex = String::from_utf8(out.stdout).expect("getblock should return valid UTF-8"); + deserialize_hex(block_hex.trim()).expect("getblock should return a valid block") + } + /// Returns the wallet's spendable UTXOs, sorted deterministically. /// /// # Panics