Skip to main content

ark_client/
send_vtxo.rs

1use crate::error::ErrorContext;
2use crate::swap_storage::SwapStorage;
3use crate::utils::timeout_op;
4use crate::wallet::OnchainWallet;
5use crate::AnnotatedVtxo;
6use crate::Blockchain;
7use crate::Client;
8use crate::Error;
9use ark_core::asset::AssetId;
10use ark_core::coin_select::select_vtxos;
11use ark_core::coin_select::select_vtxos_for_asset;
12use ark_core::coin_select::VirtualTxOutPoint;
13use ark_core::contract::SpendPathKind;
14use ark_core::intent;
15use ark_core::script::extract_checksig_pubkeys;
16use ark_core::send::build_asset_send_transactions;
17use ark_core::send::sign_ark_transaction;
18use ark_core::send::sign_checkpoint_transaction;
19use ark_core::send::OffchainTransactions;
20use ark_core::send::SendReceiver;
21use ark_core::send::VtxoInput;
22use ark_core::server;
23use ark_core::server::PendingTx;
24use bitcoin::key::Secp256k1;
25use bitcoin::psbt;
26use bitcoin::secp256k1;
27use bitcoin::secp256k1::schnorr;
28use bitcoin::Amount;
29use bitcoin::OutPoint;
30use bitcoin::TxOut;
31use bitcoin::Txid;
32use bitcoin::XOnlyPublicKey;
33use std::collections::HashMap;
34use std::collections::HashSet;
35use std::time::Duration;
36
37pub(crate) fn coin_select_vtxo(entry: &AnnotatedVtxo) -> VirtualTxOutPoint {
38    VirtualTxOutPoint {
39        outpoint: entry.vtxo().outpoint,
40        script_pubkey: entry.vtxo().script.clone(),
41        expire_at: entry.vtxo().expires_at,
42        amount: entry.vtxo().amount,
43        assets: entry.vtxo().assets.clone(),
44    }
45}
46
47pub(crate) fn select_contract_vtxos(
48    available: &[AnnotatedVtxo],
49    selected: &[VirtualTxOutPoint],
50) -> Vec<AnnotatedVtxo> {
51    selected
52        .iter()
53        .filter_map(|coin| {
54            available
55                .iter()
56                .find(|entry| entry.vtxo().outpoint == coin.outpoint)
57                .cloned()
58        })
59        .collect()
60}
61
62impl<B, W, S> Client<B, W, S>
63where
64    B: Blockchain,
65    W: OnchainWallet,
66    S: SwapStorage + 'static,
67{
68    // Send public APIs
69
70    /// Send bitcoin and/or Arkade assets offchain to one or more receivers.
71    ///
72    /// Coin selection handles both bitcoin-only and asset-bearing VTXOs. An asset packet is
73    /// attached only when the transfer actually involves carried or requested assets.
74    ///
75    /// # Arguments
76    ///
77    /// * `receivers` - a list of [`SendReceiver`]s, specifying a target address, a bitcoin amount
78    ///   and an optional list of assets.
79    ///
80    /// # Returns
81    ///
82    /// The [`Txid`] of the resulting Ark transaction.
83    pub async fn send(&self, receivers: Vec<SendReceiver>) -> Result<Txid, Error> {
84        // Apply coin selection to satisfy the given `receivers`.
85        let selected = self
86            .auto_select_send_inputs(&receivers)
87            .await
88            .context("failed to auto-select send inputs")?;
89
90        let txid = self
91            .send_with_selected_inputs(selected, receivers)
92            .await
93            .context("failed to send with selected inputs")?;
94
95        Ok(txid)
96    }
97
98    /// Spend specific VTXOs in an Ark transaction sending bitcoin and/or Arkade assets to one or
99    /// more receivers.
100    ///
101    /// Unlike [`Self::send`], this method allows the caller to specify exactly which VTXOs to
102    /// spend by providing their outpoints. This is useful for applications that want to have full
103    /// control over VTXO selection.
104    ///
105    /// # Arguments
106    ///
107    /// * `vtxo_outpoints` - a list of all the outpoints to be used as inputs to the transaction.
108    /// * `receivers` - a list of [`SendReceiver`]s, specifying a target address, a bitcoin amount
109    ///   and an optional list of assets.
110    ///
111    /// # Returns
112    ///
113    /// The [`Txid`] of the generated Ark transaction.
114    ///
115    /// # Errors
116    ///
117    /// Returns an error if the selected VTXOs don't have enough BTC value or assets to cover the
118    /// requested receiver amounts.
119    pub async fn send_selection(
120        &self,
121        vtxo_outpoints: &[OutPoint],
122        receivers: Vec<SendReceiver>,
123    ) -> Result<Txid, Error> {
124        // Resolve contract-annotated inputs for the `vtxo_outpoints` chosen by the caller.
125        let selected = self
126            .resolve_selected_send_inputs(vtxo_outpoints)
127            .await
128            .context("failed to resolve selected send inputs")?;
129
130        let txid = self
131            .send_with_selected_inputs(selected, receivers)
132            .await
133            .context("failed to send with selected inputs")?;
134
135        Ok(txid)
136    }
137
138    // Pending transactions
139
140    /// Finalize a specific pending offchain transaction.
141    ///
142    /// Fetches the pending transaction identified by `ark_txid` from the server, signs the
143    /// checkpoint transactions, and finalizes it.
144    ///
145    /// This is useful when you need fine-grained control over which pending transaction to
146    /// finalize (e.g. when a database tracks individual pending funding attempts).
147    ///
148    /// # Errors
149    ///
150    /// Returns an error if no pending transaction with the given `ark_txid` is found, or if
151    /// signing / finalization fails.
152    pub async fn finalize_pending_offchain_tx(&self, ark_txid: Txid) -> Result<(), Error> {
153        let pending_txs = self.fetch_pending_offchain_txs().await?;
154
155        let pending_tx = pending_txs
156            .into_iter()
157            .find(|tx| tx.ark_txid == ark_txid)
158            .ok_or_else(|| {
159                Error::ad_hoc(format!(
160                    "no pending transaction found for ark txid {ark_txid}"
161                ))
162            })?;
163
164        self.sign_and_finalize_pending_tx(pending_tx).await
165    }
166
167    /// Resume and finalize any pending (submitted but not finalized) offchain transactions.
168    ///
169    /// This handles the case where `send_vtxo` successfully submitted the transaction to the
170    /// server but failed before finalizing (e.g. due to a crash or network error). The server
171    /// holds the submitted-but-not-finalized transaction in a pending state. This method
172    /// retrieves it, signs the checkpoint transactions, and finalizes.
173    ///
174    /// # Returns
175    ///
176    /// The [`Txid`]s of the finalized Ark transactions, or an empty vec if there were no
177    /// pending transactions.
178    pub async fn continue_pending_offchain_txs(&self) -> Result<Vec<Txid>, Error> {
179        let pending_txs = self.fetch_pending_offchain_txs().await?;
180
181        if pending_txs.is_empty() {
182            return Ok(vec![]);
183        }
184
185        let mut finalized_txids = Vec::new();
186
187        for pending_tx in pending_txs {
188            let ark_txid = pending_tx.ark_txid;
189            self.sign_and_finalize_pending_tx(pending_tx).await?;
190            finalized_txids.push(ark_txid);
191        }
192
193        Ok(finalized_txids)
194    }
195
196    /// List pending (submitted but not finalized) offchain transactions.
197    ///
198    /// This retrieves any transactions that were submitted to the server but not yet finalized
199    /// (e.g. due to a crash or network error between submit and finalize).
200    ///
201    /// # Returns
202    ///
203    /// The pending transactions, or an empty vec if there are none.
204    pub async fn list_pending_offchain_txs(&self) -> Result<Vec<PendingTx>, Error> {
205        self.fetch_pending_offchain_txs().await
206    }
207
208    /// Build, sign and submit an offchain transaction to the server without finalizing.
209    ///
210    /// This is primarily useful for testing pending transaction recovery flows.
211    ///
212    /// Returns the Ark TXID. The transaction will remain in a pending state on the server until
213    /// [`Self::finalize_pending_offchain_tx`] or [`Self::continue_pending_offchain_txs`] completes
214    /// it.
215    pub async fn submit_offchain_tx(
216        &self,
217        vtxo_inputs: Vec<VtxoInput>,
218        address: ark_core::ArkAddress,
219        amount: Amount,
220    ) -> Result<Txid, Error> {
221        let server_info = self.server_info().await?;
222        let receivers = vec![SendReceiver {
223            address,
224            amount,
225            assets: Vec::new(),
226        }];
227        let pending_tx = self
228            .build_and_submit(vtxo_inputs, receivers, &server_info)
229            .await?;
230        Ok(pending_tx.ark_txid)
231    }
232
233    // Private helpers
234
235    /// Create a signing closure that signs with any known keypair.
236    fn make_sign_fn(
237        &self,
238    ) -> impl FnMut(
239        &mut psbt::Input,
240        secp256k1::Message,
241    ) -> Result<Vec<(schnorr::Signature, XOnlyPublicKey)>, ark_core::Error>
242           + '_ {
243        |input, msg| {
244            let script = input
245                .witness_script
246                .as_ref()
247                .ok_or_else(|| ark_core::Error::ad_hoc("Missing witness script for psbt::Input"))?;
248            let pks = extract_checksig_pubkeys(script);
249            let secp = Secp256k1::new();
250            let mut sigs = vec![];
251            for pk in pks {
252                if let Ok(keypair) = self.keypair_by_pk(&pk) {
253                    let sig = secp.sign_schnorr_no_aux_rand(&msg, &keypair);
254                    sigs.push((sig, keypair.x_only_public_key().0));
255                }
256            }
257            Ok(sigs)
258        }
259    }
260
261    async fn auto_select_send_inputs(
262        &self,
263        receivers: &[SendReceiver],
264    ) -> Result<Vec<VtxoInput>, Error> {
265        let vtxo_list = self
266            .list_vtxos()
267            .await
268            .context("failed to get spendable VTXOs")?;
269
270        let now = crate::utils::unix_now()?;
271        let server_info = self.server_info().await?;
272        let spendable_contracts = vtxo_list
273            .spendable_offchain_at(&server_info, now)
274            .cloned()
275            .collect::<Vec<_>>();
276        let spendable = spendable_contracts
277            .iter()
278            .map(coin_select_vtxo)
279            .collect::<Vec<_>>();
280
281        let mut selected_outpoints = HashSet::new();
282        let mut selected = Vec::new();
283        let mut asset_changes: HashMap<AssetId, u64> = HashMap::new();
284        let mut btc_needed = Amount::ZERO;
285        let mut btc_provided = Amount::ZERO;
286
287        for receiver in receivers {
288            btc_needed += receiver.amount;
289
290            for asset in &receiver.assets {
291                let mut amount_to_select = asset.amount;
292
293                if let Some(existing_change) = asset_changes.get_mut(&asset.asset_id) {
294                    if amount_to_select <= *existing_change {
295                        *existing_change -= amount_to_select;
296                        if *existing_change == 0 {
297                            asset_changes.remove(&asset.asset_id);
298                        }
299                        continue;
300                    }
301                    amount_to_select -= *existing_change;
302                    asset_changes.remove(&asset.asset_id);
303                }
304
305                let available: Vec<_> = spendable
306                    .iter()
307                    .filter(|v| !selected_outpoints.contains(&v.outpoint))
308                    .cloned()
309                    .collect();
310
311                let (asset_coins, asset_change) =
312                    select_vtxos_for_asset(&available, amount_to_select, asset.asset_id)
313                        .map_err(Error::from)
314                        .context("failed to select coins for asset transfer")?;
315
316                for coin in &asset_coins {
317                    if selected_outpoints.insert(coin.outpoint) {
318                        btc_provided += coin.amount;
319
320                        for carried_asset in &coin.assets {
321                            if carried_asset.asset_id != asset.asset_id {
322                                *asset_changes.entry(carried_asset.asset_id).or_insert(0) +=
323                                    carried_asset.amount;
324                            }
325                        }
326
327                        selected.push(coin.clone());
328                    }
329                }
330
331                if asset_change > 0 {
332                    *asset_changes.entry(asset.asset_id).or_insert(0) += asset_change;
333                }
334            }
335        }
336
337        if !asset_changes.is_empty() {
338            btc_needed += server_info.dust;
339        }
340
341        let btc_shortfall = btc_needed.checked_sub(btc_provided).unwrap_or(Amount::ZERO);
342
343        if btc_shortfall > Amount::ZERO {
344            let available: Vec<_> = spendable
345                .iter()
346                .filter(|v| !selected_outpoints.contains(&v.outpoint))
347                .cloned()
348                .collect();
349
350            let btc_coins = select_vtxos(available, btc_shortfall, server_info.dust, true)
351                .map_err(Error::from)
352                .context("failed to select BTC coins for asset transfer")?;
353
354            for coin in &btc_coins {
355                if selected_outpoints.insert(coin.outpoint) {
356                    for carried_asset in &coin.assets {
357                        *asset_changes.entry(carried_asset.asset_id).or_insert(0) +=
358                            carried_asset.amount;
359                    }
360                    selected.push(coin.clone());
361                }
362            }
363        }
364
365        let inputs =
366            self.build_vtxo_inputs(select_contract_vtxos(&spendable_contracts, &selected))?;
367
368        Ok(inputs)
369    }
370
371    async fn resolve_selected_send_inputs(
372        &self,
373        vtxo_outpoints: &[OutPoint],
374    ) -> Result<Vec<VtxoInput>, Error> {
375        let requested_outpoints: HashSet<_> = vtxo_outpoints.iter().copied().collect();
376
377        let vtxo_list = self
378            .list_vtxos_for_outpoints(vtxo_outpoints.to_vec())
379            .await
380            .context("failed to get VTXO list")?;
381
382        let now = crate::utils::unix_now()?;
383        let server_info = self.server_info().await?;
384        let selected_contracts: Vec<_> = vtxo_list
385            .spendable_offchain_at(&server_info, now)
386            .filter(|entry| requested_outpoints.contains(&entry.vtxo().outpoint))
387            .cloned()
388            .collect();
389        let selected: Vec<_> = selected_contracts.iter().map(coin_select_vtxo).collect();
390
391        if selected.is_empty() {
392            return Err(Error::ad_hoc("no matching VTXO outpoints found"));
393        }
394
395        if selected.len() != requested_outpoints.len() {
396            let found_outpoints: HashSet<_> = selected.iter().map(|v| v.outpoint).collect();
397            let missing_outpoints = requested_outpoints
398                .difference(&found_outpoints)
399                .map(ToString::to_string)
400                .collect::<Vec<_>>();
401
402            return Err(Error::ad_hoc(format!(
403                "some selected VTXO outpoints were not found or not spendable: {}",
404                missing_outpoints.join(", ")
405            )));
406        }
407
408        let inputs = self.build_vtxo_inputs(selected_contracts)?;
409
410        Ok(inputs)
411    }
412
413    /// Convert selected [`VirtualTxOutPoint`]s into [`send::VtxoInput`]s.
414    pub(crate) fn build_vtxo_inputs(
415        &self,
416        selected: Vec<AnnotatedVtxo>,
417    ) -> Result<Vec<VtxoInput>, Error> {
418        selected
419            .into_iter()
420            .map(|entry| {
421                let spend_selection = entry.spend_selection(SpendPathKind::Forfeit)?;
422
423                Ok(VtxoInput::new_with_spend_selection(
424                    spend_selection,
425                    entry.tapscripts(),
426                    entry.script_pubkey(),
427                    entry.vtxo().amount,
428                    entry.vtxo().outpoint,
429                    entry.vtxo().assets.clone(),
430                ))
431            })
432            .collect()
433    }
434
435    fn validate_selected_inputs_cover_receivers(
436        vtxo_inputs: &[VtxoInput],
437        receivers: &[SendReceiver],
438        dust: Amount,
439    ) -> Result<(), Error> {
440        let selected_amount = vtxo_inputs
441            .iter()
442            .fold(Amount::ZERO, |acc, v| acc + v.amount());
443        let requested_amount = receivers.iter().fold(Amount::ZERO, |acc, r| acc + r.amount);
444
445        let mut selected_assets = HashMap::<AssetId, u64>::new();
446        for vtxo_input in vtxo_inputs {
447            for asset in vtxo_input.assets() {
448                *selected_assets.entry(asset.asset_id).or_insert(0) = selected_assets
449                    .get(&asset.asset_id)
450                    .copied()
451                    .unwrap_or(0)
452                    .checked_add(asset.amount)
453                    .ok_or_else(|| Error::ad_hoc("selected asset amount overflow"))?;
454            }
455        }
456
457        let mut requested_assets = HashMap::<AssetId, u64>::new();
458        for receiver in receivers {
459            for asset in &receiver.assets {
460                *requested_assets.entry(asset.asset_id).or_insert(0) = requested_assets
461                    .get(&asset.asset_id)
462                    .copied()
463                    .unwrap_or(0)
464                    .checked_add(asset.amount)
465                    .ok_or_else(|| Error::ad_hoc("requested asset amount overflow"))?;
466            }
467        }
468
469        for (asset_id, requested_asset_amount) in &requested_assets {
470            let selected_asset_amount = selected_assets.get(asset_id).copied().unwrap_or(0);
471            if selected_asset_amount < *requested_asset_amount {
472                return Err(Error::coin_select(format!(
473                    "insufficient asset amount for {}: {} < {}",
474                    asset_id, selected_asset_amount, requested_asset_amount
475                )));
476            }
477        }
478
479        let mut has_asset_change = false;
480        for (asset_id, selected_asset_amount) in &selected_assets {
481            let requested_asset_amount = requested_assets.get(asset_id).copied().unwrap_or(0);
482
483            if *selected_asset_amount < requested_asset_amount {
484                return Err(Error::coin_select(format!(
485                    "insufficient asset amount for {}: {} < {}",
486                    asset_id, selected_asset_amount, requested_asset_amount
487                )));
488            }
489
490            if *selected_asset_amount > requested_asset_amount {
491                has_asset_change = true;
492            }
493        }
494
495        let required_amount = match has_asset_change {
496            true => requested_amount
497                .checked_add(dust)
498                .ok_or_else(|| Error::ad_hoc("required BTC amount overflow"))?,
499            false => requested_amount,
500        };
501
502        if selected_amount < required_amount {
503            return Err(Error::coin_select(format!(
504                "insufficient VTXO amount: {} < {}",
505                selected_amount, required_amount
506            )));
507        }
508
509        Ok(())
510    }
511
512    async fn send_with_selected_inputs(
513        &self,
514        vtxo_inputs: Vec<VtxoInput>,
515        receivers: Vec<SendReceiver>,
516    ) -> Result<Txid, Error> {
517        let server_info = self.server_info().await?;
518        Self::validate_selected_inputs_cover_receivers(&vtxo_inputs, &receivers, server_info.dust)?;
519
520        let pending_tx = self
521            .build_and_submit(vtxo_inputs, receivers, &server_info)
522            .await?;
523        let ark_txid = pending_tx.ark_txid;
524
525        self.sign_and_finalize_pending_tx(pending_tx).await?;
526
527        Ok(ark_txid)
528    }
529
530    /// Sign and submit a prebuilt offchain transaction to the server without finalizing.
531    ///
532    /// Returns the pending transaction payload from the server. The change-address key is marked
533    /// as used.
534    pub(crate) async fn submit_built_offchain_send(
535        &self,
536        mut ark_tx: bitcoin::Psbt,
537        checkpoint_txs: Vec<bitcoin::Psbt>,
538        used_pk: XOnlyPublicKey,
539    ) -> Result<PendingTx, Error> {
540        for i in 0..checkpoint_txs.len() {
541            sign_ark_transaction(self.make_sign_fn(), &mut ark_tx, i)?;
542        }
543
544        let res = self
545            .network_client()
546            .submit_offchain_transaction_request(ark_tx, checkpoint_txs)
547            .await
548            .map_err(Error::ark_server)
549            .context("failed to submit offchain transaction request")?;
550
551        let pending_tx = PendingTx {
552            ark_txid: res.signed_ark_tx.unsigned_tx.compute_txid(),
553            signed_ark_tx: res.signed_ark_tx,
554            signed_checkpoint_txs: res.signed_checkpoint_txs,
555        };
556
557        if let Some(key_provider) = self.inner.discoverable_key_provider.as_ref() {
558            if let Err(err) = key_provider.mark_as_used(&used_pk) {
559                tracing::warn!(
560                    "Failed updating keypair cache for used change address: {:?}",
561                    err
562                );
563            }
564        }
565
566        Ok(pending_tx)
567    }
568
569    /// Build, sign the Ark transaction, and submit to the server *without* finalizing.
570    async fn build_and_submit(
571        &self,
572        inputs: Vec<VtxoInput>,
573        receivers: Vec<SendReceiver>,
574        server_info: &server::Info,
575    ) -> Result<PendingTx, Error> {
576        let (change_address, change_address_vtxo) = self.get_offchain_address().await?;
577
578        let OffchainTransactions {
579            ark_tx,
580            checkpoint_txs,
581        } = build_asset_send_transactions(&receivers, &change_address, &inputs, server_info)
582            .map_err(Error::from)
583            .context("failed to build offchain asset-send transactions")?;
584
585        self.submit_built_offchain_send(ark_tx, checkpoint_txs, change_address_vtxo.owner_pk())
586            .await
587    }
588
589    /// Sign checkpoint transactions from a [`PendingTx`] and finalize.
590    pub(crate) async fn sign_and_finalize_pending_tx(
591        &self,
592        pending_tx: PendingTx,
593    ) -> Result<(), Error> {
594        let ark_txid = pending_tx.ark_txid;
595        let mut signed_checkpoint_txs = pending_tx.signed_checkpoint_txs;
596
597        // Build a map from checkpoint txid -> ark tx input index so we can
598        // restore witness scripts that the server may have stripped.
599        let ark_input_idx_by_cp_txid: HashMap<_, _> = pending_tx
600            .signed_ark_tx
601            .unsigned_tx
602            .input
603            .iter()
604            .enumerate()
605            .map(|(i, inp)| (inp.previous_output.txid, i))
606            .collect();
607
608        for checkpoint_psbt in signed_checkpoint_txs.iter_mut() {
609            if checkpoint_psbt.inputs[0].witness_script.is_none() {
610                let checkpoint_txid = checkpoint_psbt.unsigned_tx.compute_txid();
611                let idx = ark_input_idx_by_cp_txid
612                    .get(&checkpoint_txid)
613                    .ok_or_else(|| {
614                        Error::ad_hoc(format!(
615                            "checkpoint txid {checkpoint_txid} not found in ark tx inputs \
616                             for pending tx {ark_txid}"
617                        ))
618                    })?;
619
620                let ws = pending_tx
621                    .signed_ark_tx
622                    .inputs
623                    .get(*idx)
624                    .and_then(|input| input.witness_script.clone())
625                    .ok_or_else(|| {
626                        Error::ad_hoc(format!(
627                            "missing witness script on ark tx input {idx} \
628                             for pending tx {ark_txid}"
629                        ))
630                    })?;
631
632                checkpoint_psbt.inputs[0].witness_script = Some(ws);
633            }
634
635            sign_checkpoint_transaction(self.make_sign_fn(), checkpoint_psbt)?;
636        }
637
638        self.finalize_offchain_tx(ark_txid, signed_checkpoint_txs)
639            .await
640    }
641
642    /// Submit offchain transaction data for finalization.
643    ///
644    /// We retry a few times to overcome transient failures.
645    ///
646    /// After submit succeeds but before finalize completes, a transient error would leave the
647    /// transaction in a pending state. Retrying here attempts to resolve that, without needing full
648    /// recovery via [`Self::continue_pending_offchain_txs`].
649    pub async fn finalize_offchain_tx(
650        &self,
651        ark_txid: Txid,
652        signed_checkpoint_txs: Vec<bitcoin::Psbt>,
653    ) -> Result<(), Error> {
654        const MAX_RETRIES: usize = 3;
655
656        let mut last_err = None;
657
658        for attempt in 0..=MAX_RETRIES {
659            if attempt > 0 {
660                let delay = Duration::from_millis(500 * (1 << (attempt - 1)));
661                tracing::warn!(
662                    %ark_txid,
663                    attempt,
664                    ?delay,
665                    "Retrying finalize after transient failure"
666                );
667                crate::utils::sleep(delay).await;
668            }
669
670            match timeout_op(
671                self.inner.timeout,
672                self.network_client()
673                    .finalize_offchain_transaction(ark_txid, signed_checkpoint_txs.clone()),
674            )
675            .await
676            .context("finalize offchain transaction timed out")?
677            {
678                Ok(_) => return Ok(()),
679                Err(e) => {
680                    last_err = Some(Error::ark_server(e));
681                }
682            }
683        }
684
685        Err(last_err
686            .expect("at least one attempt was made")
687            .with_context(|| {
688                format!("failed to finalize offchain transaction after {MAX_RETRIES} retries")
689            }))
690    }
691
692    /// Fetch pending offchain transactions from the server.
693    async fn fetch_pending_offchain_txs(&self) -> Result<Vec<PendingTx>, Error> {
694        const MAX_INPUTS_PER_INTENT: usize = 20;
695
696        let ark_addresses = self.get_offchain_addresses().await?;
697
698        // Use pending_only filter to only fetch VTXOs that are spent but not
699        // finalized. This is much cheaper than fetching all VTXOs when there
700        // are no pending transactions (common case).
701        let addresses = ark_addresses.iter().map(|(a, _)| *a);
702        let request = server::GetVtxosRequest::new_for_addresses(addresses)
703            .pending_only()
704            .map_err(Error::from)?;
705
706        let vtxos = self
707            .fetch_all_vtxos(request)
708            .await
709            .context("failed to fetch pending VTXOs")?;
710        let vtxos = self.annotate_vtxos(vtxos)?;
711
712        tracing::debug!(num_pending_vtxos = vtxos.len(), "Fetched pending VTXOs");
713
714        if vtxos.is_empty() {
715            return Ok(vec![]);
716        }
717
718        let secp = Secp256k1::new();
719        let mut all_pending_txs = Vec::new();
720        let mut seen_ark_txids = HashSet::new();
721
722        // Batch inputs to avoid oversized intents.
723        for (batch_idx, batch) in vtxos.chunks(MAX_INPUTS_PER_INTENT).enumerate() {
724            let mut vtxo_inputs = Vec::new();
725            for entry in batch {
726                let spend_selection = entry
727                    .spend_selection(SpendPathKind::Forfeit)
728                    .context("failed to get forfeit spend selection")?;
729
730                vtxo_inputs.push(intent::Input::new_with_spend_selection(
731                    entry.vtxo().outpoint,
732                    entry.exit_delay()?,
733                    TxOut {
734                        value: entry.vtxo().amount,
735                        script_pubkey: entry.script_pubkey(),
736                    },
737                    entry.tapscripts(),
738                    spend_selection,
739                    false,
740                    entry.vtxo().is_swept,
741                    entry.vtxo().assets.clone(),
742                ));
743            }
744
745            if vtxo_inputs.is_empty() {
746                continue;
747            }
748
749            tracing::debug!(
750                batch = batch_idx,
751                num_inputs = vtxo_inputs.len(),
752                "Querying server for pending txs"
753            );
754
755            // expire_at = 0: server does not enforce expiry for get-pending-tx intents.
756            let message = intent::IntentMessage::GetPendingTx { expire_at: 0 };
757
758            let sign_for_vtxo_fn = |input: &mut psbt::Input,
759                                    msg: secp256k1::Message|
760             -> Result<
761                Vec<(schnorr::Signature, XOnlyPublicKey)>,
762                ark_core::Error,
763            > {
764                match &input.witness_script {
765                    None => Err(ark_core::Error::ad_hoc(
766                        "Missing witness script in psbt::Input when signing get-pending-tx intent",
767                    )),
768                    Some(script) => {
769                        let pks = extract_checksig_pubkeys(script);
770                        let mut res = vec![];
771                        for pk in &pks {
772                            if let Ok(keypair) = self.keypair_by_pk(pk) {
773                                let sig = secp.sign_schnorr_no_aux_rand(&msg, &keypair);
774                                res.push((sig, keypair.x_only_public_key().0));
775                            }
776                        }
777                        Ok(res)
778                    }
779                }
780            };
781
782            let sign_for_onchain_fn =
783                |_: &mut psbt::Input,
784                 _: secp256k1::Message|
785                 -> Result<(schnorr::Signature, XOnlyPublicKey), ark_core::Error> {
786                    Err(ark_core::Error::ad_hoc(
787                        "unexpected onchain input in get-pending-tx intent",
788                    ))
789                };
790
791            let get_pending_intent = intent::make_intent(
792                sign_for_vtxo_fn,
793                sign_for_onchain_fn,
794                vtxo_inputs,
795                vec![],
796                message,
797            )?;
798
799            let pending_txs = self
800                .network_client()
801                .get_pending_tx(get_pending_intent)
802                .await
803                .map_err(Error::ark_server)
804                .context("failed to get pending transactions")?;
805
806            tracing::debug!(
807                batch = batch_idx,
808                num_pending_txs = pending_txs.len(),
809                "Server response for batch"
810            );
811
812            for tx in pending_txs {
813                if seen_ark_txids.insert(tx.ark_txid) {
814                    tracing::info!(
815                        ark_txid = %tx.ark_txid,
816                        "Found pending transaction"
817                    );
818                    all_pending_txs.push(tx);
819                }
820            }
821        }
822
823        tracing::info!(
824            num_pending_txs = all_pending_txs.len(),
825            "Total pending transactions found"
826        );
827
828        Ok(all_pending_txs)
829    }
830}