use crate::network::txhash::calculate_wtxid;
use blvm_protocol::block::calculate_tx_id;
use blvm_protocol::{Hash, Transaction};
use sha2::{Digest, Sha256};
use std::collections::HashMap;
use tracing::{debug, info, warn};
pub struct PackageRelay {
pending_packages: HashMap<PackageId, PackageState>,
validator: PackageValidator,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct PackageId(pub Hash);
#[derive(Debug, Clone)]
pub struct TransactionPackage {
pub transactions: Vec<Transaction>,
pub package_id: PackageId,
pub combined_fee: u64,
pub combined_weight: usize,
pub fee_known: bool,
}
#[derive(Debug, Clone)]
struct PackageState {
package: TransactionPackage,
received_at: u64,
status: PackageStatus,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PackageStatus {
Pending,
Accepted,
Rejected { reason: PackageRejectReason },
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PackageRejectReason {
TooManyTransactions,
WeightExceedsLimit,
FeeRateTooLow,
InvalidOrder,
DuplicateTransactions,
InvalidStructure,
}
#[derive(Debug, Clone)]
pub struct PackageValidator {
pub max_package_size: usize,
pub max_package_weight: usize,
pub min_fee_rate: u64,
}
impl Default for PackageValidator {
fn default() -> Self {
Self {
max_package_size: 25,
max_package_weight: 404_000, min_fee_rate: 1, }
}
}
impl PackageId {
pub fn from_transactions(
transactions: &[Transaction],
witnesses: Option<&[Option<Vec<Vec<u8>>>]>,
) -> Self {
let mut wtxids: Vec<Hash> = transactions
.iter()
.enumerate()
.map(|(i, tx)| {
let w = witnesses.and_then(|w| w.get(i)).and_then(|o| o.as_ref());
match w {
Some(v) => {
let arr = [v.clone()];
calculate_wtxid(tx, Some(&arr))
}
None => calculate_wtxid(tx, None),
}
})
.collect();
wtxids.sort(); let mut hasher = Sha256::new();
for w in &wtxids {
hasher.update(w);
}
let hash_bytes = hasher.finalize();
let mut package_hash = [0u8; 32];
package_hash.copy_from_slice(&hash_bytes);
PackageId(package_hash)
}
}
impl TransactionPackage {
pub fn new(transactions: Vec<Transaction>) -> Result<Self, PackageError> {
Self::new_with_utxo_set(transactions, None)
}
pub fn new_with_utxo_set(
transactions: Vec<Transaction>,
utxo_set: Option<&blvm_protocol::UtxoSet>,
) -> Result<Self, PackageError> {
Self::new_with_utxo_set_and_witnesses(transactions, utxo_set, None)
}
pub fn new_with_utxo_set_and_witnesses(
transactions: Vec<Transaction>,
utxo_set: Option<&blvm_protocol::UtxoSet>,
witnesses: Option<&[Option<blvm_consensus::segwit::Witness>]>,
) -> Result<Self, PackageError> {
if transactions.is_empty() {
return Err(PackageError::EmptyPackage);
}
Self::validate_ordering(&transactions)?;
let package_id = PackageId::from_transactions(&transactions, witnesses);
let combined_fee = if let Some(utxo_set) = utxo_set {
package_combined_fee(&transactions, utxo_set)
} else {
0
};
let combined_weight: usize = transactions
.iter()
.enumerate()
.map(|(i, tx)| {
let witness = witnesses.and_then(|w| w.get(i)).and_then(|o| o.as_ref());
package_transaction_weight_wu(tx, witness)
})
.sum();
Ok(Self {
transactions,
package_id,
combined_fee,
combined_weight,
fee_known: utxo_set.is_some(),
})
}
fn validate_ordering(transactions: &[Transaction]) -> Result<(), PackageError> {
let mut idx = std::collections::HashMap::new();
for (i, tx) in transactions.iter().enumerate() {
idx.insert(calculate_tx_id(tx), i);
}
for (i, tx) in transactions.iter().enumerate() {
for input in &tx.inputs {
if let Some(&parent_pos) = idx.get(&input.prevout.hash) {
if parent_pos >= i {
return Err(PackageError::InvalidOrder);
}
}
}
}
Ok(())
}
pub fn fee_rate(&self) -> f64 {
if self.combined_weight == 0 {
return 0.0;
}
let vbytes = self.combined_weight as f64 / 4.0;
if vbytes == 0.0 {
return 0.0;
}
self.combined_fee as f64 / vbytes
}
}
impl Default for PackageRelay {
fn default() -> Self {
Self::new()
}
}
impl PackageRelay {
pub fn new() -> Self {
Self {
pending_packages: HashMap::new(),
validator: PackageValidator::default(),
}
}
pub fn create_package(
&self,
transactions: Vec<Transaction>,
) -> Result<TransactionPackage, PackageError> {
TransactionPackage::new(transactions)
}
pub fn validate_package(
&self,
package: &TransactionPackage,
) -> Result<(), PackageRejectReason> {
if package.transactions.len() > self.validator.max_package_size {
return Err(PackageRejectReason::TooManyTransactions);
}
if package.combined_weight > self.validator.max_package_weight {
return Err(PackageRejectReason::WeightExceedsLimit);
}
if package.fee_known && package.fee_rate() < self.validator.min_fee_rate as f64 {
return Err(PackageRejectReason::FeeRateTooLow);
}
let mut seen = std::collections::HashSet::new();
for tx in &package.transactions {
let txid = calculate_tx_id(tx);
if !seen.insert(txid) {
return Err(PackageRejectReason::DuplicateTransactions);
}
}
TransactionPackage::validate_ordering(&package.transactions)
.map_err(|_| PackageRejectReason::InvalidOrder)?;
Ok(())
}
pub fn register_package(
&mut self,
package: TransactionPackage,
) -> Result<PackageId, PackageError> {
self.validate_package(&package)
.map_err(PackageError::ValidationFailed)?;
let package_id = package.package_id;
let tx_count = package.transactions.len();
let now = crate::utils::current_timestamp();
let state = PackageState {
package,
received_at: now,
status: PackageStatus::Pending,
};
self.pending_packages.insert(package_id, state);
debug!(
"Registered package {} with {} transactions",
hex::encode(package_id.0),
tx_count
);
Ok(package_id)
}
pub fn get_package(&self, package_id: &PackageId) -> Option<&TransactionPackage> {
self.pending_packages.get(package_id).map(|s| &s.package)
}
pub fn mark_accepted(&mut self, package_id: &PackageId) {
if let Some(state) = self.pending_packages.get_mut(package_id) {
state.status = PackageStatus::Accepted;
info!("Package {} accepted", hex::encode(package_id.0));
}
}
pub fn mark_rejected(&mut self, package_id: &PackageId, reason: PackageRejectReason) {
if let Some(state) = self.pending_packages.get_mut(package_id) {
state.status = PackageStatus::Rejected { reason };
warn!(
"Package {} rejected: {:?}",
hex::encode(package_id.0),
reason
);
}
}
pub fn cleanup_old_packages(&mut self, max_age: u64) {
let now = crate::utils::current_timestamp();
let expired: Vec<PackageId> = self
.pending_packages
.iter()
.filter(|(_, state)| now - state.received_at > max_age)
.map(|(id, _)| *id)
.collect();
for id in expired {
self.pending_packages.remove(&id);
debug!("Cleaned up expired package {}", hex::encode(id.0));
}
}
}
fn package_combined_fee(transactions: &[Transaction], utxo_set: &blvm_protocol::UtxoSet) -> u64 {
use crate::node::mempool::{add_money, money_sats, output_sum_sats};
let mut created: HashMap<Hash, usize> = HashMap::new();
let mut total_fee = 0u64;
for (i, tx) in transactions.iter().enumerate() {
if let Some(output_total) = output_sum_sats(tx) {
let mut input_total = 0u64;
let mut within_money = true;
for input in &tx.inputs {
let sats = if let Some(utxo) = utxo_set.get(&input.prevout) {
money_sats(utxo.value)
} else if let Some(&parent_i) = created.get(&input.prevout.hash) {
transactions[parent_i]
.outputs
.get(input.prevout.index as usize)
.and_then(|output| money_sats(output.value))
} else {
None
};
if let Some(sats) = sats {
match add_money(input_total, sats) {
Some(sum) => input_total = sum,
None => {
within_money = false;
break;
}
}
}
}
if within_money {
total_fee = total_fee.saturating_add(input_total.saturating_sub(output_total));
}
}
created.insert(calculate_tx_id(tx), i);
}
total_fee
}
fn package_transaction_weight_wu(
tx: &Transaction,
witness: Option<&blvm_consensus::segwit::Witness>,
) -> usize {
use blvm_consensus::segwit::calculate_transaction_weight;
calculate_transaction_weight(tx, witness).unwrap_or(0) as usize
}
#[derive(Debug, thiserror::Error)]
pub enum PackageError {
#[error("Empty package (no transactions)")]
EmptyPackage,
#[error("Invalid transaction ordering (children before parents)")]
InvalidOrder,
#[error("Package validation failed: {0:?}")]
ValidationFailed(PackageRejectReason),
#[error("Package not found")]
PackageNotFound,
}
#[cfg(test)]
mod tests {
use super::*;
use blvm_protocol::opcodes::OP_1;
use blvm_protocol::{OutPoint, TransactionInput, TransactionOutput, UTXO};
use std::sync::Arc;
fn spend(prevout: OutPoint, output_value: i64) -> Transaction {
Transaction {
version: 1,
inputs: vec![TransactionInput {
prevout,
script_sig: Vec::new(),
sequence: blvm_protocol::constants::SEQUENCE_FINAL as u64,
}]
.into(),
outputs: vec![TransactionOutput {
value: output_value,
script_pubkey: vec![OP_1],
}]
.into(),
lock_time: 0,
}
}
#[test]
fn package_fee_counts_an_in_package_parent_and_ignores_a_negative_value() {
let funding = OutPoint {
hash: [1u8; 32],
index: 0,
};
let negative = OutPoint {
hash: [9u8; 32],
index: 0,
};
let mut utxo_set = blvm_protocol::UtxoSet::default();
utxo_set.insert(
funding,
Arc::new(UTXO {
value: 50_000,
script_pubkey: vec![OP_1].into(),
height: 0,
is_coinbase: false,
}),
);
utxo_set.insert(
negative,
Arc::new(UTXO {
value: -1,
script_pubkey: vec![OP_1].into(),
height: 0,
is_coinbase: false,
}),
);
let parent = spend(funding, 40_000);
let child = spend(
OutPoint {
hash: calculate_tx_id(&parent),
index: 0,
},
25_000,
);
let package = TransactionPackage::new_with_utxo_set(
vec![parent, child, spend(negative, 1_000)],
Some(&utxo_set),
)
.unwrap();
assert_eq!(package.combined_fee, 25_000);
}
#[test]
fn one_sat_per_vb_package_is_not_rejected_for_fee_rate() {
let prevout = OutPoint {
hash: [9u8; 32],
index: 0,
};
let mut tx = spend(prevout, 1);
let vsize = super::package_transaction_weight_wu(&tx, None)
.div_ceil(4)
.max(1) as i64;
let input_value = 50_000i64;
tx.outputs[0].value = input_value - vsize;
let mut utxo_set = blvm_protocol::UtxoSet::default();
utxo_set.insert(
prevout,
Arc::new(UTXO {
value: input_value,
script_pubkey: vec![OP_1].into(),
height: 1,
is_coinbase: false,
}),
);
let package = TransactionPackage::new_with_utxo_set(vec![tx], Some(&utxo_set)).unwrap();
assert!(package.fee_known);
assert!(package.fee_rate() >= 1.0);
assert!(PackageRelay::new().validate_package(&package).is_ok());
let mut free = spend(prevout, input_value);
free.outputs[0].value = input_value;
let free_package =
TransactionPackage::new_with_utxo_set(vec![free], Some(&utxo_set)).unwrap();
assert!(matches!(
PackageRelay::new().validate_package(&free_package),
Err(PackageRejectReason::FeeRateTooLow)
));
}
}