clementine_tx_sender/
client.rs

1//! # Transaction Sender Client
2//!
3//! This module is provides a client which is responsible for inserting
4//! transactions into the sending queue.
5
6use crate::TxSenderDatabase;
7use crate::{ActivatedWithOutpoint, ActivatedWithTxid};
8use bitcoin::{OutPoint, Transaction, Txid};
9use clementine_config::protocol::ProtocolParamset;
10use clementine_errors::BridgeError;
11use clementine_primitives::{TransactionType, UtxoVout};
12use clementine_utils::{FeePayingType, RbfSigningInfo, TxMetadata};
13use eyre::eyre;
14use std::collections::BTreeMap;
15
16#[derive(Debug, Clone)]
17pub struct TxSenderClient<D>
18where
19    D: TxSenderDatabase,
20{
21    pub db: D,
22}
23
24impl<D> TxSenderClient<D>
25where
26    D: TxSenderDatabase,
27{
28    pub fn new(db: D) -> Self {
29        Self { db }
30    }
31
32    /// Saves a transaction to the database queue for sending/fee bumping.
33    ///
34    /// This function determines the initial parameters for a transaction send attempt,
35    /// including its [`FeePayingType`], associated metadata, and dependencies (cancellations/activations).
36    /// It then persists this information in the database via [`Database::save_tx`] and related functions.
37    /// The actual sending logic (CPFP/RBF) is handled later by the transaction sender's task loop.
38    ///
39    /// # Default Activation and Cancellation Conditions
40    ///
41    /// By default, this function automatically adds cancellation conditions for all outpoints
42    /// spent by the `signed_tx` itself. If `signed_tx` confirms, these input outpoints
43    /// are marked as spent/cancelled in the database.
44    ///
45    /// There are no default activation conditions added implicitly; all activation prerequisites
46    /// must be explicitly provided via the `activate_txids` and `activate_outpoints` arguments.
47    ///
48    /// # Arguments
49    /// * `dbtx` - An active database transaction.
50    /// * `tx_metadata` - Optional metadata about the transaction's purpose.
51    /// * `signed_tx` - The transaction to be potentially sent.
52    /// * `fee_paying_type` - Whether to use CPFP or RBF for fee management.
53    /// * `cancel_outpoints` - Outpoints that should be marked invalid if this tx confirms (in addition to the tx's own inputs).
54    /// * `cancel_txids` - Txids that should be marked invalid if this tx confirms.
55    /// * `activate_txids` - Txids that are prerequisites for this tx, potentially with a relative timelock.
56    /// * `activate_outpoints` - Outpoints that are prerequisites for this tx, potentially with a relative timelock.
57    ///
58    /// # Returns
59    ///
60    /// - [`u32`]: The database ID (`try_to_send_id`) assigned to this send attempt.
61    #[tracing::instrument(err(level = tracing::Level::ERROR), ret(level = tracing::Level::TRACE), skip_all, fields(?tx_metadata))]
62    #[allow(clippy::too_many_arguments)]
63    pub async fn insert_try_to_send(
64        &self,
65        mut dbtx: Option<&mut D::Transaction>,
66        tx_metadata: Option<TxMetadata>,
67        signed_tx: &Transaction,
68        fee_paying_type: FeePayingType,
69        rbf_signing_info: Option<RbfSigningInfo>,
70        cancel_outpoints: &[OutPoint],
71        cancel_txids: &[Txid],
72        activate_txids: &[ActivatedWithTxid],
73        activate_outpoints: &[ActivatedWithOutpoint],
74    ) -> Result<u32, BridgeError> {
75        let txid = signed_tx.compute_txid();
76
77        // do not add duplicate transactions to the txsender
78        let tx_exists = self
79            .db
80            .check_if_tx_exists_on_txsender(dbtx.as_deref_mut(), txid)
81            .await?;
82        if let Some(try_to_send_id) = tx_exists {
83            return Ok(try_to_send_id);
84        }
85
86        tracing::info!(
87            "Added tx {} with txid {} to the queue",
88            tx_metadata
89                .as_ref()
90                .map(|data| format!("{:?}", data.tx_type))
91                .unwrap_or("N/A".to_string()),
92            txid
93        );
94
95        let try_to_send_id = self
96            .db
97            .save_tx(
98                dbtx.as_deref_mut(),
99                tx_metadata,
100                signed_tx,
101                fee_paying_type,
102                txid,
103                rbf_signing_info,
104            )
105            .await?;
106
107        // only log the raw tx in tests so that logs do not contain sensitive information
108        #[cfg(test)]
109        tracing::debug!(target: "ci", "Saved tx to database with try_to_send_id: {try_to_send_id}, metadata: {tx_metadata:?}, raw tx: {}", hex::encode(bitcoin::consensus::serialize(signed_tx)));
110
111        for input_outpoint in signed_tx.input.iter().map(|input| input.previous_output) {
112            self.db
113                .save_cancelled_outpoint(dbtx.as_deref_mut(), try_to_send_id, input_outpoint)
114                .await?;
115        }
116
117        for outpoint in cancel_outpoints {
118            self.db
119                .save_cancelled_outpoint(dbtx.as_deref_mut(), try_to_send_id, *outpoint)
120                .await?;
121        }
122
123        for txid in cancel_txids {
124            self.db
125                .save_cancelled_txid(dbtx.as_deref_mut(), try_to_send_id, *txid)
126                .await?;
127        }
128
129        let mut max_timelock_of_activated_txids = BTreeMap::new();
130
131        for activated_txid in activate_txids {
132            let timelock = max_timelock_of_activated_txids
133                .entry(activated_txid.txid)
134                .or_insert(activated_txid.relative_block_height);
135            if *timelock < activated_txid.relative_block_height {
136                *timelock = activated_txid.relative_block_height;
137            }
138        }
139
140        for input in signed_tx.input.iter() {
141            let relative_block_height = if input.sequence.is_relative_lock_time() {
142                let relative_locktime = input
143                    .sequence
144                    .to_relative_lock_time()
145                    .expect("Invalid relative locktime");
146                match relative_locktime {
147                    bitcoin::relative::LockTime::Blocks(height) => height.value() as u32,
148                    _ => {
149                        return Err(BridgeError::Eyre(eyre!("Invalid relative locktime")));
150                    }
151                }
152            } else {
153                0
154            };
155            let timelock = max_timelock_of_activated_txids
156                .entry(input.previous_output.txid)
157                .or_insert(relative_block_height);
158            if *timelock < relative_block_height {
159                *timelock = relative_block_height;
160            }
161        }
162
163        for (txid, timelock) in max_timelock_of_activated_txids {
164            self.db
165                .save_activated_txid(
166                    dbtx.as_deref_mut(),
167                    try_to_send_id,
168                    &ActivatedWithTxid {
169                        txid,
170                        relative_block_height: timelock,
171                    },
172                )
173                .await?;
174        }
175
176        for activated_outpoint in activate_outpoints {
177            self.db
178                .save_activated_outpoint(dbtx.as_deref_mut(), try_to_send_id, activated_outpoint)
179                .await?;
180        }
181
182        Ok(try_to_send_id)
183    }
184
185    /// Adds a transaction to the sending queue based on its type and configuration.
186    ///
187    /// This is a higher-level wrapper around [`Self::insert_try_to_send`]. It determines the
188    /// appropriate `FeePayingType` (CPFP or RBF) and any specific cancellation or activation
189    /// dependencies based on the `tx_type` and `config`.
190    ///
191    /// For example:
192    /// - `Challenge` transactions use `RBF`.
193    /// - Most other transactions default to `CPFP`.
194    /// - Specific types like `OperatorChallengeAck` might activate certain outpoints
195    ///   based on related transactions (`kickoff_txid`).
196    ///
197    /// # Arguments
198    /// * `dbtx` - An active database transaction.
199    /// * `tx_type` - The semantic type of the transaction.
200    /// * `signed_tx` - The transaction itself.
201    /// * `related_txs` - Other transactions potentially related (e.g., the kickoff for a challenge ack).
202    /// * `tx_metadata` - Optional metadata, `tx_type` will be added/overridden.
203    /// * `config` - Bridge configuration providing parameters like finality depth.
204    ///
205    /// # Returns
206    ///
207    /// - [`u32`]: The database ID (`try_to_send_id`) assigned to this send attempt.
208    #[allow(clippy::too_many_arguments)]
209    pub async fn add_tx_to_queue(
210        &self,
211        dbtx: Option<&mut D::Transaction>,
212        tx_type: TransactionType,
213        signed_tx: &Transaction,
214        related_txs: &[(TransactionType, Transaction)],
215        tx_metadata: Option<TxMetadata>,
216        protocol_paramset: &ProtocolParamset,
217        rbf_info: Option<RbfSigningInfo>,
218    ) -> Result<u32, BridgeError> {
219        let tx_metadata = tx_metadata.map(|mut data| {
220            data.tx_type = tx_type;
221            data
222        });
223        match tx_type {
224            TransactionType::Kickoff
225            | TransactionType::Dummy
226            | TransactionType::ChallengeTimeout
227            | TransactionType::DisproveTimeout
228            | TransactionType::Reimburse
229            | TransactionType::Round
230            | TransactionType::OperatorChallengeNack(_)
231            | TransactionType::UnspentKickoff(_)
232            | TransactionType::MoveToVault
233            | TransactionType::BurnUnusedKickoffConnectors
234            | TransactionType::KickoffNotFinalized
235            | TransactionType::MiniAssert(_)
236            | TransactionType::LatestBlockhashTimeout
237            | TransactionType::LatestBlockhash
238            | TransactionType::EmergencyStop
239            | TransactionType::OptimisticPayout
240            | TransactionType::ReadyToReimburse
241            | TransactionType::ReplacementDeposit
242            | TransactionType::WatchtowerChallenge(_)
243            | TransactionType::AssertTimeout(_) => {
244                // no_dependency and cpfp
245                self.insert_try_to_send(
246                    dbtx,
247                    tx_metadata,
248                    signed_tx,
249                    FeePayingType::CPFP,
250                    rbf_info,
251                    &[],
252                    &[],
253                    &[],
254                    &[],
255                )
256                .await
257            }
258            TransactionType::Challenge | TransactionType::Payout => {
259                self.insert_try_to_send(
260                    dbtx,
261                    tx_metadata,
262                    signed_tx,
263                    FeePayingType::RBF,
264                    rbf_info,
265                    &[],
266                    &[],
267                    &[],
268                    &[],
269                )
270                .await
271            }
272            TransactionType::WatchtowerChallengeTimeout(_) => {
273                // do not send watchtower timeout if kickoff is already finalized
274                // which is done by adding kickoff finalizer utxo to cancel_outpoints
275                // this is not needed for any timeouts that spend the kickoff finalizer utxo like AssertTimeout
276                let kickoff_txid = related_txs
277                    .iter()
278                    .find_map(|(tx_type, tx)| {
279                        if let TransactionType::Kickoff = tx_type {
280                            Some(tx.compute_txid())
281                        } else {
282                            None
283                        }
284                    })
285                    .ok_or(BridgeError::Eyre(eyre!(
286                        "Couldn't find kickoff tx in related_txs"
287                    )))?;
288                self.insert_try_to_send(
289                    dbtx,
290                    tx_metadata,
291                    signed_tx,
292                    FeePayingType::CPFP,
293                    rbf_info,
294                    &[OutPoint {
295                        txid: kickoff_txid,
296                        vout: UtxoVout::KickoffFinalizer.get_vout(),
297                    }],
298                    &[],
299                    &[],
300                    &[],
301                )
302                .await
303            }
304            TransactionType::OperatorChallengeAck(watchtower_idx) => {
305                let kickoff_txid = related_txs
306                    .iter()
307                    .find_map(|(tx_type, tx)| {
308                        if let TransactionType::Kickoff = tx_type {
309                            Some(tx.compute_txid())
310                        } else {
311                            None
312                        }
313                    })
314                    .ok_or(BridgeError::Eyre(eyre!(
315                        "Couldn't find kickoff tx in related_txs"
316                    )))?;
317                self.insert_try_to_send(
318                    dbtx,
319                    tx_metadata,
320                    signed_tx,
321                    FeePayingType::CPFP,
322                    rbf_info,
323                    &[],
324                    &[],
325                    &[],
326                    &[ActivatedWithOutpoint {
327                        // only send OperatorChallengeAck if corresponding watchtower challenge is sent
328                        outpoint: OutPoint {
329                            txid: kickoff_txid,
330                            vout: UtxoVout::WatchtowerChallenge(watchtower_idx).get_vout(),
331                        },
332                        relative_block_height: protocol_paramset.finality_depth - 1,
333                    }],
334                )
335                .await
336            }
337            TransactionType::Disprove => {
338                self.insert_try_to_send(
339                    dbtx,
340                    tx_metadata,
341                    signed_tx,
342                    FeePayingType::NoFunding,
343                    rbf_info,
344                    &[],
345                    &[],
346                    &[],
347                    &[],
348                )
349                .await
350            }
351            TransactionType::AllNeededForDeposit | TransactionType::YieldKickoffTxid => {
352                unreachable!("Higher level transaction types used for yielding kickoff txid from sighash stream or denoting all txs should not be added to the queue");
353            }
354        }
355    }
356}