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::utils::unix_now;
6use crate::wallet::OnchainWallet;
7use ark_core::asset::AssetId;
8use ark_core::build_anchor_tx;
9use ark_core::contract::BoardingContract;
10use ark_core::contract::ContractContext;
11use ark_core::contract::ContractState;
12use ark_core::contract::ContractType;
13use ark_core::contract::DefaultVtxoContract;
14use ark_core::contract::DelegateVtxoContract;
15use ark_core::contract::StoredContract;
16use ark_core::contract::VhtlcContract;
17use ark_core::history;
18use ark_core::history::generate_incoming_vtxo_transaction_history;
19use ark_core::history::generate_outgoing_vtxo_transaction_history;
20use ark_core::history::sort_transactions_by_created_at;
21use ark_core::history::OutgoingTransaction;
22use ark_core::server;
23use ark_core::server::GetVtxosRequest;
24use ark_core::server::SubscriptionResponse;
25use ark_core::server::VirtualTxOutPoint;
26use ark_core::ArkAddress;
27use ark_core::ExplorerUtxo;
28use ark_core::UtxoCoinSelection;
29use ark_core::Vtxo;
30use ark_core::VtxoList;
31use ark_core::DEFAULT_DERIVATION_PATH;
32use ark_grpc::VtxoChainResponse;
33use bitcoin::bip32::DerivationPath;
34use bitcoin::bip32::Xpriv;
35use bitcoin::key::Keypair;
36use bitcoin::key::Secp256k1;
37use bitcoin::secp256k1::schnorr::Signature;
38use bitcoin::secp256k1::All;
39use bitcoin::secp256k1::Message;
40use bitcoin::Address;
41use bitcoin::Amount;
42use bitcoin::Network;
43use bitcoin::OutPoint;
44use bitcoin::ScriptBuf;
45use bitcoin::Transaction;
46use bitcoin::Txid;
47use bitcoin::XOnlyPublicKey;
48use futures::Future;
49use futures::Stream;
50use std::collections::HashMap;
51use std::collections::HashSet;
52use std::str::FromStr;
53use std::sync::Arc;
54use std::sync::Mutex;
55use std::sync::RwLock;
56use std::time::Duration;
57use std::time::Instant;
58
59pub mod contract;
60pub mod error;
61pub mod key_provider;
62pub mod swap_storage;
63pub mod vtxo_watcher;
64pub mod wallet;
65
66mod asset;
67mod batch;
68mod boltz;
69mod coin_select;
70mod fee_estimation;
71mod migration;
72mod send_vtxo;
73mod unilateral_exit;
74mod utils;
75
76pub use ark_core::server::DeprecatedSignerStatus;
77pub use ark_core::server::ServerSignerStatus;
78pub use asset::IssueAssetResult;
79pub use boltz::ChainSwapAmount;
80pub use boltz::ChainSwapData;
81pub use boltz::ChainSwapDirection;
82pub use boltz::ChainSwapResult;
83pub use boltz::PendingVhtlcSpendTx;
84pub use boltz::PendingVhtlcSpendType;
85pub use boltz::ReverseSwapData;
86pub use boltz::SubmarineSwapData;
87pub use boltz::SwapAmount;
88pub use boltz::SwapStatus;
89pub use boltz::SwapStatusInfo;
90pub use boltz::SwapType;
91pub use boltz::TimeoutBlockHeights;
92pub use contract::AnnotatedBoardingOutput;
93pub use contract::AnnotatedVtxo;
94pub use contract::AnnotatedVtxoList;
95pub use contract::ContractManager;
96pub use contract::ContractRegistry;
97pub use contract::ContractStore;
98pub use contract::MemoryContractStore;
99#[cfg(feature = "sqlite")]
100pub use contract::SqliteContractStore;
101pub use error::Error;
102pub use key_provider::Bip32KeyProvider;
103pub use key_provider::DiscoverableKeyProvider;
104pub use key_provider::KeyProvider;
105pub use key_provider::StaticKeyProvider;
106pub use lightning_invoice;
107pub use migration::DeprecatedSignerMigrationReport;
108pub use migration::DeprecatedSignerReport;
109pub use migration::MigrationLegReport;
110pub use migration::MigrationSkipReason;
111pub use migration::MigrationVtxoRef;
112pub use migration::MAX_VTXOS_PER_SETTLEMENT;
113pub use swap_storage::InMemorySwapStorage;
114#[cfg(feature = "sqlite")]
115pub use swap_storage::SqliteSwapStorage;
116pub use swap_storage::SwapStorage;
117
118/// Default gap limit for BIP44-style key discovery
119///
120/// This is the number of consecutive unused addresses to scan before
121/// assuming all used addresses have been found.
122pub const DEFAULT_GAP_LIMIT: u32 = 20;
123
124/// Default Boltz `referralId` sent with swap creation requests when the caller does not
125/// provide one. Identifies traffic originating from this SDK.
126pub const DEFAULT_BOLTZ_REFERRAL_ID: &str = "arkade-rs-SDK";
127
128/// Summary returned by [`Client::restore_contracts`].
129#[derive(Clone, Debug, Default, PartialEq, Eq)]
130pub struct ContractRestoreReport {
131    /// Gap limit used for this scan.
132    pub gap_limit: u32,
133    /// First derived key index that was probed, if any.
134    pub scanned_from: Option<u32>,
135    /// One past the last derived key index that was probed, if any.
136    pub scanned_to_exclusive: Option<u32>,
137    /// Derived key indexes that were probed.
138    pub scanned_keys: u32,
139    /// Key indexes where at least one contract had activity.
140    pub discovered_key_indexes: Vec<u32>,
141    /// Highest discovered key index, if any.
142    pub last_used_key_index: Option<u32>,
143    /// Suggested next receive key index, if any.
144    pub next_key_index: Option<u32>,
145    /// Offchain default/delegate contracts with VTXO activity.
146    pub offchain_contracts: u32,
147    /// Boarding contracts with on-chain UTXO activity.
148    pub boarding_contracts: u32,
149    /// Contracts that were not already in the store.
150    pub inserted_contracts: u32,
151    /// Discovered contracts that were already present in the store.
152    pub known_contracts: u32,
153    /// Per-contract discovery details for caller UX.
154    pub entries: Vec<ContractRestoreEntry>,
155}
156
157impl ContractRestoreReport {
158    pub fn discovered_keys(&self) -> u32 {
159        self.discovered_key_indexes.len() as u32
160    }
161
162    pub fn discovered_contracts(&self) -> u32 {
163        self.entries.len() as u32
164    }
165}
166
167#[derive(Clone, Debug, PartialEq, Eq)]
168pub struct ContractRestoreEntry {
169    pub key_index: u32,
170    pub contract_type: ContractType,
171    pub script_pubkey: ScriptBuf,
172    pub status: ContractRestoreEntryStatus,
173    pub discovery: ContractRestoreDiscovery,
174}
175
176#[derive(Clone, Copy, Debug, PartialEq, Eq)]
177pub enum ContractRestoreEntryStatus {
178    Inserted,
179    Known,
180}
181
182#[derive(Clone, Debug, PartialEq, Eq)]
183pub enum ContractRestoreDiscovery {
184    Offchain {
185        vtxos: Vec<ContractRestoreVtxo>,
186    },
187    Boarding {
188        outpoints: Vec<ContractRestoreOutpoint>,
189    },
190}
191
192#[derive(Clone, Debug, PartialEq, Eq)]
193pub struct ContractRestoreVtxo {
194    pub outpoint: OutPoint,
195    pub amount: Amount,
196    pub is_spent: bool,
197    pub is_swept: bool,
198    pub is_unrolled: bool,
199}
200
201#[derive(Clone, Debug, PartialEq, Eq)]
202pub struct ContractRestoreOutpoint {
203    pub outpoint: OutPoint,
204    pub amount: Amount,
205    pub confirmation_blocktime: Option<u64>,
206    pub confirmations: u64,
207}
208
209/// Wallet-facing view of a stored contract.
210#[derive(Clone, Debug, PartialEq, Eq)]
211pub struct ContractInfo {
212    /// The validated stored contract row.
213    pub contract: StoredContract,
214    /// Address derived from the contract, when the SDK knows how to derive one.
215    pub address: Option<String>,
216    /// Kind of address in [`Self::address`].
217    pub address_kind: Option<ContractAddressKind>,
218    /// Server signer encoded in this contract, when the SDK knows how to decode it.
219    pub server_pk: Option<XOnlyPublicKey>,
220    /// Rotation status of [`Self::server_pk`] against the current server info.
221    pub signer_status: Option<ServerSignerStatus>,
222}
223
224#[derive(Clone, Copy, Debug, PartialEq, Eq)]
225pub enum ContractAddressKind {
226    Ark,
227    Bitcoin,
228}
229
230/// Default mainnet Arkade server URL.
231pub const ARKADE_MAINNET_URL: &str = "https://arkade.computer";
232
233/// Default mutinynet Arkade server URL.
234pub const ARKADE_MUTINYNET_URL: &str = "https://mutinynet.arkade.sh";
235
236/// Default mainnet Boltz API URL.
237pub const BOLTZ_MAINNET_URL: &str = "https://api.boltz.exchange";
238
239/// Default mutinynet Boltz API URL.
240pub const BOLTZ_MUTINYNET_URL: &str = "https://api.boltz.mutinynet.arkade.sh";
241
242/// Default timeout for network operations.
243pub const DEFAULT_TIMEOUT: Duration = Duration::from_secs(30);
244
245/// Default maximum age for cached Ark server info.
246pub const DEFAULT_SERVER_INFO_TTL: Duration = Duration::from_secs(15 * 60);
247
248/// Boltz referral ID behavior for swap creation requests.
249#[derive(Clone, Debug, Default)]
250pub enum BoltzReferralId {
251    /// Use [`DEFAULT_BOLTZ_REFERRAL_ID`].
252    #[default]
253    Default,
254    /// Send no `referralId` field with Boltz swap creation requests.
255    Disabled,
256    /// Send a custom `referralId` field with Boltz swap creation requests.
257    Custom(String),
258}
259
260/// Configuration for constructing an [`OfflineClient`].
261///
262/// The default configuration targets mainnet. Set [`Self::server_info_ttl`] to
263/// [`Duration::ZERO`] to refresh server info on every access.
264#[derive(Clone, Debug)]
265pub struct OfflineClientConfig {
266    pub ark_server_url: String,
267    pub boltz_url: String,
268    pub timeout: Duration,
269    pub server_info_ttl: Duration,
270    pub boltz_referral_id: BoltzReferralId,
271    pub delegator_pk: Option<XOnlyPublicKey>,
272    pub historical_delegator_pks: Vec<XOnlyPublicKey>,
273}
274
275impl Default for OfflineClientConfig {
276    fn default() -> Self {
277        Self {
278            ark_server_url: ARKADE_MAINNET_URL.to_string(),
279            boltz_url: BOLTZ_MAINNET_URL.to_string(),
280            timeout: DEFAULT_TIMEOUT,
281            server_info_ttl: DEFAULT_SERVER_INFO_TTL,
282            boltz_referral_id: BoltzReferralId::default(),
283            delegator_pk: None,
284            historical_delegator_pks: Vec::new(),
285        }
286    }
287}
288
289/// A client to interact with Ark Server
290///
291/// ## Example
292///
293/// ```rust
294/// # use std::future::Future;
295/// # use std::str::FromStr;
296/// # use ark_client::{Blockchain, Client, Error, SpendStatus, TxStatus};
297/// # use ark_client::OfflineClient;
298/// # use ark_client::OfflineClientConfig;
299/// # use bitcoin::key::Keypair;
300/// # use bitcoin::secp256k1::SecretKey;
301/// # use std::sync::Arc;
302/// # use bitcoin::{Address, Amount, FeeRate, Psbt, Transaction, Txid};
303/// # use ark_client::wallet::{Balance, OnchainWallet};
304/// # use ark_client::InMemorySwapStorage;
305/// # use ark_core::{UtxoCoinSelection, ExplorerUtxo};
306/// # use ark_client::StaticKeyProvider;
307///
308/// struct MyBlockchain {}
309/// #
310/// # impl MyBlockchain {
311/// #     pub fn new(_url: &str) -> Self { Self {}}
312/// # }
313/// #
314/// # impl Blockchain for MyBlockchain {
315/// #
316/// #     async fn find_outpoints(&self, address: &Address) -> Result<Vec<ExplorerUtxo>, Error> {
317/// #         unimplemented!("You can implement this function using your preferred client library such as esplora_client")
318/// #     }
319/// #
320/// #     async fn find_tx(&self, txid: &Txid) -> Result<Option<Transaction>, Error> {
321/// #         unimplemented!()
322/// #     }
323/// #
324/// #     async fn get_tx_status(&self, txid: &Txid) -> Result<TxStatus, Error> {
325/// #         unimplemented!()
326/// #     }
327/// #
328/// #     async fn get_output_status(&self, txid: &Txid, vout: u32) -> Result<SpendStatus, Error> {
329/// #         unimplemented!()
330/// #     }
331/// #
332/// #     async fn broadcast(&self, tx: &Transaction) -> Result<(), Error> {
333/// #         unimplemented!()
334/// #     }
335/// #
336/// #     async fn get_fee_rate(&self) -> Result<f64, Error> {
337/// #         unimplemented!()
338/// #     }
339/// #
340/// #     async fn broadcast_package(
341/// #         &self,
342/// #         txs: &[&Transaction],
343/// #     ) -> Result<(), Error> {
344/// #         unimplemented!()
345/// #     }
346/// # }
347///
348/// struct MyWallet {}
349/// # impl OnchainWallet for MyWallet where {
350/// #
351/// #     fn get_onchain_address(&self) -> Result<Address, Error> {
352/// #         unimplemented!("You can implement this function using your preferred client library such as bdk")
353/// #     }
354/// #
355/// #     async fn sync(&self) -> Result<(), Error> {
356/// #         unimplemented!()
357/// #     }
358/// #
359/// #     fn balance(&self) -> Result<Balance, Error> {
360/// #         unimplemented!()
361/// #     }
362/// #
363/// #     fn prepare_send_to_address(&self, address: Address, amount: Amount, fee_rate: FeeRate) -> Result<Psbt, Error> {
364/// #         unimplemented!()
365/// #     }
366/// #
367/// #     fn sign(&self, psbt: &mut Psbt) -> Result<bool, Error> {
368/// #         unimplemented!()
369/// #     }
370/// #
371/// #     fn select_coins(&self, target_amount: Amount) -> Result<ark_core::UtxoCoinSelection, Error> {
372/// #         unimplemented!()
373/// #     }
374/// # }
375/// #
376///
377/// // Initialize the client with a static keypair
378/// async fn init_client_with_keypair() -> Result<Client<MyBlockchain, MyWallet, InMemorySwapStorage>, ark_client::Error> {
379///     // Create a keypair for signing transactions
380///     let secp = bitcoin::key::Secp256k1::new();
381///     let secret_key = SecretKey::from_str("your_private_key_here").unwrap();
382///     let keypair = Keypair::from_secret_key(&secp, &secret_key);
383///
384///     // Initialize blockchain and wallet implementations
385///     let blockchain = Arc::new(MyBlockchain::new("https://esplora.example.com"));
386///     let wallet = Arc::new(MyWallet {});
387///
388///     let config = OfflineClientConfig {
389///         ark_server_url: "https://ark-server.example.com".to_string(),
390///         boltz_url: "http://boltz.example.com".to_string(),
391///         ..Default::default()
392///     };
393///
394///     let offline_client = OfflineClient::with_keypair(
395///         config,
396///         keypair,
397///         blockchain,
398///         wallet,
399///         Arc::new(InMemorySwapStorage::default()),
400///     );
401///
402///     // Connect to the Ark server and get server info
403///     let client = offline_client.connect().await?;
404///
405///     Ok(client)
406/// }
407///
408/// // Initialize the client with a BIP32 HD wallet
409/// # use bitcoin::bip32::{Xpriv, DerivationPath};
410/// async fn init_client_with_bip32() -> Result<Client<MyBlockchain, MyWallet, InMemorySwapStorage>, ark_client::Error> {
411///     // Create a BIP32 master key and derivation path
412///     let master_key = Xpriv::from_str("xprv...").unwrap();
413///     let derivation_path = DerivationPath::from_str("m/84'/0'/0'/0/0").unwrap();
414///
415///     // Initialize blockchain and wallet implementations
416///     let blockchain = Arc::new(MyBlockchain::new("https://esplora.example.com"));
417///     let wallet = Arc::new(MyWallet {});
418///
419///     let config = OfflineClientConfig {
420///         ark_server_url: "https://ark-server.example.com".to_string(),
421///         boltz_url: "http://boltz.example.com".to_string(),
422///         ..Default::default()
423///     };
424///
425///     let offline_client = OfflineClient::with_bip32(
426///         config,
427///         master_key,
428///         Some(derivation_path),
429///         blockchain,
430///         wallet,
431///         Arc::new(InMemorySwapStorage::default()),
432///     );
433///
434///     // Connect to the Ark server and get server info
435///     let client = offline_client.connect().await?;
436///
437///     Ok(client)
438/// }
439/// ```
440#[derive(Clone)]
441pub struct OfflineClient<B, W, S> {
442    // TODO: We could introduce a generic interface so that consumers can use either GRPC or REST.
443    network_client: ark_grpc::Client,
444    key_provider: Arc<dyn KeyProvider>,
445    discoverable_key_provider: Option<Arc<dyn DiscoverableKeyProvider>>,
446    blockchain: Arc<B>,
447    secp: Secp256k1<All>,
448    wallet: Arc<W>,
449    swap_storage: Arc<S>,
450    boltz_url: String,
451    boltz_referral_id: Option<String>,
452    timeout: Duration,
453    server_info_ttl: Duration,
454    contract_store: Arc<Mutex<Option<Box<dyn ContractStore>>>>,
455    delegator_pk: Option<XOnlyPublicKey>,
456    historical_delegator_pks: Vec<XOnlyPublicKey>,
457}
458
459/// A client to interact with Ark server
460///
461/// See [`OfflineClient`] docs for details.
462pub struct Client<B, W, S> {
463    inner: OfflineClient<B, W, S>,
464    state: Arc<RwLock<ServerState>>,
465    server_info_refresh_lock: Arc<tokio::sync::Mutex<()>>,
466}
467
468struct ServerState {
469    server_info: server::Info,
470    fee_estimator: ark_fees::Estimator,
471    server_info_refreshed_at: Instant,
472    contract_manager: Mutex<ContractManager>,
473}
474
475#[derive(Clone, Debug)]
476enum RestoreCandidate {
477    DefaultVtxo(DefaultVtxoContract),
478    DelegateVtxo(DelegateVtxoContract),
479    Boarding(BoardingContract),
480}
481
482#[derive(Clone, Debug)]
483enum RestoreDiscoveryTarget {
484    Offchain(ArkAddress),
485    Boarding(Address),
486}
487
488impl RestoreCandidate {
489    fn discovery_target(
490        &self,
491        secp: &Secp256k1<All>,
492        ctx: &ContractContext,
493    ) -> Result<RestoreDiscoveryTarget, Error> {
494        match self {
495            Self::DefaultVtxo(contract) => Ok(RestoreDiscoveryTarget::Offchain(
496                Vtxo::new_default(
497                    secp,
498                    contract.server,
499                    contract.owner,
500                    contract.exit_delay,
501                    ctx.network(),
502                )?
503                .to_ark_address(),
504            )),
505            Self::DelegateVtxo(contract) => Ok(RestoreDiscoveryTarget::Offchain(
506                Vtxo::new_with_delegator(
507                    secp,
508                    contract.server,
509                    contract.owner,
510                    contract.delegator,
511                    contract.exit_delay,
512                    ctx.network(),
513                )?
514                .to_ark_address(),
515            )),
516            Self::Boarding(contract) => Ok(RestoreDiscoveryTarget::Boarding(
517                contract.boarding_output(ctx)?.address().clone(),
518            )),
519        }
520    }
521
522    fn script_pubkey(
523        &self,
524        secp: &Secp256k1<All>,
525        ctx: &ContractContext,
526    ) -> Result<ScriptBuf, Error> {
527        match self {
528            Self::DefaultVtxo(contract) => Ok(Vtxo::new_default(
529                secp,
530                contract.server,
531                contract.owner,
532                contract.exit_delay,
533                ctx.network(),
534            )?
535            .script_pubkey()),
536            Self::DelegateVtxo(contract) => Ok(Vtxo::new_with_delegator(
537                secp,
538                contract.server,
539                contract.owner,
540                contract.delegator,
541                contract.exit_delay,
542                ctx.network(),
543            )?
544            .script_pubkey()),
545            Self::Boarding(contract) => Ok(contract.boarding_output(ctx)?.script_pubkey()),
546        }
547    }
548
549    fn contract_type(&self) -> ContractType {
550        match self {
551            Self::DefaultVtxo(_) => ContractType::default_vtxo(),
552            Self::DelegateVtxo(_) => ContractType::delegate_vtxo(),
553            Self::Boarding(_) => ContractType::boarding(),
554        }
555    }
556}
557
558#[derive(Clone, Copy, Debug)]
559pub struct TxStatus {
560    pub confirmed_at: Option<i64>,
561}
562
563#[derive(Clone, Copy, Debug)]
564pub struct SpendStatus {
565    pub spend_txid: Option<Txid>,
566}
567
568pub struct AddressVtxos {
569    pub unspent: Vec<VirtualTxOutPoint>,
570    pub spent: Vec<VirtualTxOutPoint>,
571}
572
573#[derive(Clone, Debug, Default)]
574pub struct OffChainBalance {
575    pre_confirmed: Amount,
576    confirmed: Amount,
577    recoverable: Amount,
578    /// Funds under a deprecated server signer whose cooperative-sign cutoff has passed.
579    /// These VTXOs cannot be spent offchain (operator won't co-sign the old key) and are not yet
580    /// recoverable (not expired). They will become recoverable once their VTXO expiry passes.
581    pending_recovery: Amount,
582    asset_balances: HashMap<AssetId, u64>,
583}
584
585impl OffChainBalance {
586    pub fn pre_confirmed(&self) -> Amount {
587        self.pre_confirmed
588    }
589
590    pub fn confirmed(&self) -> Amount {
591        self.confirmed
592    }
593
594    /// Balance which can only be settled, and does not require a forfeit transaction per VTXO.
595    pub fn recoverable(&self) -> Amount {
596        self.recoverable
597    }
598
599    /// Funds locked under a deprecated signer past its cutoff — cannot be spent offchain,
600    /// waiting for VTXO expiry to become recoverable. Still counted in `total()`.
601    pub fn pending_recovery(&self) -> Amount {
602        self.pending_recovery
603    }
604
605    pub fn total(&self) -> Amount {
606        self.pre_confirmed + self.confirmed + self.recoverable + self.pending_recovery
607    }
608
609    /// Asset balances keyed by asset ID.
610    pub fn asset_balances(&self) -> &HashMap<AssetId, u64> {
611        &self.asset_balances
612    }
613}
614
615pub trait Blockchain {
616    fn find_outpoints(
617        &self,
618        address: &Address,
619    ) -> impl Future<Output = Result<Vec<ExplorerUtxo>, Error>> + Send;
620
621    fn find_tx(
622        &self,
623        txid: &Txid,
624    ) -> impl Future<Output = Result<Option<Transaction>, Error>> + Send;
625
626    fn get_tx_status(&self, txid: &Txid) -> impl Future<Output = Result<TxStatus, Error>> + Send;
627
628    fn get_output_status(
629        &self,
630        txid: &Txid,
631        vout: u32,
632    ) -> impl Future<Output = Result<SpendStatus, Error>> + Send;
633
634    fn broadcast(&self, tx: &Transaction) -> impl Future<Output = Result<(), Error>> + Send;
635
636    fn get_fee_rate(&self) -> impl Future<Output = Result<f64, Error>> + Send;
637
638    fn broadcast_package(
639        &self,
640        txs: &[&Transaction],
641    ) -> impl Future<Output = Result<(), Error>> + Send;
642}
643
644impl<B, W, S> OfflineClient<B, W, S>
645where
646    B: Blockchain,
647    W: OnchainWallet,
648    S: SwapStorage + 'static,
649{
650    /// Create a new offline client with a generic key provider.
651    pub fn with_key_provider(
652        config: OfflineClientConfig,
653        key_provider: Arc<dyn KeyProvider>,
654        blockchain: Arc<B>,
655        wallet: Arc<W>,
656        swap_storage: Arc<S>,
657    ) -> Self {
658        Self::with_key_provider_parts(config, key_provider, None, blockchain, wallet, swap_storage)
659    }
660
661    /// Create a new offline client with a discoverable key provider.
662    pub fn with_discoverable_key_provider<P>(
663        config: OfflineClientConfig,
664        key_provider: Arc<P>,
665        blockchain: Arc<B>,
666        wallet: Arc<W>,
667        swap_storage: Arc<S>,
668    ) -> Self
669    where
670        P: DiscoverableKeyProvider + 'static,
671    {
672        let core_key_provider: Arc<dyn KeyProvider> = key_provider.clone();
673        let discoverable_key_provider: Arc<dyn DiscoverableKeyProvider> = key_provider;
674        Self::with_key_provider_parts(
675            config,
676            core_key_provider,
677            Some(discoverable_key_provider),
678            blockchain,
679            wallet,
680            swap_storage,
681        )
682    }
683
684    fn with_key_provider_parts(
685        config: OfflineClientConfig,
686        key_provider: Arc<dyn KeyProvider>,
687        discoverable_key_provider: Option<Arc<dyn DiscoverableKeyProvider>>,
688        blockchain: Arc<B>,
689        wallet: Arc<W>,
690        swap_storage: Arc<S>,
691    ) -> Self {
692        let secp = Secp256k1::new();
693        let network_client = ark_grpc::Client::new(config.ark_server_url);
694
695        // Normalize historical delegator keys once (preserve order, remove duplicates), then
696        // ensure the current delegator key is present at the front.
697        let mut seen = HashSet::new();
698        let mut historical_delegator_pks: Vec<_> = config
699            .historical_delegator_pks
700            .into_iter()
701            .filter(|pk| seen.insert(*pk))
702            .collect();
703
704        if let Some(pk) = config.delegator_pk {
705            historical_delegator_pks.retain(|k| *k != pk);
706            historical_delegator_pks.insert(0, pk);
707        }
708
709        let boltz_referral_id = match config.boltz_referral_id {
710            BoltzReferralId::Default => Some(DEFAULT_BOLTZ_REFERRAL_ID.to_string()),
711            BoltzReferralId::Disabled => None,
712            BoltzReferralId::Custom(referral_id) => Some(referral_id),
713        };
714        Self {
715            network_client,
716            key_provider,
717            discoverable_key_provider,
718            blockchain,
719            secp,
720            wallet,
721            swap_storage,
722            boltz_url: config.boltz_url.trim_end_matches('/').to_string(),
723            boltz_referral_id,
724            timeout: config.timeout,
725            server_info_ttl: config.server_info_ttl,
726            contract_store: Arc::new(Mutex::new(None)),
727            delegator_pk: config.delegator_pk,
728            historical_delegator_pks,
729        }
730    }
731
732    /// Create a new offline client with a static keypair.
733    pub fn with_keypair(
734        config: OfflineClientConfig,
735        kp: Keypair,
736        blockchain: Arc<B>,
737        wallet: Arc<W>,
738        swap_storage: Arc<S>,
739    ) -> Self {
740        let key_provider = Arc::new(StaticKeyProvider::new(kp));
741        Self::with_key_provider(config, key_provider, blockchain, wallet, swap_storage)
742    }
743
744    /// Create a new offline client with an [`Xpriv`].
745    pub fn with_bip32(
746        config: OfflineClientConfig,
747        xpriv: Xpriv,
748        path: Option<DerivationPath>,
749        blockchain: Arc<B>,
750        wallet: Arc<W>,
751        swap_storage: Arc<S>,
752    ) -> Self {
753        let path = path.unwrap_or(
754            DerivationPath::from_str(DEFAULT_DERIVATION_PATH).expect("valid derivation path"),
755        );
756        let key_provider = Arc::new(Bip32KeyProvider::new(xpriv, path));
757        Self::with_discoverable_key_provider(config, key_provider, blockchain, wallet, swap_storage)
758    }
759
760    /// Use a custom contract store for the connected client.
761    ///
762    /// If unset, the client uses an in-memory contract store.
763    pub fn with_contract_store(self, store: Box<dyn ContractStore>) -> Self {
764        let mut contract_store = self
765            .contract_store
766            .lock()
767            .expect("contract store lock should not be poisoned");
768        *contract_store = Some(store);
769        drop(contract_store);
770        self
771    }
772
773    /// Returns the currently configured delegator pubkey, if any.
774    pub fn delegator_pk(&self) -> Option<XOnlyPublicKey> {
775        self.delegator_pk
776    }
777
778    /// Returns the Boltz referral ID sent with all swap creation requests, if any.
779    pub fn boltz_referral_id(&self) -> Option<&str> {
780        self.boltz_referral_id.as_deref()
781    }
782
783    fn contract_manager(&self, network: Network) -> Result<ContractManager, Error> {
784        let store = self
785            .contract_store
786            .lock()
787            .map_err(|_| Error::ad_hoc("contract store lock poisoned"))?
788            .take()
789            .unwrap_or_else(|| Box::new(MemoryContractStore::new()));
790        Ok(ContractManager::new(network, store))
791    }
792
793    /// Connects to the Ark server and retrieves server information.
794    ///
795    /// # Errors
796    ///
797    /// Returns an error if the connection fails or times out.
798    pub async fn connect(mut self) -> Result<Client<B, W, S>, Error> {
799        timeout_op(self.timeout, self.network_client.connect())
800            .await
801            .context("Failed to connect to Ark server")??;
802
803        self.finish_connect().await
804    }
805
806    /// Connects to the Ark server and retrieves server information.
807    ///
808    /// If it encounters errors, it will retry `max_retries`.
809    ///
810    /// # Errors
811    ///
812    /// Returns an error if the connection fails or times out.
813    pub async fn connect_with_retries(
814        mut self,
815        max_retries: usize,
816    ) -> Result<Client<B, W, S>, Error> {
817        let mut n_retries = 0;
818        while n_retries < max_retries {
819            let res = timeout_op(self.timeout, self.network_client.connect())
820                .await
821                .context("Failed to connect to Ark server")?;
822
823            match res {
824                Ok(()) => break,
825                Err(error) => {
826                    tracing::warn!(?error, "Failed to connect to Ark server, retrying");
827
828                    sleep(Duration::from_secs(2)).await;
829
830                    n_retries += 1;
831
832                    continue;
833                }
834            };
835        }
836
837        self.finish_connect().await
838    }
839
840    async fn finish_connect(mut self) -> Result<Client<B, W, S>, Error> {
841        let server_info = timeout_op(self.timeout, self.network_client.get_info())
842            .await
843            .context("Failed to get Ark server info")??;
844
845        tracing::debug!(ark_server_url = ?self.network_client, "Connected to Ark server");
846
847        let fee_estimator = build_fee_estimator(&server_info)?;
848        let mut contract_manager = self.contract_manager(server_info.network)?;
849        contract_manager.register_builtins()?;
850        let state = Arc::new(RwLock::new(ServerState {
851            server_info: server_info.clone(),
852            fee_estimator,
853            server_info_refreshed_at: Instant::now(),
854            contract_manager: Mutex::new(contract_manager),
855        }));
856        let hook_state = state.clone();
857        self.network_client
858            .set_info_refresh_hook(move |server_info| {
859                update_server_state(&hook_state, server_info)
860                    .map_err(|err| Box::new(err) as Box<dyn std::error::Error + Send + Sync>)
861            });
862
863        let client = Client {
864            inner: self,
865            state,
866            server_info_refresh_lock: Arc::new(tokio::sync::Mutex::new(())),
867        };
868
869        client.hydrate_persisted_contract_keys()?;
870
871        // Eagerly persist the bounded baseline contract set. This mirrors the TS SDK split:
872        // connect() registers the always-watched index-0/current-key surface, while full
873        // gap-limit wallet regeneration is explicit via restore_contracts().
874        if let Err(error) = client.persist_baseline_contracts(&server_info) {
875            tracing::warn!(?error, "Failed to persist baseline contracts at connect");
876        }
877
878        Ok(client)
879    }
880}
881
882fn contract_info_from_stored(
883    contract: StoredContract,
884    server_info: &server::Info,
885    now_unix_secs: i64,
886) -> Result<ContractInfo, Error> {
887    let ctx = ContractContext::new(server_info.network);
888    let (address, address_kind, server_pk) = match &contract.contract_type {
889        contract_type if *contract_type == ContractType::default_vtxo() => {
890            let data: DefaultVtxoContract =
891                serde_json::from_value(contract.data.clone()).map_err(|e| {
892                    Error::ad_hoc(format!("failed to decode default vtxo contract: {e}"))
893                })?;
894            (
895                Some(data.vtxo(&ctx)?.to_ark_address().to_string()),
896                Some(ContractAddressKind::Ark),
897                Some(data.server),
898            )
899        }
900        contract_type if *contract_type == ContractType::delegate_vtxo() => {
901            let data: DelegateVtxoContract = serde_json::from_value(contract.data.clone())
902                .map_err(|e| {
903                    Error::ad_hoc(format!("failed to decode delegate vtxo contract: {e}"))
904                })?;
905            (
906                Some(data.vtxo(&ctx)?.to_ark_address().to_string()),
907                Some(ContractAddressKind::Ark),
908                Some(data.server),
909            )
910        }
911        contract_type if *contract_type == ContractType::boarding() => {
912            let data: BoardingContract = serde_json::from_value(contract.data.clone())
913                .map_err(|e| Error::ad_hoc(format!("failed to decode boarding contract: {e}")))?;
914            (
915                Some(data.boarding_output(&ctx)?.address().to_string()),
916                Some(ContractAddressKind::Bitcoin),
917                Some(data.server),
918            )
919        }
920        contract_type if *contract_type == ContractType::vhtlc() => {
921            let data: VhtlcContract = serde_json::from_value(contract.data.clone())
922                .map_err(|e| Error::ad_hoc(format!("failed to decode vhtlc contract: {e}")))?;
923            let address =
924                ark_core::vhtlc::VhtlcScript::new(data.options.clone(), server_info.network)
925                    .map_err(|e| Error::ad_hoc(format!("failed to build vhtlc address: {e}")))?
926                    .address()
927                    .to_string();
928            (
929                Some(address),
930                Some(ContractAddressKind::Ark),
931                Some(data.options.server),
932            )
933        }
934        _ => {
935            let address = Address::from_script(&contract.script_pubkey, server_info.network)
936                .ok()
937                .map(|address| address.to_string());
938            let address_kind = address.as_ref().map(|_| ContractAddressKind::Bitcoin);
939            (address, address_kind, None)
940        }
941    };
942    let signer_status =
943        server_pk.map(|server_pk| server_info.signer_status_at(server_pk, now_unix_secs));
944
945    Ok(ContractInfo {
946        contract,
947        address,
948        address_kind,
949        server_pk,
950        signer_status,
951    })
952}
953
954fn build_fee_estimator(server_info: &server::Info) -> Result<ark_fees::Estimator, Error> {
955    let fee_estimator_config = server_info
956        .fees
957        .clone()
958        .map(|fees| ark_fees::Config {
959            intent_offchain_input_program: fees.intent_fee.offchain_input.unwrap_or_default(),
960            intent_onchain_input_program: fees.intent_fee.onchain_input.unwrap_or_default(),
961            intent_offchain_output_program: fees.intent_fee.offchain_output.unwrap_or_default(),
962            intent_onchain_output_program: fees.intent_fee.onchain_output.unwrap_or_default(),
963        })
964        .unwrap_or_default();
965
966    ark_fees::Estimator::new(fee_estimator_config).map_err(Error::ark_server)
967}
968
969fn update_server_state(
970    state: &Arc<RwLock<ServerState>>,
971    server_info: server::Info,
972) -> Result<(), Error> {
973    let fee_estimator = build_fee_estimator(&server_info)?;
974    let mut state = state
975        .write()
976        .map_err(|_| Error::ad_hoc("client server state lock poisoned"))?;
977    state.server_info = server_info;
978    state.fee_estimator = fee_estimator;
979    state.server_info_refreshed_at = Instant::now();
980    Ok(())
981}
982
983impl<B, W, S> Client<B, W, S>
984where
985    B: Blockchain,
986    W: OnchainWallet,
987    S: SwapStorage + 'static,
988{
989    /// Returns Ark server info, refreshing the cached snapshot when its TTL has expired.
990    pub async fn server_info(&self) -> Result<server::Info, Error> {
991        // Fast path avoids taking the async mutex while the cache is fresh.
992        if let Some(server_info) = self.cached_server_info_if_fresh()? {
993            return Ok(server_info);
994        }
995
996        let _guard = self.server_info_refresh_lock.lock().await;
997        // Re-check after acquiring the mutex: another task may have refreshed while we waited.
998        if let Some(server_info) = self.cached_server_info_if_fresh()? {
999            return Ok(server_info);
1000        }
1001
1002        self.refresh_server_info_unlocked().await
1003    }
1004
1005    fn cached_server_info_if_fresh(&self) -> Result<Option<server::Info>, Error> {
1006        self.state
1007            .read()
1008            .map(|state| {
1009                (state.server_info_refreshed_at.elapsed() < self.inner.server_info_ttl)
1010                    .then(|| state.server_info.clone())
1011            })
1012            .map_err(|_| Error::ad_hoc("client server state lock poisoned"))
1013    }
1014
1015    fn with_server_state<T>(&self, f: impl FnOnce(&ServerState) -> T) -> Result<T, Error> {
1016        self.state
1017            .read()
1018            .map(|state| f(&state))
1019            .map_err(|_| Error::ad_hoc("client server state lock poisoned"))
1020    }
1021
1022    fn eval_onchain_output_fee(&self, output: ark_fees::Output) -> Result<Amount, Error> {
1023        self.with_server_state(|state| state.fee_estimator.eval_onchain_output(output))?
1024            .map(|fee| Amount::from_sat(fee.to_satoshis()))
1025            .map_err(Error::ad_hoc)
1026    }
1027
1028    /// Force-fetch the latest Ark server `/info` and replace the cached server state.
1029    ///
1030    /// This bypasses the TTL used by [`Self::server_info`]. Concurrent refreshes are serialized
1031    /// with the same refresh gate used by TTL-based refreshes.
1032    ///
1033    /// Returns the freshly fetched server info. The refreshed snapshot includes the server's
1034    /// current [`server::Info::deprecated_signers`], allowing subsequent wallet operations to
1035    /// observe signer rotations.
1036    pub async fn refresh_server_info(&self) -> Result<server::Info, Error> {
1037        let _guard = self.server_info_refresh_lock.lock().await;
1038        self.refresh_server_info_unlocked().await
1039    }
1040
1041    /// Fetch `/info` and update cached server state without acquiring the refresh gate.
1042    ///
1043    /// Callers must already hold `server_info_refresh_lock`, or otherwise guarantee that
1044    /// concurrent refreshes are serialized.
1045    async fn refresh_server_info_unlocked(&self) -> Result<server::Info, Error> {
1046        let server_info = timeout_op(self.inner.timeout, self.network_client().get_info())
1047            .await
1048            .context("Failed to refresh Ark server info")??;
1049
1050        update_server_state(&self.state, server_info.clone())?;
1051
1052        Ok(server_info)
1053    }
1054
1055    /// Returns the currently configured delegator pubkey, if any.
1056    pub fn delegator_pk(&self) -> Option<XOnlyPublicKey> {
1057        self.inner.delegator_pk()
1058    }
1059
1060    /// Returns the Boltz referral ID sent with all swap creation requests, if any.
1061    pub fn boltz_referral_id(&self) -> Option<&str> {
1062        self.inner.boltz_referral_id()
1063    }
1064
1065    /// List all contracts currently known to this wallet.
1066    ///
1067    /// This is a wallet-facing view over the contract store: each row includes the stored contract,
1068    /// a derived address when the SDK knows the contract type, and signer-rotation status for
1069    /// contracts that encode a server signer.
1070    pub async fn list_contracts(&self) -> Result<Vec<ContractInfo>, Error> {
1071        let server_info = self.server_info().await?;
1072        let now = unix_now()?;
1073        let contracts = {
1074            let state = self
1075                .state
1076                .read()
1077                .map_err(|_| Error::ad_hoc("client server state lock poisoned"))?;
1078            let contracts = state
1079                .contract_manager
1080                .lock()
1081                .map_err(|_| Error::ad_hoc("contract manager lock poisoned"))?
1082                .list()?;
1083            contracts
1084        };
1085
1086        contracts
1087            .into_iter()
1088            .map(|contract| contract_info_from_stored(contract, &server_info, now))
1089            .collect()
1090    }
1091
1092    /// Get a new offchain receiving address.
1093    ///
1094    /// When a delegator is configured (via [`OfflineClientConfig::delegator_pk`]),
1095    /// returns a 3-leaf delegate address. Otherwise returns a standard 2-leaf address.
1096    ///
1097    /// For HD wallets, this will derive a new address each time it's called.
1098    /// For static key providers, this will always return the same address.
1099    pub async fn get_offchain_address(&self) -> Result<(ArkAddress, Vtxo), Error> {
1100        let server_info = self.server_info().await?;
1101        self.get_offchain_address_with_server_info(&server_info)
1102    }
1103
1104    pub(crate) fn get_offchain_address_with_server_info(
1105        &self,
1106        server_info: &server::Info,
1107    ) -> Result<(ArkAddress, Vtxo), Error> {
1108        let server_signer = server_info.signer_pk.into();
1109        let owner = self
1110            .next_keypair(KeypairIndex::LastUnused)?
1111            .public_key()
1112            .into();
1113
1114        self.persist_offchain_vtxo_contract(server_info, server_signer, owner)
1115    }
1116
1117    /// Get all known offchain addresses for this wallet.
1118    ///
1119    /// When a delegator is configured, this returns **both** the default (2-leaf) and delegate
1120    /// (3-leaf) addresses for each key, so that VTXOs at either address are visible. If
1121    /// historical delegator keys are set via `historical_delegator_pks` passed to
1122    /// [`OfflineClientConfig::historical_delegator_pks`], addresses for those are included too.
1123    pub async fn get_offchain_addresses(&self) -> Result<Vec<(ArkAddress, Vtxo)>, Error> {
1124        let server_info = self.server_info().await?;
1125        self.get_offchain_addresses_with_server_info(&server_info)
1126    }
1127
1128    fn persist_baseline_contracts(&self, server_info: &server::Info) -> Result<(), Error> {
1129        self.persist_baseline_offchain_contracts(server_info)?;
1130        self.persist_watch_boarding_outputs(server_info)?;
1131        Ok(())
1132    }
1133
1134    fn hydrate_persisted_contract_keys(&self) -> Result<(), Error> {
1135        let state = self
1136            .state
1137            .read()
1138            .map_err(|_| Error::ad_hoc("client server state lock poisoned"))?;
1139        let contracts = state
1140            .contract_manager
1141            .lock()
1142            .map_err(|_| Error::ad_hoc("contract manager lock poisoned"))?
1143            .list()?;
1144
1145        let mut indices: Vec<u32> = contracts
1146            .into_iter()
1147            .filter_map(|contract| contract.key_index)
1148            .collect();
1149        indices.sort_unstable();
1150        indices.dedup();
1151
1152        let Some(key_provider) = self.inner.discoverable_key_provider.as_ref() else {
1153            return Ok(());
1154        };
1155        for index in indices {
1156            key_provider.cache_keypair_at_index(index)?;
1157        }
1158
1159        Ok(())
1160    }
1161
1162    fn persist_baseline_offchain_contracts(
1163        &self,
1164        server_info: &server::Info,
1165    ) -> Result<Vec<(ArkAddress, Vtxo)>, Error> {
1166        let owner = self
1167            .next_keypair(KeypairIndex::LastUnused)?
1168            .x_only_public_key()
1169            .0;
1170        let candidate_delays = ark_core::candidate_exit_delays(
1171            server_info.unilateral_exit_delay,
1172            server_info.network,
1173        )?;
1174
1175        let mut results = Vec::new();
1176        for server_signer in server_info.all_server_keys() {
1177            for exit_delay in &candidate_delays {
1178                results.push(self.persist_default_vtxo_contract(
1179                    server_info.network,
1180                    server_signer,
1181                    owner,
1182                    *exit_delay,
1183                )?);
1184
1185                let mut seen = HashSet::new();
1186                for dpk in &self.inner.historical_delegator_pks {
1187                    if !seen.insert(dpk) {
1188                        continue;
1189                    }
1190                    results.push(self.persist_delegate_vtxo_contract(
1191                        server_info.network,
1192                        server_signer,
1193                        owner,
1194                        *dpk,
1195                        *exit_delay,
1196                    )?);
1197                }
1198            }
1199        }
1200        Ok(results)
1201    }
1202
1203    pub(crate) fn get_offchain_addresses_with_server_info(
1204        &self,
1205        server_info: &server::Info,
1206    ) -> Result<Vec<(ArkAddress, Vtxo)>, Error> {
1207        let pks = self.inner.key_provider.get_cached_pks()?;
1208
1209        // Build addresses for current signer + all deprecated signers so VTXOs under any
1210        // known server key are discovered and visible in the balance.
1211        let all_server_keys: Vec<XOnlyPublicKey> = server_info.all_server_keys().collect();
1212        // Enumerate under every candidate exit delay (the advertised delay plus, on mainnet, the
1213        // legacy delay), mirroring `restore_contracts`: a VTXO minted before the operator shortened
1214        // the delay lives at a legacy-delay address, so building only the current delay
1215        // would hide it from `list_vtxos`/balance/migration even though its key was
1216        // discovered. Off mainnet this is just the advertised delay (no behaviour change).
1217        let candidate_delays = ark_core::candidate_exit_delays(
1218            server_info.unilateral_exit_delay,
1219            server_info.network,
1220        )?;
1221
1222        let mut results = Vec::new();
1223
1224        for owner_pk in &pks {
1225            for server_signer in &all_server_keys {
1226                for exit_delay in &candidate_delays {
1227                    results.push(self.persist_default_vtxo_contract(
1228                        server_info.network,
1229                        *server_signer,
1230                        *owner_pk,
1231                        *exit_delay,
1232                    )?);
1233
1234                    // Delegate addresses for all known delegator keys.
1235                    let mut seen = HashSet::new();
1236                    for dpk in &self.inner.historical_delegator_pks {
1237                        if !seen.insert(dpk) {
1238                            continue;
1239                        }
1240                        results.push(self.persist_delegate_vtxo_contract(
1241                            server_info.network,
1242                            *server_signer,
1243                            *owner_pk,
1244                            *dpk,
1245                            *exit_delay,
1246                        )?);
1247                    }
1248                }
1249            }
1250        }
1251
1252        Ok(results)
1253    }
1254
1255    fn persist_offchain_vtxo_contract(
1256        &self,
1257        server_info: &server::Info,
1258        server_signer: XOnlyPublicKey,
1259        owner: XOnlyPublicKey,
1260    ) -> Result<(ArkAddress, Vtxo), Error> {
1261        match self.inner.delegator_pk {
1262            Some(delegator) => self.persist_delegate_vtxo_contract(
1263                server_info.network,
1264                server_signer,
1265                owner,
1266                delegator,
1267                server_info.unilateral_exit_delay,
1268            ),
1269            None => self.persist_default_vtxo_contract(
1270                server_info.network,
1271                server_signer,
1272                owner,
1273                server_info.unilateral_exit_delay,
1274            ),
1275        }
1276    }
1277
1278    fn persist_default_vtxo_contract(
1279        &self,
1280        network: Network,
1281        server: XOnlyPublicKey,
1282        owner: XOnlyPublicKey,
1283        exit_delay: bitcoin::Sequence,
1284    ) -> Result<(ArkAddress, Vtxo), Error> {
1285        let key_index = self.derivation_index_for_pk(&owner);
1286        let contract = DefaultVtxoContract {
1287            server,
1288            owner,
1289            exit_delay,
1290        };
1291        let state = self
1292            .state
1293            .read()
1294            .map_err(|_| Error::ad_hoc("client server state lock poisoned"))?;
1295        let mut manager = state
1296            .contract_manager
1297            .lock()
1298            .map_err(|_| Error::ad_hoc("contract manager lock poisoned"))?;
1299        manager.insert_or_get(contract.clone(), ContractState::Active, key_index)?;
1300        let ctx = ContractContext::new(network);
1301        // Derive from the requested default VTXO contract, not from the stored row: the store may
1302        // already contain an equivalent boarding row for this script, but the caller still needs
1303        // the offchain Arkade address for this default VTXO script.
1304        let vtxo = contract.vtxo(&ctx)?;
1305        Ok((vtxo.to_ark_address(), vtxo))
1306    }
1307
1308    fn persist_delegate_vtxo_contract(
1309        &self,
1310        network: Network,
1311        server: XOnlyPublicKey,
1312        owner: XOnlyPublicKey,
1313        delegator: XOnlyPublicKey,
1314        exit_delay: bitcoin::Sequence,
1315    ) -> Result<(ArkAddress, Vtxo), Error> {
1316        let key_index = self.derivation_index_for_pk(&owner);
1317        let contract = DelegateVtxoContract {
1318            server,
1319            owner,
1320            delegator,
1321            exit_delay,
1322        };
1323        let state = self
1324            .state
1325            .read()
1326            .map_err(|_| Error::ad_hoc("client server state lock poisoned"))?;
1327        let mut manager = state
1328            .contract_manager
1329            .lock()
1330            .map_err(|_| Error::ad_hoc("contract manager lock poisoned"))?;
1331        let stored = manager.insert_or_get(contract, ContractState::Active, key_index)?;
1332        let contract = manager
1333            .get_typed::<DelegateVtxoContract>(&stored.script_pubkey)?
1334            .ok_or_else(|| Error::ad_hoc("missing delegate vtxo contract"))?;
1335        let ctx = ContractContext::new(network);
1336        let vtxo = contract.vtxo(&ctx)?;
1337        Ok((vtxo.to_ark_address(), vtxo))
1338    }
1339
1340    /// Restore persisted contracts using explicit contract discovery.
1341    ///
1342    /// This method derives candidate default VTXO, delegate VTXO, and boarding contracts for each
1343    /// key index. It queries the Arkade VTXO index for offchain candidates and the configured
1344    /// blockchain backend for boarding candidates, persists contracts that have activity, and stops
1345    /// when a full batch has no discovered contracts.
1346    ///
1347    /// No-op for StaticKeyProvider.
1348    ///
1349    /// # Arguments
1350    ///
1351    /// * `gap_limit` - Number of consecutive unused key indexes before stopping
1352    pub async fn restore_contracts(&self, gap_limit: u32) -> Result<ContractRestoreReport, Error> {
1353        if gap_limit == 0 {
1354            return Err(Error::ad_hoc("restore gap limit must be greater than zero"));
1355        }
1356
1357        let Some(key_provider) = self.inner.discoverable_key_provider.as_ref() else {
1358            tracing::debug!("Key provider does not support discovery, skipping");
1359            return Ok(ContractRestoreReport {
1360                gap_limit,
1361                ..Default::default()
1362            });
1363        };
1364
1365        let server_info = &self.server_info().await?;
1366        let ctx = ContractContext::new(server_info.network);
1367        let all_server_keys: Vec<XOnlyPublicKey> = server_info.all_server_keys().collect();
1368        let offchain_exit_delays = ark_core::candidate_exit_delays(
1369            server_info.unilateral_exit_delay,
1370            server_info.network,
1371        )?;
1372        let boarding_exit_delays =
1373            ark_core::candidate_exit_delays(server_info.boarding_exit_delay, server_info.network)?;
1374
1375        let mut start_index = 0u32;
1376        let mut report = ContractRestoreReport {
1377            gap_limit,
1378            ..Default::default()
1379        };
1380
1381        tracing::info!(gap_limit, "Starting contract restore");
1382
1383        loop {
1384            let batch = self.restore_candidate_batch(
1385                start_index,
1386                gap_limit,
1387                &all_server_keys,
1388                &offchain_exit_delays,
1389                &boarding_exit_delays,
1390            )?;
1391
1392            if batch.is_empty() {
1393                break;
1394            }
1395
1396            report.scanned_from.get_or_insert(start_index);
1397            let scanned_to_exclusive = start_index
1398                .checked_add(batch.len() as u32)
1399                .ok_or_else(|| Error::ad_hoc("Key discovery index overflow"))?;
1400            report.scanned_to_exclusive = Some(scanned_to_exclusive);
1401            report.scanned_keys += batch.len() as u32;
1402
1403            let mut offchain_addresses = Vec::new();
1404            for (_, _, candidates) in &batch {
1405                for candidate in candidates {
1406                    if let RestoreDiscoveryTarget::Offchain(address) =
1407                        candidate.discovery_target(self.secp(), &ctx)?
1408                    {
1409                        offchain_addresses.push(address);
1410                    }
1411                }
1412            }
1413            let vtxo_list = self
1414                .list_vtxos_for_addresses(offchain_addresses.into_iter())
1415                .await?;
1416            let mut offchain_vtxos_by_script =
1417                HashMap::<ScriptBuf, Vec<ContractRestoreVtxo>>::new();
1418            for vtxo in vtxo_list.all() {
1419                offchain_vtxos_by_script
1420                    .entry(vtxo.script.clone())
1421                    .or_default()
1422                    .push(ContractRestoreVtxo {
1423                        outpoint: vtxo.outpoint,
1424                        amount: vtxo.amount,
1425                        is_spent: vtxo.is_spent,
1426                        is_swept: vtxo.is_swept,
1427                        is_unrolled: vtxo.is_unrolled,
1428                    });
1429            }
1430
1431            let mut found_any = false;
1432            for (index, kp, candidates) in batch {
1433                let mut found_for_key = false;
1434
1435                for candidate in candidates {
1436                    let script = candidate.script_pubkey(self.secp(), &ctx)?;
1437                    let contract_type = candidate.contract_type();
1438                    let target = candidate.discovery_target(self.secp(), &ctx)?;
1439                    let discovery = match target {
1440                        RestoreDiscoveryTarget::Offchain(_) => offchain_vtxos_by_script
1441                            .get(&script)
1442                            .filter(|vtxos| !vtxos.is_empty())
1443                            .cloned()
1444                            .map(|vtxos| ContractRestoreDiscovery::Offchain { vtxos }),
1445                        RestoreDiscoveryTarget::Boarding(address) => {
1446                            let outpoints = self
1447                                .blockchain()
1448                                .find_outpoints(&address)
1449                                .await?
1450                                .into_iter()
1451                                .filter(|utxo| !utxo.is_spent)
1452                                .map(|utxo| ContractRestoreOutpoint {
1453                                    outpoint: utxo.outpoint,
1454                                    amount: utxo.amount,
1455                                    confirmation_blocktime: utxo.confirmation_blocktime,
1456                                    confirmations: utxo.confirmations,
1457                                })
1458                                .collect::<Vec<_>>();
1459                            (!outpoints.is_empty())
1460                                .then_some(ContractRestoreDiscovery::Boarding { outpoints })
1461                        }
1462                    };
1463
1464                    let Some(discovery) = discovery else {
1465                        continue;
1466                    };
1467
1468                    let inserted = self.persist_restore_candidate(candidate, index)?;
1469                    let status = if inserted {
1470                        report.inserted_contracts += 1;
1471                        ContractRestoreEntryStatus::Inserted
1472                    } else {
1473                        report.known_contracts += 1;
1474                        ContractRestoreEntryStatus::Known
1475                    };
1476                    match &discovery {
1477                        ContractRestoreDiscovery::Offchain { .. } => report.offchain_contracts += 1,
1478                        ContractRestoreDiscovery::Boarding { .. } => report.boarding_contracts += 1,
1479                    }
1480                    report.entries.push(ContractRestoreEntry {
1481                        key_index: index,
1482                        contract_type,
1483                        script_pubkey: script,
1484                        status,
1485                        discovery,
1486                    });
1487                    found_for_key = true;
1488                }
1489
1490                if found_for_key {
1491                    key_provider.cache_discovered_keypair(index, kp)?;
1492                    report.discovered_key_indexes.push(index);
1493                    report.last_used_key_index = Some(
1494                        report
1495                            .last_used_key_index
1496                            .map_or(index, |last| last.max(index)),
1497                    );
1498                    report.next_key_index = report
1499                        .last_used_key_index
1500                        .and_then(|last| last.checked_add(1));
1501                    found_any = true;
1502                }
1503            }
1504
1505            if !found_any {
1506                break;
1507            }
1508
1509            start_index = start_index
1510                .checked_add(gap_limit)
1511                .ok_or_else(|| Error::ad_hoc("Key discovery index overflow"))?;
1512        }
1513
1514        tracing::info!(?report, "Contract restore completed");
1515
1516        Ok(report)
1517    }
1518
1519    fn restore_candidate_batch(
1520        &self,
1521        start_index: u32,
1522        gap_limit: u32,
1523        server_keys: &[XOnlyPublicKey],
1524        offchain_exit_delays: &[bitcoin::Sequence],
1525        boarding_exit_delays: &[bitcoin::Sequence],
1526    ) -> Result<Vec<(u32, Keypair, Vec<RestoreCandidate>)>, Error> {
1527        let mut batch = Vec::with_capacity(gap_limit as usize);
1528
1529        for i in 0..gap_limit {
1530            let index = start_index
1531                .checked_add(i)
1532                .ok_or_else(|| Error::ad_hoc("Key discovery index overflow"))?;
1533            let Some(key_provider) = self.inner.discoverable_key_provider.as_ref() else {
1534                break;
1535            };
1536            let Some(kp) = key_provider.derive_at_discovery_index(index)? else {
1537                break;
1538            };
1539            let owner = kp.x_only_public_key().0;
1540            let mut candidates = Vec::new();
1541
1542            for server in server_keys {
1543                for exit_delay in offchain_exit_delays {
1544                    candidates.push(RestoreCandidate::DefaultVtxo(DefaultVtxoContract {
1545                        server: *server,
1546                        owner,
1547                        exit_delay: *exit_delay,
1548                    }));
1549
1550                    let mut seen_delegators = HashSet::new();
1551                    for delegator in &self.inner.historical_delegator_pks {
1552                        if !seen_delegators.insert(delegator) {
1553                            continue;
1554                        }
1555                        candidates.push(RestoreCandidate::DelegateVtxo(DelegateVtxoContract {
1556                            server: *server,
1557                            owner,
1558                            delegator: *delegator,
1559                            exit_delay: *exit_delay,
1560                        }));
1561                    }
1562                }
1563
1564                for exit_delay in boarding_exit_delays {
1565                    candidates.push(RestoreCandidate::Boarding(BoardingContract {
1566                        server: *server,
1567                        owner,
1568                        exit_delay: *exit_delay,
1569                    }));
1570                }
1571            }
1572
1573            batch.push((index, kp, candidates));
1574        }
1575
1576        Ok(batch)
1577    }
1578
1579    fn persist_restore_candidate(
1580        &self,
1581        candidate: RestoreCandidate,
1582        key_index: u32,
1583    ) -> Result<bool, Error> {
1584        let state = self
1585            .state
1586            .read()
1587            .map_err(|_| Error::ad_hoc("client server state lock poisoned"))?;
1588        let mut manager = state
1589            .contract_manager
1590            .lock()
1591            .map_err(|_| Error::ad_hoc("contract manager lock poisoned"))?;
1592        let ctx = ContractContext::new(state.server_info.network);
1593        let script = candidate.script_pubkey(self.secp(), &ctx)?;
1594        let existed = manager.get(&script)?.is_some();
1595
1596        match candidate {
1597            RestoreCandidate::DefaultVtxo(contract) => {
1598                manager.insert_or_get(contract, ContractState::Active, Some(key_index))?;
1599            }
1600            RestoreCandidate::DelegateVtxo(contract) => {
1601                manager.insert_or_get(contract, ContractState::Active, Some(key_index))?;
1602            }
1603            RestoreCandidate::Boarding(contract) => {
1604                manager.insert_or_get(contract, ContractState::Active, Some(key_index))?;
1605            }
1606        }
1607
1608        Ok(!existed)
1609    }
1610
1611    // At the moment we are always generating the same address.
1612    pub async fn get_boarding_address(&self) -> Result<Address, Error> {
1613        let server_info = &self.server_info().await?;
1614        let owner = self
1615            .next_keypair(KeypairIndex::LastUnused)?
1616            .x_only_public_key()
1617            .0;
1618
1619        let contract = BoardingContract {
1620            server: server_info.signer_pk.into(),
1621            owner,
1622            exit_delay: server_info.boarding_exit_delay,
1623        };
1624        let key_index = self.derivation_index_for_pk(&owner);
1625        let state = self
1626            .state
1627            .read()
1628            .map_err(|_| Error::ad_hoc("client server state lock poisoned"))?;
1629        let stored = state
1630            .contract_manager
1631            .lock()
1632            .map_err(|_| Error::ad_hoc("contract manager lock poisoned"))?
1633            .insert_or_get(contract, ContractState::Active, key_index)?;
1634
1635        Address::from_script(&stored.script_pubkey, server_info.network)
1636            .map_err(|e| Error::ad_hoc(format!("invalid boarding contract script: {e}")))
1637    }
1638
1639    pub fn get_onchain_address(&self) -> Result<Address, Error> {
1640        self.inner.wallet.get_onchain_address()
1641    }
1642
1643    pub async fn get_boarding_addresses(&self) -> Result<Vec<Address>, Error> {
1644        let server_info = &self.server_info().await?;
1645
1646        // Persist a boarding output for every (signer, exit-delay) candidate and return the
1647        // de-duplicated addresses. This is the watch/history surface: it covers the current signer
1648        // plus all deprecated signers (server-key rotation), each crossed with the candidate
1649        // exit-delay set (the advertised delay plus, on mainnet, the legacy delay) so deposits
1650        // minted under an older delay or an older key are still visible.
1651        //
1652        // The spend path (settle) deliberately stays current-signer-only;
1653        // deprecated-signer boarding recovery is handled via migrate_deprecated_signer_vtxos().
1654        let outputs = self.persist_watch_boarding_outputs(server_info)?;
1655
1656        let mut seen = HashSet::new();
1657        let mut addresses = Vec::with_capacity(outputs.len());
1658        for output in &outputs {
1659            let address = output.address().clone();
1660            if seen.insert(address.clone()) {
1661                addresses.push(address);
1662            }
1663        }
1664
1665        Ok(addresses)
1666    }
1667
1668    /// Persist (idempotently) a boarding output for each signer the wallet should watch crossed
1669    /// with each candidate exit delay, returning annotated boarding outputs.
1670    ///
1671    /// Covers the current signer plus every deprecated signer, each paired with
1672    /// [`ark_core::candidate_exit_delays`] (the advertised boarding-exit delay plus, on mainnet,
1673    /// the legacy delay). Re-persisting the same boarding contract is idempotent, so this is safe
1674    /// to call repeatedly — at connect time and again from [`Client::get_boarding_addresses`].
1675    fn persist_watch_boarding_outputs(
1676        &self,
1677        server_info: &server::Info,
1678    ) -> Result<Vec<AnnotatedBoardingOutput>, Error> {
1679        let candidate_delays =
1680            ark_core::candidate_exit_delays(server_info.boarding_exit_delay, server_info.network)?;
1681        let owner = self
1682            .next_keypair(KeypairIndex::LastUnused)?
1683            .x_only_public_key()
1684            .0;
1685        let key_index = self.derivation_index_for_pk(&owner);
1686        let state = self
1687            .state
1688            .read()
1689            .map_err(|_| Error::ad_hoc("client server state lock poisoned"))?;
1690        let mut manager = state
1691            .contract_manager
1692            .lock()
1693            .map_err(|_| Error::ad_hoc("contract manager lock poisoned"))?;
1694
1695        for server_pk in server_info.all_server_keys() {
1696            for exit_delay in &candidate_delays {
1697                let contract = BoardingContract {
1698                    server: server_pk,
1699                    owner,
1700                    exit_delay: *exit_delay,
1701                };
1702                manager.insert_or_get(contract, ContractState::Active, key_index)?;
1703            }
1704        }
1705
1706        manager.annotated_boarding_outputs_for_exit_delays(&candidate_delays)
1707    }
1708
1709    pub async fn get_virtual_tx_outpoints(
1710        &self,
1711        addresses: impl Iterator<Item = ArkAddress>,
1712    ) -> Result<Vec<VirtualTxOutPoint>, Error> {
1713        let request = GetVtxosRequest::new_for_addresses(addresses);
1714        self.fetch_all_vtxos(request).await
1715    }
1716
1717    pub async fn list_vtxos(&self) -> Result<AnnotatedVtxoList, Error> {
1718        let server_info = self.server_info().await?;
1719        self.list_vtxos_with_server_info(&server_info).await
1720    }
1721
1722    pub(crate) async fn list_vtxos_with_server_info(
1723        &self,
1724        server_info: &server::Info,
1725    ) -> Result<AnnotatedVtxoList, Error> {
1726        let addresses = self.active_offchain_contract_addresses()?;
1727        let virtual_tx_outpoints = self.get_virtual_tx_outpoints(addresses.into_iter()).await?;
1728        let contract_vtxos = self.annotate_vtxos(virtual_tx_outpoints)?;
1729
1730        Ok(AnnotatedVtxoList::new(server_info.dust, contract_vtxos))
1731    }
1732
1733    pub async fn list_vtxos_for_addresses(
1734        &self,
1735        addresses: impl Iterator<Item = ArkAddress>,
1736    ) -> Result<VtxoList, Error> {
1737        let server_info = self.server_info().await?;
1738        self.list_vtxos_for_addresses_with_server_info(&server_info, addresses)
1739            .await
1740    }
1741
1742    pub(crate) async fn list_vtxos_for_addresses_with_server_info(
1743        &self,
1744        server_info: &server::Info,
1745        addresses: impl Iterator<Item = ArkAddress>,
1746    ) -> Result<VtxoList, Error> {
1747        let virtual_tx_outpoints = self
1748            .get_virtual_tx_outpoints(addresses)
1749            .await
1750            .context("failed to get VTXOs for addresses")?;
1751
1752        let vtxo_list = VtxoList::new(server_info.dust, virtual_tx_outpoints);
1753
1754        Ok(vtxo_list)
1755    }
1756
1757    pub async fn list_vtxos_for_outpoints(
1758        &self,
1759        outpoints: Vec<OutPoint>,
1760    ) -> Result<AnnotatedVtxoList, Error> {
1761        let request = GetVtxosRequest::new_for_outpoints(&outpoints);
1762        let virtual_tx_outpoints = self.fetch_all_vtxos(request).await?;
1763        let contract_vtxos = self.annotate_vtxos(virtual_tx_outpoints)?;
1764        Ok(AnnotatedVtxoList::new(
1765            self.server_info().await?.dust,
1766            contract_vtxos,
1767        ))
1768    }
1769
1770    pub async fn get_vtxo_chain(
1771        &self,
1772        out_point: OutPoint,
1773        size: i32,
1774        index: i32,
1775    ) -> Result<Option<VtxoChainResponse>, Error> {
1776        let vtxo_chain = timeout_op(
1777            self.inner.timeout,
1778            self.network_client()
1779                .get_vtxo_chain(Some(out_point), Some((size, index))),
1780        )
1781        .await
1782        .context("Failed to fetch VTXO chain")??;
1783
1784        Ok(Some(vtxo_chain))
1785    }
1786
1787    pub async fn offchain_balance(&self) -> Result<OffChainBalance, Error> {
1788        let vtxo_list = self.list_vtxos().await.context("failed to list VTXOs")?;
1789        let now = unix_now()?;
1790        let server_info = self.server_info().await?;
1791
1792        let spendable_outpoints: HashSet<OutPoint> = vtxo_list
1793            .spendable_offchain_at(&server_info, now)
1794            .map(|entry| entry.vtxo().outpoint)
1795            .collect();
1796
1797        let pre_confirmed = vtxo_list
1798            .pre_confirmed()
1799            .filter(|entry| spendable_outpoints.contains(&entry.vtxo().outpoint))
1800            .fold(Amount::ZERO, |acc, entry| acc + entry.vtxo().amount);
1801
1802        let confirmed = vtxo_list
1803            .confirmed()
1804            .filter(|entry| spendable_outpoints.contains(&entry.vtxo().outpoint))
1805            .fold(Amount::ZERO, |acc, entry| acc + entry.vtxo().amount);
1806
1807        let recoverable = vtxo_list
1808            .recoverable()
1809            .fold(Amount::ZERO, |acc, entry| acc + entry.vtxo().amount);
1810
1811        let pending_recovery = vtxo_list
1812            .pending_recovery_due_to_signer_at(&server_info, now)
1813            .fold(Amount::ZERO, |acc, entry| acc + entry.vtxo().amount);
1814
1815        // Aggregate asset balances from currently offchain-spendable VTXOs only.
1816        let mut asset_balances: HashMap<AssetId, u64> = HashMap::new();
1817        for entry in vtxo_list.spendable_offchain_at(&server_info, now) {
1818            for asset in &entry.vtxo().assets {
1819                let total = asset_balances
1820                    .get(&asset.asset_id)
1821                    .copied()
1822                    .unwrap_or(0)
1823                    .checked_add(asset.amount)
1824                    .ok_or_else(|| Error::ad_hoc("asset balance overflow"))?;
1825                asset_balances.insert(asset.asset_id, total);
1826            }
1827        }
1828
1829        Ok(OffChainBalance {
1830            pre_confirmed,
1831            confirmed,
1832            recoverable,
1833            pending_recovery,
1834            asset_balances,
1835        })
1836    }
1837
1838    /// Get information about an asset by its ID.
1839    pub async fn get_asset(&self, asset_id: AssetId) -> Result<server::AssetInfo, Error> {
1840        timeout_op(
1841            self.inner.timeout,
1842            self.network_client().get_asset(asset_id),
1843        )
1844        .await
1845        .context("Failed to get asset info")?
1846        .map_err(Error::ark_server)
1847    }
1848
1849    pub async fn transaction_history(&self) -> Result<Vec<history::Transaction>, Error> {
1850        let mut boarding_transactions = Vec::new();
1851        let mut boarding_commitment_transactions = Vec::new();
1852
1853        let boarding_addresses = self.get_boarding_addresses().await?;
1854        for boarding_address in boarding_addresses.iter() {
1855            let outpoints = timeout_op(
1856                self.inner.timeout,
1857                self.blockchain().find_outpoints(boarding_address),
1858            )
1859            .await
1860            .context("Failed to find outpoints")??;
1861
1862            for ExplorerUtxo {
1863                outpoint,
1864                amount,
1865                confirmation_blocktime,
1866                ..
1867            } in outpoints.iter()
1868            {
1869                let confirmed_at = confirmation_blocktime.map(|t| t as i64);
1870
1871                boarding_transactions.push(history::Transaction::Boarding {
1872                    txid: outpoint.txid,
1873                    amount: *amount,
1874                    confirmed_at,
1875                });
1876
1877                let status = timeout_op(
1878                    self.inner.timeout,
1879                    self.blockchain()
1880                        .get_output_status(&outpoint.txid, outpoint.vout),
1881                )
1882                .await
1883                .context("Failed to get Tx output status")??;
1884
1885                if let Some(spend_txid) = status.spend_txid {
1886                    boarding_commitment_transactions.push(spend_txid);
1887                }
1888            }
1889        }
1890
1891        let vtxo_list = self.list_vtxos().await?;
1892
1893        let spent_outpoints = vtxo_list
1894            .spent()
1895            .map(|entry| entry.vtxo().clone())
1896            .collect::<Vec<_>>();
1897        let unspent_outpoints = vtxo_list
1898            .all_unspent()
1899            .map(|entry| entry.vtxo().clone())
1900            .collect::<Vec<_>>();
1901
1902        let incoming_transactions = generate_incoming_vtxo_transaction_history(
1903            &spent_outpoints,
1904            &unspent_outpoints,
1905            &boarding_commitment_transactions,
1906        )?;
1907
1908        let outgoing_txs =
1909            generate_outgoing_vtxo_transaction_history(&spent_outpoints, &unspent_outpoints)?;
1910
1911        let mut outgoing_transactions = vec![];
1912        for tx in outgoing_txs {
1913            let tx = match tx {
1914                OutgoingTransaction::Complete(tx) => tx,
1915                OutgoingTransaction::Incomplete(incomplete_tx) => {
1916                    let first_outpoint = incomplete_tx.first_outpoint();
1917
1918                    let request = GetVtxosRequest::new_for_outpoints(&[first_outpoint]);
1919                    let vtxos = self.fetch_all_vtxos(request).await?;
1920
1921                    match vtxos.first() {
1922                        Some(virtual_tx_outpoint) => {
1923                            match incomplete_tx.finish(virtual_tx_outpoint) {
1924                                Ok(tx) => tx,
1925                                Err(e) => {
1926                                    tracing::warn!(
1927                                        %first_outpoint,
1928                                        "Could not finish outgoing TX, skipping: {e}"
1929                                    );
1930                                    continue;
1931                                }
1932                            }
1933                        }
1934                        None => {
1935                            tracing::warn!(
1936                                %first_outpoint,
1937                                "Could not find virtual TX outpoint for outgoing TX, skipping"
1938                            );
1939                            continue;
1940                        }
1941                    }
1942                }
1943                OutgoingTransaction::IncompleteOffboard(incomplete_offboard) => {
1944                    let status = timeout_op(
1945                        self.inner.timeout,
1946                        self.blockchain()
1947                            .get_tx_status(&incomplete_offboard.commitment_txid()),
1948                    )
1949                    .await
1950                    .context("failed to get commitment TX status")??;
1951
1952                    incomplete_offboard.finish(status.confirmed_at)
1953                }
1954            };
1955
1956            outgoing_transactions.push(tx);
1957        }
1958
1959        let mut txs = [
1960            boarding_transactions,
1961            incoming_transactions,
1962            outgoing_transactions,
1963        ]
1964        .concat();
1965
1966        sort_transactions_by_created_at(&mut txs);
1967
1968        Ok(txs)
1969    }
1970
1971    /// The server's dust threshold amount.
1972    pub async fn dust(&self) -> Result<Amount, Error> {
1973        Ok(self.server_info().await?.dust)
1974    }
1975
1976    pub fn network_client(&self) -> ark_grpc::Client {
1977        self.inner.network_client.clone()
1978    }
1979
1980    /// Fetch all VTXOs for a request, handling pagination internally.
1981    async fn fetch_all_vtxos(
1982        &self,
1983        request: GetVtxosRequest,
1984    ) -> Result<Vec<VirtualTxOutPoint>, Error> {
1985        if request.reference().is_empty() {
1986            return Ok(Vec::new());
1987        }
1988
1989        let mut all_vtxos = Vec::new();
1990        let mut cursor = 0;
1991        const PAGE_SIZE: i32 = 100;
1992
1993        loop {
1994            let paged_request = request.clone().with_page(PAGE_SIZE, cursor);
1995            let response = timeout_op(
1996                self.inner.timeout,
1997                self.network_client().list_vtxos(paged_request),
1998            )
1999            .await
2000            .context("failed to fetch list of VTXOs")??;
2001
2002            all_vtxos.extend(response.vtxos);
2003
2004            // Use server-provided cursor for next page; next == total means end
2005            match response.page {
2006                Some(page) if page.next < page.total => {
2007                    cursor = page.next;
2008                }
2009                _ => break,
2010            }
2011        }
2012
2013        Ok(all_vtxos)
2014    }
2015
2016    fn next_keypair(&self, keypair_index: KeypairIndex) -> Result<Keypair, Error> {
2017        self.inner.key_provider.get_next_keypair(keypair_index)
2018    }
2019    fn keypair_by_pk(&self, pk: &XOnlyPublicKey) -> Result<Keypair, Error> {
2020        self.inner.key_provider.get_keypair_for_pk(pk)
2021    }
2022
2023    fn sign_for_pk(&self, pk: &XOnlyPublicKey, msg: &Message) -> Result<Signature, Error> {
2024        let keypair = self.keypair_by_pk(pk)?;
2025        Ok(self.secp().sign_schnorr_no_aux_rand(msg, &keypair))
2026    }
2027
2028    fn boarding_outputs(&self) -> Result<Vec<AnnotatedBoardingOutput>, Error> {
2029        let state = self
2030            .state
2031            .read()
2032            .map_err(|_| Error::ad_hoc("client server state lock poisoned"))?;
2033        // Include default VTXO rows only when their CSV delay matches a boarding delay candidate.
2034        // This covers the equal-delay case where a default VTXO row is the stored row for a script
2035        // that can also be used for boarding, without turning every default VTXO receive script
2036        // into a boarding watch.
2037        let candidate_delays = ark_core::candidate_exit_delays(
2038            state.server_info.boarding_exit_delay,
2039            state.server_info.network,
2040        )?;
2041        let outputs = state
2042            .contract_manager
2043            .lock()
2044            .map_err(|_| Error::ad_hoc("contract manager lock poisoned"))?
2045            .annotated_boarding_outputs_for_exit_delays(&candidate_delays)?;
2046        Ok(outputs)
2047    }
2048
2049    fn active_offchain_contract_addresses(&self) -> Result<Vec<ArkAddress>, Error> {
2050        self.active_offchain_contracts().map(|contracts| {
2051            contracts
2052                .into_iter()
2053                .map(|contract| contract.address)
2054                .collect()
2055        })
2056    }
2057
2058    fn active_offchain_contracts(&self) -> Result<Vec<contract::ActiveOffchainContract>, Error> {
2059        let state = self
2060            .state
2061            .read()
2062            .map_err(|_| Error::ad_hoc("client server state lock poisoned"))?;
2063        let candidate_delays = ark_core::candidate_exit_delays(
2064            state.server_info.unilateral_exit_delay,
2065            state.server_info.network,
2066        )?;
2067        let contracts = state
2068            .contract_manager
2069            .lock()
2070            .map_err(|_| Error::ad_hoc("contract manager lock poisoned"))?
2071            .active_offchain_contracts(&candidate_delays)?;
2072        Ok(contracts)
2073    }
2074
2075    fn annotate_vtxos(&self, vtxos: Vec<VirtualTxOutPoint>) -> Result<Vec<AnnotatedVtxo>, Error> {
2076        let state = self
2077            .state
2078            .read()
2079            .map_err(|_| Error::ad_hoc("client server state lock poisoned"))?;
2080        let annotated = state
2081            .contract_manager
2082            .lock()
2083            .map_err(|_| Error::ad_hoc("contract manager lock poisoned"))?
2084            .annotate_vtxos(vtxos)?;
2085        Ok(annotated)
2086    }
2087
2088    fn derivation_index_for_pk(&self, pk: &XOnlyPublicKey) -> Option<u32> {
2089        self.inner
2090            .discoverable_key_provider
2091            .as_ref()
2092            .and_then(|provider| provider.get_derivation_index_for_pk(pk))
2093    }
2094
2095    fn secp(&self) -> &Secp256k1<All> {
2096        &self.inner.secp
2097    }
2098
2099    fn blockchain(&self) -> &B {
2100        &self.inner.blockchain
2101    }
2102
2103    fn swap_storage(&self) -> &S {
2104        &self.inner.swap_storage
2105    }
2106
2107    /// Use the P2A output of a transaction to bump its transaction fee with a child transaction.
2108    pub async fn bump_tx(&self, parent: &Transaction) -> Result<Transaction, Error> {
2109        let fee_rate = timeout_op(self.inner.timeout, self.blockchain().get_fee_rate())
2110            .await
2111            .context("Failed to retrieve fee rate")??;
2112
2113        let change_address = self.inner.wallet.get_onchain_address()?;
2114
2115        // Create a closure that converts CoinSelectionResult to UtxoCoinSelection
2116        let select_coins_fn =
2117            |target_amount: Amount| -> Result<UtxoCoinSelection, ark_core::Error> {
2118                self.inner.wallet.select_coins(target_amount).map_err(|e| {
2119                    ark_core::Error::ad_hoc(format!("failed to select coins for anchor TX: {e}"))
2120                })
2121            };
2122
2123        // Build the PSBT using ark-core (includes witness UTXO setup)
2124        let mut psbt = build_anchor_tx(parent, change_address, fee_rate, select_coins_fn)
2125            .map_err(|e| Error::ad_hoc(e.to_string()))?;
2126
2127        // Sign the transaction
2128        self.inner
2129            .wallet
2130            .sign(&mut psbt)
2131            .context("failed to sign bump TX")?;
2132
2133        // Extract the final transaction
2134        let tx = psbt.extract_tx().map_err(Error::ad_hoc)?;
2135
2136        Ok(tx)
2137    }
2138
2139    /// Subscribe to receive transaction notifications for specific VTXO scripts
2140    ///
2141    /// This method allows you to subscribe to get notified about transactions
2142    /// affecting the provided VTXO addresses. It can also be used to update an
2143    /// existing subscription by adding new scripts to it.
2144    ///
2145    /// # Arguments
2146    ///
2147    /// * `scripts` - Vector of ArkAddress to subscribe to
2148    /// * `subscription_id` - Unique identifier for the subscription. Use the same ID to update an
2149    ///   existing subscription. Use None for new subscriptions
2150    ///
2151    /// # Returns
2152    ///
2153    /// Returns the subscription ID if successful
2154    pub async fn subscribe_to_scripts(
2155        &self,
2156        scripts: Vec<ArkAddress>,
2157        subscription_id: Option<String>,
2158    ) -> Result<String, Error> {
2159        self.network_client()
2160            .subscribe_to_scripts(scripts, subscription_id)
2161            .await
2162            .map_err(Into::into)
2163    }
2164
2165    /// Remove scripts from an existing subscription
2166    ///
2167    /// This method allows you to unsubscribe from receiving notifications for
2168    /// specific VTXO scripts while keeping the subscription active for other scripts.
2169    ///
2170    /// # Arguments
2171    ///
2172    /// * `scripts` - Vector of ArkAddress to unsubscribe from
2173    /// * `subscription_id` - The subscription ID to update
2174    pub async fn unsubscribe_from_scripts(
2175        &self,
2176        scripts: Vec<ArkAddress>,
2177        subscription_id: String,
2178    ) -> Result<(), Error> {
2179        self.network_client()
2180            .unsubscribe_from_scripts(scripts, subscription_id)
2181            .await
2182            .map_err(Into::into)
2183    }
2184
2185    /// Get a subscription stream that returns subscription responses
2186    ///
2187    /// This method returns a stream that yields SubscriptionResponse messages
2188    /// containing information about new and spent VTXOs for the subscribed scripts.
2189    ///
2190    /// # Arguments
2191    ///
2192    /// * `subscription_id` - The subscription ID to get the stream for
2193    ///
2194    /// # Returns
2195    ///
2196    /// Returns a Stream of SubscriptionResponse messages
2197    pub async fn get_subscription(
2198        &self,
2199        subscription_id: String,
2200    ) -> Result<impl Stream<Item = Result<SubscriptionResponse, ark_grpc::Error>> + Unpin, Error>
2201    {
2202        self.network_client()
2203            .get_subscription(subscription_id)
2204            .await
2205            .map_err(Into::into)
2206    }
2207}
2208
2209#[cfg(test)]
2210mod digest_guard_tests {
2211    use super::*;
2212    use ark_grpc::test_utils;
2213    use bitcoin::key::Secp256k1;
2214    use bitcoin::secp256k1::SecretKey;
2215    use bitcoin::Address;
2216    use bitcoin::FeeRate;
2217    use bitcoin::Network;
2218    use bitcoin::Psbt;
2219    use std::convert::Infallible;
2220    use std::future::Future;
2221    use std::pin::Pin;
2222    use std::sync::atomic::AtomicUsize;
2223    use std::sync::atomic::Ordering;
2224    use std::task::Context;
2225    use std::task::Poll;
2226    use tokio::net::TcpListener;
2227    use tonic::body::Body;
2228    use tonic::codegen::http;
2229    use tonic::codegen::Service;
2230    use tonic::server::NamedService;
2231    use tonic::server::UnaryService;
2232
2233    #[derive(Clone, Default)]
2234    struct MockArkServer {
2235        state: Arc<MockState>,
2236    }
2237
2238    #[derive(Default)]
2239    struct MockState {
2240        get_info_calls: AtomicUsize,
2241        list_vtxos_calls: AtomicUsize,
2242    }
2243
2244    #[derive(Clone)]
2245    struct DummyBlockchain;
2246
2247    impl Blockchain for DummyBlockchain {
2248        async fn find_outpoints(&self, _address: &Address) -> Result<Vec<ExplorerUtxo>, Error> {
2249            Ok(Vec::new())
2250        }
2251
2252        async fn find_tx(&self, _txid: &Txid) -> Result<Option<Transaction>, Error> {
2253            Ok(None)
2254        }
2255
2256        async fn get_tx_status(&self, _txid: &Txid) -> Result<TxStatus, Error> {
2257            Ok(TxStatus { confirmed_at: None })
2258        }
2259
2260        async fn get_output_status(&self, _txid: &Txid, _vout: u32) -> Result<SpendStatus, Error> {
2261            Ok(SpendStatus { spend_txid: None })
2262        }
2263
2264        async fn broadcast(&self, _tx: &Transaction) -> Result<(), Error> {
2265            Ok(())
2266        }
2267
2268        async fn get_fee_rate(&self) -> Result<f64, Error> {
2269            Ok(1.0)
2270        }
2271
2272        async fn broadcast_package(&self, _txs: &[&Transaction]) -> Result<(), Error> {
2273            Ok(())
2274        }
2275    }
2276
2277    struct DummyWallet {
2278        keypair: Keypair,
2279        secp: Secp256k1<All>,
2280    }
2281
2282    impl DummyWallet {
2283        fn new() -> Self {
2284            let secp = Secp256k1::new();
2285            let secret_key = SecretKey::from_slice(&[2; 32]).unwrap();
2286            let keypair = Keypair::from_secret_key(&secp, &secret_key);
2287            Self { keypair, secp }
2288        }
2289    }
2290
2291    impl OnchainWallet for DummyWallet {
2292        fn get_onchain_address(&self) -> Result<Address, Error> {
2293            Ok(Address::p2tr(
2294                &self.secp,
2295                self.keypair.x_only_public_key().0,
2296                None,
2297                Network::Regtest,
2298            ))
2299        }
2300
2301        async fn sync(&self) -> Result<(), Error> {
2302            Ok(())
2303        }
2304
2305        fn balance(&self) -> Result<wallet::Balance, Error> {
2306            Ok(wallet::Balance {
2307                immature: Amount::ZERO,
2308                trusted_pending: Amount::ZERO,
2309                untrusted_pending: Amount::ZERO,
2310                confirmed: Amount::ZERO,
2311            })
2312        }
2313
2314        fn prepare_send_to_address(
2315            &self,
2316            _address: Address,
2317            _amount: Amount,
2318            _fee_rate: FeeRate,
2319        ) -> Result<Psbt, Error> {
2320            Err(Error::wallet("not implemented"))
2321        }
2322
2323        fn sign(&self, _psbt: &mut Psbt) -> Result<bool, Error> {
2324            Ok(true)
2325        }
2326
2327        fn select_coins(&self, _target_amount: Amount) -> Result<UtxoCoinSelection, Error> {
2328            Err(Error::wallet("not implemented"))
2329        }
2330    }
2331
2332    impl Service<http::Request<Body>> for MockArkServer {
2333        type Response = http::Response<Body>;
2334        type Error = Infallible;
2335        type Future = Pin<Box<dyn Future<Output = Result<Self::Response, Self::Error>> + Send>>;
2336
2337        fn poll_ready(&mut self, _cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
2338            Poll::Ready(Ok(()))
2339        }
2340
2341        fn call(&mut self, req: http::Request<Body>) -> Self::Future {
2342            match req.uri().path() {
2343                "/ark.v1.ArkService/GetInfo" => {
2344                    let method = GetInfoSvc {
2345                        state: self.state.clone(),
2346                    };
2347                    Box::pin(async move {
2348                        let codec = tonic_prost::ProstCodec::default();
2349                        let mut grpc = tonic::server::Grpc::new(codec);
2350                        Ok(grpc.unary(method, req).await)
2351                    })
2352                }
2353                "/ark.v1.IndexerService/GetVtxos" => {
2354                    let method = ListVtxosSvc {
2355                        state: self.state.clone(),
2356                    };
2357                    Box::pin(async move {
2358                        let codec = tonic_prost::ProstCodec::default();
2359                        let mut grpc = tonic::server::Grpc::new(codec);
2360                        Ok(grpc.unary(method, req).await)
2361                    })
2362                }
2363                _ => Box::pin(async move {
2364                    Ok(http::Response::builder()
2365                        .status(200)
2366                        .header("grpc-status", "12")
2367                        .header("content-type", "application/grpc")
2368                        .body(Body::empty())
2369                        .unwrap())
2370                }),
2371            }
2372        }
2373    }
2374
2375    impl NamedService for MockArkServer {
2376        const NAME: &'static str = "ark.v1.ArkService";
2377    }
2378
2379    #[derive(Clone)]
2380    struct MockIndexerServer(MockArkServer);
2381
2382    impl Service<http::Request<Body>> for MockIndexerServer {
2383        type Response = http::Response<Body>;
2384        type Error = Infallible;
2385        type Future = <MockArkServer as Service<http::Request<Body>>>::Future;
2386
2387        fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
2388            self.0.poll_ready(cx)
2389        }
2390
2391        fn call(&mut self, req: http::Request<Body>) -> Self::Future {
2392            self.0.call(req)
2393        }
2394    }
2395
2396    impl NamedService for MockIndexerServer {
2397        const NAME: &'static str = "ark.v1.IndexerService";
2398    }
2399
2400    #[derive(Clone)]
2401    struct GetInfoSvc {
2402        state: Arc<MockState>,
2403    }
2404
2405    impl UnaryService<test_utils::GetInfoRequest> for GetInfoSvc {
2406        type Response = test_utils::GetInfoResponse;
2407        type Future = Pin<
2408            Box<dyn Future<Output = Result<tonic::Response<Self::Response>, tonic::Status>> + Send>,
2409        >;
2410
2411        fn call(&mut self, _request: tonic::Request<test_utils::GetInfoRequest>) -> Self::Future {
2412            self.state.get_info_calls.fetch_add(1, Ordering::SeqCst);
2413            Box::pin(async { Ok(tonic::Response::new(info_response("fresh-digest"))) })
2414        }
2415    }
2416
2417    #[derive(Clone)]
2418    struct ListVtxosSvc {
2419        state: Arc<MockState>,
2420    }
2421
2422    impl UnaryService<test_utils::GetVtxosRequest> for ListVtxosSvc {
2423        type Response = test_utils::GetVtxosResponse;
2424        type Future = Pin<
2425            Box<dyn Future<Output = Result<tonic::Response<Self::Response>, tonic::Status>> + Send>,
2426        >;
2427
2428        fn call(&mut self, _request: tonic::Request<test_utils::GetVtxosRequest>) -> Self::Future {
2429            self.state.list_vtxos_calls.fetch_add(1, Ordering::SeqCst);
2430            Box::pin(async {
2431                Err(tonic::Status::failed_precondition(
2432                    "DIGEST_MISMATCH: invalid digest header",
2433                ))
2434            })
2435        }
2436    }
2437
2438    async fn connect_test_client(
2439        mock: MockArkServer,
2440    ) -> Client<DummyBlockchain, DummyWallet, InMemorySwapStorage> {
2441        let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
2442        let addr = listener.local_addr().unwrap();
2443        let incoming = tokio_stream::wrappers::TcpListenerStream::new(listener);
2444        let indexer_mock = MockIndexerServer(mock.clone());
2445        tokio::spawn(async move {
2446            tonic::transport::Server::builder()
2447                .add_service(mock)
2448                .add_service(indexer_mock)
2449                .serve_with_incoming(incoming)
2450                .await
2451                .unwrap();
2452        });
2453
2454        let secp = Secp256k1::new();
2455        let keypair = Keypair::from_secret_key(&secp, &SecretKey::from_slice(&[3; 32]).unwrap());
2456        OfflineClient::<DummyBlockchain, DummyWallet, InMemorySwapStorage>::with_keypair(
2457            OfflineClientConfig {
2458                ark_server_url: format!("http://{addr}"),
2459                boltz_url: "http://127.0.0.1:1".to_string(),
2460                ..Default::default()
2461            },
2462            keypair,
2463            Arc::new(DummyBlockchain),
2464            Arc::new(DummyWallet::new()),
2465            Arc::new(InMemorySwapStorage::default()),
2466        )
2467        .connect()
2468        .await
2469        .unwrap()
2470    }
2471
2472    fn info_response(digest: &str) -> test_utils::GetInfoResponse {
2473        let secp = Secp256k1::new();
2474        let secret_key = SecretKey::from_slice(&[1; 32]).unwrap();
2475        let keypair = Keypair::from_secret_key(&secp, &secret_key);
2476        let public_key = bitcoin::secp256k1::PublicKey::from_secret_key(&secp, &secret_key);
2477        let (xonly, _) = keypair.x_only_public_key();
2478        let address = Address::p2tr(&secp, xonly, None, Network::Regtest);
2479
2480        test_utils::GetInfoResponse {
2481            version: "0.9.9".to_string(),
2482            signer_pubkey: public_key.to_string(),
2483            forfeit_pubkey: public_key.to_string(),
2484            forfeit_address: address.to_string(),
2485            checkpoint_tapscript: String::new(),
2486            network: "regtest".to_string(),
2487            session_duration: 60,
2488            unilateral_exit_delay: 144,
2489            boarding_exit_delay: 144,
2490            utxo_min_amount: 0,
2491            utxo_max_amount: 0,
2492            vtxo_min_amount: 0,
2493            vtxo_max_amount: 0,
2494            dust: 1000,
2495            fees: None,
2496            scheduled_session: None,
2497            deprecated_signers: Vec::new(),
2498            service_status: Default::default(),
2499            digest: digest.to_string(),
2500            max_tx_weight: 0,
2501            max_op_return_outputs: 0,
2502        }
2503    }
2504
2505    #[tokio::test]
2506    async fn server_info_uses_fresh_cache_without_refetching() {
2507        let _ = rustls::crypto::ring::default_provider().install_default();
2508
2509        let mock = MockArkServer::default();
2510        let state = mock.state.clone();
2511        let client = connect_test_client(mock).await;
2512        assert_eq!(state.get_info_calls.load(Ordering::SeqCst), 1);
2513
2514        let info = client.server_info().await.unwrap();
2515        assert_eq!(info.digest, "fresh-digest");
2516        assert_eq!(state.get_info_calls.load(Ordering::SeqCst), 1);
2517    }
2518
2519    #[tokio::test]
2520    async fn list_contracts_returns_wallet_contract_views() {
2521        let _ = rustls::crypto::ring::default_provider().install_default();
2522
2523        let client = connect_test_client(MockArkServer::default()).await;
2524        let contracts = client.list_contracts().await.unwrap();
2525
2526        assert!(!contracts.is_empty());
2527        assert!(contracts.iter().all(|entry| entry.address.is_some()));
2528        assert!(contracts.iter().all(|entry| entry.signer_status.is_some()));
2529    }
2530
2531    #[tokio::test]
2532    async fn boarding_addresses_include_default_row_when_scripts_overlap() {
2533        let _ = rustls::crypto::ring::default_provider().install_default();
2534
2535        let client = connect_test_client(MockArkServer::default()).await;
2536        let addresses = client.get_boarding_addresses().await.unwrap();
2537
2538        assert_eq!(addresses.len(), 1);
2539    }
2540
2541    #[tokio::test]
2542    async fn restore_contracts_rejects_zero_gap_limit() {
2543        let _ = rustls::crypto::ring::default_provider().install_default();
2544
2545        let client = connect_test_client(MockArkServer::default()).await;
2546        let err = client.restore_contracts(0).await.unwrap_err();
2547
2548        assert!(err.to_string().contains("gap limit"));
2549    }
2550
2551    #[tokio::test]
2552    async fn restore_contracts_reports_gap_limit_for_static_provider() {
2553        let _ = rustls::crypto::ring::default_provider().install_default();
2554
2555        let client = connect_test_client(MockArkServer::default()).await;
2556        let report = client.restore_contracts(20).await.unwrap();
2557
2558        assert_eq!(report.gap_limit, 20);
2559        assert_eq!(report.scanned_keys, 0);
2560        assert!(report.entries.is_empty());
2561    }
2562
2563    #[tokio::test]
2564    async fn server_info_zero_ttl_always_refreshes() {
2565        let _ = rustls::crypto::ring::default_provider().install_default();
2566
2567        let mock = MockArkServer::default();
2568        let state = mock.state.clone();
2569        let mut client = connect_test_client(mock).await;
2570        client.inner.server_info_ttl = Duration::ZERO;
2571        assert_eq!(state.get_info_calls.load(Ordering::SeqCst), 1);
2572
2573        client.server_info().await.unwrap();
2574        client.server_info().await.unwrap();
2575
2576        assert_eq!(state.get_info_calls.load(Ordering::SeqCst), 3);
2577    }
2578
2579    #[tokio::test]
2580    async fn server_info_refreshes_expired_cache_once_for_concurrent_callers() {
2581        let _ = rustls::crypto::ring::default_provider().install_default();
2582
2583        let mock = MockArkServer::default();
2584        let state = mock.state.clone();
2585        let client = Arc::new(connect_test_client(mock).await);
2586        client.state.write().unwrap().server_info_refreshed_at =
2587            Instant::now() - DEFAULT_SERVER_INFO_TTL - Duration::from_secs(1);
2588
2589        let a = Arc::clone(&client);
2590        let b = Arc::clone(&client);
2591        let (info_a, info_b) = tokio::join!(a.server_info(), b.server_info());
2592        assert_eq!(info_a.unwrap().digest, "fresh-digest");
2593        assert_eq!(info_b.unwrap().digest, "fresh-digest");
2594        assert_eq!(state.get_info_calls.load(Ordering::SeqCst), 2);
2595    }
2596
2597    #[tokio::test]
2598    async fn guarded_client_refreshes_info_and_does_not_retry_on_digest_mismatch() {
2599        let _ = rustls::crypto::ring::default_provider().install_default();
2600
2601        let mock = MockArkServer::default();
2602        let state = mock.state.clone();
2603        let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
2604        let addr = listener.local_addr().unwrap();
2605        let incoming = tokio_stream::wrappers::TcpListenerStream::new(listener);
2606
2607        let indexer_mock = MockIndexerServer(mock.clone());
2608        tokio::spawn(async move {
2609            tonic::transport::Server::builder()
2610                .add_service(mock)
2611                .add_service(indexer_mock)
2612                .serve_with_incoming(incoming)
2613                .await
2614                .unwrap();
2615        });
2616
2617        let mut inner = ark_grpc::Client::new(format!("http://{addr}"));
2618        inner.connect().await.unwrap();
2619
2620        let initial_info: server::Info = info_response("stale-digest").try_into().unwrap();
2621        let mut contract_manager = ContractManager::in_memory(initial_info.network);
2622        contract_manager.register_builtins().unwrap();
2623        let cached_state = Arc::new(RwLock::new(ServerState {
2624            server_info: initial_info,
2625            fee_estimator: build_fee_estimator(&info_response("stale-digest").try_into().unwrap())
2626                .unwrap(),
2627            server_info_refreshed_at: Instant::now()
2628                - DEFAULT_SERVER_INFO_TTL
2629                - Duration::from_secs(1),
2630            contract_manager: Mutex::new(contract_manager),
2631        }));
2632        let hook_state = cached_state.clone();
2633        inner.set_info_refresh_hook(move |server_info| {
2634            update_server_state(&hook_state, server_info)
2635                .map_err(|err| Box::new(err) as Box<dyn std::error::Error + Send + Sync>)
2636        });
2637
2638        let err = match inner
2639            .list_vtxos(GetVtxosRequest::new_for_outpoints(&[OutPoint::null()]))
2640            .await
2641        {
2642            Ok(_) => panic!("list_vtxos unexpectedly succeeded"),
2643            Err(err) => err,
2644        };
2645
2646        assert!(err.is_server_info_changed());
2647        assert!(Error::from(err).is_server_info_changed());
2648        assert_eq!(state.list_vtxos_calls.load(Ordering::SeqCst), 1);
2649        assert_eq!(state.get_info_calls.load(Ordering::SeqCst), 1);
2650        let refreshed_state = cached_state.read().unwrap();
2651        assert_eq!(refreshed_state.server_info.digest, "fresh-digest");
2652        assert!(refreshed_state.server_info_refreshed_at.elapsed() < DEFAULT_SERVER_INFO_TTL);
2653    }
2654}