clementine_core/
extended_bitcoin_rpc.rs

1//! # Bitcoin Extended RPC Interface
2//!
3//! Extended RPC interface communicates with the Bitcoin node. It features some
4//! common wrappers around typical RPC operations as well as direct
5//! communication interface with the Bitcoin node.
6//!
7//! ## Tests
8//!
9//! In tests, Bitcoind node and client are usually created using
10//! [`crate::test::common::create_regtest_rpc`]. Please refer to
11//! [`crate::test::common`] for using [`ExtendedBitcoinRpc`] in tests.
12
13// Re-export types from clementine-extended-rpc
14pub use clementine_extended_rpc::{
15    get_fee_rate_from_mempool_space, BitcoinRPCError, ExtendedBitcoinRpc, RetryConfig,
16    RetryableError,
17};
18
19use async_trait::async_trait;
20#[cfg(test)]
21use bitcoin::BlockHash;
22use bitcoin::OutPoint;
23use bitcoincore_rpc::RpcApi;
24use eyre::eyre;
25use eyre::Context;
26
27use crate::builder::address::create_taproot_address;
28use crate::builder::transaction::create_round_txhandlers;
29use crate::builder::transaction::input::UtxoVout;
30use crate::builder::transaction::KickoffWinternitzKeys;
31use crate::builder::transaction::TxHandler;
32use crate::config::protocol::ProtocolParamset;
33use crate::deposit::OperatorData;
34use clementine_errors::BridgeError;
35use clementine_errors::TransactionType;
36use clementine_primitives::RoundIndex;
37
38#[cfg(test)]
39use crate::test::common::citrea::CitreaE2EData;
40#[cfg(test)]
41use crate::{
42    citrea::CitreaClientT,
43    test::common::{are_all_nodes_synced, test_actors::TestActors},
44};
45#[cfg(test)]
46type Result<T> = std::result::Result<T, BitcoinRPCError>;
47
48#[cfg(test)]
49pub const MINE_BLOCK_COUNT: u64 = 1;
50
51/// Extension trait for bridge-specific RPC queries.
52///
53/// These methods are kept in clementine-core because they depend on
54/// bridge-specific types like `KickoffWinternitzKeys` and `OperatorData`.
55#[async_trait]
56pub trait BridgeRpcQueries {
57    /// Checks if an operator's collateral is valid and available for use.
58    ///
59    /// This function validates the operator's collateral by:
60    /// 1. Verifying the collateral UTXO exists and has the correct amount
61    /// 2. Creating the round transaction chain to track current collateral position
62    /// 3. Determining if the current collateral UTXO in the chain is spent in a non-protocol tx, signaling the exit of operator from the protocol
63    ///
64    /// # Parameters
65    ///
66    /// * `operator_data`: Data about the operator including collateral funding outpoint
67    /// * `kickoff_wpks`: Kickoff Winternitz public keys for round transaction creation
68    /// * `paramset`: Protocol parameters
69    ///
70    /// # Returns
71    ///
72    /// - [`bool`]: `true` if the collateral is still usable, thus operator is still in protocol, `false` if the collateral is spent, thus operator is not in protocol anymore
73    ///
74    /// # Errors
75    ///
76    /// - [`BridgeError`]: If there was an error retrieving transaction data, creating round transactions,
77    ///   or checking UTXO status
78    async fn collateral_check(
79        &self,
80        operator_data: &OperatorData,
81        kickoff_wpks: &KickoffWinternitzKeys,
82        paramset: &'static ProtocolParamset,
83    ) -> std::result::Result<bool, BridgeError>;
84}
85
86#[async_trait]
87impl BridgeRpcQueries for ExtendedBitcoinRpc {
88    async fn collateral_check(
89        &self,
90        operator_data: &OperatorData,
91        kickoff_wpks: &KickoffWinternitzKeys,
92        paramset: &'static ProtocolParamset,
93    ) -> std::result::Result<bool, BridgeError> {
94        // first check if the collateral utxo is on chain or mempool
95        let tx = self
96            .get_tx_of_txid(&operator_data.collateral_funding_outpoint.txid)
97            .await
98            .wrap_err(format!(
99                "Failed to find collateral utxo in chain for outpoint {:?}",
100                operator_data.collateral_funding_outpoint
101            ))?;
102        let collateral_outpoint = match tx
103            .output
104            .get(operator_data.collateral_funding_outpoint.vout as usize)
105        {
106            Some(output) => output,
107            None => {
108                tracing::warn!(
109                    "No output at index {} for txid {} while checking for collateral existence",
110                    operator_data.collateral_funding_outpoint.vout,
111                    operator_data.collateral_funding_outpoint.txid
112                );
113                return Ok(false);
114            }
115        };
116
117        if collateral_outpoint.value != paramset.collateral_funding_amount {
118            tracing::error!(
119                "Collateral amount for collateral {:?} is not correct: expected {}, got {}",
120                operator_data.collateral_funding_outpoint,
121                paramset.collateral_funding_amount,
122                collateral_outpoint.value
123            );
124            return Ok(false);
125        }
126
127        let operator_tpr_address =
128            create_taproot_address(&[], Some(operator_data.xonly_pk), paramset.network).0;
129
130        if collateral_outpoint.script_pubkey != operator_tpr_address.script_pubkey() {
131            tracing::error!(
132                "Collateral script pubkey for collateral {:?} is not correct: expected {}, got {}",
133                operator_data.collateral_funding_outpoint,
134                operator_tpr_address.script_pubkey(),
135                collateral_outpoint.script_pubkey
136            );
137            return Ok(false);
138        }
139
140        // we additionally check if collateral utxo is on chain (so not in mempool)
141        // on mainnet we fail if collateral utxo is not on chain because if it is in mempool,
142        // the txid of the utxo can change if the fee is bumped
143        // on other networks, we allow collateral to be in mempool to not wait for collateral to be on chain to do deposits for faster testing
144        let is_on_chain = self
145            .is_tx_on_chain(&operator_data.collateral_funding_outpoint.txid)
146            .await?;
147        if !is_on_chain {
148            return match paramset.network {
149                bitcoin::Network::Bitcoin => Ok(false),
150                _ => Ok(true),
151            };
152        }
153
154        let mut current_collateral_outpoint: OutPoint = operator_data.collateral_funding_outpoint;
155        let mut prev_ready_to_reimburse: Option<TxHandler> = None;
156        // iterate over all rounds
157        for round_idx in RoundIndex::iter_rounds(paramset.num_round_txs) {
158            // create round and ready to reimburse txs for the round
159            let txhandlers = create_round_txhandlers(
160                paramset,
161                round_idx,
162                operator_data,
163                kickoff_wpks,
164                prev_ready_to_reimburse.as_ref(),
165            )?;
166
167            let mut round_txhandler_opt = None;
168            let mut ready_to_reimburse_txhandler_opt = None;
169            for txhandler in &txhandlers {
170                match txhandler.get_transaction_type() {
171                    TransactionType::Round => round_txhandler_opt = Some(txhandler),
172                    TransactionType::ReadyToReimburse => {
173                        ready_to_reimburse_txhandler_opt = Some(txhandler)
174                    }
175                    _ => {}
176                }
177            }
178            if round_txhandler_opt.is_none() || ready_to_reimburse_txhandler_opt.is_none() {
179                return Err(eyre!(
180                    "Failed to create round and ready to reimburse txs for round {:?} for operator {}",
181                    round_idx,
182                    operator_data.xonly_pk
183                ).into());
184            }
185
186            let round_txid = round_txhandler_opt
187                .expect("Round txhandler should exist, checked above")
188                .get_cached_tx()
189                .compute_txid();
190            let is_round_tx_on_chain = self.is_tx_on_chain(&round_txid).await?;
191            if !is_round_tx_on_chain {
192                break;
193            }
194            let block_hash = self.get_blockhash_of_tx(&round_txid).await?;
195            let block_height = self
196                .get_block_info(&block_hash)
197                .await
198                .wrap_err(format!(
199                    "Failed to get block info for block hash {block_hash}"
200                ))?
201                .height;
202            if block_height < paramset.start_height as usize {
203                tracing::warn!(
204                    "Collateral utxo of operator {operator_data:?} is spent in a block before paramset start height: {block_height} < {0}",
205                    paramset.start_height
206                );
207                return Ok(false);
208            }
209            current_collateral_outpoint = OutPoint {
210                txid: round_txid,
211                vout: UtxoVout::CollateralInRound.get_vout(),
212            };
213            if round_idx == RoundIndex::Round(paramset.num_round_txs - 1) {
214                // for the last round, only check round tx, as if the operator sent the ready to reimburse tx of last round,
215                // it cannot create more kickoffs anymore
216                break;
217            }
218            let ready_to_reimburse_txhandler = ready_to_reimburse_txhandler_opt
219                .expect("Ready to reimburse txhandler should exist");
220            let ready_to_reimburse_txid =
221                ready_to_reimburse_txhandler.get_cached_tx().compute_txid();
222            let is_ready_to_reimburse_tx_on_chain =
223                self.is_tx_on_chain(&ready_to_reimburse_txid).await?;
224            if !is_ready_to_reimburse_tx_on_chain {
225                break;
226            }
227
228            current_collateral_outpoint = OutPoint {
229                txid: ready_to_reimburse_txid,
230                vout: UtxoVout::CollateralInReadyToReimburse.get_vout(),
231            };
232
233            prev_ready_to_reimburse = Some(ready_to_reimburse_txhandler.clone());
234        }
235
236        // if the collateral utxo we found latest in the round tx chain is spent, operators collateral is spent from Clementine
237        // bridge protocol, thus it is unusable and operator cannot fulfill withdrawals anymore
238        // if not spent, it should exist in chain, which is checked below
239        Ok(!self.is_utxo_spent(&current_collateral_outpoint).await?)
240    }
241}
242
243/// Extension trait for test-only RPC methods that depend on core test infrastructure.
244#[cfg(test)]
245#[async_trait]
246pub trait TestRpcExtensions {
247    /// A helper fn to safely mine blocks while waiting for all actors to be synced.
248    async fn mine_blocks_while_synced<C: CitreaClientT>(
249        &self,
250        block_num: u64,
251        actors: &TestActors<C>,
252        e2e: Option<&CitreaE2EData<'_>>,
253    ) -> Result<Vec<BlockHash>>;
254}
255
256#[cfg(test)]
257#[async_trait]
258impl TestRpcExtensions for ExtendedBitcoinRpc {
259    async fn mine_blocks_while_synced<C: CitreaClientT>(
260        &self,
261        block_num: u64,
262        actors: &TestActors<C>,
263        e2e: Option<&CitreaE2EData<'_>>,
264    ) -> Result<Vec<BlockHash>> {
265        match e2e {
266            Some(e2e) if e2e.bitcoin_nodes.iter().count() > 1 => {
267                use bitcoin::secp256k1::rand::{thread_rng, Rng};
268                e2e.bitcoin_nodes
269                    .disconnect_nodes()
270                    .await
271                    .map_err(|e| eyre::eyre!("Failed to disconnect nodes: {}", e))?;
272                let reorg_blocks =
273                    thread_rng().gen_range(0..e2e.config.protocol_paramset().finality_depth as u64);
274                let da0 = e2e.bitcoin_nodes.get(0).expect("node 0 should exist");
275                let da1 = e2e.bitcoin_nodes.get(1).expect("node 1 should exist");
276
277                let mut mined_blocks = Vec::new();
278                while mined_blocks.len() < reorg_blocks as usize {
279                    if !are_all_nodes_synced(self, actors).await? {
280                        tokio::time::sleep(std::time::Duration::from_millis(300)).await;
281                        continue;
282                    }
283                    let num_mine_blocks = std::cmp::min(
284                        MINE_BLOCK_COUNT,
285                        reorg_blocks.saturating_sub(mined_blocks.len() as u64),
286                    );
287                    da0.generate(num_mine_blocks)
288                        .await
289                        .wrap_err("Failed to generate blocks")?;
290                    let new_blocks = da1
291                        .generate(num_mine_blocks)
292                        .await
293                        .wrap_err("Failed to generate blocks")?;
294                    mined_blocks.extend(new_blocks);
295                }
296                mined_blocks.extend(
297                    da1.generate(1)
298                        .await
299                        .wrap_err("Failed to generate blocks")?,
300                );
301                e2e.bitcoin_nodes
302                    .connect_nodes()
303                    .await
304                    .map_err(|e| eyre::eyre!("Failed to connect nodes: {}", e))?;
305                e2e.bitcoin_nodes
306                    .wait_for_sync(None)
307                    .await
308                    .map_err(|e| eyre::eyre!("Failed to wait for sync: {}", e))?;
309                while mined_blocks.len() != (reorg_blocks + block_num + 1) as usize {
310                    if !are_all_nodes_synced(self, actors).await? {
311                        tokio::time::sleep(std::time::Duration::from_millis(300)).await;
312                        continue;
313                    }
314                    let num_mine_blocks = std::cmp::min(
315                        MINE_BLOCK_COUNT,
316                        (reorg_blocks + block_num + 1).saturating_sub(mined_blocks.len() as u64),
317                    );
318                    mined_blocks.extend(self.mine_blocks(num_mine_blocks).await?);
319                }
320                Ok(mined_blocks)
321            }
322            _ => {
323                let mut mined_blocks = Vec::new();
324                while mined_blocks.len() < block_num as usize {
325                    if !are_all_nodes_synced(self, actors).await? {
326                        tokio::time::sleep(std::time::Duration::from_millis(300)).await;
327                        continue;
328                    }
329                    let num_mine_blocks = std::cmp::min(
330                        MINE_BLOCK_COUNT,
331                        block_num.saturating_sub(mined_blocks.len() as u64),
332                    );
333                    let new_blocks = self.mine_blocks(num_mine_blocks).await?;
334                    mined_blocks.extend(new_blocks);
335                }
336                Ok(mined_blocks)
337            }
338        }
339    }
340}
341
342#[cfg(test)]
343mod tests {
344    use std::collections::HashMap;
345
346    use crate::actor::Actor;
347    use crate::config::protocol::{ProtocolParamset, REGTEST_PARAMSET};
348    use crate::extended_bitcoin_rpc::ExtendedBitcoinRpc;
349    use crate::test::common::{
350        create_test_config_with_thread_name, run_citrea_e2e_with_docker_port_retry,
351    };
352    use crate::{
353        bitvm_client::SECP, extended_bitcoin_rpc::BitcoinRPCError, test::common::create_regtest_rpc,
354    };
355    use bitcoin::Amount;
356    use bitcoin::{amount, key::Keypair, Address, XOnlyPublicKey};
357    use bitcoincore_rpc::RpcApi;
358    use citrea_e2e::bitcoin::DEFAULT_FINALITY_DEPTH;
359    use citrea_e2e::config::{BitcoinConfig, TestCaseDockerConfig};
360    use citrea_e2e::node::NodeKind;
361    use citrea_e2e::test_case::TestCaseRunner;
362    use citrea_e2e::Result;
363    use citrea_e2e::{config::TestCaseConfig, framework::TestFramework, test_case::TestCase};
364    use clementine_primitives::FeeRateKvb;
365    use tonic::async_trait;
366
367    #[tokio::test]
368    async fn new_extended_rpc_with_clone() {
369        let mut config = create_test_config_with_thread_name().await;
370        let regtest = create_regtest_rpc(&mut config).await;
371        let rpc = regtest.rpc();
372
373        rpc.mine_blocks(101).await.unwrap();
374        let height = rpc.get_block_count().await.unwrap();
375        let hash = rpc.get_block_hash(height).await.unwrap();
376
377        let cloned_rpc = rpc.clone_inner().await.unwrap();
378        assert_eq!(cloned_rpc.get_block_count().await.unwrap(), height);
379        assert_eq!(cloned_rpc.get_block_hash(height).await.unwrap(), hash);
380    }
381
382    #[tokio::test]
383    async fn test_rpc_call_retry_with_invalid_credentials() {
384        use crate::extended_bitcoin_rpc::RetryableError;
385        use secrecy::SecretString;
386
387        let mut config = create_test_config_with_thread_name().await;
388        let regtest = create_regtest_rpc(&mut config).await;
389
390        // Get a working connection first
391        let working_rpc = regtest.rpc();
392        let url = working_rpc.url().to_string();
393
394        // Create connection with invalid credentials
395        let invalid_user = SecretString::new("invalid_user".to_string().into());
396        let invalid_password = SecretString::new("invalid_password".to_string().into());
397
398        let res = ExtendedBitcoinRpc::connect(url, invalid_user, invalid_password, None).await;
399
400        assert!(res.is_err());
401        assert!(!res.unwrap_err().is_retryable());
402    }
403
404    #[tokio::test]
405    async fn tx_checks_in_mempool_and_on_chain() {
406        let mut config = create_test_config_with_thread_name().await;
407        let regtest = create_regtest_rpc(&mut config).await;
408        let rpc = regtest.rpc();
409
410        let keypair = Keypair::from_secret_key(&SECP, &config.secret_key);
411        let (xonly, _parity) = XOnlyPublicKey::from_keypair(&keypair);
412        let address = Address::p2tr(&SECP, xonly, None, config.protocol_paramset.network);
413
414        let amount = amount::Amount::from_sat(10000);
415
416        // Prepare a transaction.
417        let utxo = rpc.send_to_address(&address, amount).await.unwrap();
418        let tx = rpc.get_tx_of_txid(&utxo.txid).await.unwrap();
419        let txid = tx.compute_txid();
420        tracing::debug!("TXID: {}", txid);
421
422        assert_eq!(tx.output[utxo.vout as usize].value, amount);
423        assert_eq!(utxo.txid, txid);
424        assert!(rpc
425            .check_utxo_address_and_amount(&utxo, &address.script_pubkey(), amount)
426            .await
427            .unwrap());
428
429        // In mempool.
430        assert!(rpc.confirmation_blocks(&utxo.txid).await.is_err());
431        assert!(rpc.get_blockhash_of_tx(&utxo.txid).await.is_err());
432        assert!(!rpc.is_tx_on_chain(&txid).await.unwrap());
433        assert!(rpc.is_utxo_spent(&utxo).await.is_err());
434
435        rpc.mine_blocks(1).await.unwrap();
436        let height = rpc.get_block_count().await.unwrap();
437        assert_eq!(height as u32, rpc.get_current_chain_height().await.unwrap());
438        let blockhash = rpc.get_block_hash(height).await.unwrap();
439
440        // On chain.
441        assert_eq!(rpc.confirmation_blocks(&utxo.txid).await.unwrap(), 1);
442        assert_eq!(
443            rpc.get_blockhash_of_tx(&utxo.txid).await.unwrap(),
444            blockhash
445        );
446        assert_eq!(rpc.get_tx_of_txid(&txid).await.unwrap(), tx);
447        assert!(rpc.is_tx_on_chain(&txid).await.unwrap());
448        assert!(!rpc.is_utxo_spent(&utxo).await.unwrap());
449
450        // Doesn't matter if in mempool or on chain.
451        let txout = rpc.get_txout_from_outpoint(&utxo).await.unwrap();
452        assert_eq!(txout.value, amount);
453        assert_eq!(rpc.get_tx_of_txid(&txid).await.unwrap(), tx);
454
455        let height = rpc.get_current_chain_height().await.unwrap();
456        let (hash, header) = rpc.get_block_info_by_height(height.into()).await.unwrap();
457        assert_eq!(blockhash, hash);
458        assert_eq!(rpc.get_block_header(&hash).await.unwrap(), header);
459    }
460
461    #[tokio::test]
462    async fn bump_fee_with_fee_rate() {
463        let mut config = create_test_config_with_thread_name().await;
464        let regtest = create_regtest_rpc(&mut config).await;
465        let rpc = regtest.rpc();
466
467        let keypair = Keypair::from_secret_key(&SECP, &config.secret_key);
468        let (xonly, _parity) = XOnlyPublicKey::from_keypair(&keypair);
469        let address = Address::p2tr(&SECP, xonly, None, config.protocol_paramset.network);
470
471        let amount = amount::Amount::from_sat(10000);
472
473        // Confirmed transaction cannot be fee bumped.
474        let utxo = rpc.send_to_address(&address, amount).await.unwrap();
475        rpc.mine_blocks(1).await.unwrap();
476        assert!(rpc
477            .bump_fee_with_fee_rate(utxo.txid, FeeRateKvb::from_sat_per_vb(1).unwrap())
478            .await
479            .inspect_err(|e| {
480                match e {
481                    BitcoinRPCError::TransactionAlreadyInBlock(_) => {}
482                    _ => panic!("Unexpected error: {e:?}"),
483                }
484            })
485            .is_err());
486
487        let current_fee_rate = FeeRateKvb::from_sat_per_vb_unchecked(1);
488
489        // Trying to bump a transaction with a fee rate that is already enough
490        // should return the original txid.
491        let utxo = rpc.send_to_address(&address, amount).await.unwrap();
492        let txid = rpc
493            .bump_fee_with_fee_rate(utxo.txid, current_fee_rate)
494            .await
495            .unwrap();
496        assert_eq!(txid, utxo.txid);
497
498        // A bigger fee rate should return a different txid.
499        let new_fee_rate = FeeRateKvb::from_sat_per_vb_unchecked(10000);
500        let txid = rpc
501            .bump_fee_with_fee_rate(utxo.txid, new_fee_rate)
502            .await
503            .unwrap();
504        assert_ne!(txid, utxo.txid);
505    }
506
507    struct ReorgChecks;
508    #[async_trait]
509    impl TestCase for ReorgChecks {
510        fn bitcoin_config() -> BitcoinConfig {
511            BitcoinConfig {
512                extra_args: vec![
513                    "-txindex=1",
514                    "-fallbackfee=0.000001",
515                    "-rpcallowip=0.0.0.0/0",
516                    "-dustrelayfee=0",
517                ],
518                ..Default::default()
519            }
520        }
521
522        fn test_config() -> TestCaseConfig {
523            TestCaseConfig {
524                with_sequencer: false,
525                with_batch_prover: false,
526                n_nodes: HashMap::from([(NodeKind::Bitcoin, 2)]),
527                docker: TestCaseDockerConfig {
528                    bitcoin: true,
529                    citrea: false,
530                },
531                ..Default::default()
532            }
533        }
534
535        async fn run_test(&mut self, f: &mut TestFramework) -> Result<()> {
536            let (da0, da1) = (
537                f.bitcoin_nodes.get(0).unwrap(),
538                f.bitcoin_nodes.get(1).unwrap(),
539            );
540
541            let mut config = create_test_config_with_thread_name().await;
542            const PARAMSET: ProtocolParamset = ProtocolParamset {
543                finality_depth: DEFAULT_FINALITY_DEPTH as u32,
544                ..REGTEST_PARAMSET
545            };
546            config.protocol_paramset = &PARAMSET;
547            config.bitcoin_rpc_user = da0.config.rpc_user.clone().into();
548            config.bitcoin_rpc_password = da0.config.rpc_password.clone().into();
549            config.bitcoin_rpc_url = format!(
550                "http://127.0.0.1:{}/wallet/{}",
551                da0.config.rpc_port,
552                NodeKind::Bitcoin
553            );
554
555            let rpc = ExtendedBitcoinRpc::connect(
556                config.bitcoin_rpc_url.clone(),
557                config.bitcoin_rpc_user.clone(),
558                config.bitcoin_rpc_password.clone(),
559                None,
560            )
561            .await
562            .unwrap();
563
564            // Reorg starts here.
565            f.bitcoin_nodes.disconnect_nodes().await?;
566
567            let before_reorg_tip_height = rpc.get_block_count().await?;
568            let before_reorg_tip_hash = rpc.get_block_hash(before_reorg_tip_height).await?;
569
570            let address = Actor::new(config.secret_key, config.protocol_paramset.network).address;
571            let tx = rpc
572                .send_to_address(&address, Amount::from_sat(10000))
573                .await?;
574
575            assert!(!rpc.is_tx_on_chain(&tx.txid).await?);
576            rpc.mine_blocks(1).await?;
577            assert!(rpc.is_tx_on_chain(&tx.txid).await?);
578
579            // Make the second branch longer and perform a reorg.
580            let reorg_depth = 4;
581            da1.generate(reorg_depth).await.unwrap();
582            f.bitcoin_nodes.connect_nodes().await?;
583            f.bitcoin_nodes.wait_for_sync(None).await?;
584
585            // Check that reorg happened.
586            let current_tip_height = rpc.get_block_count().await?;
587            assert_eq!(
588                before_reorg_tip_height + reorg_depth,
589                current_tip_height,
590                "Re-org did not occur"
591            );
592            let current_tip_hash = rpc.get_block_hash(current_tip_height).await?;
593            assert_ne!(
594                before_reorg_tip_hash, current_tip_hash,
595                "Re-org did not occur"
596            );
597
598            assert!(!rpc.is_tx_on_chain(&tx.txid).await?);
599
600            Ok(())
601        }
602    }
603
604    #[tokio::test]
605    async fn reorg_checks() -> Result<()> {
606        run_citrea_e2e_with_docker_port_retry(|| TestCaseRunner::new(ReorgChecks).run()).await
607    }
608
609    mod retry_config_tests {
610        use crate::extended_bitcoin_rpc::RetryConfig;
611
612        use std::time::Duration;
613
614        #[test]
615        fn test_retry_config_default() {
616            let config = RetryConfig::default();
617            assert_eq!(config.initial_delay_millis, 100);
618            assert_eq!(config.max_delay, Duration::from_secs(30));
619            assert_eq!(config.max_attempts, 5);
620            assert_eq!(config.backoff_multiplier, 2);
621            assert!(!config.is_jitter);
622        }
623
624        #[test]
625        fn test_retry_config_custom() {
626            let initial = 200;
627            let max = Duration::from_secs(10);
628            let attempts = 7;
629            let backoff_multiplier = 3;
630            let jitter = true;
631            let config = RetryConfig::new(initial, max, attempts, backoff_multiplier, jitter);
632            assert_eq!(config.initial_delay_millis, initial);
633            assert_eq!(config.max_delay, max);
634            assert_eq!(config.max_attempts, attempts);
635            assert_eq!(config.backoff_multiplier, backoff_multiplier);
636            assert!(config.is_jitter);
637        }
638
639        #[test]
640        fn test_retry_strategy_initial_delay() {
641            // Test that the first delay matches the expected initial_delay_millis
642            // when initial_delay_millis is divisible by backoff_multiplier
643            let initial_delay_millis = 100;
644            let backoff_multiplier = 2;
645            let config = RetryConfig::new(
646                initial_delay_millis,
647                Duration::from_secs(30),
648                5,
649                backoff_multiplier,
650                false, // no jitter for predictable testing
651            );
652
653            let mut strategy = config.get_strategy();
654            let first_delay = strategy.next().expect("Should have first delay");
655
656            // The formula is: first_delay = base * factor
657            // We set base = initial_delay_millis / backoff_multiplier
658            // So: first_delay = (initial_delay_millis / backoff_multiplier) * backoff_multiplier = initial_delay_millis
659            assert_eq!(
660                first_delay,
661                Duration::from_millis(initial_delay_millis),
662                "First delay should match initial_delay_millis"
663            );
664
665            // Verify the second delay is approximately initial_delay_millis * backoff_multiplier
666            let second_delay = strategy.next().expect("Should have second delay");
667            assert_eq!(
668                second_delay,
669                Duration::from_millis(initial_delay_millis * backoff_multiplier),
670                "Second delay should be initial_delay_millis * backoff_multiplier"
671            );
672        }
673    }
674
675    mod retryable_error_tests {
676        use bitcoin::{hashes::Hash, BlockHash, Txid};
677
678        use crate::extended_bitcoin_rpc::RetryableError;
679
680        use super::*;
681        use std::io::{Error as IoError, ErrorKind};
682
683        #[test]
684        fn test_bitcoin_rpc_error_retryable_io_errors() {
685            let retryable_kinds = [
686                ErrorKind::ConnectionRefused,
687                ErrorKind::ConnectionReset,
688                ErrorKind::ConnectionAborted,
689                ErrorKind::NotConnected,
690                ErrorKind::BrokenPipe,
691                ErrorKind::TimedOut,
692                ErrorKind::Interrupted,
693                ErrorKind::UnexpectedEof,
694            ];
695
696            for kind in retryable_kinds {
697                let io_error = IoError::new(kind, "test error");
698                let rpc_error = bitcoincore_rpc::Error::Io(io_error);
699                assert!(
700                    rpc_error.is_retryable(),
701                    "ErrorKind::{kind:?} should be retryable"
702                );
703            }
704        }
705
706        #[test]
707        fn test_bitcoin_rpc_error_non_retryable_io_errors() {
708            let non_retryable_kinds = [
709                ErrorKind::PermissionDenied,
710                ErrorKind::NotFound,
711                ErrorKind::InvalidInput,
712                ErrorKind::InvalidData,
713            ];
714
715            for kind in non_retryable_kinds {
716                let io_error = IoError::new(kind, "test error");
717                let rpc_error = bitcoincore_rpc::Error::Io(io_error);
718                assert!(
719                    !rpc_error.is_retryable(),
720                    "ErrorKind::{kind:?} should not be retryable"
721                );
722            }
723        }
724
725        #[test]
726        fn test_bitcoin_rpc_error_auth_not_retryable() {
727            let auth_error = bitcoincore_rpc::Error::Auth("Invalid credentials".to_string());
728            assert!(!auth_error.is_retryable());
729        }
730
731        #[test]
732        fn test_bitcoin_rpc_error_url_parse_not_retryable() {
733            let url_error = url::ParseError::EmptyHost;
734            let rpc_error = bitcoincore_rpc::Error::UrlParse(url_error);
735            assert!(!rpc_error.is_retryable());
736        }
737
738        #[test]
739        fn test_bitcoin_rpc_error_invalid_cookie_not_retryable() {
740            let rpc_error = bitcoincore_rpc::Error::InvalidCookieFile;
741            assert!(!rpc_error.is_retryable());
742        }
743
744        #[test]
745        fn test_bitcoin_rpc_error_returned_error_non_retryable_patterns() {
746            let non_retryable_messages = [
747                "insufficient funds",
748                "transaction already in blockchain",
749                "invalid transaction",
750                "not found in mempool",
751                "transaction conflict",
752            ];
753
754            for msg in non_retryable_messages {
755                let rpc_error = bitcoincore_rpc::Error::ReturnedError(msg.to_string());
756                assert!(
757                    !rpc_error.is_retryable(),
758                    "Message '{msg}' should not be retryable"
759                );
760            }
761        }
762
763        #[test]
764        fn test_bitcoin_rpc_error_unexpected_structure_retryable() {
765            let rpc_error = bitcoincore_rpc::Error::UnexpectedStructure;
766            assert!(rpc_error.is_retryable());
767        }
768
769        #[test]
770        fn test_bitcoin_rpc_error_serialization_errors_not_retryable() {
771            use bitcoin::consensus::encode::Error as EncodeError;
772
773            let serialization_errors = [
774                bitcoincore_rpc::Error::BitcoinSerialization(EncodeError::Io(
775                    IoError::other("test").into(),
776                )),
777                // bitcoincore_rpc::Error::Hex(HexToBytesError::InvalidChar(InvalidCharError{invalid: 0})),
778                bitcoincore_rpc::Error::Json(serde_json::Error::io(IoError::other("test"))),
779            ];
780
781            for error in serialization_errors {
782                assert!(
783                    !error.is_retryable(),
784                    "Serialization error should not be retryable"
785                );
786            }
787        }
788
789        #[test]
790        fn test_bridge_rpc_error_retryable() {
791            // Test permanent errors
792            assert!(
793                !BitcoinRPCError::TransactionAlreadyInBlock(BlockHash::all_zeros()).is_retryable()
794            );
795            assert!(!BitcoinRPCError::BumpFeeUTXOSpent(Default::default()).is_retryable());
796
797            // Test potentially retryable errors
798            let txid = Txid::all_zeros();
799            let fee_rate = FeeRateKvb::from_sat_per_vb_unchecked(1);
800            assert!(BitcoinRPCError::BumpFeeError(txid, fee_rate).is_retryable());
801
802            // Test Other error with retryable patterns
803            let retryable_other = BitcoinRPCError::Other(eyre::eyre!("timeout occurred"));
804            assert!(retryable_other.is_retryable());
805
806            let non_retryable_other = BitcoinRPCError::Other(eyre::eyre!("permission denied"));
807            assert!(!non_retryable_other.is_retryable());
808        }
809    }
810
811    mod rpc_call_retry_tests {
812
813        use crate::extended_bitcoin_rpc::RetryableError;
814
815        use super::*;
816        use secrecy::SecretString;
817
818        #[tokio::test]
819        async fn test_rpc_call_retry_with_invalid_host() {
820            let user = SecretString::new("user".to_string().into());
821            let password = SecretString::new("password".to_string().into());
822            let invalid_url = "http://nonexistent-host:8332".to_string();
823
824            let res = ExtendedBitcoinRpc::connect(invalid_url, user, password, None).await;
825
826            assert!(res.is_err());
827            assert!(!res.unwrap_err().is_retryable());
828        }
829    }
830
831    mod convenience_method_tests {
832        use super::*;
833
834        #[tokio::test]
835        async fn test_get_block_hash_with_retry() {
836            let mut config = create_test_config_with_thread_name().await;
837            let regtest = create_regtest_rpc(&mut config).await;
838            let rpc = regtest.rpc();
839
840            // Mine a block first
841            rpc.mine_blocks(1).await.unwrap();
842            let height = rpc.get_block_count().await.unwrap();
843
844            let result = rpc.get_block_hash(height).await;
845            assert!(result.is_ok());
846
847            let expected_hash = rpc.get_block_hash(height).await.unwrap();
848            assert_eq!(result.unwrap(), expected_hash);
849        }
850
851        #[tokio::test]
852        async fn test_get_tx_out_with_retry() {
853            let mut config = create_test_config_with_thread_name().await;
854            let regtest = create_regtest_rpc(&mut config).await;
855            let rpc = regtest.rpc();
856
857            // Create a transaction
858            let keypair = Keypair::from_secret_key(&SECP, &config.secret_key);
859            let (xonly, _parity) = XOnlyPublicKey::from_keypair(&keypair);
860            let address = Address::p2tr(&SECP, xonly, None, config.protocol_paramset.network);
861            let amount = Amount::from_sat(10000);
862
863            let utxo = rpc.send_to_address(&address, amount).await.unwrap();
864
865            let result = rpc.get_tx_of_txid(&utxo.txid).await;
866            assert!(result.is_ok());
867
868            let tx = result.unwrap();
869            assert_eq!(tx.compute_txid(), utxo.txid);
870        }
871
872        #[tokio::test]
873        async fn test_send_to_address_with_retry() {
874            let mut config = create_test_config_with_thread_name().await;
875            let regtest = create_regtest_rpc(&mut config).await;
876            let rpc = regtest.rpc();
877
878            let keypair = Keypair::from_secret_key(&SECP, &config.secret_key);
879            let (xonly, _parity) = XOnlyPublicKey::from_keypair(&keypair);
880            let address = Address::p2tr(&SECP, xonly, None, config.protocol_paramset.network);
881            let amount = Amount::from_sat(10000);
882
883            let result = rpc.send_to_address(&address, amount).await;
884            assert!(result.is_ok());
885
886            let outpoint = result.unwrap();
887
888            // Verify the transaction exists
889            let tx = rpc.get_tx_of_txid(&outpoint.txid).await.unwrap();
890            assert_eq!(tx.output[outpoint.vout as usize].value, amount);
891        }
892
893        #[tokio::test]
894        async fn test_bump_fee_with_retry() {
895            let mut config = create_test_config_with_thread_name().await;
896            let regtest = create_regtest_rpc(&mut config).await;
897            let rpc = regtest.rpc();
898
899            let keypair = Keypair::from_secret_key(&SECP, &config.secret_key);
900            let (xonly, _parity) = XOnlyPublicKey::from_keypair(&keypair);
901            let address = Address::p2tr(&SECP, xonly, None, config.protocol_paramset.network);
902            let amount = Amount::from_sat(10000);
903
904            // Create an unconfirmed transaction
905            let utxo = rpc.send_to_address(&address, amount).await.unwrap();
906            let new_fee_rate = FeeRateKvb::from_sat_per_vb_unchecked(10000);
907
908            let result = rpc.bump_fee_with_fee_rate(utxo.txid, new_fee_rate).await;
909            assert!(result.is_ok());
910
911            let new_txid = result.unwrap();
912            // Should return a different txid since fee was actually bumped
913            assert_ne!(new_txid, utxo.txid);
914        }
915    }
916}