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 pub async fn send(&self, receivers: Vec<SendReceiver>) -> Result<Txid, Error> {
84 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 pub async fn send_selection(
120 &self,
121 vtxo_outpoints: &[OutPoint],
122 receivers: Vec<SendReceiver>,
123 ) -> Result<Txid, Error> {
124 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 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 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 pub async fn list_pending_offchain_txs(&self) -> Result<Vec<PendingTx>, Error> {
205 self.fetch_pending_offchain_txs().await
206 }
207
208 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 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 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 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 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 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 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 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 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 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 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 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}