From 75e0c5eee3e7b3a95710ec5ffa8a738baef3d53b Mon Sep 17 00:00:00 2001 From: Nishant Bansal Date: Wed, 26 Aug 2026 11:56:00 +0530 Subject: [PATCH 1/3] smite: add chain reorg to BitcoinCli Adds `reorg_chain(depth)`, which disconnects the top depth blocks with `invalidateblock` and mines depth + 1 empty blocks, causing a natural chain reorg. Since the replacement blocks are empty, disconnected transactions become unconfirmed, Bitcoin Core restores mempool-eligible transactions, while the rest are returned in `ReorgedTxs::rejected_txs`, along with all disconnected non-coinbase txids in block order. Rejected transactions must be queued for direct mining since they typically violate mempool policy. Signed-off-by: Nishant Bansal --- smite-scenarios/src/executor.rs | 50 +++++++--- smite/src/bitcoin.rs | 158 ++++++++++++++++++++++++++++++-- 2 files changed, 189 insertions(+), 19 deletions(-) diff --git a/smite-scenarios/src/executor.rs b/smite-scenarios/src/executor.rs index 1c01b145..cb4d1263 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) } @@ -1431,6 +1440,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 +1493,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 +1504,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 +1545,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 +3060,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 +3272,12 @@ 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)] ); } @@ -3937,7 +3965,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 +4002,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 From 72a5481bc841a2b54708e8d8b7946e85f195271e Mon Sep 17 00:00:00 2001 From: Nishant Bansal Date: Wed, 26 Aug 2026 12:05:26 +0530 Subject: [PATCH 2/3] smite-ir: add ReorgChain operation Adds a `ReorgChain` operation that disconnects the top blocks and mines empty replacement blocks, allowing scenarios to exercise transactions losing their confirmations. After the reorg, the executor restores disconnected transactions to the unmined state and places transactions rejected by Bitcoin Core at the front of the private mempool so they can be mined directly again. This is necessary for transactions that violate mempool policy and would otherwise remain unconfirmed. Supports reorgs of one or two blocks, covering the shallow reorgs a node is expected to handle naturally. Signed-off-by: Nishant Bansal --- smite-ir/src/mutators/operation_param.rs | 7 ++ smite-ir/src/operation.rs | 12 ++- smite-ir/src/tests.rs | 51 ++++++++-- smite-scenarios/src/executor.rs | 118 +++++++++++++++++++++++ 4 files changed, 179 insertions(+), 9 deletions(-) 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..290639ef 100644 --- a/smite-ir/src/tests.rs +++ b/smite-ir/src/tests.rs @@ -670,16 +670,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] @@ -1752,7 +1758,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 +1785,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 cb4d1263..507d8ab6 100644 --- a/smite-scenarios/src/executor.rs +++ b/smite-scenarios/src/executor.rs @@ -526,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(); @@ -3281,6 +3306,99 @@ mod tests { ); } + #[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)], + ); + } + #[test] fn execute_create_funding_transaction_insufficient_funds() { // UTXO too small to cover the funding amount and fees. From dc38077e8ec86067f17e405db3e21c51cb61dc64 Mon Sep 17 00:00:00 2001 From: Nishant Bansal Date: Wed, 26 Aug 2026 12:08:09 +0530 Subject: [PATCH 3/3] smite-ir: add ReorgChainGenerator Generates a shallow chain reorganization: mines blocks to confirm any broadcast transaction, then reorgs the chain out from under it, so generated programs exercise a funding transaction losing its confirmations. Depth is 1 or 2, the shallow reorgs that occur naturally on mainnet and that a node is expected to handle. Signed-off-by: Nishant Bansal --- smite-ir/src/generators.rs | 5 ++ smite-ir/src/generators/reorg_chain.rs | 26 ++++++++++ smite-ir/src/tests.rs | 71 +++++++++++++++++++++++++- 3 files changed, 101 insertions(+), 1 deletion(-) create mode 100644 smite-ir/src/generators/reorg_chain.rs 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/tests.rs b/smite-ir/src/tests.rs index 290639ef..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::{ @@ -892,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])); @@ -1357,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(); @@ -1504,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);