Skip to content
763 changes: 521 additions & 242 deletions src/coordinator.rs

Large diffs are not rendered by default.

23 changes: 5 additions & 18 deletions src/speedup.rs
Original file line number Diff line number Diff line change
Expand Up @@ -33,8 +33,6 @@ pub trait SpeedupStore {
txid: &Txid,
) -> Result<CoordinatedSpeedUpTransaction, BitcoinCoordinatorStoreError>;

fn can_speedup(&self) -> Result<bool, BitcoinCoordinatorStoreError>;

fn is_funding_available(&self) -> Result<bool, BitcoinCoordinatorStoreError>;

fn has_enough_unconfirmed_txs_for_cpfp(&self) -> Result<bool, BitcoinCoordinatorStoreError>;
Expand Down Expand Up @@ -88,14 +86,16 @@ impl SpeedupStore for BitcoinCoordinatorStore {
// The broadcast block height is set to 0 and Finalized because funding should be confirmed on chain.
let funding_to_speedup = CoordinatedSpeedUpTransaction::new(
next_funding.txid,
next_funding.clone(),
next_funding,
None, // Funding is not an RBF replacement
None, // Funding transactions don't have an associated speedup tx
next_funding.clone(), // prev_funding
next_funding, // next_funding
None, // Funding is not an RBF replacement
0,
TransactionState::Finalized,
1.0,
vec![],
1,
None,
);

self.save_speedup(funding_to_speedup)?;
Expand Down Expand Up @@ -281,19 +281,6 @@ impl SpeedupStore for BitcoinCoordinatorStore {
Ok(active_speedups)
}

/// Determines if a speedup (CPFP) transaction can be created and dispatched.
///
/// Returns `true` if:
/// - There is a funding transaction available to pay for the speedup.
/// - There are enough available unconfirmed transaction slots to satisfy Bitcoin's mempool chain limit policy.
/// (At least `MIN_UNCONFIRMED_TXS_FOR_CPFP` unconfirmed transactions are required: one for the CPFP itself and at least one unconfirmed output to spend.)
fn can_speedup(&self) -> Result<bool, BitcoinCoordinatorStoreError> {
let is_funding_available = self.is_funding_available()?;
let is_enough_unconfirmed_txs = self.has_enough_unconfirmed_txs_for_cpfp()?;

Ok(is_funding_available && is_enough_unconfirmed_txs)
}

fn is_funding_available(&self) -> Result<bool, BitcoinCoordinatorStoreError> {
let funding = self.get_funding()?;
let is_funding_available = funding.is_some();
Expand Down
14 changes: 13 additions & 1 deletion src/storage.rs
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ pub trait BitcoinCoordinatorStoreApi {
target_block_height: Option<BlockHeight>,
context: String,
stuck_in_mempool_blocks: Option<u32>,
number_confirmation_trigger: Option<u32>,
) -> Result<(), BitcoinCoordinatorStoreError>;

fn remove_tx(&self, tx_id: Txid) -> Result<(), BitcoinCoordinatorStoreError>;
Expand Down Expand Up @@ -186,6 +187,7 @@ impl BitcoinCoordinatorStoreApi for BitcoinCoordinatorStore {
target_block_height: Option<BlockHeight>,
context: String,
stuck_in_mempool_blocks: Option<u32>,
number_confirmation_trigger: Option<u32>,
) -> Result<(), BitcoinCoordinatorStoreError> {
let key = self.get_key(StoreKey::Transaction(tx.compute_txid()));

Expand All @@ -196,6 +198,7 @@ impl BitcoinCoordinatorStoreApi for BitcoinCoordinatorStore {
target_block_height,
context,
stuck_in_mempool_blocks,
number_confirmation_trigger,
);

self.store.set(&key, &tx_info, None)?;
Expand Down Expand Up @@ -258,10 +261,19 @@ impl BitcoinCoordinatorStoreApi for BitcoinCoordinatorStore {

// Validate state transitions
let valid_transition = match (&tx.state, &new_state) {
// Valid transitions
(TransactionState::ToDispatch, TransactionState::InMempool) => true,
(TransactionState::ToDispatch, TransactionState::Failed) => true,

// From ToDispatch to Confirmed, Finalized is possible if the client crash and in a
// new tick detects that the transaction was already in the chain and is confirmed or finalized.
// Same happens with InMempool to Confirmed, Finalized.
(TransactionState::ToDispatch, TransactionState::Confirmed) => true,
(TransactionState::ToDispatch, TransactionState::Finalized) => true,

(TransactionState::InMempool, TransactionState::Confirmed) => true,
(TransactionState::InMempool, TransactionState::ToDispatch) => true,
(TransactionState::InMempool, TransactionState::Finalized) => true,

(TransactionState::Confirmed, TransactionState::Finalized) => true,
// Allow transition from Confirmed to InMempool when transaction becomes orphan (reorg)
(TransactionState::Confirmed, TransactionState::InMempool) => true,
Expand Down
26 changes: 26 additions & 0 deletions src/types.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
use bitcoin::{Transaction, Txid};
use bitvmx_bitcoin_rpc::types::BlockHeight;
use bitvmx_transaction_monitor::types::{AckMonitorNews, MonitorNews};
use bitvmx_transaction_monitor::TransactionBlockchainStatus;
use protocol_builder::types::{output::SpeedupData, Utxo};
use serde::{Deserialize, Serialize};

Expand Down Expand Up @@ -39,6 +40,7 @@ pub struct CoordinatedTransaction {
// Number of blocks to wait before considering the transaction stuck in mempool
// None means this transaction doesn't have a stuck threshold
pub stuck_in_mempool_blocks: Option<u32>,
pub number_confirmation_trigger: Option<u32>,
}

impl CoordinatedTransaction {
Expand All @@ -49,6 +51,7 @@ impl CoordinatedTransaction {
target_block_height: Option<BlockHeight>,
context: String,
stuck_in_mempool_blocks: Option<u32>,
number_confirmation_trigger: Option<u32>,
) -> Self {
Self {
tx_id: tx.compute_txid(),
Expand All @@ -59,6 +62,7 @@ impl CoordinatedTransaction {
target_block_height,
context,
stuck_in_mempool_blocks,
number_confirmation_trigger,
}
}
}
Expand All @@ -72,6 +76,9 @@ pub struct CoordinatedSpeedUpTransaction {

pub tx_id: Txid,

// The speedup transaction itself (needed for dispatch, None for funding transactions)
pub tx: Option<Transaction>,

// The previous funding utxo.
pub prev_funding: Utxo,

Expand All @@ -97,6 +104,8 @@ pub struct CoordinatedSpeedUpTransaction {
pub speedup_tx_data: Vec<(SpeedupData, Transaction, String)>,

pub network_fee_rate_used: u64,

pub confirmation_trigger: Option<u32>,
}

#[derive(Deserialize, Serialize, Debug, Clone)]
Expand Down Expand Up @@ -124,6 +133,7 @@ impl RetryInfo {
impl CoordinatedSpeedUpTransaction {
pub fn new(
tx_id: Txid,
tx: Option<Transaction>,
prev_funding: Utxo,
next_funding: Utxo,
replaces_tx_id: Option<Txid>,
Expand All @@ -132,6 +142,7 @@ impl CoordinatedSpeedUpTransaction {
bump_fee_percentage_used: f64,
speedup_tx_data: Vec<(SpeedupData, Transaction, String)>,
network_fee_rate_used: u64,
confirmation_trigger: Option<u32>,
) -> Self {
let is_rbf = replaces_tx_id.is_some();
let mut context = if is_rbf {
Expand All @@ -154,6 +165,7 @@ impl CoordinatedSpeedUpTransaction {
SpeedupType::CPFP
},
tx_id,
tx,
prev_funding,
next_funding,
replaced_by_tx_id: None, // Initially, no transaction replaces this one
Expand All @@ -164,6 +176,7 @@ impl CoordinatedSpeedUpTransaction {
bump_fee_percentage_used,
speedup_tx_data,
network_fee_rate_used,
confirmation_trigger,
}
}
}
Expand Down Expand Up @@ -196,6 +209,19 @@ impl CoordinatedSpeedUpTransaction {
}
}

/// Direct, enum-to-enum mapping between the indexer blockchain status and our internal TransactionState.
impl From<TransactionBlockchainStatus> for TransactionState {
fn from(status: TransactionBlockchainStatus) -> Self {
match status {
TransactionBlockchainStatus::InMempool => TransactionState::InMempool,
TransactionBlockchainStatus::Confirmed => TransactionState::Confirmed,
TransactionBlockchainStatus::Finalized => TransactionState::Finalized,
TransactionBlockchainStatus::NotFound => TransactionState::ToDispatch,
TransactionBlockchainStatus::Orphan => TransactionState::InMempool,
}
}
}

#[derive(Deserialize, Serialize, Debug, Clone)]
pub struct TransactionFullInfo {
pub tx: Transaction,
Expand Down
11 changes: 10 additions & 1 deletion tests/batch_txs_test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,10 @@ fn batch_txs_regtest_test() -> Result<(), anyhow::Error> {
config_trace_aux();

let mut blocks_mined = 102;
info!("Starting batch_txs_regtest_test with {} initial blocks mined", blocks_mined);
info!(
"Starting batch_txs_regtest_test with {} initial blocks mined",
blocks_mined
);
let setup = create_test_setup(TestSetupConfig {
blocks_mined,
bitcoind_flags: None,
Expand Down Expand Up @@ -151,6 +154,12 @@ fn batch_txs_regtest_test() -> Result<(), anyhow::Error> {

info!("Ticking coordinator after second block mined");
coordinator.tick()?;
coordinator.tick()?;
setup
.bitcoin_client
.mine_blocks_to_address(1, &setup.funding_wallet)?;
coordinator.tick()?;
coordinator.tick()?;

let news = coordinator.get_news()?;
info!(
Expand Down
11 changes: 6 additions & 5 deletions tests/reorg_rbf_test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,9 @@ use protocol_builder::types::Utxo;
use std::rc::Rc;
use tracing::info;

use crate::utils::{config_trace_aux, coordinate_tx, create_test_setup, TestSetupConfig};
use crate::utils::{
config_trace_aux, coordinate_tx, create_test_setup, tick_until_coordinator_ready, TestSetupConfig,
};
mod utils;

#[test]
Expand Down Expand Up @@ -109,7 +111,7 @@ fn replace_speedup_regtest_test() -> Result<(), anyhow::Error> {

// Mine 3 blocks to confirm tx1 and its speedup transaction
// Each block mined advances the blockchain, and each tick processes the new blocks
for _ in 0..3 {
for _ in 0..6 {
info!("{} Mine and Tick", style("Test").green());
setup
.bitcoin_client
Expand All @@ -120,6 +122,7 @@ fn replace_speedup_regtest_test() -> Result<(), anyhow::Error> {

// Verify that tx1 has been confirmed (1 confirmation)
let news = coordinator.get_news()?;

assert_eq!(
news.monitor_news.len(),
1,
Expand Down Expand Up @@ -224,9 +227,7 @@ fn replace_speedup_regtest_test() -> Result<(), anyhow::Error> {
.unwrap();

// Wait for coordinator to be ready (indexer synced with blockchain)
while !coordinator.is_ready()? {
coordinator.tick()?;
}
tick_until_coordinator_ready(&coordinator)?;

// After mining a new block, tx1 should be confirmed again (re-mined in the new chain)
let news = coordinator.get_news()?;
Expand Down
2 changes: 1 addition & 1 deletion tests/reorg_test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ fn replace_speedup_regtest_test() -> Result<(), anyhow::Error> {

coordinator.tick()?;

for _ in 0..4 {
for _ in 0..8 {
info!("{} Mine and Tick", style("Test").green());
// Mine a block to confirm tx1 and its speedup transaction
setup
Expand Down
10 changes: 4 additions & 6 deletions tests/replace_speedup_regtest_test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,9 @@ use protocol_builder::types::Utxo;
use std::rc::Rc;
use tracing::info;

use crate::utils::{config_trace_aux, coordinate_tx, create_test_setup, TestSetupConfig};
use crate::utils::{
config_trace_aux, coordinate_tx, create_test_setup, tick_until_coordinator_ready, TestSetupConfig,
};
mod utils;

// Almost every transaction sent in the protocol uses a CPFP (Child Pays For Parent) transaction for broadcasting.
Expand Down Expand Up @@ -66,12 +68,8 @@ fn replace_speedup_regtest_test() -> Result<(), anyhow::Error> {
None,
)?);

// Since we've already mined 102 blocks, we need to advance the coordinator by 102 ticks
// so the indexer can catch up with the current blockchain height.
// Tick coordinator until it is ready (indexer is caught up with the current blockchain height)
while !coordinator.is_ready()? {
coordinator.tick()?;
}
tick_until_coordinator_ready(&coordinator)?;

// Add funding for speed up transaction
coordinator.add_funding(Utxo::new(
Expand Down
10 changes: 6 additions & 4 deletions tests/speedup_chain_recompute_fee_test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -129,10 +129,12 @@ fn speedup_chain_recompute_fee_test() -> Result<(), anyhow::Error> {
coordinator.tick()?;
}

setup
.bitcoin_client
.mine_blocks_to_address(1, &setup.funding_wallet)?;
coordinator.tick()?;
for _ in 0..4 {
setup
.bitcoin_client
.mine_blocks_to_address(1, &setup.funding_wallet)?;
coordinator.tick()?;
}

let news = coordinator.get_news()?;
assert_eq!(news.monitor_news.len(), 1);
Expand Down
Loading