From 23af26645331d39046ae4c3d382dff85251a7d51 Mon Sep 17 00:00:00 2001 From: Bruno Garcia Date: Fri, 26 Jun 2026 17:17:20 -0300 Subject: [PATCH] ir: add BIP152 getblocktxn/getdata(cmpctblock) generators + probe feedback loop Add GetCompactBlockGenerator and GetBlockTxnGenerator so the IR can drive the pull side of compact block relay: request a compact block via getdata(MSG_CMPCT_BLOCK) for a block the node announced, and follow up with a getblocktxn for a cmpctblock the node sent us. Neither flow can be synthesized in a single program (a getblocktxn is only meaningful after the node itself sends a cmpctblock, which the IR cannot fabricate). Instead, extend the probe mechanism to record headers/inv and cmpctblock messages, decode them into BlockAnnouncement/ CompactBlockAnnouncement metadata, and let generators insert the matching follow-up request right after the instruction that triggered the announcement on a later mutation pass. IrScenario now always records getblocktxn/cmpctblock/headers/inv and always drains connections with recording enabled while probing; any block the node announces was built by the IR itself on another connection, so there is nothing to gate behind a separate mode. --- doc/src/design/ir.md | 16 ++ doc/src/design/scenarios.md | 26 +- fuzzamoto-cli/src/commands/ir.rs | 7 +- fuzzamoto-ir/src/compiler.rs | 90 ++++++- fuzzamoto-ir/src/generators/block_txn.rs | 26 +- fuzzamoto-ir/src/generators/get_block_txn.rs | 240 ++++++++++++++++++ .../src/generators/get_compact_block.rs | 157 ++++++++++++ fuzzamoto-ir/src/generators/mod.rs | 4 + fuzzamoto-ir/src/instruction.rs | 6 + fuzzamoto-ir/src/lib.rs | 36 +++ fuzzamoto-ir/src/metadata.rs | 24 +- fuzzamoto-ir/src/operation.rs | 43 +++- fuzzamoto-ir/src/variable.rs | 3 + fuzzamoto-libafl/src/instance.rs | 16 +- fuzzamoto-libafl/src/stages/probe.rs | 18 ++ fuzzamoto-scenarios/bin/ir.rs | 200 ++++++++++++++- 16 files changed, 883 insertions(+), 29 deletions(-) create mode 100644 fuzzamoto-ir/src/generators/get_block_txn.rs create mode 100644 fuzzamoto-ir/src/generators/get_compact_block.rs diff --git a/doc/src/design/ir.md b/doc/src/design/ir.md index a8bc86a1..b73971ee 100644 --- a/doc/src/design/ir.md +++ b/doc/src/design/ir.md @@ -212,6 +212,10 @@ containing the correctly serialized transactions `v15` and `v30`. | `BeginBuildBlockTxn` | Begins building a blocktxn message after sending a compact block. | | `AddTxToBlockTxn` | Adds a transaction to the blocktxn message. | | `EndBuildBlockTxn` | Finishes building a blocktxn message. | +| **Getblocktxn building** | **Construct a BIP152 getblocktxn request (compact block reconstruction side).**| +| `BeginBuildGetBlockTxn` | Begins building a getblocktxn request for a block the node announced via `cmpctblock`. | +| `AddIndexToGetBlockTxn` | Adds a block-level transaction index to the request. | +| `EndBuildGetBlockTxn` | Finishes building a getblocktxn request. | | **Filter building** | **Construct a BIP37 filter.** | | `BeginBuildFilterLoad` | Begins building a filter. | | `AddTxToFilter` | Adds a transaction to a filter. | @@ -257,6 +261,7 @@ containing the correctly serialized transactions `v15` and `v30`. | `SendGetCFCheckpt`| Sends a `getcfcheckpt` message. | | `SendCompactBlock` | Sends a `cmpctblock` message. | | `SendBlockTxn` | Sends a `blocktxn` message. | +| `SendGetBlockTxn` | Sends a `getblocktxn` message (requesting transactions for a compact block the node announced to us). | | **Other** | | | `Nop` | No operation. Used during minimization. | | `Probe` | Tells the scenario to probe state for the fuzzer (e.g. received messages, tip hash, ...). | @@ -294,6 +299,17 @@ fuzzing campaign. The following generators are available: block - `CompactBlockGenerator`: Generates instructions to build and send a compact block for an existing block, with a randomly chosen prefill transaction list +- `BlockTxnGenerator`: Generates instructions to build and send a `blocktxn` + message in response to a `getblocktxn` the node sent us (recorded by the probe) +- `GetCompactBlockGenerator`: Generates a `getdata(MSG_CMPCT_BLOCK)` to actively + fetch a compact block for a block the node announced via `headers`/`inv` + (BIP152 low bandwidth path) +- `GetBlockTxnGenerator`: Generates a `getblocktxn` request in response to a + `cmpctblock` the node announced to us (recorded by the probe), simulating the + compact block reconstruction side. Requested indexes are either *faithful* + (the transactions a peer with an empty mempool could not reconstruct, i.e. the + non-prefilled positions) or *adversarial* (an arbitrary, possibly out-of-range + set to exercise the node's validation and `blocktxn` response path) - `OneParentOneChildGenerator`: Generates instructions for building two new transactions (a 1-parent 1-child package) and sending them to a node - ... see diff --git a/doc/src/design/scenarios.md b/doc/src/design/scenarios.md index 69fa0505..c37dfb5e 100644 --- a/doc/src/design/scenarios.md +++ b/doc/src/design/scenarios.md @@ -33,4 +33,28 @@ crate. For example: * [`IrScenario`](https://github.com/dergoegge/fuzzamoto/tree/master/fuzzamoto-scenarios/bin/ir.rs): generic scenario for testing Bitcoin full nodes through the p2p interface. Primarily meant to be fuzzed using `fuzzamoto-libafl` (custom fuzzer for - [Fuzzamoto IR](./ir.md)). + [Fuzzamoto IR](./ir.md)). Built as `scenario-ir`. In addition to `getblocktxn` + requests, it records compact block messages the node emits (`cmpctblock`, + plus `headers`/`inv` block announcements) and drains each connection with + recording enabled at the end of a run so that unsolicited high-bandwidth + `cmpctblock` announcements are observed. This feeds the `GetBlockTxnGenerator` + and `GetCompactBlockGenerator`, which simulate the BIP152 compact block + reconstruction side. + +### Probe-driven feedback loop + +Some p2p flows can only be exercised in response to a message the node sends +*us*. For example, a `getblocktxn` is only meaningful after the node announces a +`cmpctblock`, and that announcement is itself triggered by a new block arriving +on a *different* connection — a single source cannot send a `cmpctblock` and a +`getblocktxn` about it. + +To handle this, `IrScenario` supports a `Probe` operation. When present, the +scenario records selected messages received from the node during execution, +decodes them, and reports them back to the fuzzer (the +[probing stage](https://github.com/oss-garage/fuzzamoto/tree/master/fuzzamoto-libafl/src/stages/probe.rs) +prepends `Probe` to a testcase and runs it once). The decoded observations +(e.g. "the node sent a `getblocktxn`/`cmpctblock`/block announcement, triggered +by instruction N, on connection C") are stored as per-testcase metadata. +Generators then use that metadata to insert the appropriate response right after +the instruction that triggered it. diff --git a/fuzzamoto-cli/src/commands/ir.rs b/fuzzamoto-cli/src/commands/ir.rs index 97937665..8f495a5f 100644 --- a/fuzzamoto-cli/src/commands/ir.rs +++ b/fuzzamoto-cli/src/commands/ir.rs @@ -5,8 +5,9 @@ use fuzzamoto_ir::compiler::Compiler; use fuzzamoto_ir::{ AddTxToBlockGenerator, AddrRelayGenerator, AddrRelayV2Generator, AdvanceTimeGenerator, BlockGenerator, BloomFilterAddGenerator, BloomFilterClearGenerator, BloomFilterLoadGenerator, - CompactFilterQueryGenerator, FullProgramContext, Generator, GetAddrGenerator, GetDataGenerator, - HeaderGenerator, InstructionContext, InventoryGenerator, LargeTxGenerator, LongChainGenerator, + CompactFilterQueryGenerator, FullProgramContext, Generator, GetAddrGenerator, + GetBlockTxnGenerator, GetCompactBlockGenerator, GetDataGenerator, HeaderGenerator, + InstructionContext, InventoryGenerator, LargeTxGenerator, LongChainGenerator, OneParentOneChildGenerator, Program, ProgramBuilder, SendBlockGenerator, SendMessageGenerator, SingleTxGenerator, TxoGenerator, WitnessGenerator, }; @@ -216,6 +217,8 @@ fn all_generators(context: &FullProgramContext) -> Vec { + self.handle_bip152_getblocktxn_operations(instruction)?; + } + Operation::AddConnection | Operation::AddConnectionWithHandshake { .. } => { self.handle_new_connection_operations(instruction)?; } @@ -498,7 +504,8 @@ impl Compiler { | Operation::SendFilterAdd | Operation::SendFilterClear | Operation::SendCompactBlock - | Operation::SendBlockTxn => { + | Operation::SendBlockTxn + | Operation::SendGetBlockTxn => { self.handle_message_sending_operations(instruction)?; } @@ -1385,6 +1392,17 @@ impl Compiler { .clone(); self.emit_send_message(*connection_var, "blocktxn", &blocktxn); } + Operation::SendGetBlockTxn => { + let connection_var = *self.get_input::(&instruction.inputs, 0)?; + let request = self + .get_input::(&instruction.inputs, 1)? + .clone(); + let sanitized = bitcoin::bip152::BlockTransactionsRequest { + block_hash: request.block_hash, + indexes: sanitize_getblocktxn_indexes(request.indexes), + }; + self.emit_send_message(connection_var, "getblocktxn", &sanitized); + } Operation::SendRawMessage => { let connection_var = self.get_input::(&instruction.inputs, 0)?; let message_type_var = self.get_input::<[char; 12]>(&instruction.inputs, 1)?; @@ -1592,6 +1610,40 @@ impl Compiler { Ok(()) } + fn handle_bip152_getblocktxn_operations( + &mut self, + instruction: &Instruction, + ) -> Result<(), CompilerError> { + match &instruction.operation { + Operation::BeginBuildGetBlockTxn => { + let block = self.get_input::(&instruction.inputs, 0)?; + let request = bitcoin::bip152::BlockTransactionsRequest { + block_hash: block.block_hash(), + indexes: Vec::new(), + }; + self.append_variable(request); + } + Operation::AddIndexToGetBlockTxn => { + let index = *self.get_input::(&instruction.inputs, 1)?; + let request = self.get_input_mut::( + &instruction.inputs, + 0, + )?; + request.indexes.push(index as u64); + } + Operation::EndBuildGetBlockTxn => { + let request = self + .get_input::(&instruction.inputs, 0)? + .clone(); + self.append_variable(request); + } + _ => unreachable!( + "Non-getblocktxn operation passed to handle_bip152_getblocktxn_operations" + ), + } + Ok(()) + } + fn handle_load_operations(&mut self, instruction: &Instruction) { match &instruction.operation { Operation::Nop { @@ -2327,6 +2379,19 @@ impl Compiler { } } +/// Sanitize the index list of a `getblocktxn` request before encoding. +/// +/// `BlockTransactionsRequest` is differentially `VarInt` encoded, which panics on non-increasing +/// indexes or on `u64::MAX`. The IR (and its mutators) may produce arbitrary index values, so we +/// drop `u64::MAX`, sort and dedup to guarantee a panic-free, well-formed encoding. The semantic +/// fuzzing value (e.g. out-of-range indexes past the end of the block) is preserved. +fn sanitize_getblocktxn_indexes(mut indexes: Vec) -> Vec { + indexes.retain(|&i| i != u64::MAX); + indexes.sort_unstable(); + indexes.dedup(); + indexes +} + #[cfg(test)] mod tests { use super::*; @@ -2337,6 +2402,29 @@ mod tests { Transaction, consensus::Decodable, opcodes::all::OP_PUSHNUM_1, taproot::LeafVersion, }; + #[test] + fn sanitize_getblocktxn_indexes_yields_encodable_request() { + use bitcoin::bip152::BlockTransactionsRequest; + use bitcoin::hashes::Hash; + + // Hostile index list: out of order, duplicates, and u64::MAX (which would otherwise panic + // the differential VarInt encoder). + let raw = vec![5u64, 1, 1, 0, 9, u64::MAX, 3, 9]; + let sanitized = sanitize_getblocktxn_indexes(raw); + assert_eq!(sanitized, vec![0, 1, 3, 5, 9]); + + let request = BlockTransactionsRequest { + block_hash: bitcoin::BlockHash::all_zeros(), + indexes: sanitized.clone(), + }; + + // Must encode without panicking and decode back to the same indexes. + let bytes = bitcoin::consensus::encode::serialize(&request); + let decoded = BlockTransactionsRequest::consensus_decode(&mut bytes.as_slice()) + .expect("sanitized getblocktxn request should decode"); + assert_eq!(decoded.indexes, sanitized); + } + #[test] fn compile_send_getaddr_emits_getaddr_message() { let context = ProgramContext { diff --git a/fuzzamoto-ir/src/generators/block_txn.rs b/fuzzamoto-ir/src/generators/block_txn.rs index b5516bce..954b8505 100644 --- a/fuzzamoto-ir/src/generators/block_txn.rs +++ b/fuzzamoto-ir/src/generators/block_txn.rs @@ -138,17 +138,23 @@ impl Generator for BlockTxnGenerator { rng: &mut R, meta: Option<&PerTestcaseMetadata>, ) -> Option { - if let Some(meta) = meta - && !meta.block_txn_request().is_empty() - { - let blocktxn_req = meta.block_txn_request(); - let choice = rng.gen_range(0..blocktxn_req.len()); - let insertion_point = blocktxn_req[choice].triggering_instruction_index + 1; - Some(insertion_point) - } else { - program - .get_random_instruction_index(rng, &>::requested_context(self)) + if let Some(meta) = meta { + // A `triggering_instruction_index` recorded against a different (e.g. since + // minimized/replaced) version of this corpus entry's program can point past its + // current end; `index <= program.instructions.len()` is exactly what makes the + // `instructions[..index]` slice in `IrGenerator::mutate` safe. + let valid: Vec = meta + .block_txn_request() + .iter() + .map(|r| r.triggering_instruction_index + 1) + .filter(|&index| index <= program.instructions.len()) + .collect(); + if !valid.is_empty() { + return Some(valid[rng.gen_range(0..valid.len())]); + } } + + program.get_random_instruction_index(rng, &>::requested_context(self)) } } diff --git a/fuzzamoto-ir/src/generators/get_block_txn.rs b/fuzzamoto-ir/src/generators/get_block_txn.rs new file mode 100644 index 00000000..9965850c --- /dev/null +++ b/fuzzamoto-ir/src/generators/get_block_txn.rs @@ -0,0 +1,240 @@ +use crate::{ + Generator, GeneratorError, GeneratorResult, Instruction, Operation, PerTestcaseMetadata, + ProgramBuilder, Variable, +}; +use rand::{Rng, RngCore}; + +/// Upper bound on the number of indexes a single `getblocktxn` request asks for. +/// +/// Each requested index becomes a transaction in the node's `blocktxn` reply, so an unbounded +/// request against a large block yields a large response that the harness then blocks reading (the +/// connection transport has no read timeout, so a large round-trip can trip the fuzzer's hang +/// detector). Capping the count keeps the round-trip cheap while preserving the semantic value of +/// the request; the cap is generous relative to typical regtest blocks, so most faithful requests +/// are unaffected. +const MAX_REQUESTED_INDEXES: usize = 64; + +/// `GetBlockTxnGenerator` inserts a `getblocktxn` message, simulating the compact block +/// reconstruction side of BIP152. +/// +/// When the node under test announced a `cmpctblock` to us (recorded as a +/// [`crate::CompactBlockAnnouncement`] by the probe), this generator responds with a `getblocktxn` +/// requesting the transactions we are "missing". Two index selection strategies are used: +/// +/// * **faithful**: request exactly the block positions the node did *not* prefill (i.e. the +/// transactions a real peer with an empty mempool could not reconstruct). +/// * **adversarial**: request an arbitrary set of indexes, possibly out of range, to exercise the +/// node's `getblocktxn` validation and `blocktxn` response path. +/// +/// Without recorded announcements, it falls back to building a `getblocktxn` against a random +/// block (lower value, but keeps the generator productive before probe data exists). +#[derive(Debug, Copy, Clone, Default)] +pub struct GetBlockTxnGenerator; + +impl GetBlockTxnGenerator { + #[must_use] + pub fn new() -> Self { + Self {} + } + + /// Emit `BeginBuildGetBlockTxn` / `AddIndexToGetBlockTxn` / `EndBuildGetBlockTxn` / + /// `SendGetBlockTxn` for the given block, connection and (block-level) indexes. + fn build_and_send( + builder: &mut ProgramBuilder, + connection_var: usize, + block_var: usize, + indexes: &[usize], + ) { + let request = builder + .append(Instruction { + inputs: vec![block_var], + operation: Operation::BeginBuildGetBlockTxn, + }) + .expect("Inserting BeginBuildGetBlockTxn should always succeed") + .pop() + .expect("BeginBuildGetBlockTxn should always produce a var"); + + for &index in indexes { + let size_var = builder.force_append_expect_output(vec![], &Operation::LoadSize(index)); + builder + .append(Instruction { + inputs: vec![request.index, size_var.index], + operation: Operation::AddIndexToGetBlockTxn, + }) + .expect("Inserting AddIndexToGetBlockTxn should always succeed"); + } + + let const_request = builder + .append(Instruction { + inputs: vec![request.index], + operation: Operation::EndBuildGetBlockTxn, + }) + .expect("Inserting EndBuildGetBlockTxn should always succeed") + .pop() + .expect("EndBuildGetBlockTxn should always produce a var"); + + builder + .append(Instruction { + inputs: vec![connection_var, const_request.index], + operation: Operation::SendGetBlockTxn, + }) + .expect("Inserting SendGetBlockTxn should always succeed"); + } + + /// Whether `connection_index`/`block_variable` still refer to a connection/block that exists + /// and is in scope in `builder`. + /// + /// Recorded announcements are cached per corpus entry and can go stale relative to the program + /// currently being built — e.g. a minimizer stage can shrink/renumber a corpus entry's IR + /// in-place under the same id after it was probed, orphaning any indices recorded before that. + /// Blindly trusting them would make `build_and_send` panic instead of the generator falling + /// back gracefully. + fn announcement_is_valid( + builder: &ProgramBuilder, + connection_index: usize, + block_variable: usize, + ) -> bool { + matches!(builder.get_variable(connection_index), Some(v) if v.var == Variable::Connection) + && matches!(builder.get_variable(block_variable), Some(v) if v.var == Variable::Block) + } + + /// Pick the block-level indexes to request for a block with `num_block_txs` transactions. + /// `prefilled` is the set of positions the node already prefilled (0 = coinbase). + fn choose_indexes( + rng: &mut R, + num_block_txs: usize, + prefilled: &[usize], + ) -> Vec { + // Faithful reconstruction half the time: request exactly the non-prefilled positions. + let faithful = rng.gen_bool(0.5); + if faithful { + let mut missing: Vec = (0..num_block_txs) + .filter(|i| !prefilled.contains(i)) + .collect(); + if !missing.is_empty() { + // Bound the request size: each requested index is a transaction in the node's + // `blocktxn` reply, so an unbounded faithful request against a large block produces + // a large response. Keep a random, ascending subset via a partial Fisher–Yates + // shuffle. + if missing.len() > MAX_REQUESTED_INDEXES { + for i in 0..MAX_REQUESTED_INDEXES { + let j = rng.gen_range(i..missing.len()); + missing.swap(i, j); + } + missing.truncate(MAX_REQUESTED_INDEXES); + missing.sort_unstable(); + } + return missing; + } + // Everything was prefilled; fall through to the adversarial path so we still produce a + // request worth sending. + } + + // Adversarial: request a random, sorted, distinct set of indexes that may reach past the + // end of the block to exercise out-of-range handling. The index *range* still extends past + // the block end, but the *count* is capped so the node's `blocktxn` reply stays small. + // Bounded well below u64::MAX (the compiler additionally sanitizes), keeping the + // differential encoding panic-free. + let upper = num_block_txs.saturating_add(4).max(1); + let count = rng.gen_range(1..=upper.min(MAX_REQUESTED_INDEXES)); + let mut indexes: Vec = (0..count).map(|_| rng.gen_range(0..upper)).collect(); + indexes.sort_unstable(); + indexes.dedup(); + indexes + } +} + +impl Generator for GetBlockTxnGenerator { + fn generate( + &self, + builder: &mut ProgramBuilder, + rng: &mut R, + meta: Option<&PerTestcaseMetadata>, + ) -> GeneratorResult { + // Metadata-driven path: respond to a `cmpctblock` the node actually announced to us. + if let Some(meta) = meta + && !meta.compact_block_announcements().is_empty() + { + let insertion_point = builder.instructions.len(); + let announcements = meta.compact_block_announcements(); + // Several announcements can share a triggering instruction — in particular, every + // announcement captured by the end-of-run `recording_drain` is pinned to the last + // action. Pick uniformly among *all* that match this insertion point instead of always + // taking the first, otherwise only one announcement per trigger is ever reachable and + // `choose_index`'s random selection is wasted. + let matching: Vec = announcements + .iter() + .enumerate() + .filter(|(_, a)| a.triggering_instruction_index + 1 == insertion_point) + .filter(|(_, a)| { + Self::announcement_is_valid(builder, a.connection_index, a.block_variable) + }) + .map(|(i, _)| i) + .collect(); + if !matching.is_empty() { + let announcement = &announcements[matching[rng.gen_range(0..matching.len())]]; + let indexes = Self::choose_indexes( + rng, + announcement.num_block_txs, + &announcement.prefilled_indexes, + ); + Self::build_and_send( + builder, + announcement.connection_index, + announcement.block_variable, + &indexes, + ); + return Ok(()); + } + } + + // Fallback path: build a `getblocktxn` against a random block. + let connection_var = builder.get_or_create_random_connection(rng); + + let Some(block) = builder.get_random_variable(rng, &Variable::Block) else { + return Err(GeneratorError::MissingVariables); + }; + + let Some(tx_var_indices) = builder.get_block_vars(block.index) else { + return Err(GeneratorError::MissingVariables); + }; + + // Block tx count = coinbase (position 0) + non-coinbase txs. + let num_block_txs = tx_var_indices.len() + 1; + // No prefill information available here; treat the coinbase as prefilled. + let indexes = Self::choose_indexes(rng, num_block_txs, &[0]); + + Self::build_and_send(builder, connection_var.index, block.index, &indexes); + + Ok(()) + } + + fn name(&self) -> &'static str { + "GetBlockTxnGenerator" + } + + fn choose_index( + &self, + program: &crate::Program, + rng: &mut R, + meta: Option<&PerTestcaseMetadata>, + ) -> Option { + if let Some(meta) = meta { + // A `triggering_instruction_index` recorded against a different (e.g. since + // minimized/replaced) version of this corpus entry's program can point past its + // current end; `index <= program.instructions.len()` is exactly what makes the + // `instructions[..index]` slice in `IrGenerator::mutate` safe. + let valid: Vec = meta + .compact_block_announcements() + .iter() + .map(|a| a.triggering_instruction_index + 1) + .filter(|&index| index <= program.instructions.len()) + .collect(); + if !valid.is_empty() { + return Some(valid[rng.gen_range(0..valid.len())]); + } + } + + program.get_random_instruction_index(rng, &>::requested_context(self)) + } +} diff --git a/fuzzamoto-ir/src/generators/get_compact_block.rs b/fuzzamoto-ir/src/generators/get_compact_block.rs new file mode 100644 index 00000000..50e3852c --- /dev/null +++ b/fuzzamoto-ir/src/generators/get_compact_block.rs @@ -0,0 +1,157 @@ +use crate::{ + Generator, GeneratorError, GeneratorResult, Instruction, Operation, PerTestcaseMetadata, + ProgramBuilder, Variable, +}; +use rand::{Rng, RngCore}; + +/// `GetCompactBlockGenerator` actively fetches a compact block via `getdata(MSG_CMPCT_BLOCK)`, +/// driving the BIP152 low bandwidth path. +/// +/// When the node under test announces a block to us via `headers`/`inv` (recorded as a +/// [`crate::BlockAnnouncement`] by the probe), this generator requests the compact block for it on +/// the announcing connection. The node then replies with a `cmpctblock`, which the probe captures +/// as a [`crate::CompactBlockAnnouncement`], enabling [`super::GetBlockTxnGenerator`] to respond +/// with a `getblocktxn`. +/// +/// Without recorded announcements it falls back to requesting a compact block for a random block. +#[derive(Debug, Copy, Clone, Default)] +pub struct GetCompactBlockGenerator; + +impl GetCompactBlockGenerator { + #[must_use] + pub fn new() -> Self { + Self {} + } + + /// Emit an inventory containing a single `MSG_CMPCT_BLOCK` entry for `block_var` and send it as + /// a `getdata` on `connection_var`. + fn build_and_send(builder: &mut ProgramBuilder, connection_var: usize, block_var: usize) { + let inventory = builder + .append(Instruction { + inputs: vec![], + operation: Operation::BeginBuildInventory, + }) + .expect("Inserting BeginBuildInventory should always succeed") + .pop() + .expect("BeginBuildInventory should always produce a var"); + + builder + .append(Instruction { + inputs: vec![inventory.index, block_var], + operation: Operation::AddCompactBlockInv, + }) + .expect("Inserting AddCompactBlockInv should always succeed"); + + let const_inventory = builder + .append(Instruction { + inputs: vec![inventory.index], + operation: Operation::EndBuildInventory, + }) + .expect("Inserting EndBuildInventory should always succeed") + .pop() + .expect("EndBuildInventory should always produce a var"); + + builder + .append(Instruction { + inputs: vec![connection_var, const_inventory.index], + operation: Operation::SendGetData, + }) + .expect("Inserting SendGetData should always succeed"); + } + + /// Whether `connection_index`/`block_variable` still refer to a connection/block that exists + /// and is in scope in `builder`. + /// + /// Recorded announcements are cached per corpus entry and can go stale relative to the program + /// currently being built — e.g. a minimizer stage can shrink/renumber a corpus entry's IR + /// in-place under the same id after it was probed, orphaning any indices recorded before that. + /// Blindly trusting them would make `build_and_send` panic instead of the generator falling + /// back gracefully. + fn announcement_is_valid( + builder: &ProgramBuilder, + connection_index: usize, + block_variable: usize, + ) -> bool { + matches!(builder.get_variable(connection_index), Some(v) if v.var == Variable::Connection) + && matches!(builder.get_variable(block_variable), Some(v) if v.var == Variable::Block) + } +} + +impl Generator for GetCompactBlockGenerator { + fn generate( + &self, + builder: &mut ProgramBuilder, + rng: &mut R, + meta: Option<&PerTestcaseMetadata>, + ) -> GeneratorResult { + // Metadata-driven path: fetch the compact block for a block the node announced to us. + if let Some(meta) = meta + && !meta.block_announcements().is_empty() + { + let insertion_point = builder.instructions.len(); + let announcements = meta.block_announcements(); + // Several announcements can share a triggering instruction — in particular, every + // announcement captured by the end-of-run `recording_drain` is pinned to the last + // action. Pick uniformly among *all* that match this insertion point instead of always + // taking the first, otherwise only one announcement per trigger is ever reachable and + // `choose_index`'s random selection is wasted. + let matching: Vec = announcements + .iter() + .enumerate() + .filter(|(_, a)| a.triggering_instruction_index + 1 == insertion_point) + .filter(|(_, a)| { + Self::announcement_is_valid(builder, a.connection_index, a.block_variable) + }) + .map(|(i, _)| i) + .collect(); + if !matching.is_empty() { + let announcement = &announcements[matching[rng.gen_range(0..matching.len())]]; + Self::build_and_send( + builder, + announcement.connection_index, + announcement.block_variable, + ); + return Ok(()); + } + } + + // Fallback path: request a compact block for a random block. + let connection_var = builder.get_or_create_random_connection(rng); + let Some(block) = builder.get_random_variable(rng, &Variable::Block) else { + return Err(GeneratorError::MissingVariables); + }; + + Self::build_and_send(builder, connection_var.index, block.index); + + Ok(()) + } + + fn name(&self) -> &'static str { + "GetCompactBlockGenerator" + } + + fn choose_index( + &self, + program: &crate::Program, + rng: &mut R, + meta: Option<&PerTestcaseMetadata>, + ) -> Option { + if let Some(meta) = meta { + // A `triggering_instruction_index` recorded against a different (e.g. since + // minimized/replaced) version of this corpus entry's program can point past its + // current end; `index <= program.instructions.len()` is exactly what makes the + // `instructions[..index]` slice in `IrGenerator::mutate` safe. + let valid: Vec = meta + .block_announcements() + .iter() + .map(|a| a.triggering_instruction_index + 1) + .filter(|&index| index <= program.instructions.len()) + .collect(); + if !valid.is_empty() { + return Some(valid[rng.gen_range(0..valid.len())]); + } + } + + program.get_random_instruction_index(rng, &>::requested_context(self)) + } +} diff --git a/fuzzamoto-ir/src/generators/mod.rs b/fuzzamoto-ir/src/generators/mod.rs index b57f6e57..b16ada5a 100644 --- a/fuzzamoto-ir/src/generators/mod.rs +++ b/fuzzamoto-ir/src/generators/mod.rs @@ -6,6 +6,8 @@ pub mod block_txn; pub mod bloom_filter; pub mod compact_block; pub mod compact_filters; +pub mod get_block_txn; +pub mod get_compact_block; pub mod getaddr; pub mod getdata; pub mod send_raw_message; @@ -21,6 +23,8 @@ pub use block_txn::*; pub use bloom_filter::*; pub use compact_block::*; pub use compact_filters::*; +pub use get_block_txn::*; +pub use get_compact_block::*; pub use getaddr::*; pub use getdata::*; pub use send_raw_message::*; diff --git a/fuzzamoto-ir/src/instruction.rs b/fuzzamoto-ir/src/instruction.rs index e99d7f54..94690c79 100644 --- a/fuzzamoto-ir/src/instruction.rs +++ b/fuzzamoto-ir/src/instruction.rs @@ -144,6 +144,8 @@ impl Instruction { | Operation::BuildCoinbaseTxInput | Operation::AddCoinbaseTxOutput | Operation::AddTxToBlockTxn + | Operation::AddIndexToGetBlockTxn + | Operation::SendGetBlockTxn | Operation::SendGetData | Operation::SendGetAddr | Operation::SendInv @@ -190,6 +192,8 @@ impl Instruction { | Operation::EndBuildCoinbaseTxOutputs | Operation::BeginBuildBlockTxn | Operation::EndBuildBlockTxn + | Operation::BeginBuildGetBlockTxn + | Operation::EndBuildGetBlockTxn | Operation::Probe => false, } } @@ -211,6 +215,7 @@ impl Instruction { Operation::BeginBuildFilterLoad => Some(InstructionContext::BuildFilter), Operation::BeginBuildCoinbaseTx => Some(InstructionContext::BuildCoinbaseTx), Operation::BeginBuildBlockTxn => Some(InstructionContext::BuildBlockTxn), + Operation::BeginBuildGetBlockTxn => Some(InstructionContext::BuildGetBlockTxn), Operation::BeginBuildCoinbaseTxOutputs => { Some(InstructionContext::BuildCoinbaseTxOutputs) } @@ -248,5 +253,6 @@ pub enum InstructionContext { BuildCoinbaseTx, BuildCoinbaseTxOutputs, BuildBlockTxn, + BuildGetBlockTxn, BuildPrefill, } diff --git a/fuzzamoto-ir/src/lib.rs b/fuzzamoto-ir/src/lib.rs index 0a8c5b8d..91d36ceb 100644 --- a/fuzzamoto-ir/src/lib.rs +++ b/fuzzamoto-ir/src/lib.rs @@ -329,11 +329,47 @@ pub struct GetBlockTxn { pub tx_indices_variables: Vec, } +/// A `cmpctblock` message the node under test announced to us (BIP152). Captured so that a +/// generator can simulate the compact block reconstruction side and respond with a `getblocktxn`. +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct CompactBlockAnnouncement { + /// Variable index of the connection the `cmpctblock` was received on + pub connection_index: usize, + /// Index of the instruction that triggered the node to send the `cmpctblock` + pub triggering_instruction_index: usize, + /// Variable index of the block the compact block refers to + pub block_variable: usize, + /// Total number of transactions in the block (prefilled + short ids) + pub num_block_txs: usize, + /// Block-level positions (0 = coinbase) that the node prefilled in the `cmpctblock`. A faithful + /// reconstruction is missing exactly the positions that are *not* in this list. + pub prefilled_indexes: Vec, +} + +/// A block announcement (`headers` or `inv`) the node under test sent us. Captured so that a +/// generator can actively fetch the compact block via `getdata(MSG_CMPCT_BLOCK)` (BIP152 low +/// bandwidth mode). +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct BlockAnnouncement { + /// Variable index of the connection the announcement was received on + pub connection_index: usize, + /// Index of the instruction that triggered the node to announce the block + pub triggering_instruction_index: usize, + /// Variable index of the announced block + pub block_variable: usize, +} + #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] pub enum ProbeResult { GetBlockTxn { get_block_txn: GetBlockTxn, }, + CompactBlock { + announcement: CompactBlockAnnouncement, + }, + BlockAnnounced { + announcement: BlockAnnouncement, + }, Failure { /// The command that failed to be decoded command: String, diff --git a/fuzzamoto-ir/src/metadata.rs b/fuzzamoto-ir/src/metadata.rs index b7a64717..04ccff74 100644 --- a/fuzzamoto-ir/src/metadata.rs +++ b/fuzzamoto-ir/src/metadata.rs @@ -1,12 +1,14 @@ use serde::{Deserialize, Serialize}; -use crate::{GetBlockTxn, RecentBlock}; +use crate::{BlockAnnouncement, CompactBlockAnnouncement, GetBlockTxn, RecentBlock}; /// The runtime data observed during the course of harness execution #[derive(Default, Debug, Clone, Serialize, Deserialize)] pub struct PerTestcaseMetadata { pub block_txn_request: Vec, pub recent_blocks: Vec, + pub compact_block_announcements: Vec, + pub block_announcements: Vec, } impl PerTestcaseMetadata { @@ -15,6 +17,8 @@ impl PerTestcaseMetadata { Self { block_txn_request: Vec::new(), recent_blocks: Vec::new(), + compact_block_announcements: Vec::new(), + block_announcements: Vec::new(), } } @@ -28,10 +32,28 @@ impl PerTestcaseMetadata { &self.recent_blocks } + #[must_use] + pub fn compact_block_announcements(&self) -> &[CompactBlockAnnouncement] { + &self.compact_block_announcements + } + + #[must_use] + pub fn block_announcements(&self) -> &[BlockAnnouncement] { + &self.block_announcements + } + pub fn add_block_tx_request(&mut self, req: GetBlockTxn) { self.block_txn_request.push(req); } + pub fn add_compact_block_announcement(&mut self, announcement: CompactBlockAnnouncement) { + self.compact_block_announcements.push(announcement); + } + + pub fn add_block_announcement(&mut self, announcement: BlockAnnouncement) { + self.block_announcements.push(announcement); + } + pub fn add_recent_blocks(&mut self, blocks: Vec) { self.recent_blocks = blocks; self.recent_blocks.sort(); diff --git a/fuzzamoto-ir/src/operation.rs b/fuzzamoto-ir/src/operation.rs index 54160992..67ffb9c2 100644 --- a/fuzzamoto-ir/src/operation.rs +++ b/fuzzamoto-ir/src/operation.rs @@ -89,6 +89,13 @@ pub enum Operation { AddTxToBlockTxn, EndBuildBlockTxn, + /// `getblocktxn` request building operations (BIP152). Used to ask the node + /// under test for specific transactions of a block it announced to us via a + /// `cmpctblock` message (simulating the compact block reconstruction side). + BeginBuildGetBlockTxn, + AddIndexToGetBlockTxn, + EndBuildGetBlockTxn, + /// Send a message given a connection, message type and bytes SendRawMessage, /// Advance a time variable by a given duration @@ -199,6 +206,7 @@ pub enum Operation { SendFilterClear, SendCompactBlock, SendBlockTxn, + SendGetBlockTxn, TaprootScriptsUseAnnex, TaprootTxoUseAnnex, @@ -208,7 +216,6 @@ pub enum Operation { /// None = key-path only spend; Some = script-path with one spendable leaf script_leaf: Option, }, - // TODO: SendGetBlockTxn // TODO: SendGetBlocks // TODO: SendGetHeaders } @@ -350,6 +357,9 @@ impl fmt::Display for Operation { Operation::BeginBuildBlockTxn => write!(f, "BeginBuildBlockTxn"), Operation::AddTxToBlockTxn => write!(f, "AddTxToBlockTxn"), Operation::EndBuildBlockTxn => write!(f, "EndBuildBlockTxn"), + Operation::BeginBuildGetBlockTxn => write!(f, "BeginBuildGetBlockTxn"), + Operation::AddIndexToGetBlockTxn => write!(f, "AddIndexToGetBlockTxn"), + Operation::EndBuildGetBlockTxn => write!(f, "EndBuildGetBlockTxn"), Operation::BeginBuildFilterLoad => write!(f, "BeginBuildFilterLoad"), Operation::EndBuildFilterLoad => write!(f, "EndBuildFilterLoad"), Operation::AddTxToFilter => write!(f, "AddTxToFilter"), @@ -422,6 +432,7 @@ impl fmt::Display for Operation { Operation::SendFilterClear => write!(f, "SendFilterClear"), Operation::SendCompactBlock => write!(f, "SendCompactBlock"), Operation::SendBlockTxn => write!(f, "SendBlockTxn"), + Operation::SendGetBlockTxn => write!(f, "SendGetBlockTxn"), Operation::Probe => write!(f, "Probe"), @@ -473,6 +484,7 @@ impl Operation { | Operation::BeginBuildFilterLoad | Operation::BeginBuildCoinbaseTx | Operation::BeginBuildBlockTxn + | Operation::BeginBuildGetBlockTxn | Operation::BeginBuildCoinbaseTxOutputs | Operation::BeginPrefillTransactions => true, // Exhaustive match to fail when new ops are added @@ -526,6 +538,9 @@ impl Operation { | Operation::LoadNonce(..) | Operation::AddTxToBlockTxn | Operation::EndBuildBlockTxn + | Operation::AddIndexToGetBlockTxn + | Operation::EndBuildGetBlockTxn + | Operation::SendGetBlockTxn | Operation::EndBuildTx | Operation::EndBuildTxInputs | Operation::EndBuildTxOutputs @@ -618,6 +633,10 @@ impl Operation { Operation::EndBuildCoinbaseTxOutputs ) | (Operation::BeginBuildBlockTxn, Operation::EndBuildBlockTxn) + | ( + Operation::BeginBuildGetBlockTxn, + Operation::EndBuildGetBlockTxn + ) | ( Operation::BeginPrefillTransactions, Operation::EndPrefillTransactions @@ -639,6 +658,7 @@ impl Operation { | Operation::EndBuildFilterLoad | Operation::EndBuildCoinbaseTx | Operation::EndBuildBlockTxn + | Operation::EndBuildGetBlockTxn | Operation::EndBuildCoinbaseTxOutputs | Operation::EndPrefillTransactions => true, // Exhaustive match to fail when new ops are added @@ -684,6 +704,9 @@ impl Operation { | Operation::LoadNonce(..) | Operation::BeginBuildBlockTxn | Operation::AddTxToBlockTxn + | Operation::BeginBuildGetBlockTxn + | Operation::AddIndexToGetBlockTxn + | Operation::SendGetBlockTxn | Operation::TaprootScriptsUseAnnex | Operation::TaprootTxoUseAnnex | Operation::BuildTaprootTree { .. } @@ -846,6 +869,10 @@ impl Operation { Operation::AddTxToBlockTxn => vec![], Operation::EndBuildBlockTxn => vec![Variable::ConstBlockTxn], + Operation::BeginBuildGetBlockTxn => vec![], + Operation::AddIndexToGetBlockTxn => vec![], + Operation::EndBuildGetBlockTxn => vec![Variable::ConstBlockTxnRequest], + Operation::BeginBuildFilterLoad => vec![], Operation::AddTxToFilter => vec![], Operation::AddTxoToFilter => vec![], @@ -916,6 +943,7 @@ impl Operation { Operation::SendFilterClear => vec![], Operation::SendCompactBlock => vec![], Operation::SendBlockTxn => vec![], + Operation::SendGetBlockTxn => vec![], Operation::Probe => vec![], } } @@ -1044,6 +1072,15 @@ impl Operation { Operation::AddTxToBlockTxn => vec![Variable::MutBlockTxn, Variable::ConstTx], Operation::EndBuildBlockTxn => vec![Variable::MutBlockTxn], + Operation::SendGetBlockTxn => { + vec![Variable::Connection, Variable::ConstBlockTxnRequest] + } + Operation::BeginBuildGetBlockTxn => vec![Variable::Block], + Operation::AddIndexToGetBlockTxn => { + vec![Variable::MutBlockTxnRequest, Variable::Size] + } + Operation::EndBuildGetBlockTxn => vec![Variable::MutBlockTxnRequest], + Operation::BeginBuildFilterLoad => vec![Variable::ConstFilterLoad], Operation::AddTxToFilter => vec![Variable::MutFilterLoad, Variable::ConstTx], Operation::AddTxoToFilter => vec![Variable::MutFilterLoad, Variable::Txo], @@ -1127,6 +1164,7 @@ impl Operation { Operation::BeginBuildCoinbaseTx => vec![Variable::MutTx], Operation::BeginBuildCoinbaseTxOutputs => vec![Variable::MutTxOutputs], Operation::BeginBuildBlockTxn => vec![Variable::MutBlockTxn], + Operation::BeginBuildGetBlockTxn => vec![Variable::MutBlockTxnRequest], Operation::BeginPrefillTransactions => vec![Variable::MutPrefillTransactions], Operation::Nop { outputs: _, @@ -1231,6 +1269,9 @@ impl Operation { | Operation::EndBuildBlockTxn | Operation::AddTxToBlockTxn | Operation::SendBlockTxn + | Operation::AddIndexToGetBlockTxn + | Operation::EndBuildGetBlockTxn + | Operation::SendGetBlockTxn | Operation::Probe => vec![], } } diff --git a/fuzzamoto-ir/src/variable.rs b/fuzzamoto-ir/src/variable.rs index 98f7dc5b..4e62b226 100644 --- a/fuzzamoto-ir/src/variable.rs +++ b/fuzzamoto-ir/src/variable.rs @@ -68,6 +68,9 @@ pub enum Variable { ConstBlockTxn, ConstCoinbaseTx, + MutBlockTxnRequest, + ConstBlockTxnRequest, + TaprootSpendInfo, TaprootAnnex, } diff --git a/fuzzamoto-libafl/src/instance.rs b/fuzzamoto-libafl/src/instance.rs index b2c9ad26..b758304f 100644 --- a/fuzzamoto-libafl/src/instance.rs +++ b/fuzzamoto-libafl/src/instance.rs @@ -4,11 +4,12 @@ use fuzzamoto_ir::{ AddConnectionGenerator, AddTxToBlockGenerator, AddrRelayGenerator, AddrRelayV2Generator, AdvanceTimeGenerator, BlockGenerator, BlockTxnGenerator, BloomFilterAddGenerator, BloomFilterClearGenerator, BloomFilterLoadGenerator, CombineMutator, CompactBlockGenerator, - CompactFilterQueryGenerator, GetAddrGenerator, GetDataGenerator, HeaderGenerator, InputMutator, - InventoryGenerator, LargeTxGenerator, LongChainGenerator, OneParentOneChildGenerator, - OperationMutator, Program, ReorgBlockGenerator, SendBlockGenerator, SendMessageGenerator, - SingleTxGenerator, TipBlockGenerator, TxoGenerator, WitnessGenerator, - cutting::CuttingMinimizer, instr_block::InstrBlockMinimizer, nopping::NoppingMinimizer, + CompactFilterQueryGenerator, GetAddrGenerator, GetBlockTxnGenerator, GetCompactBlockGenerator, + GetDataGenerator, HeaderGenerator, InputMutator, InventoryGenerator, LargeTxGenerator, + LongChainGenerator, OneParentOneChildGenerator, OperationMutator, Program, ReorgBlockGenerator, + SendBlockGenerator, SendMessageGenerator, SingleTxGenerator, TipBlockGenerator, TxoGenerator, + WitnessGenerator, cutting::CuttingMinimizer, instr_block::InstrBlockMinimizer, + nopping::NoppingMinimizer, }; use libafl::{ @@ -394,6 +395,11 @@ where (10.0, IrGenerator::new(GetAddrGenerator, rng.clone())), (200.0, IrGenerator::new(CompactBlockGenerator, rng.clone())), (200.0, IrGenerator::new(BlockTxnGenerator, rng.clone())), + (200.0, IrGenerator::new(GetBlockTxnGenerator, rng.clone())), + ( + 200.0, + IrGenerator::new(GetCompactBlockGenerator, rng.clone()) + ), ( 20.0, IrGenerator::new(AddConnectionGenerator::handshake_outbound(), rng.clone()) diff --git a/fuzzamoto-libafl/src/stages/probe.rs b/fuzzamoto-libafl/src/stages/probe.rs index b8a6ce52..a48b5503 100644 --- a/fuzzamoto-libafl/src/stages/probe.rs +++ b/fuzzamoto-libafl/src/stages/probe.rs @@ -77,6 +77,24 @@ where txvec.add_block_tx_request(get_block_txn.clone()); } } + ProbeResult::CompactBlock { announcement } => { + let current = *state.corpus().current(); + if let Some(cur) = current + && let Ok(meta) = state.metadata_mut::() + { + let txvec = meta.metadatas.entry(cur).or_default(); + txvec.add_compact_block_announcement(announcement.clone()); + } + } + ProbeResult::BlockAnnounced { announcement } => { + let current = *state.corpus().current(); + if let Some(cur) = current + && let Ok(meta) = state.metadata_mut::() + { + let txvec = meta.metadatas.entry(cur).or_default(); + txvec.add_block_announcement(announcement.clone()); + } + } ProbeResult::Failure { command, reason } => { log::info!("Command {command:?} couln't be parsed; reason: {reason:?}"); } diff --git a/fuzzamoto-scenarios/bin/ir.rs b/fuzzamoto-scenarios/bin/ir.rs index 151dc84c..583ab3f7 100644 --- a/fuzzamoto-scenarios/bin/ir.rs +++ b/fuzzamoto-scenarios/bin/ir.rs @@ -2,10 +2,14 @@ use std::time::{Duration, Instant}; use bitcoin::{ - bip152::BlockTransactionsRequest, + VarInt, + bip152::{BlockTransactionsRequest, HeaderAndShortIds}, + block::Header as BitcoinHeader, consensus::{Decodable, encode}, hashes::Hash, - p2p::{message::NetworkMessage, message_compact_blocks::SendCmpct}, + p2p::{ + message::NetworkMessage, message_blockdata::Inventory, message_compact_blocks::SendCmpct, + }, }; use fuzzamoto::{ connections::Transport, @@ -18,7 +22,7 @@ use fuzzamoto::{ }; #[cfg(feature = "nyx")] -use fuzzamoto_nyx_sys::*; +use fuzzamoto_nyx_sys::{nyx_dump_file_to_host, nyx_println}; use io::Cursor; #[cfg(feature = "nyx")] use std::ffi::CString; @@ -36,7 +40,8 @@ use fuzzamoto::oracles::{NetSplitContext, NetSplitOracle}; use fuzzamoto::oracles::{ConsensusContext, ConsensusOracle}; use fuzzamoto_ir::{ - ProbeResult, ProbeResults, Program, ProgramContext, RecentBlock, + BlockAnnouncement, CompactBlockAnnouncement, ProbeResult, ProbeResults, Program, + ProgramContext, RecentBlock, compiler::{CompiledAction, CompiledMetadata, CompiledProgram, Compiler}, }; @@ -55,6 +60,10 @@ const OP_TRUE_SCRIPT_PUBKEY: [u8; 34] = [ 225, 85, 30, 111, 114, 30, 233, 192, 11, 140, 195, 50, 96, ]; +/// Messages the scenario records and feeds through the probe: `getblocktxn` requests, `cmpctblock` +/// announcements, and the `headers`/`inv` block announcements that drive the BIP152 fetch path. +const RECORDED_COMMANDS: [&str; 4] = ["getblocktxn", "cmpctblock", "headers", "inv"]; + /// `IrScenario` is a scenario with the same context as `GenericScenario` but it operates on /// `fuzzamoto_ir::CompiledProgram`s as input. struct IrScenario + ConnectableTarget> { @@ -122,12 +131,143 @@ fn probe_result_mapper( ProbeResult::GetBlockTxn { get_block_txn } } + "cmpctblock" => { + let Ok(cmpct) = HeaderAndShortIds::consensus_decode_from_finite_reader( + &mut Cursor::new(&mut bytes), + ) else { + return ProbeResult::Failure { + command: s.clone(), + reason: "cmpctblock: Fail to call consensus_decode_from_finite_reader" + .to_string(), + }; + }; + + let block_hash = cmpct.header.block_hash(); + let Some((_, block_var, _, _)) = metadata.block_variables(&block_hash) else { + return ProbeResult::Failure { + command: s.clone(), + reason: "cmpctblock: block hash is not registered in the metadata".to_string(), + }; + }; + + let Some(conn_var) = metadata.connection_map().get(&conn) else { + return ProbeResult::Failure { + command: s.clone(), + reason: "cmpctblock: couldn't find matching connection var".to_string(), + }; + }; + + let num_block_txs = cmpct.prefilled_txs.len() + cmpct.short_ids.len(); + + // The prefilled `idx` fields are differentially encoded; reconstruct absolute + // block-level positions (0 = coinbase) so a generator knows which transactions were + // prefilled vs. only referenced by short id. + let mut prefilled_indexes = Vec::with_capacity(cmpct.prefilled_txs.len()); + let mut running = 0usize; + for prefilled in &cmpct.prefilled_txs { + let abs = running + prefilled.idx as usize; + prefilled_indexes.push(abs); + running = abs + 1; + } + + ProbeResult::CompactBlock { + announcement: CompactBlockAnnouncement { + connection_index: *conn_var, + triggering_instruction_index: metadata.instruction_indices()[action_index], + block_variable: block_var, + num_block_txs, + prefilled_indexes, + }, + } + } + "headers" => { + let mut cursor = Cursor::new(&mut bytes); + let Ok(count) = VarInt::consensus_decode(&mut cursor) else { + return ProbeResult::Failure { + command: s.clone(), + reason: "headers: failed to decode header count".to_string(), + }; + }; + + let mut announced_block = None; + for _ in 0..count.0 { + let Ok(header) = BitcoinHeader::consensus_decode(&mut cursor) else { + break; + }; + // Each header in a `headers` message is followed by a (always 0) txn count VarInt. + let _ = VarInt::consensus_decode(&mut cursor); + if let Some((_, block_var, _, _)) = metadata.block_variables(&header.block_hash()) { + announced_block = Some(block_var); + break; + } + } + + block_announcement_or_failure(&s, conn, announced_block, action_index, metadata) + } + "inv" => { + let Ok(inventory) = + Vec::::consensus_decode_from_finite_reader(&mut Cursor::new(&mut bytes)) + else { + return ProbeResult::Failure { + command: s.clone(), + reason: "inv: failed to decode inventory".to_string(), + }; + }; + + let mut announced_block = None; + for item in &inventory { + let block_hash = match item { + Inventory::Block(hash) + | Inventory::WitnessBlock(hash) + | Inventory::CompactBlock(hash) => Some(*hash), + _ => None, + }; + if let Some(hash) = block_hash + && let Some((_, block_var, _, _)) = metadata.block_variables(&hash) + { + announced_block = Some(block_var); + break; + } + } + + block_announcement_or_failure(&s, conn, announced_block, action_index, metadata) + } _ => unreachable!( "Unexpected command; The filter must ensure only supported commands reach this point" ), } } +/// Build a [`ProbeResult::BlockAnnounced`] for `block_var` on `conn`, or a [`ProbeResult::Failure`] +/// if the announcement didn't reference a block we know about or the connection is unknown. +fn block_announcement_or_failure( + command: &str, + conn: usize, + block_var: Option, + action_index: usize, + metadata: &CompiledMetadata, +) -> ProbeResult { + let Some(block_variable) = block_var else { + return ProbeResult::Failure { + command: command.to_string(), + reason: format!("{command}: no known block announced"), + }; + }; + let Some(conn_var) = metadata.connection_map().get(&conn) else { + return ProbeResult::Failure { + command: command.to_string(), + reason: format!("{command}: couldn't find matching connection var"), + }; + }; + ProbeResult::BlockAnnounced { + announcement: BlockAnnouncement { + connection_index: *conn_var, + triggering_instruction_index: metadata.instruction_indices()[action_index], + block_variable, + }, + } +} + impl<'a> ScenarioInput<'a> for TestCase { fn decode(bytes: &'a [u8]) -> Result { let program = if cfg!(feature = "compile_in_vm") { @@ -220,7 +360,7 @@ where const CONTEXT_FILE_NAME: &str = "ir.context"; unsafe { nyx_dump_file_to_host( - CONTEXT_FILE_NAME.as_ptr() as *const i8, + CONTEXT_FILE_NAME.as_ptr().cast::(), CONTEXT_FILE_NAME.len(), full_context.as_ptr(), full_context.len(), @@ -277,7 +417,7 @@ where } fn process_actions(&mut self, mut program: CompiledProgram) { - let message_filter = |(s, _): &(String, Vec)| ["getblocktxn"].contains(&s.as_str()); + let message_filter = |(s, _): &(String, Vec)| RECORDED_COMMANDS.contains(&s.as_str()); let mut non_probe_action_count = 0; for action in program.actions.drain(..) { match action { @@ -400,6 +540,41 @@ where } } + /// Like `ping_connections`, but records the messages flushed during the ping/pong roundtrip and + /// feeds them through the probe so asynchronously-queued announcements are captured. Each + /// captured message is attributed to the last action instruction in the program, so generators + /// responding to it insert their reply at the end of the program. + fn recording_drain(&mut self, metadata: &CompiledMetadata) { + let message_filter = |(s, _): &(String, Vec)| RECORDED_COMMANDS.contains(&s.as_str()); + + // No actions means no instruction to attribute captures to; fall back to a plain ping. + let num_actions = metadata.instruction_indices().len(); + if num_actions == 0 { + self.ping_connections(); + return; + } + let action_index = num_actions - 1; + + let num_connections = self.inner.connections.len(); + for dst in 0..num_connections { + if let Some(connection) = self.inner.connections.get_mut(dst) { + // The message we send is irrelevant; the two pings inside `send_and_recv` force the + // node's `SendMessages` to run and flush any queued messages into the recording + // window. We send an extra ping for that purpose. + let ping = ("ping".to_string(), 0x0u64.to_le_bytes().to_vec()); + if let Ok(received) = connection.send_and_recv(&ping, true) { + self.probe_results.extend( + received + .into_iter() + .filter(message_filter) + .map(|(s, v)| (dst, s, v)) + .map(probe_result_mapper(action_index, metadata)), + ); + } + } + } + } + fn evaluate_oracles(&mut self) -> ScenarioResult { let crash_oracle = CrashOracle::::default(); if let OracleResult::Fail(e) = crash_oracle.evaluate(&mut self.inner.target) { @@ -503,7 +678,7 @@ fn dump_file_to_host(path: &str, dump_name: &str) { if let Ok(data) = std::fs::read(path) { unsafe { nyx_dump_file_to_host( - dump_name.as_ptr() as *const i8, + dump_name.as_ptr().cast::(), dump_name.len(), data.as_ptr(), data.len(), @@ -557,7 +732,16 @@ where fn run(&mut self, testcase: TestCase) -> ScenarioResult { let metadata = testcase.program.metadata.clone(); self.process_actions(testcase.program); - self.ping_connections(); + + // Drain each connection with recording enabled when probing so that messages the node + // queued asynchronously (e.g. an unsolicited high-bandwidth `cmpctblock`, or a `headers`/ + // `inv` block announcement) after the last send on that connection are still observed. When + // not probing, `recording_received_messages` is false and this is just a plain ping. + if self.recording_received_messages { + self.recording_drain(&metadata); + } else { + self.ping_connections(); + } if self.recording_received_messages && let Some(ret) = probe_recent_block_hashes(&self.inner.target, &metadata)