Skip to content
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
197 changes: 150 additions & 47 deletions crates/floresta-chain/src/pruned_utreexo/consensus.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ use bitcoin::script;
use bitcoin::Amount;
use bitcoin::Block;
use bitcoin::CompactTarget;
use bitcoin::Network;
use bitcoin::OutPoint;
use bitcoin::ScriptBuf;
use bitcoin::Target;
Expand Down Expand Up @@ -76,6 +77,14 @@ pub struct Consensus {
pub parameters: ChainParams,
}

impl From<Network> for Consensus {
fn from(network: Network) -> Self {
Consensus {
parameters: network.into(),
}
}
}

impl Consensus {
/// Returns the amount of block subsidy to be paid in a block, given it's height.
///
Expand Down Expand Up @@ -539,8 +548,9 @@ mod tests {
use bitcoin::Witness;
use floresta_common::assert_err;
use floresta_common::assert_ok;
use rand::rngs::OsRng;
use rand::rngs::StdRng;
use rand::seq::SliceRandom;
use rand::SeedableRng;

use super::*;

Expand All @@ -554,8 +564,17 @@ mod tests {
};
}

/// Macro for generating a legacy TxIn with an optional sequence number
/// Macro for constructing a legacy [`TxIn`] with optional scriptSig and sequence number.
/// Needs the outpoint and, if not provided, defaults to empty scriptSig and `Sequence::MAX`.
macro_rules! txin {
($outpoint:expr) => {
TxIn {
previous_output: $outpoint,
script_sig: ScriptBuf::new(),
sequence: Sequence::MAX,
witness: Witness::new(),
}
};
($outpoint:expr, $script:expr) => {
TxIn {
previous_output: $outpoint,
Expand All @@ -574,6 +593,25 @@ mod tests {
};
}

/// Helper for building a zero-locktime transaction given the input and output list.
fn build_tx(input: Vec<TxIn>, output: Vec<TxOut>) -> Transaction {
Transaction {
version: Version::TWO,
lock_time: LockTime::ZERO,
input,
output,
}
}

/// Helper to avoid boilerplate in test cases. Note this is not a null outpoint, restricted to
/// coinbase transactions only, as that requires the vout to be `u32::MAX`.
fn dummy_outpoint() -> OutPoint {
OutPoint {
txid: Txid::all_zeros(),
vout: 0,
}
}

#[cfg(feature = "bitcoinkernel")]
/// Some made up transactions that test our script limits checks.
/// Here's what is wrong with each transaction:
Expand Down Expand Up @@ -611,16 +649,16 @@ mod tests {
let output = txout!(5_000_350_000, output_script);

Transaction {
version: Version(1),
version: Version::ONE,
lock_time: LockTime::from_height(150_007).unwrap(),
input: vec![input],
output: vec![output],
}
}

/// Modifies a block to have an invalid output script (txdata is tampered with)
fn make_block_invalid(block: &mut Block) {
let mut rng = OsRng;
/// Modifies a block to have a different output script (txdata is tampered with).
fn mutate_block(block: &mut Block) {
let mut rng = StdRng::seed_from_u64(0x_bebe_cafe);
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sagaz! ☕


let tx = block.txdata.choose_mut(&mut rng).unwrap();
let out = tx.output.choose_mut(&mut rng).unwrap();
Expand All @@ -631,14 +669,49 @@ mod tests {
*byte += 1;
}

/// Test helper to update the witness commitment in a block, assuming txdata was modified.
/// This ensures `block` is not considered mutated, so we can exercise other error cases.
fn update_witness_commitment(block: &mut Block) -> Option<()> {
// BIP141 witness-commitment prefix, where the full commitment data is:
// 1-byte - OP_RETURN (0x6a)
// 1-byte - Push the following 36 bytes (0x24)
// 4-byte - Commitment header (0xaa21a9ed)
// 32-byte - Commitment hash: Double-SHA256(witness root hash|witness reserved value)
const MAGIC: [u8; 6] = [0x6a, 0x24, 0xaa, 0x21, 0xa9, 0xed];

let coinbase = &block.txdata[0];

// Commitment is in the last coinbase output that starts with magic bytes.
let pos = coinbase.output.iter().rposition(|out| {
let spk = out.script_pubkey.as_bytes();
spk.len() >= 38 && spk[0..6] == MAGIC
})?;

// Witness reserved value is in coinbase input witness.
let witness_rv: &[u8; 32] = {
let mut it = coinbase.input[0].witness.iter();
match (it.next(), it.next()) {
(Some(rv), None) => rv.try_into().ok()?,
_ => return None,
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: since the coinbase input must have one and only one element, what about leaving an unreachable here?

Copy link
Copy Markdown
Member Author

@JoseSK999 JoseSK999 Mar 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Makes sense

Actually better to return None, this may be a legacy block, so unreachable is not true.

}
};

let root = block.witness_root()?;
let c = *Block::compute_witness_commitment(&root, witness_rv).as_byte_array();

block.txdata[0].output[pos].script_pubkey.as_mut_bytes()[6..38].copy_from_slice(&c);
Some(())
}

/// Decode and deserialize a zstd-compressed block in the given file path.
fn decode_block(file_path: &str) -> Block {
let block_file = File::open(file_path).unwrap();
let block_bytes = zstd::decode_all(block_file).unwrap();
deserialize(&block_bytes).unwrap()
Comment on lines +707 to +710
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unrelated: Bitcoin Core leaves those test assets in another repo, called qa-assets. Leaving in the same tee has the disadvantage of bloating our tree with those test data, even though some people may not use it. Wdyt about moving these to our own floresta-qa-assets?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

+1

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@Davidson-Souza you mean the data under floresta-chain/testdata?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The idea that i got was is that davidson wants a floresta-qa-assets to gather test utils/data

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

oh like a separate test utils crate

}

#[test]
fn test_check_merkle_root() {
fn decode_block(file_path: &str) -> Block {
let block_file = File::open(file_path).unwrap();
let block_bytes = zstd::decode_all(block_file).unwrap();
deserialize(&block_bytes).unwrap()
}

let blocks = [
genesis_block(Network::Bitcoin),
genesis_block(Network::Testnet),
Expand All @@ -659,7 +732,7 @@ mod tests {
}

// Modifying the txdata should invalidate the block
make_block_invalid(&mut block);
mutate_block(&mut block);

assert!(!block.check_merkle_root());
if Consensus::check_merkle_root(&block).is_some() {
Expand All @@ -668,6 +741,56 @@ mod tests {
}
}

/// Modifies historical block at height 866,342 by adding one extra transaction so that the
/// updated block weight is 4,000,001 WUs. The block merkle roots are updated accordingly.
fn build_oversized_866_342() -> Block {
let mut block = decode_block("./testdata/block_866342/raw.zst");
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Consider remove hard coded file location to avoid test failures on other machines.


let consensus = Consensus::from(Network::Bitcoin);
consensus.check_block(&block, 866_342).expect("valid block");

// This block is close but below to the max weight
assert_eq!(block.weight().to_wu(), 3_993_209);

// Modify the block by adding one transaction that makes it exceed the weight limit
let mut script_out = ScriptBuf::default();
for _ in 0..1_636 {
script_out.push_opcode(OP_NOP);
}
let out = txout!(1, script_out);
let tx = build_tx(vec![txin!(dummy_outpoint())], vec![out]);

block.txdata.insert(1, tx);

// Update the witness commitment, and then the merkle root, which depends on the former
update_witness_commitment(&mut block).expect("should be able to update");
block.header.merkle_root = block.compute_merkle_root().unwrap();

block
}

#[test]
fn test_block_too_big() {
let height = 866_342;
let consensus = Consensus::from(Network::Bitcoin);
let block = build_oversized_866_342();

// This block is now just over the weight limit, by one unit!
assert_eq!(block.weight().to_wu(), 4_000_001);
assert!(block.weight() > Weight::MAX_BLOCK);
assert_eq!(Weight::MAX_BLOCK.to_wu(), 4_000_000);

// The txdata commitments match
assert!(block.check_merkle_root());
assert!(block.check_witness_commitment());
Consensus::check_merkle_root(&block).expect("merkle root matches");

match consensus.check_block(&block, height) {
Err(BlockchainError::BlockValidation(BlockValidationErrors::BlockTooBig)) => (),
other => panic!("We should have `BlockValidationErrors::BlockTooBig`, got {other:?}"),
}
}

#[test]
fn test_validate_script_size() {
use bitcoin::hashes::Hash;
Expand Down Expand Up @@ -839,15 +962,12 @@ mod tests {

#[test]
fn test_output_value_overflow() {
let tx = Transaction {
version: Version::TWO,
lock_time: LockTime::ZERO,
input: vec![txin!(OutPoint::new(Txid::all_zeros(), 0), ScriptBuf::new())],
output: vec![
txout!(u64::MAX, ScriptBuf::new()),
txout!(1, ScriptBuf::new()),
],
};
let ins = vec![txin!(dummy_outpoint())];
let outs = vec![
txout!(u64::MAX, ScriptBuf::new()),
txout!(1, ScriptBuf::new()),
];
let tx = build_tx(ins, outs);

match Consensus::check_transaction_context_free(&tx) {
Err(BlockchainError::BlockValidation(BlockValidationErrors::TooManyCoins)) => (),
Expand All @@ -857,7 +977,7 @@ mod tests {

#[test]
fn test_input_value_above_max_money() {
let outpoint = OutPoint::new(Txid::all_zeros(), 0);
let outpoint = dummy_outpoint();

let mut utxos = HashMap::new();
utxos.insert(
Expand All @@ -873,12 +993,7 @@ mod tests {
},
);

let tx = Transaction {
version: Version::TWO,
lock_time: LockTime::ZERO,
input: vec![txin!(outpoint, ScriptBuf::new())],
output: vec![txout!(1, ScriptBuf::new())],
};
let tx = build_tx(vec![txin!(outpoint)], vec![txout!(1, ScriptBuf::new())]);

match Consensus::verify_transaction(&tx, &mut utxos, 0, false, 0) {
Err(BlockchainError::BlockValidation(BlockValidationErrors::TooManyCoins)) => (),
Expand Down Expand Up @@ -956,25 +1071,13 @@ mod tests {
// This test creates an over-sized script, make sure that transaction containing it is valid.
// Then we try to spend this output, and verify if this causes an error.
fn test_spending_script_too_big() {
fn build_tx(input: TxIn, output: TxOut) -> Transaction {
Transaction {
version: Version(1),
lock_time: LockTime::ZERO,
input: vec![input],
output: vec![output],
}
}

let dummy_outpoint = OutPoint {
txid: Txid::all_zeros(),
vout: 0,
};
let outpoint = dummy_outpoint();
let flags = 0;
let dummy_height = 0;

let mut utxos = HashMap::new();
utxos.insert(
dummy_outpoint,
outpoint,
UtxoData {
txout: txout!(0, true_script()),
is_coinbase: false,
Expand All @@ -984,9 +1087,9 @@ mod tests {
);

// 1. Build a valid transaction that produces an oversized, unspendable output.
let dummy_in = txin!(dummy_outpoint, ScriptBuf::new());
let dummy_in = txin!(outpoint);
let oversized_out = txout!(0, oversized_script());
let tx_with_oversized = build_tx(dummy_in, oversized_out.clone());
let tx_with_oversized = build_tx(vec![dummy_in], vec![oversized_out.clone()]);

Consensus::verify_transaction(&tx_with_oversized, &mut utxos, dummy_height, false, flags)
.unwrap();
Expand All @@ -1004,8 +1107,8 @@ mod tests {
);

// 3. Attempt to spend the oversized output.
let spending_in = txin!(prevout, ScriptBuf::new());
let spending_tx = build_tx(spending_in, txout!(0, true_script()));
let spending_in = txin!(prevout);
let spending_tx = build_tx(vec![spending_in], vec![txout!(0, true_script())]);
let err =
Consensus::verify_transaction(&spending_tx, &mut utxos, dummy_height, false, flags)
.unwrap_err();
Expand Down
Loading