Skip to main content

ark_client/
lib.rs

1use crate::error::ErrorContext;
2use crate::key_provider::KeypairIndex;
3use crate::utils::sleep;
4use crate::utils::timeout_op;
5use crate::wallet::BoardingWallet;
6use crate::wallet::OnchainWallet;
7use ark_core::build_anchor_tx;
8use ark_core::history;
9use ark_core::history::generate_incoming_vtxo_transaction_history;
10use ark_core::history::generate_outgoing_vtxo_transaction_history;
11use ark_core::history::sort_transactions_by_created_at;
12use ark_core::history::OutgoingTransaction;
13use ark_core::server;
14use ark_core::server::GetVtxosRequest;
15use ark_core::server::SubscriptionResponse;
16use ark_core::server::VirtualTxOutPoint;
17use ark_core::ArkAddress;
18use ark_core::ExplorerUtxo;
19use ark_core::UtxoCoinSelection;
20use ark_core::Vtxo;
21use ark_core::VtxoList;
22use ark_core::DEFAULT_DERIVATION_PATH;
23use ark_grpc::VtxoChainResponse;
24use bitcoin::bip32::DerivationPath;
25use bitcoin::bip32::Xpriv;
26use bitcoin::key::Keypair;
27use bitcoin::key::Secp256k1;
28use bitcoin::secp256k1::All;
29use bitcoin::Address;
30use bitcoin::Amount;
31use bitcoin::OutPoint;
32use bitcoin::ScriptBuf;
33use bitcoin::Transaction;
34use bitcoin::Txid;
35use bitcoin::XOnlyPublicKey;
36use futures::Future;
37use futures::Stream;
38use std::collections::HashMap;
39use std::collections::HashSet;
40use std::str::FromStr;
41use std::sync::Arc;
42use std::time::Duration;
43
44pub mod error;
45pub mod key_provider;
46pub mod swap_storage;
47pub mod wallet;
48
49mod batch;
50mod boltz;
51mod coin_select;
52mod fee_estimation;
53mod send_vtxo;
54mod unilateral_exit;
55mod utils;
56
57pub use boltz::ReverseSwapData;
58pub use boltz::SubmarineSwapData;
59pub use boltz::SwapAmount;
60pub use boltz::TimeoutBlockHeights;
61pub use error::Error;
62pub use key_provider::Bip32KeyProvider;
63pub use key_provider::KeyProvider;
64pub use key_provider::StaticKeyProvider;
65pub use lightning_invoice;
66pub use swap_storage::InMemorySwapStorage;
67#[cfg(feature = "sqlite")]
68pub use swap_storage::SqliteSwapStorage;
69pub use swap_storage::SwapStorage;
70
71/// Default gap limit for BIP44-style key discovery
72///
73/// This is the number of consecutive unused addresses to scan before
74/// assuming all used addresses have been found.
75pub const DEFAULT_GAP_LIMIT: u32 = 20;
76
77/// A client to interact with Ark Server
78///
79/// ## Example
80///
81/// ```rust
82/// # use std::future::Future;
83/// # use std::str::FromStr;
84/// # use std::time::Duration;
85/// # use ark_client::{Blockchain, Client, Error, SpendStatus, TxStatus};
86/// # use ark_client::OfflineClient;
87/// # use bitcoin::key::Keypair;
88/// # use bitcoin::secp256k1::{Message, SecretKey};
89/// # use std::sync::Arc;
90/// # use bitcoin::{Address, Amount, FeeRate, Network, Psbt, Transaction, Txid, XOnlyPublicKey};
91/// # use bitcoin::secp256k1::schnorr::Signature;
92/// # use ark_client::wallet::{Balance, BoardingWallet, OnchainWallet, Persistence};
93/// # use ark_client::InMemorySwapStorage;
94/// # use ark_core::{BoardingOutput, UtxoCoinSelection, ExplorerUtxo};
95/// # use ark_client::StaticKeyProvider;
96///
97/// struct MyBlockchain {}
98/// #
99/// # impl MyBlockchain {
100/// #     pub fn new(_url: &str) -> Self { Self {}}
101/// # }
102/// #
103/// # impl Blockchain for MyBlockchain {
104/// #
105/// #     async fn find_outpoints(&self, address: &Address) -> Result<Vec<ExplorerUtxo>, Error> {
106/// #         unimplemented!("You can implement this function using your preferred client library such as esplora_client")
107/// #     }
108/// #
109/// #     async fn find_tx(&self, txid: &Txid) -> Result<Option<Transaction>, Error> {
110/// #         unimplemented!()
111/// #     }
112/// #
113/// #     async fn get_tx_status(&self, txid: &Txid) -> Result<TxStatus, Error> {
114/// #         unimplemented!()
115/// #     }
116/// #
117/// #     async fn get_output_status(&self, txid: &Txid, vout: u32) -> Result<SpendStatus, Error> {
118/// #         unimplemented!()
119/// #     }
120/// #
121/// #     async fn broadcast(&self, tx: &Transaction) -> Result<(), Error> {
122/// #         unimplemented!()
123/// #     }
124/// #
125/// #     async fn get_fee_rate(&self) -> Result<f64, Error> {
126/// #         unimplemented!()
127/// #     }
128/// #
129/// #     async fn broadcast_package(
130/// #         &self,
131/// #         txs: &[&Transaction],
132/// #     ) -> Result<(), Error> {
133/// #         unimplemented!()
134/// #     }
135/// # }
136///
137/// struct MyWallet {}
138/// # impl OnchainWallet for MyWallet where {
139/// #
140/// #     fn get_onchain_address(&self) -> Result<Address, Error> {
141/// #         unimplemented!("You can implement this function using your preferred client library such as bdk")
142/// #     }
143/// #
144/// #     async fn sync(&self) -> Result<(), Error> {
145/// #         unimplemented!()
146/// #     }
147/// #
148/// #     fn balance(&self) -> Result<Balance, Error> {
149/// #         unimplemented!()
150/// #     }
151/// #
152/// #     fn prepare_send_to_address(&self, address: Address, amount: Amount, fee_rate: FeeRate) -> Result<Psbt, Error> {
153/// #         unimplemented!()
154/// #     }
155/// #
156/// #     fn sign(&self, psbt: &mut Psbt) -> Result<bool, Error> {
157/// #         unimplemented!()
158/// #     }
159/// #
160/// #     fn select_coins(&self, target_amount: Amount) -> Result<ark_core::UtxoCoinSelection, Error> {
161/// #         unimplemented!()
162/// #     }
163/// # }
164/// #
165///
166/// struct InMemoryDb {}
167/// # impl Persistence for InMemoryDb {
168/// #
169/// #     fn save_boarding_output(
170/// #         &self,
171/// #         sk: SecretKey,
172/// #         boarding_output: BoardingOutput,
173/// #     ) -> Result<(), Error> {
174/// #       unimplemented!()
175/// #     }
176/// #
177/// #     fn load_boarding_outputs(&self) -> Result<Vec<BoardingOutput>, Error> {
178/// #           unimplemented!()
179/// #     }
180/// #
181/// #     fn sk_for_pk(&self, pk: &XOnlyPublicKey) -> Result<SecretKey, Error> {
182/// #         unimplemented!()
183/// #     }
184/// # }
185/// #
186/// #
187/// # impl BoardingWallet for MyWallet
188/// # where
189/// # {
190/// #     fn new_boarding_output(
191/// #         &self,
192/// #         server_pk: XOnlyPublicKey,
193/// #         exit_delay: bitcoin::Sequence,
194/// #         network: Network,
195/// #     ) -> Result<BoardingOutput, Error> {
196/// #         unimplemented!()
197/// #     }
198/// #
199/// #     fn get_boarding_outputs(&self) -> Result<Vec<BoardingOutput>, Error> {
200/// #         unimplemented!()
201/// #     }
202/// #
203/// #     fn sign_for_pk(&self, pk: &XOnlyPublicKey, msg: &Message) -> Result<Signature, Error> {
204/// #         unimplemented!()
205/// #     }
206/// # }
207/// #
208/// // Initialize the client with a static keypair
209/// async fn init_client_with_keypair() -> Result<Client<MyBlockchain, MyWallet, InMemorySwapStorage, ark_client::StaticKeyProvider>, ark_client::Error> {
210///     // Create a keypair for signing transactions
211///     let secp = bitcoin::key::Secp256k1::new();
212///     let secret_key = SecretKey::from_str("your_private_key_here").unwrap();
213///     let keypair = Keypair::from_secret_key(&secp, &secret_key);
214///
215///     // Initialize blockchain and wallet implementations
216///     let blockchain = Arc::new(MyBlockchain::new("https://esplora.example.com"));
217///     let wallet = Arc::new(MyWallet {});
218///     let timeout = Duration::from_secs(30);
219///
220///     // Create the offline client (backward compatible method)
221///     let offline_client = OfflineClient::<MyBlockchain, MyWallet, InMemorySwapStorage, StaticKeyProvider>::new_with_keypair(
222///         "my-ark-client".to_string(),
223///         keypair,
224///         blockchain,
225///         wallet,
226///         "https://ark-server.example.com".to_string(),
227///         Arc::new(InMemorySwapStorage::default()),
228///         "http://boltz.example.com".to_string(),
229///         timeout
230///     );
231///
232///     // Connect to the Ark server and get server info
233///     let client = offline_client.connect().await?;
234///
235///     Ok(client)
236/// }
237///
238/// // Initialize the client with a BIP32 HD wallet
239/// # use bitcoin::bip32::{Xpriv, DerivationPath};
240/// async fn init_client_with_bip32() -> Result<Client<MyBlockchain, MyWallet, InMemorySwapStorage, ark_client::Bip32KeyProvider>, ark_client::Error> {
241///     // Create a BIP32 master key and derivation path
242///     let master_key = Xpriv::from_str("xprv...").unwrap();
243///     let derivation_path = DerivationPath::from_str("m/84'/0'/0'/0/0").unwrap();
244///
245///     let key_provider = Arc::new(ark_client::Bip32KeyProvider::new(master_key, derivation_path));
246///
247///     // Initialize blockchain and wallet implementations
248///     let blockchain = Arc::new(MyBlockchain::new("https://esplora.example.com"));
249///     let wallet = Arc::new(MyWallet {});
250///     let timeout = Duration::from_secs(30);
251///
252///     // Create the offline client with BIP32 key provider
253///     let offline_client = OfflineClient::new(
254///         "my-ark-client".to_string(),
255///         key_provider,
256///         blockchain,
257///         wallet,
258///         "https://ark-server.example.com".to_string(),
259///         Arc::new(InMemorySwapStorage::default()),
260///         "http://boltz.example.com".to_string(),
261///         timeout
262///     );
263///
264///     // Connect to the Ark server and get server info
265///     let client = offline_client.connect().await?;
266///
267///     Ok(client)
268/// }
269/// ```
270#[derive(Clone)]
271pub struct OfflineClient<B, W, S, K> {
272    // TODO: We could introduce a generic interface so that consumers can use either GRPC or REST.
273    network_client: ark_grpc::Client,
274    pub name: String,
275    key_provider: Arc<K>,
276    blockchain: Arc<B>,
277    secp: Secp256k1<All>,
278    wallet: Arc<W>,
279    swap_storage: Arc<S>,
280    boltz_url: String,
281    timeout: Duration,
282}
283
284/// A client to interact with Ark server
285///
286/// See [`OfflineClient`] docs for details.
287pub struct Client<B, W, S, K> {
288    inner: OfflineClient<B, W, S, K>,
289    pub server_info: server::Info,
290    fee_estimator: ark_fees::Estimator,
291}
292
293#[derive(Clone, Copy, Debug)]
294pub struct TxStatus {
295    pub confirmed_at: Option<i64>,
296}
297
298#[derive(Clone, Copy, Debug)]
299pub struct SpendStatus {
300    pub spend_txid: Option<Txid>,
301}
302
303pub struct AddressVtxos {
304    pub unspent: Vec<VirtualTxOutPoint>,
305    pub spent: Vec<VirtualTxOutPoint>,
306}
307
308#[derive(Clone, Copy, Debug, Default)]
309pub struct OffChainBalance {
310    pre_confirmed: Amount,
311    confirmed: Amount,
312    recoverable: Amount,
313}
314
315impl OffChainBalance {
316    pub fn pre_confirmed(&self) -> Amount {
317        self.pre_confirmed
318    }
319
320    pub fn confirmed(&self) -> Amount {
321        self.confirmed
322    }
323
324    /// Balance which can only be settled, and does not require a forfeit transaction per VTXO.
325    pub fn recoverable(&self) -> Amount {
326        self.recoverable
327    }
328
329    pub fn total(&self) -> Amount {
330        self.pre_confirmed + self.confirmed + self.recoverable
331    }
332}
333
334pub trait Blockchain {
335    fn find_outpoints(
336        &self,
337        address: &Address,
338    ) -> impl Future<Output = Result<Vec<ExplorerUtxo>, Error>> + Send;
339
340    fn find_tx(
341        &self,
342        txid: &Txid,
343    ) -> impl Future<Output = Result<Option<Transaction>, Error>> + Send;
344
345    fn get_tx_status(&self, txid: &Txid) -> impl Future<Output = Result<TxStatus, Error>> + Send;
346
347    fn get_output_status(
348        &self,
349        txid: &Txid,
350        vout: u32,
351    ) -> impl Future<Output = Result<SpendStatus, Error>> + Send;
352
353    fn broadcast(&self, tx: &Transaction) -> impl Future<Output = Result<(), Error>> + Send;
354
355    fn get_fee_rate(&self) -> impl Future<Output = Result<f64, Error>> + Send;
356
357    fn broadcast_package(
358        &self,
359        txs: &[&Transaction],
360    ) -> impl Future<Output = Result<(), Error>> + Send;
361}
362
363impl<B, W, S, K> OfflineClient<B, W, S, K>
364where
365    B: Blockchain,
366    W: BoardingWallet + OnchainWallet,
367    S: SwapStorage + 'static,
368    K: KeyProvider,
369{
370    /// Create a new offline client with a generic key provider
371    ///
372    /// # Arguments
373    ///
374    /// * `name` - Client identifier
375    /// * `key_provider` - Implementation of KeyProvider trait (StaticKeyProvider, Bip32KeyProvider,
376    ///   etc.)
377    /// * `blockchain` - Blockchain interface implementation
378    /// * `wallet` - Wallet implementation
379    /// * `ark_server_url` - URL of the Ark server
380    /// * `swap_storage` - Storage implementation for swap data
381    /// * `boltz_url` - URL of the Boltz server
382    /// * `timeout` - Timeout duration for network operations
383    #[allow(clippy::too_many_arguments)]
384    pub fn new(
385        name: String,
386        key_provider: Arc<K>,
387        blockchain: Arc<B>,
388        wallet: Arc<W>,
389        ark_server_url: String,
390        swap_storage: Arc<S>,
391        boltz_url: String,
392        timeout: Duration,
393    ) -> Self {
394        let secp = Secp256k1::new();
395
396        let network_client = ark_grpc::Client::new(ark_server_url);
397
398        Self {
399            network_client,
400            name,
401            key_provider,
402            blockchain,
403            secp,
404            wallet,
405            swap_storage,
406            boltz_url,
407            timeout,
408        }
409    }
410
411    /// Create a new offline client with a static keypair (backward compatible)
412    ///
413    /// This is a convenience method that wraps a single keypair in a StaticKeyProvider.
414    ///
415    /// # Arguments
416    ///
417    /// * `name` - Client identifier
418    /// * `kp` - Static keypair for signing
419    /// * `blockchain` - Blockchain interface implementation
420    /// * `wallet` - Wallet implementation
421    /// * `ark_server_url` - URL of the Ark server
422    /// * `swap_storage` - Storage implementation for swap data
423    /// * `boltz_url` - URL of the Boltz server
424    /// * `timeout` - Timeout duration for network operations
425    #[allow(clippy::too_many_arguments)]
426    pub fn new_with_keypair(
427        name: String,
428        kp: Keypair,
429        blockchain: Arc<B>,
430        wallet: Arc<W>,
431        ark_server_url: String,
432        swap_storage: Arc<S>,
433        boltz_url: String,
434        timeout: Duration,
435    ) -> OfflineClient<B, W, S, StaticKeyProvider> {
436        let key_provider = Arc::new(StaticKeyProvider::new(kp));
437
438        OfflineClient::new(
439            name,
440            key_provider,
441            blockchain,
442            wallet,
443            ark_server_url,
444            swap_storage,
445            boltz_url,
446            timeout,
447        )
448    }
449
450    /// Create a new offline client with an [`Xpriv`]
451    ///
452    /// # Arguments
453    ///
454    /// * `name` - Client identifier
455    /// * `xpriv` - BIP32 Xpriv
456    /// * `blockchain` - Blockchain interface implementation
457    /// * `wallet` - Wallet implementation
458    /// * `ark_server_url` - URL of the Ark server
459    /// * `swap_storage` - Storage implementation for swap data
460    /// * `boltz_url` - URL of the Boltz server
461    /// * `timeout` - Timeout duration for network operations
462    #[allow(clippy::too_many_arguments)]
463    pub fn new_with_bip32(
464        name: String,
465        xpriv: Xpriv,
466        path: Option<DerivationPath>,
467        blockchain: Arc<B>,
468        wallet: Arc<W>,
469        ark_server_url: String,
470        swap_storage: Arc<S>,
471        boltz_url: String,
472        timeout: Duration,
473    ) -> OfflineClient<B, W, S, Bip32KeyProvider> {
474        let path = path.unwrap_or(
475            DerivationPath::from_str(DEFAULT_DERIVATION_PATH).expect("valid derivation path"),
476        );
477        let key_provider = Arc::new(Bip32KeyProvider::new(xpriv, path));
478
479        OfflineClient::new(
480            name,
481            key_provider,
482            blockchain,
483            wallet,
484            ark_server_url,
485            swap_storage,
486            boltz_url,
487            timeout,
488        )
489    }
490
491    /// Connects to the Ark server and retrieves server information.
492    ///
493    /// # Errors
494    ///
495    /// Returns an error if the connection fails or times out.
496    pub async fn connect(mut self) -> Result<Client<B, W, S, K>, Error> {
497        timeout_op(self.timeout, self.network_client.connect())
498            .await
499            .context("Failed to connect to Ark server")??;
500
501        self.finish_connect().await
502    }
503
504    /// Connects to the Ark server and retrieves server information.
505    ///
506    /// If it encounters errors, it will retry `max_retries`.
507    ///
508    /// # Errors
509    ///
510    /// Returns an error if the connection fails or times out.
511    pub async fn connect_with_retries(
512        mut self,
513        max_retries: usize,
514    ) -> Result<Client<B, W, S, K>, Error> {
515        let mut n_retries = 0;
516        while n_retries < max_retries {
517            let res = timeout_op(self.timeout, self.network_client.connect())
518                .await
519                .context("Failed to connect to Ark server")?;
520
521            match res {
522                Ok(()) => break,
523                Err(error) => {
524                    tracing::warn!(?error, "Failed to connect to Ark server, retrying");
525
526                    sleep(Duration::from_secs(2)).await;
527
528                    n_retries += 1;
529
530                    continue;
531                }
532            };
533        }
534
535        self.finish_connect().await
536    }
537
538    async fn finish_connect(mut self) -> Result<Client<B, W, S, K>, Error> {
539        let server_info = timeout_op(self.timeout, self.network_client.get_info())
540            .await
541            .context("Failed to get Ark server info")??;
542
543        tracing::debug!(
544            name = self.name,
545            ark_server_url = ?self.network_client,
546            "Connected to Ark server"
547        );
548
549        let fee_estimator_config = server_info
550            .fees
551            .clone()
552            .map(|fees| ark_fees::Config {
553                intent_offchain_input_program: fees.intent_fee.offchain_input.unwrap_or_default(),
554                intent_onchain_input_program: fees.intent_fee.onchain_input.unwrap_or_default(),
555                intent_offchain_output_program: fees.intent_fee.offchain_output.unwrap_or_default(),
556                intent_onchain_output_program: fees.intent_fee.onchain_output.unwrap_or_default(),
557            })
558            .unwrap_or_default();
559
560        let fee_estimator =
561            ark_fees::Estimator::new(fee_estimator_config).map_err(Error::ark_server)?;
562
563        let client = Client {
564            inner: self,
565            server_info,
566            fee_estimator,
567        };
568
569        if let Err(error) = client.discover_keys(DEFAULT_GAP_LIMIT).await {
570            tracing::warn!(?error, "Failed during key discovery");
571        };
572
573        if let Err(error) = client.continue_pending_offchain_txs().await {
574            tracing::warn!(?error, "Failed to recover pending transactions");
575        };
576
577        Ok(client)
578    }
579}
580
581impl<B, W, S, K> Client<B, W, S, K>
582where
583    B: Blockchain,
584    W: BoardingWallet + OnchainWallet,
585    S: SwapStorage + 'static,
586    K: KeyProvider,
587{
588    /// Get a new offchain receiving address
589    ///
590    /// For HD wallets, this will derive a new address each time it's called.
591    /// For static key providers, this will always return the same address.
592    pub fn get_offchain_address(&self) -> Result<(ArkAddress, Vtxo), Error> {
593        let server_info = &self.server_info;
594
595        let server_signer = server_info.signer_pk.into();
596        let owner = self
597            .next_keypair(KeypairIndex::LastUnused)?
598            .public_key()
599            .into();
600
601        let vtxo = Vtxo::new_default(
602            self.secp(),
603            server_signer,
604            owner,
605            server_info.unilateral_exit_delay,
606            server_info.network,
607        )?;
608
609        let ark_address = vtxo.to_ark_address();
610
611        Ok((ark_address, vtxo))
612    }
613
614    pub fn get_offchain_addresses(&self) -> Result<Vec<(ArkAddress, Vtxo)>, Error> {
615        let server_info = &self.server_info;
616        let server_signer = server_info.signer_pk.into();
617
618        let pks = self.inner.key_provider.get_cached_pks()?;
619
620        pks.into_iter()
621            .map(|owner_pk| {
622                let vtxo = Vtxo::new_default(
623                    self.secp(),
624                    server_signer,
625                    owner_pk,
626                    server_info.unilateral_exit_delay,
627                    server_info.network,
628                )?;
629
630                let ark_address = vtxo.to_ark_address();
631
632                Ok((ark_address, vtxo))
633            })
634            .collect::<Result<Vec<_>, _>>()
635    }
636
637    /// Discover and cache used keys using BIP44-style gap limit
638    ///
639    /// This method derives keys in batches, checks all at once via list_vtxos,
640    /// caches used ones, and stops when a full batch has no used keys.
641    ///
642    /// Returns the number of discovered keys. No-op for StaticKeyProvider.
643    ///
644    /// # Arguments
645    ///
646    /// * `gap_limit` - Number of consecutive unused addresses before stopping
647    pub async fn discover_keys(&self, gap_limit: u32) -> Result<u32, Error> {
648        if !self.inner.key_provider.supports_discovery() {
649            tracing::debug!("Key provider does not support discovery, skipping");
650            return Ok(0);
651        }
652
653        let server_info = &self.server_info;
654        let server_signer: XOnlyPublicKey = server_info.signer_pk.into();
655
656        let mut start_index = 0u32;
657        let mut discovered_count = 0u32;
658
659        tracing::info!(gap_limit, "Starting key discovery");
660
661        loop {
662            // Generate a batch of gap_limit keys
663            let mut batch: Vec<(u32, Keypair, ArkAddress)> = Vec::with_capacity(gap_limit as usize);
664
665            for i in 0..gap_limit {
666                let index = start_index
667                    .checked_add(i)
668                    .ok_or_else(|| Error::ad_hoc("Key discovery index overflow"))?;
669
670                let kp = match self.inner.key_provider.derive_at_discovery_index(index)? {
671                    Some(kp) => kp,
672                    None => break,
673                };
674
675                let vtxo = Vtxo::new_default(
676                    self.secp(),
677                    server_signer,
678                    kp.x_only_public_key().0,
679                    server_info.unilateral_exit_delay,
680                    server_info.network,
681                )?;
682
683                batch.push((index, kp, vtxo.to_ark_address()));
684            }
685
686            if batch.is_empty() {
687                break;
688            }
689
690            // Query all addresses in batch at once
691            let addresses = batch.iter().map(|(_, _, a)| *a);
692
693            let vtxo_list = self.list_vtxos_for_addresses(addresses).await?;
694
695            // Build set of used scripts from response
696            let used_scripts: HashSet<&ScriptBuf> = vtxo_list.all().map(|v| &v.script).collect();
697
698            // Cache keypairs for used addresses (match by script)
699            let mut found_any = false;
700            for (index, kp, addr) in batch {
701                let script = addr.to_p2tr_script_pubkey();
702                if used_scripts.contains(&script) {
703                    tracing::debug!(index, %addr, "Found used address");
704                    self.inner
705                        .key_provider
706                        .cache_discovered_keypair(index, kp)?;
707                    discovered_count += 1;
708                    found_any = true;
709                }
710            }
711
712            // Stop if no used addresses found in this batch (gap limit reached)
713            if !found_any {
714                break;
715            }
716
717            start_index = start_index
718                .checked_add(gap_limit)
719                .ok_or_else(|| Error::ad_hoc("Key discovery index overflow"))?;
720        }
721
722        tracing::info!(discovered_count, "Key discovery completed");
723
724        Ok(discovered_count)
725    }
726
727    // At the moment we are always generating the same address.
728    pub fn get_boarding_address(&self) -> Result<Address, Error> {
729        let server_info = &self.server_info;
730
731        let boarding_output = self.inner.wallet.new_boarding_output(
732            server_info.signer_pk.into(),
733            server_info.boarding_exit_delay,
734            server_info.network,
735        )?;
736
737        Ok(boarding_output.address().clone())
738    }
739
740    pub fn get_onchain_address(&self) -> Result<Address, Error> {
741        self.inner.wallet.get_onchain_address()
742    }
743
744    pub fn get_boarding_addresses(&self) -> Result<Vec<Address>, Error> {
745        let address = self.get_boarding_address()?;
746
747        Ok(vec![address])
748    }
749
750    pub async fn get_virtual_tx_outpoints(
751        &self,
752        addresses: impl Iterator<Item = ArkAddress>,
753    ) -> Result<Vec<VirtualTxOutPoint>, Error> {
754        let request = GetVtxosRequest::new_for_addresses(addresses);
755        self.fetch_all_vtxos(request).await
756    }
757
758    pub async fn list_vtxos(&self) -> Result<(VtxoList, HashMap<ScriptBuf, Vtxo>), Error> {
759        let ark_addresses = self.get_offchain_addresses()?;
760
761        let script_pubkey_to_vtxo_map = ark_addresses
762            .iter()
763            .map(|(a, v)| (a.to_p2tr_script_pubkey(), v.clone()))
764            .collect();
765
766        let addresses = ark_addresses.iter().map(|(a, _)| a).copied();
767
768        let vtxo_list = self.list_vtxos_for_addresses(addresses).await?;
769
770        Ok((vtxo_list, script_pubkey_to_vtxo_map))
771    }
772
773    pub async fn list_vtxos_for_addresses(
774        &self,
775        addresses: impl Iterator<Item = ArkAddress>,
776    ) -> Result<VtxoList, Error> {
777        let virtual_tx_outpoints = self
778            .get_virtual_tx_outpoints(addresses)
779            .await
780            .context("failed to get VTXOs for addresses")?;
781
782        let vtxo_list = VtxoList::new(self.server_info.dust, virtual_tx_outpoints);
783
784        Ok(vtxo_list)
785    }
786
787    pub async fn get_vtxo_chain(
788        &self,
789        out_point: OutPoint,
790        size: i32,
791        index: i32,
792    ) -> Result<Option<VtxoChainResponse>, Error> {
793        let vtxo_chain = timeout_op(
794            self.inner.timeout,
795            self.network_client()
796                .get_vtxo_chain(Some(out_point), Some((size, index))),
797        )
798        .await
799        .context("Failed to fetch VTXO chain")??;
800
801        Ok(Some(vtxo_chain))
802    }
803
804    pub async fn offchain_balance(&self) -> Result<OffChainBalance, Error> {
805        let (vtxo_list, _) = self.list_vtxos().await.context("failed to list VTXOs")?;
806
807        let pre_confirmed = vtxo_list
808            .pre_confirmed()
809            .fold(Amount::ZERO, |acc, x| acc + x.amount);
810
811        let confirmed = vtxo_list
812            .confirmed()
813            .fold(Amount::ZERO, |acc, x| acc + x.amount);
814
815        let recoverable = vtxo_list
816            .recoverable()
817            .fold(Amount::ZERO, |acc, x| acc + x.amount);
818
819        Ok(OffChainBalance {
820            pre_confirmed,
821            confirmed,
822            recoverable,
823        })
824    }
825
826    pub async fn transaction_history(&self) -> Result<Vec<history::Transaction>, Error> {
827        let mut boarding_transactions = Vec::new();
828        let mut boarding_commitment_transactions = Vec::new();
829
830        let boarding_addresses = self.get_boarding_addresses()?;
831        for boarding_address in boarding_addresses.iter() {
832            let outpoints = timeout_op(
833                self.inner.timeout,
834                self.blockchain().find_outpoints(boarding_address),
835            )
836            .await
837            .context("Failed to find outpoints")??;
838
839            for ExplorerUtxo {
840                outpoint,
841                amount,
842                confirmation_blocktime,
843                ..
844            } in outpoints.iter()
845            {
846                let confirmed_at = confirmation_blocktime.map(|t| t as i64);
847
848                boarding_transactions.push(history::Transaction::Boarding {
849                    txid: outpoint.txid,
850                    amount: *amount,
851                    confirmed_at,
852                });
853
854                let status = timeout_op(
855                    self.inner.timeout,
856                    self.blockchain()
857                        .get_output_status(&outpoint.txid, outpoint.vout),
858                )
859                .await
860                .context("Failed to get Tx output status")??;
861
862                if let Some(spend_txid) = status.spend_txid {
863                    boarding_commitment_transactions.push(spend_txid);
864                }
865            }
866        }
867
868        let (vtxo_list, _) = self.list_vtxos().await?;
869
870        let spent_outpoints = vtxo_list.spent().cloned().collect::<Vec<_>>();
871        let unspent_outpoints = vtxo_list.all_unspent().cloned().collect::<Vec<_>>();
872
873        let incoming_transactions = generate_incoming_vtxo_transaction_history(
874            &spent_outpoints,
875            &unspent_outpoints,
876            &boarding_commitment_transactions,
877        )?;
878
879        let outgoing_txs =
880            generate_outgoing_vtxo_transaction_history(&spent_outpoints, &unspent_outpoints)?;
881
882        let mut outgoing_transactions = vec![];
883        for tx in outgoing_txs {
884            let tx = match tx {
885                OutgoingTransaction::Complete(tx) => tx,
886                OutgoingTransaction::Incomplete(incomplete_tx) => {
887                    let first_outpoint = incomplete_tx.first_outpoint();
888
889                    let request = GetVtxosRequest::new_for_outpoints(&[first_outpoint]);
890                    let vtxos = self.fetch_all_vtxos(request).await?;
891
892                    match vtxos.first() {
893                        Some(virtual_tx_outpoint) => {
894                            match incomplete_tx.finish(virtual_tx_outpoint) {
895                                Ok(tx) => tx,
896                                Err(e) => {
897                                    tracing::warn!(
898                                        %first_outpoint,
899                                        "Could not finish outgoing TX, skipping: {e}"
900                                    );
901                                    continue;
902                                }
903                            }
904                        }
905                        None => {
906                            tracing::warn!(
907                                %first_outpoint,
908                                "Could not find virtual TX outpoint for outgoing TX, skipping"
909                            );
910                            continue;
911                        }
912                    }
913                }
914                OutgoingTransaction::IncompleteOffboard(incomplete_offboard) => {
915                    let status = timeout_op(
916                        self.inner.timeout,
917                        self.blockchain()
918                            .get_tx_status(&incomplete_offboard.commitment_txid()),
919                    )
920                    .await
921                    .context("failed to get commitment TX status")??;
922
923                    incomplete_offboard.finish(status.confirmed_at)
924                }
925            };
926
927            outgoing_transactions.push(tx);
928        }
929
930        let mut txs = [
931            boarding_transactions,
932            incoming_transactions,
933            outgoing_transactions,
934        ]
935        .concat();
936
937        sort_transactions_by_created_at(&mut txs);
938
939        Ok(txs)
940    }
941
942    /// Get the boarding exit delay defined by the Ark server, in seconds.
943    ///
944    /// # Panics
945    ///
946    /// This will panic if the boarding exit delay corresponds to a relative locktime specified in
947    /// blocks. We expect the Ark server to use a relative locktime in seconds.
948    ///
949    /// This will also panic if the sequence number returned by the server is not a valid relative
950    /// locktime.
951    pub fn boarding_exit_delay_seconds(&self) -> u64 {
952        match self
953            .server_info
954            .boarding_exit_delay
955            .to_relative_lock_time()
956            .expect("relative locktime")
957        {
958            bitcoin::relative::LockTime::Time(time) => time.value() as u64 * 512,
959            bitcoin::relative::LockTime::Blocks(_) => unreachable!(),
960        }
961    }
962
963    /// Get the unilateral exit delay for VTXOs defined by the Ark server, in seconds.
964    ///
965    /// # Panics
966    ///
967    /// This will panic if the unilateral exit delay corresponds to a relative locktime specified in
968    /// blocks. We expect the Ark server to use a relative locktime in seconds.
969    ///
970    /// This will also panic if the sequence number returned by the server is not a valid relative
971    /// locktime.
972    pub fn unilateral_vtxo_exit_delay_seconds(&self) -> u64 {
973        match self
974            .server_info
975            .unilateral_exit_delay
976            .to_relative_lock_time()
977            .expect("relative locktime")
978        {
979            bitcoin::relative::LockTime::Time(time) => time.value() as u64 * 512,
980            bitcoin::relative::LockTime::Blocks(_) => unreachable!(),
981        }
982    }
983
984    pub fn network_client(&self) -> ark_grpc::Client {
985        self.inner.network_client.clone()
986    }
987
988    /// Fetch all VTXOs for a request, handling pagination internally.
989    async fn fetch_all_vtxos(
990        &self,
991        request: GetVtxosRequest,
992    ) -> Result<Vec<VirtualTxOutPoint>, Error> {
993        if request.reference().is_empty() {
994            return Ok(Vec::new());
995        }
996
997        let mut all_vtxos = Vec::new();
998        let mut cursor = 0;
999        const PAGE_SIZE: i32 = 100;
1000
1001        loop {
1002            let paged_request = request.clone().with_page(PAGE_SIZE, cursor);
1003            let response = timeout_op(
1004                self.inner.timeout,
1005                self.network_client().list_vtxos(paged_request),
1006            )
1007            .await
1008            .context("failed to fetch list of VTXOs")??;
1009
1010            all_vtxos.extend(response.vtxos);
1011
1012            // Use server-provided cursor for next page; next == total means end
1013            match response.page {
1014                Some(page) if page.next < page.total => {
1015                    cursor = page.next;
1016                }
1017                _ => break,
1018            }
1019        }
1020
1021        Ok(all_vtxos)
1022    }
1023
1024    fn next_keypair(&self, keypair_index: KeypairIndex) -> Result<Keypair, Error> {
1025        self.inner.key_provider.get_next_keypair(keypair_index)
1026    }
1027    fn keypair_by_pk(&self, pk: &XOnlyPublicKey) -> Result<Keypair, Error> {
1028        self.inner.key_provider.get_keypair_for_pk(pk)
1029    }
1030
1031    fn secp(&self) -> &Secp256k1<All> {
1032        &self.inner.secp
1033    }
1034
1035    fn blockchain(&self) -> &B {
1036        &self.inner.blockchain
1037    }
1038
1039    fn swap_storage(&self) -> &S {
1040        &self.inner.swap_storage
1041    }
1042
1043    /// Use the P2A output of a transaction to bump its transaction fee with a child transaction.
1044    pub async fn bump_tx(&self, parent: &Transaction) -> Result<Transaction, Error> {
1045        let fee_rate = timeout_op(self.inner.timeout, self.blockchain().get_fee_rate())
1046            .await
1047            .context("Failed to retrieve fee rate")??;
1048
1049        let change_address = self.inner.wallet.get_onchain_address()?;
1050
1051        // Create a closure that converts CoinSelectionResult to UtxoCoinSelection
1052        let select_coins_fn =
1053            |target_amount: Amount| -> Result<UtxoCoinSelection, ark_core::Error> {
1054                self.inner.wallet.select_coins(target_amount).map_err(|e| {
1055                    ark_core::Error::ad_hoc(format!("failed to select coins for anchor TX: {e}"))
1056                })
1057            };
1058
1059        // Build the PSBT using ark-core (includes witness UTXO setup)
1060        let mut psbt = build_anchor_tx(parent, change_address, fee_rate, select_coins_fn)
1061            .map_err(|e| Error::ad_hoc(e.to_string()))?;
1062
1063        // Sign the transaction
1064        self.inner
1065            .wallet
1066            .sign(&mut psbt)
1067            .context("failed to sign bump TX")?;
1068
1069        // Extract the final transaction
1070        let tx = psbt.extract_tx().map_err(Error::ad_hoc)?;
1071
1072        Ok(tx)
1073    }
1074
1075    /// Subscribe to receive transaction notifications for specific VTXO scripts
1076    ///
1077    /// This method allows you to subscribe to get notified about transactions
1078    /// affecting the provided VTXO addresses. It can also be used to update an
1079    /// existing subscription by adding new scripts to it.
1080    ///
1081    /// # Arguments
1082    ///
1083    /// * `scripts` - Vector of ArkAddress to subscribe to
1084    /// * `subscription_id` - Unique identifier for the subscription. Use the same ID to update an
1085    ///   existing subscription. Use None for new subscriptions
1086    ///
1087    /// # Returns
1088    ///
1089    /// Returns the subscription ID if successful
1090    pub async fn subscribe_to_scripts(
1091        &self,
1092        scripts: Vec<ArkAddress>,
1093        subscription_id: Option<String>,
1094    ) -> Result<String, Error> {
1095        self.network_client()
1096            .subscribe_to_scripts(scripts, subscription_id)
1097            .await
1098            .map_err(Into::into)
1099    }
1100
1101    /// Remove scripts from an existing subscription
1102    ///
1103    /// This method allows you to unsubscribe from receiving notifications for
1104    /// specific VTXO scripts while keeping the subscription active for other scripts.
1105    ///
1106    /// # Arguments
1107    ///
1108    /// * `scripts` - Vector of ArkAddress to unsubscribe from
1109    /// * `subscription_id` - The subscription ID to update
1110    pub async fn unsubscribe_from_scripts(
1111        &self,
1112        scripts: Vec<ArkAddress>,
1113        subscription_id: String,
1114    ) -> Result<(), Error> {
1115        self.network_client()
1116            .unsubscribe_from_scripts(scripts, subscription_id)
1117            .await
1118            .map_err(Into::into)
1119    }
1120
1121    /// Get a subscription stream that returns subscription responses
1122    ///
1123    /// This method returns a stream that yields SubscriptionResponse messages
1124    /// containing information about new and spent VTXOs for the subscribed scripts.
1125    ///
1126    /// # Arguments
1127    ///
1128    /// * `subscription_id` - The subscription ID to get the stream for
1129    ///
1130    /// # Returns
1131    ///
1132    /// Returns a Stream of SubscriptionResponse messages
1133    pub async fn get_subscription(
1134        &self,
1135        subscription_id: String,
1136    ) -> Result<impl Stream<Item = Result<SubscriptionResponse, ark_grpc::Error>> + Unpin, Error>
1137    {
1138        self.network_client()
1139            .get_subscription(subscription_id)
1140            .await
1141            .map_err(Into::into)
1142    }
1143}