Skip to main content

ark_client/
boltz.rs

1// Active VHTLC contracts are not swept by deprecated-signer migration. Their claim/refund
2// recovery paths reconstruct scripts against both current and deprecated server keys so swaps
3// created before a signer rotation remain recoverable.
4
5use crate::batch::BatchOutputType;
6use crate::error::ErrorContext as _;
7use crate::swap_storage::SwapStorage;
8use crate::timeout_op;
9use crate::wallet::OnchainWallet;
10use crate::Blockchain;
11use crate::Client;
12use crate::Error;
13use ark_core::intent;
14use ark_core::script::extract_checksig_pubkeys;
15use ark_core::send::build_offchain_transactions;
16use ark_core::send::sign_ark_transaction;
17use ark_core::send::sign_checkpoint_transaction;
18use ark_core::send::OffchainTransactions;
19use ark_core::send::SendReceiver;
20use ark_core::send::VtxoInput;
21use ark_core::server::parse_sequence_number;
22use ark_core::server::Info;
23use ark_core::server::PendingTx;
24use ark_core::vhtlc::VhtlcOptions;
25use ark_core::vhtlc::VhtlcScript;
26use ark_core::ArkAddress;
27use ark_core::VtxoList;
28use ark_core::VTXO_CONDITION_KEY;
29use bitcoin::absolute;
30use bitcoin::consensus::Encodable;
31use bitcoin::hashes::ripemd160;
32use bitcoin::hashes::sha256;
33use bitcoin::hashes::Hash;
34use bitcoin::io::Write;
35use bitcoin::key::Secp256k1;
36use bitcoin::psbt;
37use bitcoin::secp256k1;
38use bitcoin::secp256k1::schnorr;
39use bitcoin::taproot::LeafVersion;
40use bitcoin::Amount;
41use bitcoin::Psbt;
42use bitcoin::PublicKey;
43use bitcoin::ScriptBuf;
44use bitcoin::TxOut;
45use bitcoin::Txid;
46use bitcoin::VarInt;
47use bitcoin::XOnlyPublicKey;
48use lightning_invoice::Bolt11Invoice;
49use rand::CryptoRng;
50use rand::Rng;
51use serde::Deserialize;
52use serde::Serialize;
53use serde_with::serde_as;
54use serde_with::DisplayFromStr;
55use std::str::FromStr;
56use std::time::SystemTime;
57use std::time::UNIX_EPOCH;
58
59/// Maximum byte length of a BOLT11 invoice description (`d` field).
60///
61/// BOLT11 tagged fields use a 10-bit length in 5-bit groups, capping the payload at
62/// `floor(1023 * 5 / 8) = 639` UTF-8 bytes.
63const MAX_BOLT11_DESCRIPTION_BYTES: usize = 639;
64
65fn validate_invoice_description(description: Option<&str>) -> Result<(), Error> {
66    if let Some(d) = description {
67        if d.len() > MAX_BOLT11_DESCRIPTION_BYTES {
68            return Err(Error::consumer(format!(
69                "invoice description is {} bytes (> {} bytes).",
70                d.len(),
71                MAX_BOLT11_DESCRIPTION_BYTES,
72            )));
73        }
74    }
75    Ok(())
76}
77
78/// The type of a Boltz swap.
79#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
80pub enum SwapType {
81    Submarine,
82    Reverse,
83    Chain,
84    /// Swap ID not found in local storage.
85    Unknown,
86}
87
88impl std::fmt::Display for SwapType {
89    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
90        match self {
91            Self::Submarine => write!(f, "submarine"),
92            Self::Reverse => write!(f, "reverse"),
93            Self::Chain => write!(f, "chain"),
94            Self::Unknown => write!(f, "unknown"),
95        }
96    }
97}
98
99/// Status information for a Boltz swap.
100#[derive(Clone, Debug)]
101pub struct SwapStatusInfo {
102    pub swap_id: String,
103    pub swap_type: SwapType,
104    pub status: SwapStatus,
105}
106
107#[derive(Clone, Debug)]
108pub struct SubmarineSwapResult {
109    pub swap_id: String,
110    pub txid: Txid,
111    pub amount: Amount,
112}
113
114#[derive(Clone, Debug)]
115pub struct ReverseSwapResult {
116    pub swap_id: String,
117    pub amount: Amount,
118    pub invoice: Bolt11Invoice,
119}
120
121#[derive(Clone, Debug)]
122pub struct ClaimVhtlcResult {
123    pub swap_id: String,
124    pub claim_txid: Txid,
125    pub claim_amount: Amount,
126    pub preimage: [u8; 32],
127}
128
129/// The type of VHTLC spend that was submitted but not yet finalized.
130///
131/// Determined by matching the spend script in the pending transaction's PSBT against the known
132/// VHTLC spend paths.
133#[derive(Clone, Debug)]
134pub enum PendingVhtlcSpendType {
135    /// Claim via `claim_script`: preimage + receiver + server.
136    ///
137    /// Used in reverse submarine swaps (receiving Lightning → Ark).
138    Claim { swap_id: String, preimage: [u8; 32] },
139    /// Collaborative refund via `refund_script`: sender + receiver (Boltz) + server.
140    ///
141    /// Used in submarine swaps when Boltz cooperates.
142    CollaborativeRefund { swap_id: String },
143    /// Expired refund via `refund_without_receiver_script`: CLTV timeout + sender + server.
144    ///
145    /// Used in submarine swaps when the timelock has expired and Boltz is unavailable.
146    ExpiredRefund { swap_id: String },
147}
148
149impl PendingVhtlcSpendType {
150    pub fn swap_id(&self) -> &str {
151        match self {
152            Self::Claim { swap_id, .. }
153            | Self::CollaborativeRefund { swap_id }
154            | Self::ExpiredRefund { swap_id } => swap_id,
155        }
156    }
157
158    pub fn name(&self) -> &'static str {
159        match self {
160            Self::Claim { .. } => "Claim",
161            Self::CollaborativeRefund { .. } => "CollaborativeRefund",
162            Self::ExpiredRefund { .. } => "ExpiredRefund",
163        }
164    }
165}
166
167/// A pending (submitted but not finalized) VHTLC spend transaction.
168#[derive(Clone, Debug)]
169pub struct PendingVhtlcSpendTx {
170    pub spend_type: PendingVhtlcSpendType,
171    pub pending_tx: PendingTx,
172}
173
174impl<B, W, S> Client<B, W, S>
175where
176    B: Blockchain,
177    W: OnchainWallet,
178    S: SwapStorage + 'static,
179{
180    // Submarine swap.
181
182    /// Prepare the payment of a BOLT11 invoice by setting up a submarine swap via Boltz.
183    ///
184    /// This function does not execute the payment itself. Once you are ready for payment you
185    /// will have to send the required `amount` to the `vhtlc_address`.
186    ///
187    /// If you are looking for a function which pays the invoice immediately, consider using
188    /// [`Client::pay_ln_invoice`] instead.
189    ///
190    /// # Arguments
191    ///
192    /// - `invoice`: a [`Bolt11Invoice`] to be paid.
193    ///
194    /// # Returns
195    ///
196    /// - A [`SubmarineSwapData`] object, including an identifier for the swap.
197    pub async fn prepare_ln_invoice_payment(
198        &self,
199        invoice: Bolt11Invoice,
200    ) -> Result<SubmarineSwapData, Error> {
201        let refund_keypair = self.next_keypair(crate::key_provider::KeypairIndex::New)?;
202        let refund_public_key = refund_keypair.public_key();
203        let key_derivation_index =
204            self.derivation_index_for_pk(&refund_keypair.x_only_public_key().0);
205
206        let preimage_hash = invoice.payment_hash();
207        let preimage_hash = ripemd160::Hash::hash(preimage_hash.as_byte_array());
208
209        let request = CreateSubmarineSwapRequest {
210            from: Asset::Ark,
211            to: Asset::Btc,
212            invoice,
213            refund_public_key: refund_public_key.into(),
214            referral_id: self.inner.boltz_referral_id.clone(),
215        };
216        let url = format!("{}/v2/swap/submarine", self.inner.boltz_url);
217
218        let client = reqwest::Client::new();
219        let response = client
220            .post(&url)
221            .json(&request)
222            .send()
223            .await
224            .map_err(|e| Error::ad_hoc(e.to_string()))
225            .context("failed to send submarine swap request")?;
226
227        if !response.status().is_success() {
228            let error_text = response
229                .text()
230                .await
231                .map_err(|e| Error::ad_hoc(e.to_string()))
232                .context("failed to read error text")?;
233
234            return Err(Error::ad_hoc(format!(
235                "failed to create submarine swap: {error_text}"
236            )));
237        }
238
239        let swap_response: CreateSubmarineSwapResponse = response
240            .json()
241            .await
242            .map_err(|e| Error::ad_hoc(e.to_string()))
243            .context("failed to deserialize submarine swap response")?;
244
245        let created_at = SystemTime::now()
246            .duration_since(UNIX_EPOCH)
247            .map_err(Error::ad_hoc)
248            .context("failed to compute created_at")?;
249
250        let data = SubmarineSwapData {
251            id: swap_response.id.clone(),
252            status: SwapStatus::Created,
253            preimage: None,
254            preimage_hash,
255            refund_public_key: refund_public_key.into(),
256            claim_public_key: swap_response.claim_public_key,
257            vhtlc_address: swap_response.address,
258            timeout_block_heights: swap_response.timeout_block_heights,
259            amount: swap_response.expected_amount,
260            invoice: request.invoice.clone(),
261            created_at: created_at.as_secs(),
262            key_derivation_index,
263        };
264
265        self.swap_storage()
266            .insert_submarine(swap_response.id.clone(), data.clone())
267            .await?;
268
269        tracing::info!(
270            swap_id = swap_response.id,
271            vhtlc_address = %data.vhtlc_address,
272            expected_amount = %data.amount,
273            "Prepared Lightning invoice payment"
274        );
275
276        Ok(data)
277    }
278
279    /// Pay a BOLT11 invoice by performing a submarine swap via Boltz. This allows to make Lightning
280    /// payments with an Ark wallet.
281    ///
282    /// # Arguments
283    ///
284    /// - `invoice`: a [`Bolt11Invoice`] to be paid.
285    ///
286    /// # Returns
287    ///
288    /// - A [`SubmarineSwapResult`], including an identifier for the swap and the TXID of the Ark
289    ///   transaction that funds the VHTLC.
290    pub async fn pay_ln_invoice(
291        &self,
292        invoice: Bolt11Invoice,
293    ) -> Result<SubmarineSwapResult, Error> {
294        let refund_keypair = self.next_keypair(crate::key_provider::KeypairIndex::New)?;
295        let refund_public_key = refund_keypair.public_key();
296        let key_derivation_index =
297            self.derivation_index_for_pk(&refund_keypair.x_only_public_key().0);
298
299        let preimage_hash = invoice.payment_hash();
300        let preimage_hash = ripemd160::Hash::hash(preimage_hash.as_byte_array());
301
302        let request = CreateSubmarineSwapRequest {
303            from: Asset::Ark,
304            to: Asset::Btc,
305            invoice,
306            refund_public_key: refund_public_key.into(),
307            referral_id: self.inner.boltz_referral_id.clone(),
308        };
309        let url = format!("{}/v2/swap/submarine", self.inner.boltz_url);
310
311        let client = reqwest::Client::new();
312        let response = client
313            .post(&url)
314            .json(&request)
315            .send()
316            .await
317            .map_err(|e| Error::ad_hoc(e.to_string()))
318            .context("failed to send submarine swap request")?;
319
320        if !response.status().is_success() {
321            let error_text = response
322                .text()
323                .await
324                .map_err(|e| Error::ad_hoc(e.to_string()))
325                .context("failed to read error text")?;
326
327            return Err(Error::ad_hoc(format!(
328                "failed to create submarine swap: {error_text}"
329            )));
330        }
331
332        let swap_response: CreateSubmarineSwapResponse = response
333            .json()
334            .await
335            .map_err(|e| Error::ad_hoc(e.to_string()))
336            .context("failed to deserialize submarine swap response")?;
337
338        let created_at = SystemTime::now()
339            .duration_since(UNIX_EPOCH)
340            .map_err(Error::ad_hoc)
341            .context("failed to compute created_at")?;
342
343        self.swap_storage()
344            .insert_submarine(
345                swap_response.id.clone(),
346                SubmarineSwapData {
347                    id: swap_response.id.clone(),
348                    status: SwapStatus::Created,
349                    preimage: None,
350                    preimage_hash,
351                    refund_public_key: refund_public_key.into(),
352                    claim_public_key: swap_response.claim_public_key,
353                    vhtlc_address: swap_response.address,
354                    timeout_block_heights: swap_response.timeout_block_heights,
355                    amount: swap_response.expected_amount,
356                    invoice: request.invoice.clone(),
357                    created_at: created_at.as_secs(),
358                    key_derivation_index,
359                },
360            )
361            .await?;
362
363        let vhtlc_address = swap_response.address;
364        let amount = swap_response.expected_amount;
365
366        let txid = self
367            .send(vec![SendReceiver::bitcoin(vhtlc_address, amount)])
368            .await?;
369
370        tracing::info!(swap_id = swap_response.id, %amount, "Funded VHTLC");
371
372        Ok(SubmarineSwapResult {
373            swap_id: swap_response.id,
374            txid,
375            amount,
376        })
377    }
378
379    /// Wait for the Lightning invoice associated with a submarine swap to be paid by Boltz.
380    ///
381    /// Boltz will first need to claim our VHTLC before paying the invoice. When Boltz claims
382    /// the VHTLC, the preimage is revealed in the claim transaction's witness. This method
383    /// extracts and persists the preimage to swap storage.
384    ///
385    /// # Returns
386    ///
387    /// The 32-byte preimage that was revealed when Boltz claimed the VHTLC.
388    pub async fn wait_for_invoice_paid(&self, swap_id: &str) -> Result<[u8; 32], Error> {
389        use futures::StreamExt;
390
391        let stream = self.subscribe_to_swap_updates(swap_id.to_string());
392        tokio::pin!(stream);
393
394        while let Some(status_result) = stream.next().await {
395            match status_result {
396                Ok(status) => {
397                    tracing::debug!(swap_id, current = ?status, "Swap status");
398                    match status {
399                        SwapStatus::InvoicePaid => {
400                            let deadline = tokio::time::Instant::now() + self.inner.timeout;
401
402                            loop {
403                                match self.extract_submarine_swap_preimage(swap_id).await {
404                                    Ok(preimage) => return Ok(preimage),
405                                    Err(e) => {
406                                        if tokio::time::Instant::now() >= deadline {
407                                            return Err(e.context(
408                                                "invoice paid but failed to extract preimage from claim tx",
409                                            ));
410                                        }
411
412                                        tracing::debug!(
413                                            swap_id,
414                                            "Preimage not available yet, retrying: {e}"
415                                        );
416                                    }
417                                }
418
419                                tokio::time::sleep(std::time::Duration::from_secs(1)).await;
420                            }
421                        }
422                        SwapStatus::InvoiceExpired => {
423                            return Err(Error::ad_hoc(format!(
424                                "invoice expired for swap {swap_id}"
425                            )));
426                        }
427                        SwapStatus::Error { error } => {
428                            tracing::error!(
429                                swap_id,
430                                "Got error from swap updates subscription: {error}"
431                            );
432                        }
433                        SwapStatus::InvoiceSet
434                        | SwapStatus::InvoicePending
435                        | SwapStatus::Created
436                        | SwapStatus::TransactionMempool
437                        | SwapStatus::TransactionConfirmed
438                        | SwapStatus::TransactionServerMempool
439                        | SwapStatus::TransactionServerConfirmed
440                        | SwapStatus::TransactionRefunded
441                        | SwapStatus::TransactionFailed
442                        | SwapStatus::TransactionClaimed
443                        | SwapStatus::TransactionLockupFailed
444                        | SwapStatus::InvoiceFailedToPay
445                        | SwapStatus::SwapExpired
446                        | SwapStatus::Other(_) => {}
447                    }
448                }
449                Err(e) => return Err(e),
450            }
451        }
452
453        Err(Error::ad_hoc("Status stream ended unexpectedly"))
454    }
455
456    /// Extract the preimage from a claimed submarine swap VHTLC.
457    ///
458    /// After Boltz claims the VHTLC, the preimage is embedded in the claim transaction's PSBT
459    /// via the `VTXO_CONDITION_KEY` unknown field. This method fetches that transaction and
460    /// extracts the preimage.
461    ///
462    /// The extracted preimage is validated against the stored preimage hash and persisted to
463    /// swap storage.
464    pub async fn extract_submarine_swap_preimage(&self, swap_id: &str) -> Result<[u8; 32], Error> {
465        let mut swap_data = self
466            .swap_storage()
467            .get_submarine(swap_id)
468            .await?
469            .ok_or(Error::ad_hoc("submarine swap not found"))?;
470
471        // If the preimage was already extracted, return it.
472        if let Some(preimage) = swap_data.preimage {
473            return Ok(preimage);
474        }
475
476        let vhtlc_address = swap_data.vhtlc_address;
477
478        // Find the VHTLC outpoint — it should be spent by now.
479        let virtual_tx_outpoints = self
480            .get_virtual_tx_outpoints(std::iter::once(vhtlc_address))
481            .await
482            .context("failed to get virtual tx outpoints for VHTLC address")?;
483
484        let vhtlc_outpoint = virtual_tx_outpoints
485            .iter()
486            .find(|o| o.is_spent)
487            .ok_or_else(|| Error::ad_hoc("VHTLC outpoint not found or not yet spent (claimed)"))?;
488
489        let claim_txid = vhtlc_outpoint.ark_txid.ok_or_else(|| {
490            Error::ad_hoc("VHTLC is spent but has no ark_txid (claim transaction)")
491        })?;
492
493        // Fetch the claim transaction PSBT.
494        let claim_txs = timeout_op(
495            self.inner.timeout,
496            self.network_client()
497                .get_virtual_txs(vec![claim_txid.to_string()], None),
498        )
499        .await?
500        .map_err(|e| Error::ad_hoc(e.to_string()))
501        .context("failed to fetch claim transaction")?;
502
503        let claim_psbt = claim_txs
504            .txs
505            .first()
506            .ok_or_else(|| Error::ad_hoc("claim transaction not found"))?;
507
508        // Extract the preimage from the PSBT's unknown fields.
509        let preimage = extract_preimage_from_psbt(claim_psbt)?;
510
511        // Validate against the stored hash.
512        let computed_hash = ripemd160::Hash::hash(sha256::Hash::hash(&preimage).as_byte_array());
513        if computed_hash != swap_data.preimage_hash {
514            return Err(Error::ad_hoc(format!(
515                "extracted preimage does not match stored hash: expected {}, got {}",
516                swap_data.preimage_hash, computed_hash
517            )));
518        }
519
520        // Persist the preimage.
521        swap_data.preimage = Some(preimage);
522        self.swap_storage()
523            .update_submarine(swap_id, swap_data)
524            .await
525            .context("failed to persist preimage to swap storage")?;
526
527        tracing::info!(
528            swap_id,
529            "Extracted and persisted preimage from claim transaction"
530        );
531
532        Ok(preimage)
533    }
534
535    /// Refund a VHTLC after the timelock has expired.
536    ///
537    /// This path does not require a signature from Boltz.
538    pub async fn refund_expired_vhtlc(&self, swap_id: &str) -> Result<Txid, Error> {
539        let swap_data = self
540            .swap_storage()
541            .get_submarine(swap_id)
542            .await?
543            .ok_or(Error::ad_hoc("Submarine swap not found"))?;
544
545        let timeout_block_heights = swap_data.timeout_block_heights;
546        let server_info = self.server_info().await?;
547
548        let vhtlc = self.reconstruct_vhtlc_for_address(
549            &server_info,
550            |server| {
551                Ok(VhtlcOptions {
552                    sender: swap_data.refund_public_key.into(),
553                    receiver: swap_data.claim_public_key.into(),
554                    server,
555                    preimage_hash: swap_data.preimage_hash,
556                    refund_locktime: timeout_block_heights.refund,
557                    unilateral_claim_delay: parse_sequence_number(
558                        timeout_block_heights.unilateral_claim as i64,
559                    )
560                    .map_err(|e| Error::ad_hoc(format!("invalid unilateral claim timeout: {e}")))?,
561                    unilateral_refund_delay: parse_sequence_number(
562                        timeout_block_heights.unilateral_refund as i64,
563                    )
564                    .map_err(|e| {
565                        Error::ad_hoc(format!("invalid unilateral refund timeout: {e}"))
566                    })?,
567                    unilateral_refund_without_receiver_delay: parse_sequence_number(
568                        timeout_block_heights.unilateral_refund_without_receiver as i64,
569                    )
570                    .map_err(|e| {
571                        Error::ad_hoc(format!("invalid refund without receiver timeout: {e}"))
572                    })?,
573                })
574            },
575            &swap_data.vhtlc_address,
576        )?;
577        let vhtlc_address = vhtlc.address();
578
579        let vhtlc_outpoint = {
580            let virtual_tx_outpoints = self
581                .get_virtual_tx_outpoints(std::iter::once(vhtlc_address))
582                .await?;
583
584            let vtxo_list = VtxoList::new(server_info.dust, virtual_tx_outpoints);
585
586            // We expect a single outpoint.
587            let mut unspent = vtxo_list.all_unspent();
588            let vhtlc_outpoint = unspent.next().ok_or_else(|| {
589                Error::ad_hoc(format!("no outpoint found for address {vhtlc_address}"))
590            })?;
591
592            vhtlc_outpoint.clone()
593        };
594
595        let (refund_address, _) = self.get_offchain_address().await?;
596        let refund_amount = swap_data.amount;
597
598        let outputs = vec![SendReceiver {
599            address: refund_address,
600            amount: refund_amount,
601            assets: Vec::new(),
602        }];
603
604        let refund_script = vhtlc.refund_without_receiver_script();
605
606        let spend_info = vhtlc.taproot_spend_info();
607        let script_ver = (refund_script, LeafVersion::TapScript);
608        let control_block = spend_info
609            .control_block(&script_ver)
610            .ok_or(Error::ad_hoc("control block not found for refund script"))?;
611
612        let script_pubkey = vhtlc.script_pubkey();
613
614        let refunder_pk = swap_data.refund_public_key.inner.x_only_public_key().0;
615        let vhtlc_input = VtxoInput::new(
616            script_ver.0,
617            Some(absolute::LockTime::from_consensus(
618                swap_data.timeout_block_heights.refund,
619            )),
620            control_block,
621            vhtlc.tapscripts(),
622            script_pubkey,
623            refund_amount,
624            vhtlc_outpoint.outpoint,
625            vhtlc_outpoint.assets,
626        );
627
628        // The change address is superfluous because we are _draining_ the VHTLC.
629        let change_address = &refund_address;
630
631        let OffchainTransactions {
632            mut ark_tx,
633            checkpoint_txs,
634        } = build_offchain_transactions(
635            &outputs,
636            change_address,
637            std::slice::from_ref(&vhtlc_input),
638            &server_info,
639        )?;
640
641        let kp = self.keypair_by_pk(&refunder_pk)?;
642        let sign_fn =
643            |_: &mut psbt::Input,
644             msg: secp256k1::Message|
645             -> Result<Vec<(schnorr::Signature, XOnlyPublicKey)>, ark_core::Error> {
646                let sig = Secp256k1::new().sign_schnorr_no_aux_rand(&msg, &kp);
647                let pk = kp.x_only_public_key().0;
648
649                Ok(vec![(sig, pk)])
650            };
651
652        sign_ark_transaction(sign_fn, &mut ark_tx, 0)?;
653
654        let ark_txid = ark_tx.unsigned_tx.compute_txid();
655
656        let res = self
657            .network_client()
658            .submit_offchain_transaction_request(ark_tx, checkpoint_txs)
659            .await?;
660
661        let mut checkpoint_psbt = res
662            .signed_checkpoint_txs
663            .first()
664            .ok_or_else(|| Error::ad_hoc("no checkpoint PSBTs found"))?
665            .clone();
666
667        let kp = self.keypair_by_pk(&refunder_pk)?;
668        let sign_fn =
669            |_: &mut psbt::Input,
670             msg: secp256k1::Message|
671             -> Result<Vec<(schnorr::Signature, XOnlyPublicKey)>, ark_core::Error> {
672                let sig = Secp256k1::new().sign_schnorr_no_aux_rand(&msg, &kp);
673                let pk = kp.x_only_public_key().0;
674
675                Ok(vec![(sig, pk)])
676            };
677
678        sign_checkpoint_transaction(sign_fn, &mut checkpoint_psbt)?;
679
680        timeout_op(
681            self.inner.timeout,
682            self.network_client()
683                .finalize_offchain_transaction(ark_txid, vec![checkpoint_psbt]),
684        )
685        .await?
686        .map_err(Error::ark_server)
687        .context("failed to finalize offchain transaction")?;
688
689        tracing::info!(txid = %ark_txid, "Refunded VHTLC");
690
691        Ok(ark_txid)
692    }
693
694    /// Refund a VHTLC after the timelock has expired via settlement.
695    ///
696    /// This path does not require a signature from Boltz.
697    pub async fn refund_expired_vhtlc_via_settlement<R>(
698        &self,
699        rng: &mut R,
700        swap_id: &str,
701    ) -> Result<Txid, Error>
702    where
703        R: Rng + CryptoRng,
704    {
705        let swap_data = self
706            .swap_storage()
707            .get_submarine(swap_id)
708            .await?
709            .ok_or(Error::ad_hoc("Submarine swap not found"))?;
710
711        let timeout_block_heights = swap_data.timeout_block_heights;
712        let server_info = self.server_info().await?;
713
714        let vhtlc = self.reconstruct_vhtlc_for_address(
715            &server_info,
716            |server| {
717                Ok(VhtlcOptions {
718                    sender: swap_data.refund_public_key.into(),
719                    receiver: swap_data.claim_public_key.into(),
720                    server,
721                    preimage_hash: swap_data.preimage_hash,
722                    refund_locktime: timeout_block_heights.refund,
723                    unilateral_claim_delay: parse_sequence_number(
724                        timeout_block_heights.unilateral_claim as i64,
725                    )
726                    .map_err(|e| Error::ad_hoc(format!("invalid unilateral claim timeout: {e}")))?,
727                    unilateral_refund_delay: parse_sequence_number(
728                        timeout_block_heights.unilateral_refund as i64,
729                    )
730                    .map_err(|e| {
731                        Error::ad_hoc(format!("invalid unilateral refund timeout: {e}"))
732                    })?,
733                    unilateral_refund_without_receiver_delay: parse_sequence_number(
734                        timeout_block_heights.unilateral_refund_without_receiver as i64,
735                    )
736                    .map_err(|e| {
737                        Error::ad_hoc(format!("invalid refund without receiver timeout: {e}"))
738                    })?,
739                })
740            },
741            &swap_data.vhtlc_address,
742        )?;
743        let vhtlc_address = vhtlc.address();
744
745        let vhtlc_outpoint = {
746            let virtual_tx_outpoints = self
747                .get_virtual_tx_outpoints(std::iter::once(vhtlc_address))
748                .await?;
749
750            let vtxo_list = VtxoList::new(server_info.dust, virtual_tx_outpoints);
751
752            // We expect a single outpoint.
753            let mut recoverable = vtxo_list.recoverable();
754
755            recoverable
756                .next()
757                .ok_or_else(|| {
758                    Error::ad_hoc(format!("no outpoint found for address {vhtlc_address}"))
759                })?
760                .clone()
761        };
762
763        let refund_script = vhtlc.refund_without_receiver_script();
764
765        let spend_info = vhtlc.taproot_spend_info();
766        let script_ver = (refund_script, LeafVersion::TapScript);
767        let control_block = spend_info
768            .control_block(&script_ver)
769            .ok_or(Error::ad_hoc("control block not found for refund script"))?;
770
771        let script_pubkey = vhtlc.script_pubkey();
772
773        let (refund_address, _) = self.get_offchain_address_with_server_info(&server_info)?;
774        let refund_amount = swap_data.amount;
775
776        let vhtlc_input = intent::Input::new(
777            vhtlc_outpoint.outpoint,
778            parse_sequence_number(timeout_block_heights.unilateral_refund as i64)
779                .map_err(|e| Error::ad_hoc(format!("invalid unilateral refund timeout: {e}")))?,
780            Some(absolute::LockTime::from_consensus(
781                timeout_block_heights.refund,
782            )),
783            TxOut {
784                value: refund_amount,
785                script_pubkey,
786            },
787            vhtlc.tapscripts(),
788            (script_ver.0, control_block),
789            false,
790            true,
791            vhtlc_outpoint.assets,
792        );
793
794        let commitment_txid = self
795            .join_next_batch(
796                rng,
797                &server_info,
798                Vec::new(),
799                vec![vhtlc_input],
800                BatchOutputType::Board {
801                    to_address: refund_address,
802                    to_amount: refund_amount,
803                },
804            )
805            .await
806            .context("failed to join batch")?;
807
808        tracing::info!(txid = %commitment_txid, "Refunded VHTLC via settlement");
809
810        Ok(commitment_txid)
811    }
812
813    /// Refund a VHTLC with collaboration from Boltz.
814    ///
815    /// This path requires Boltz's cooperation to sign the refund transaction. It allows refunding
816    /// a submarine swap before the timelock expires. For refunds after timelock expiry without
817    /// Boltz cooperation, use [`Client::refund_expired_vhtlc`] instead.
818    pub async fn refund_vhtlc(&self, swap_id: &str) -> Result<Txid, Error> {
819        let swap_data = self
820            .swap_storage()
821            .get_submarine(swap_id)
822            .await?
823            .ok_or(Error::ad_hoc("submarine swap not found"))?;
824
825        let timeout_block_heights = swap_data.timeout_block_heights;
826        let server_info = self.server_info().await?;
827
828        let vhtlc = self.reconstruct_vhtlc_for_address(
829            &server_info,
830            |server| {
831                Ok(VhtlcOptions {
832                    sender: swap_data.refund_public_key.into(),
833                    receiver: swap_data.claim_public_key.into(),
834                    server,
835                    preimage_hash: swap_data.preimage_hash,
836                    refund_locktime: timeout_block_heights.refund,
837                    unilateral_claim_delay: parse_sequence_number(
838                        timeout_block_heights.unilateral_claim as i64,
839                    )
840                    .map_err(|e| Error::ad_hoc(format!("invalid unilateral claim timeout: {e}")))?,
841                    unilateral_refund_delay: parse_sequence_number(
842                        timeout_block_heights.unilateral_refund as i64,
843                    )
844                    .map_err(|e| {
845                        Error::ad_hoc(format!("invalid unilateral refund timeout: {e}"))
846                    })?,
847                    unilateral_refund_without_receiver_delay: parse_sequence_number(
848                        timeout_block_heights.unilateral_refund_without_receiver as i64,
849                    )
850                    .map_err(|e| {
851                        Error::ad_hoc(format!("invalid refund without receiver timeout: {e}"))
852                    })?,
853                })
854            },
855            &swap_data.vhtlc_address,
856        )?;
857        let vhtlc_address = vhtlc.address();
858
859        let vhtlc_outpoint = {
860            let virtual_tx_outpoints = self
861                .get_virtual_tx_outpoints(std::iter::once(vhtlc_address))
862                .await?;
863
864            let vtxo_list = VtxoList::new(server_info.dust, virtual_tx_outpoints);
865
866            // We expect a single outpoint.
867            let mut unspent = vtxo_list.all_unspent();
868            let vhtlc_outpoint = unspent.next().ok_or_else(|| {
869                Error::ad_hoc(format!("no outpoint found for address {vhtlc_address}"))
870            })?;
871
872            vhtlc_outpoint.clone()
873        };
874
875        let (refund_address, _) = self.get_offchain_address().await?;
876        let refund_amount = swap_data.amount;
877
878        let outputs = vec![SendReceiver {
879            address: refund_address,
880            amount: refund_amount,
881            assets: Vec::new(),
882        }];
883
884        // Use the collaborative refund script which requires sender + receiver + server signatures.
885        let refund_script = vhtlc.refund_script();
886
887        let spend_info = vhtlc.taproot_spend_info();
888        let script_ver = (refund_script, LeafVersion::TapScript);
889        let control_block = spend_info
890            .control_block(&script_ver)
891            .ok_or(Error::ad_hoc("control block not found for refund script"))?;
892
893        let script_pubkey = vhtlc.script_pubkey();
894
895        let refunder_pk = swap_data.refund_public_key.inner.x_only_public_key().0;
896        let vhtlc_input = VtxoInput::new(
897            script_ver.0,
898            None, // No locktime required for collaborative refund
899            control_block,
900            vhtlc.tapscripts(),
901            script_pubkey,
902            refund_amount,
903            vhtlc_outpoint.outpoint,
904            vhtlc_outpoint.assets,
905        );
906
907        // The change address is superfluous because we are _draining_ the VHTLC.
908        let change_address = &refund_address;
909
910        let OffchainTransactions {
911            mut ark_tx,
912            checkpoint_txs,
913        } = build_offchain_transactions(
914            &outputs,
915            change_address,
916            std::slice::from_ref(&vhtlc_input),
917            &server_info,
918        )?;
919
920        // Sign the ark transaction with the sender's (user's) key.
921        let kp = self.keypair_by_pk(&refunder_pk)?;
922        let sign_fn =
923            |_: &mut psbt::Input,
924             msg: secp256k1::Message|
925             -> Result<Vec<(schnorr::Signature, XOnlyPublicKey)>, ark_core::Error> {
926                let sig = Secp256k1::new().sign_schnorr_no_aux_rand(&msg, &kp);
927                let pk = kp.x_only_public_key().0;
928
929                Ok(vec![(sig, pk)])
930            };
931
932        sign_ark_transaction(sign_fn, &mut ark_tx, 0)?;
933
934        // Get the unsigned checkpoint - we'll sign it after arkd adds its signature.
935        let checkpoint_psbt = checkpoint_txs
936            .first()
937            .ok_or_else(|| Error::ad_hoc("no checkpoint PSBTs found"))?
938            .clone();
939
940        // Send ark transaction (with user signature) and unsigned checkpoint to Boltz.
941        // Boltz will add their signature (receiver) to the ark transaction.
942        let url = format!(
943            "{}/v2/swap/submarine/{swap_id}/refund/ark",
944            self.inner.boltz_url
945        );
946        let client = reqwest::Client::new();
947        let response = client
948            .post(&url)
949            .json(&RefundSwapRequest {
950                transaction: ark_tx.to_string(),
951                checkpoint: checkpoint_psbt.to_string(),
952            })
953            .send()
954            .await
955            .map_err(Error::ad_hoc)
956            .context("failed to send refund request to Boltz")?;
957
958        if !response.status().is_success() {
959            let error_text = response
960                .text()
961                .await
962                .map_err(|e| Error::ad_hoc(e.to_string()))
963                .context("failed to read error text")?;
964
965            return Err(Error::ad_hoc(format!(
966                "Boltz refund request failed: {error_text}"
967            )));
968        }
969
970        let refund_response: RefundSwapResponse = response
971            .json()
972            .await
973            .map_err(Error::ad_hoc)
974            .context("failed to deserialize refund response")?;
975
976        if let Some(err) = refund_response.error.as_deref() {
977            return Err(Error::ad_hoc(format!("Boltz refund request failed: {err}")));
978        }
979
980        // Parse the Boltz-signed transactions.
981        let boltz_signed_ark_tx = Psbt::from_str(&refund_response.transaction)
982            .map_err(Error::ad_hoc)
983            .context("could not parse refund transaction PSBT")?;
984
985        let boltz_signed_checkpoint = Psbt::from_str(&refund_response.checkpoint)
986            .map_err(Error::ad_hoc)
987            .context("could not parse refund checkpoint PSBT")?;
988
989        let ark_txid = boltz_signed_ark_tx.unsigned_tx.compute_txid();
990
991        // Extract Boltz's signatures before sending to arkd (server strips incoming sigs).
992        let boltz_tap_script_sigs = boltz_signed_checkpoint
993            .inputs
994            .first()
995            .ok_or_else(|| Error::ad_hoc("boltz checkpoint has no inputs"))?
996            .tap_script_sigs
997            .clone();
998
999        // Submit to arkd for server signature.
1000        // We send the Boltz-signed transactions so arkd can add its signature.
1001        let res = self
1002            .network_client()
1003            .submit_offchain_transaction_request(boltz_signed_ark_tx, vec![boltz_signed_checkpoint])
1004            .await?;
1005
1006        // The server returns the checkpoint with its signature added.
1007        // Now we need to add our (sender) signature to the checkpoint.
1008        let mut server_signed_checkpoint = res
1009            .signed_checkpoint_txs
1010            .first()
1011            .ok_or_else(|| Error::ad_hoc("no signed checkpoint PSBTs returned"))?
1012            .clone();
1013
1014        let kp = self.keypair_by_pk(&refunder_pk)?;
1015        let sign_fn =
1016            |_: &mut psbt::Input,
1017             msg: secp256k1::Message|
1018             -> Result<Vec<(schnorr::Signature, XOnlyPublicKey)>, ark_core::Error> {
1019                let sig = Secp256k1::new().sign_schnorr_no_aux_rand(&msg, &kp);
1020                let pk = kp.x_only_public_key().0;
1021
1022                Ok(vec![(sig, pk)])
1023            };
1024
1025        server_signed_checkpoint
1026            .inputs
1027            .first_mut()
1028            .ok_or_else(|| Error::ad_hoc("server checkpoint has no inputs"))?
1029            .tap_script_sigs
1030            .extend(boltz_tap_script_sigs);
1031
1032        sign_checkpoint_transaction(sign_fn, &mut server_signed_checkpoint)?;
1033
1034        // Finalize the transaction with the fully-signed checkpoint.
1035        timeout_op(
1036            self.inner.timeout,
1037            self.network_client()
1038                .finalize_offchain_transaction(ark_txid, vec![server_signed_checkpoint]),
1039        )
1040        .await?
1041        .map_err(Error::ark_server)
1042        .context("failed to finalize offchain transaction")?;
1043
1044        tracing::info!(swap_id, txid = %ark_txid, "Refunded VHTLC via collaborative refund");
1045
1046        Ok(ark_txid)
1047    }
1048
1049    // Reverse submarine swap.
1050
1051    async fn validate_reverse_recipient_address(
1052        &self,
1053        recipient_address: Option<&ArkAddress>,
1054    ) -> Result<(), Error> {
1055        let Some(recipient_address) = recipient_address else {
1056            return Ok(());
1057        };
1058
1059        let server_info = self.server_info().await?;
1060        let server_signer: XOnlyPublicKey = server_info.signer_pk.into();
1061        if recipient_address.server() != server_signer {
1062            return Err(Error::consumer(format!(
1063                "recipient Arkade address belongs to a different server: expected {server_signer}, got {}",
1064                recipient_address.server()
1065            )));
1066        }
1067
1068        Ok(())
1069    }
1070
1071    async fn reverse_claim_address(&self, swap: &ReverseSwapData) -> Result<ArkAddress, Error> {
1072        if let Some(address) = swap.claim_address {
1073            self.validate_reverse_recipient_address(Some(&address))
1074                .await?;
1075            return Ok(address);
1076        }
1077
1078        let (address, _) = self
1079            .get_offchain_address()
1080            .await
1081            .context("failed to get offchain address")?;
1082
1083        Ok(address)
1084    }
1085
1086    /// Generate a BOLT11 invoice to perform a reverse submarine swap via Boltz. This allows to
1087    /// receive Lightning payments into an Ark wallet.
1088    ///
1089    /// # Arguments
1090    ///
1091    /// - `amount`: the expected [`Amount`] to be received.
1092    /// - `expiry_secs`: optional invoice expiry, in seconds from now. If `None`, Boltz's default is
1093    ///   used.
1094    /// - `description`: optional memo embedded in the BOLT11 invoice's `d` field (visible to the
1095    ///   payer).
1096    ///
1097    /// # Returns
1098    ///
1099    /// - A `ReverseSwapResult`, including an identifier for the reverse swap and the
1100    ///   [`Bolt11Invoice`] to be paid.
1101    pub async fn get_ln_invoice(
1102        &self,
1103        amount: SwapAmount,
1104        expiry_secs: Option<u64>,
1105        description: Option<String>,
1106    ) -> Result<ReverseSwapResult, Error> {
1107        self.create_reverse_swap_invoice_with_new_preimage(amount, expiry_secs, None, description)
1108            .await
1109    }
1110
1111    /// Generate a BOLT11 invoice to receive Lightning into another user's Arkade address.
1112    ///
1113    /// The local client still creates and claims the Boltz reverse-swap VHTLC, but the resulting
1114    /// Ark output is sent to `recipient_address` instead of a fresh local address.
1115    ///
1116    /// # Arguments
1117    ///
1118    /// - `amount`: the expected [`Amount`] to be received.
1119    /// - `recipient_address`: Arkade address that receives the claimed VHTLC output.
1120    /// - `expiry_secs`: optional invoice expiry, in seconds from now. If `None`, Boltz's default is
1121    ///   used.
1122    /// - `description`: optional memo embedded in the BOLT11 invoice's `d` field (visible to the
1123    ///   payer).
1124    ///
1125    /// # Returns
1126    ///
1127    /// - A `ReverseSwapResult`, including an identifier for the reverse swap and the
1128    ///   [`Bolt11Invoice`] to be paid.
1129    pub async fn get_ln_invoice_for_address(
1130        &self,
1131        amount: SwapAmount,
1132        recipient_address: ArkAddress,
1133        expiry_secs: Option<u64>,
1134        description: Option<String>,
1135    ) -> Result<ReverseSwapResult, Error> {
1136        self.create_reverse_swap_invoice_with_new_preimage(
1137            amount,
1138            expiry_secs,
1139            Some(recipient_address),
1140            description,
1141        )
1142        .await
1143    }
1144
1145    async fn create_reverse_swap_invoice_with_new_preimage(
1146        &self,
1147        amount: SwapAmount,
1148        expiry_secs: Option<u64>,
1149        recipient_address: Option<ArkAddress>,
1150        description: Option<String>,
1151    ) -> Result<ReverseSwapResult, Error> {
1152        let preimage: [u8; 32] = rand::random();
1153        let preimage_hash_sha256 = sha256::Hash::hash(&preimage);
1154
1155        self.create_reverse_swap_invoice(
1156            amount,
1157            expiry_secs,
1158            preimage_hash_sha256,
1159            Some(preimage),
1160            recipient_address,
1161            description,
1162        )
1163        .await
1164    }
1165
1166    /// Generate a BOLT11 invoice using a provided SHA256 preimage hash for a reverse submarine
1167    /// swap via Boltz. This allows receiving Lightning payments when the preimage is managed
1168    /// externally.
1169    ///
1170    /// # Arguments
1171    ///
1172    /// - `amount`: the expected [`Amount`] to be received.
1173    /// - `expiry_secs`: optional invoice expiry, in seconds from now. If `None`, Boltz's default is
1174    ///   used.
1175    /// - `preimage_hash_sha256`: the SHA256 hash of the preimage. The preimage itself is not stored
1176    ///   and must be provided later when claiming via [`Self::claim_vhtlc`].
1177    /// - `description`: optional memo embedded in the BOLT11 invoice's `d` field (visible to the
1178    ///   payer).
1179    ///
1180    /// # Returns
1181    ///
1182    /// - A [`ReverseSwapResult`], including an identifier for the reverse swap and the
1183    ///   [`Bolt11Invoice`] to be paid.
1184    ///
1185    /// # Note
1186    ///
1187    /// After calling this method, use [`Self::wait_for_vhtlc_funding`] to wait for the VHTLC to
1188    /// be funded, then [`Self::claim_vhtlc`] with the preimage to claim the funds.
1189    pub async fn get_ln_invoice_from_hash(
1190        &self,
1191        amount: SwapAmount,
1192        expiry_secs: Option<u64>,
1193        preimage_hash_sha256: sha256::Hash,
1194        description: Option<String>,
1195    ) -> Result<ReverseSwapResult, Error> {
1196        self.create_reverse_swap_invoice(
1197            amount,
1198            expiry_secs,
1199            preimage_hash_sha256,
1200            None,
1201            None,
1202            description,
1203        )
1204        .await
1205    }
1206
1207    /// Generate a BOLT11 invoice from an externally managed preimage hash and receive the claimed
1208    /// VHTLC output into another user's Arkade address.
1209    ///
1210    /// After calling this method, use [`Self::wait_for_vhtlc_funding`] to wait for the VHTLC to
1211    /// be funded, then [`Self::claim_vhtlc`] with the preimage to claim the funds.
1212    pub async fn get_ln_invoice_from_hash_for_address(
1213        &self,
1214        amount: SwapAmount,
1215        recipient_address: ArkAddress,
1216        expiry_secs: Option<u64>,
1217        preimage_hash_sha256: sha256::Hash,
1218        description: Option<String>,
1219    ) -> Result<ReverseSwapResult, Error> {
1220        self.create_reverse_swap_invoice(
1221            amount,
1222            expiry_secs,
1223            preimage_hash_sha256,
1224            None,
1225            Some(recipient_address),
1226            description,
1227        )
1228        .await
1229    }
1230
1231    async fn create_reverse_swap_invoice(
1232        &self,
1233        amount: SwapAmount,
1234        expiry_secs: Option<u64>,
1235        preimage_hash_sha256: sha256::Hash,
1236        preimage: Option<[u8; 32]>,
1237        recipient_address: Option<ArkAddress>,
1238        description: Option<String>,
1239    ) -> Result<ReverseSwapResult, Error> {
1240        validate_invoice_description(description.as_deref())?;
1241        self.validate_reverse_recipient_address(recipient_address.as_ref())
1242            .await?;
1243
1244        let preimage_hash = ripemd160::Hash::hash(preimage_hash_sha256.as_byte_array());
1245
1246        let claim_keypair = self.next_keypair(crate::key_provider::KeypairIndex::New)?;
1247        let claim_public_key = claim_keypair.public_key();
1248        let key_derivation_index =
1249            self.derivation_index_for_pk(&claim_keypair.x_only_public_key().0);
1250
1251        let (invoice_amount, onchain_amount) = match amount {
1252            SwapAmount::Invoice(amount) => (Some(amount), None),
1253            SwapAmount::Vhtlc(amount) => (None, Some(amount)),
1254        };
1255
1256        let request = CreateReverseSwapRequest {
1257            from: Asset::Btc,
1258            to: Asset::Ark,
1259            invoice_amount,
1260            onchain_amount,
1261            claim_public_key: claim_public_key.into(),
1262            preimage_hash: preimage_hash_sha256,
1263            invoice_expiry: expiry_secs,
1264            referral_id: self.inner.boltz_referral_id.clone(),
1265            description,
1266        };
1267
1268        let url = format!("{}/v2/swap/reverse", self.inner.boltz_url);
1269
1270        let client = reqwest::Client::new();
1271        let response = client
1272            .post(&url)
1273            .json(&request)
1274            .send()
1275            .await
1276            .map_err(|e| Error::ad_hoc(e.to_string()))
1277            .context("failed to send reverse swap request")?;
1278
1279        if !response.status().is_success() {
1280            let error_text = response
1281                .text()
1282                .await
1283                .map_err(|e| Error::ad_hoc(e.to_string()))
1284                .context("failed to read error text")?;
1285
1286            return Err(Error::ad_hoc(format!(
1287                "failed to create reverse swap: {error_text}"
1288            )));
1289        }
1290
1291        let response: CreateReverseSwapResponse = response
1292            .json()
1293            .await
1294            .map_err(|e| Error::ad_hoc(e.to_string()))
1295            .context("failed to deserialize reverse swap response")?;
1296
1297        let created_at = SystemTime::now()
1298            .duration_since(UNIX_EPOCH)
1299            .map_err(Error::ad_hoc)
1300            .context("failed to compute created_at")?;
1301
1302        let swap_amount = response.onchain_amount.or(onchain_amount).ok_or_else(|| {
1303            Error::ad_hoc("onchain_amount not provided by Boltz and not specified in request")
1304        })?;
1305
1306        let swap = ReverseSwapData {
1307            id: response.id.clone(),
1308            status: SwapStatus::Created,
1309            preimage,
1310            vhtlc_address: response.lockup_address,
1311            preimage_hash,
1312            refund_public_key: response.refund_public_key,
1313            amount: swap_amount,
1314            claim_public_key: claim_public_key.into(),
1315            timeout_block_heights: response.timeout_block_heights,
1316            created_at: created_at.as_secs(),
1317            key_derivation_index,
1318            bolt11: response.invoice.to_string(),
1319            invoice_expiry: response.invoice.expiry_time().as_secs(),
1320            claim_address: recipient_address,
1321        };
1322
1323        self.swap_storage()
1324            .insert_reverse(response.id.clone(), swap.clone())
1325            .await
1326            .context("failed to persist swap data")?;
1327
1328        Ok(ReverseSwapResult {
1329            swap_id: swap.id,
1330            invoice: response.invoice,
1331            amount: swap_amount,
1332        })
1333    }
1334
1335    /// Wait for the VHTLC associated with a reverse submarine swap to be funded.
1336    ///
1337    /// This method only waits for the funding transaction to be detected (in mempool or confirmed).
1338    /// It does not claim the VHTLC. Use [`Self::claim_vhtlc`] to claim after the preimage is known.
1339    ///
1340    /// # Arguments
1341    ///
1342    /// - `swap_id`: The unique identifier for the reverse swap.
1343    ///
1344    /// # Returns
1345    ///
1346    /// Returns `Ok(())` when the VHTLC funding transaction is detected.
1347    pub async fn wait_for_vhtlc_funding(&self, swap_id: &str) -> Result<(), Error> {
1348        use futures::StreamExt;
1349
1350        let stream = self.subscribe_to_swap_updates(swap_id.to_string());
1351        tokio::pin!(stream);
1352
1353        while let Some(status_result) = stream.next().await {
1354            match status_result {
1355                Ok(status) => {
1356                    tracing::debug!(swap_id, current = ?status, "Swap status");
1357
1358                    match status {
1359                        SwapStatus::TransactionMempool | SwapStatus::TransactionConfirmed => {
1360                            tracing::debug!(swap_id, "VHTLC funding detected");
1361                            return Ok(());
1362                        }
1363                        SwapStatus::InvoiceExpired => {
1364                            return Err(Error::ad_hoc(format!(
1365                                "invoice expired for swap {swap_id}"
1366                            )));
1367                        }
1368                        SwapStatus::Error { error } => {
1369                            tracing::error!(
1370                                swap_id,
1371                                "Got error from swap updates subscription: {error}"
1372                            );
1373                        }
1374                        // TODO: We may still need to handle some of these explicitly.
1375                        SwapStatus::Created
1376                        | SwapStatus::TransactionRefunded
1377                        | SwapStatus::TransactionFailed
1378                        | SwapStatus::TransactionClaimed
1379                        | SwapStatus::TransactionLockupFailed
1380                        | SwapStatus::TransactionServerMempool
1381                        | SwapStatus::TransactionServerConfirmed
1382                        | SwapStatus::InvoiceSet
1383                        | SwapStatus::InvoicePending
1384                        | SwapStatus::InvoicePaid
1385                        | SwapStatus::InvoiceFailedToPay
1386                        | SwapStatus::SwapExpired
1387                        | SwapStatus::Other(_) => {}
1388                    }
1389                }
1390                Err(e) => return Err(e),
1391            }
1392        }
1393
1394        Err(Error::ad_hoc("Status stream ended unexpectedly"))
1395    }
1396
1397    /// Claim a funded VHTLC for a reverse submarine swap using the preimage.
1398    ///
1399    /// This method should be called after the VHTLC has been funded (after
1400    /// [`Self::wait_for_vhtlc_funding`] returns) and the preimage is known.
1401    ///
1402    /// # Arguments
1403    ///
1404    /// - `swap_id`: The unique identifier for the reverse swap.
1405    /// - `preimage`: The 32-byte preimage that unlocks the VHTLC.
1406    ///
1407    /// # Returns
1408    ///
1409    /// Returns a [`ClaimVhtlcResult`] with details about the claim transaction.
1410    pub async fn claim_vhtlc(
1411        &self,
1412        swap_id: &str,
1413        preimage: [u8; 32],
1414    ) -> Result<ClaimVhtlcResult, Error> {
1415        let swap = self
1416            .swap_storage()
1417            .get_reverse(swap_id)
1418            .await
1419            .context("failed to get reverse swap data")?
1420            .ok_or_else(|| Error::ad_hoc(format!("reverse swap data not found: {swap_id}")))?;
1421
1422        // Verify the preimage matches the stored hash
1423        let preimage_hash_sha256 = sha256::Hash::hash(&preimage);
1424        let preimage_hash = ripemd160::Hash::hash(preimage_hash_sha256.as_byte_array());
1425
1426        if preimage_hash != swap.preimage_hash {
1427            return Err(Error::ad_hoc(format!(
1428                "preimage does not match stored hash for swap {swap_id}"
1429            )));
1430        }
1431
1432        tracing::debug!(swap_id, "Claiming VHTLC with verified preimage");
1433
1434        let timeout_block_heights = swap.timeout_block_heights;
1435        let server_info = self.server_info().await?;
1436
1437        let vhtlc = self.reconstruct_vhtlc_for_address(
1438            &server_info,
1439            |server| {
1440                Ok(VhtlcOptions {
1441                    sender: swap.refund_public_key.into(),
1442                    receiver: swap.claim_public_key.into(),
1443                    server,
1444                    preimage_hash: swap.preimage_hash,
1445                    refund_locktime: timeout_block_heights.refund,
1446                    unilateral_claim_delay: parse_sequence_number(
1447                        timeout_block_heights.unilateral_claim as i64,
1448                    )
1449                    .map_err(|e| Error::ad_hoc(format!("invalid unilateral claim timeout: {e}")))?,
1450                    unilateral_refund_delay: parse_sequence_number(
1451                        timeout_block_heights.unilateral_refund as i64,
1452                    )
1453                    .map_err(|e| {
1454                        Error::ad_hoc(format!("invalid unilateral refund timeout: {e}"))
1455                    })?,
1456                    unilateral_refund_without_receiver_delay: parse_sequence_number(
1457                        timeout_block_heights.unilateral_refund_without_receiver as i64,
1458                    )
1459                    .map_err(|e| {
1460                        Error::ad_hoc(format!("invalid refund without receiver timeout: {e}"))
1461                    })?,
1462                })
1463            },
1464            &swap.vhtlc_address,
1465        )?;
1466        let vhtlc_address = vhtlc.address();
1467
1468        // TODO: Ideally we can skip this if the vout is always the same (probably 0).
1469        let vhtlc_outpoint = {
1470            let virtual_tx_outpoints = self
1471                .get_virtual_tx_outpoints(std::iter::once(vhtlc_address))
1472                .await?;
1473
1474            let vtxo_list = VtxoList::new(server_info.dust, virtual_tx_outpoints);
1475
1476            // We expect a single outpoint.
1477            let mut unspent = vtxo_list.all_unspent();
1478            let vhtlc_outpoint = unspent.next().ok_or_else(|| {
1479                Error::ad_hoc(format!("no outpoint found for address {vhtlc_address}"))
1480            })?;
1481
1482            vhtlc_outpoint.clone()
1483        };
1484
1485        let claim_address = self.reverse_claim_address(&swap).await?;
1486        let claim_amount = swap.amount;
1487
1488        let outputs = vec![SendReceiver {
1489            address: claim_address,
1490            amount: claim_amount,
1491            assets: Vec::new(),
1492        }];
1493
1494        let spend_info = vhtlc.taproot_spend_info();
1495        let script_ver = (vhtlc.claim_script(), LeafVersion::TapScript);
1496        let control_block = spend_info
1497            .control_block(&script_ver)
1498            .ok_or(Error::ad_hoc("control block not found for claim script"))?;
1499
1500        let script_pubkey = vhtlc.script_pubkey();
1501
1502        let claimer_pk = swap.claim_public_key.inner.x_only_public_key().0;
1503        let vhtlc_input = VtxoInput::new(
1504            script_ver.0,
1505            None,
1506            control_block,
1507            vhtlc.tapscripts(),
1508            script_pubkey,
1509            claim_amount,
1510            vhtlc_outpoint.outpoint,
1511            vhtlc_outpoint.assets,
1512        );
1513
1514        // The change address is superfluous because we are _draining_ the VHTLC.
1515        let change_address = &claim_address;
1516
1517        let OffchainTransactions {
1518            mut ark_tx,
1519            checkpoint_txs,
1520        } = build_offchain_transactions(
1521            &outputs,
1522            change_address,
1523            std::slice::from_ref(&vhtlc_input),
1524            &server_info,
1525        )
1526        .map_err(Error::from)
1527        .context("failed to build offchain TXs")?;
1528
1529        let kp = self.keypair_by_pk(&claimer_pk)?;
1530        let sign_fn =
1531            |input: &mut psbt::Input,
1532             msg: secp256k1::Message|
1533             -> Result<Vec<(schnorr::Signature, XOnlyPublicKey)>, ark_core::Error> {
1534                // Add preimage to PSBT input.
1535                {
1536                    // Initialized with a 1, because we only have one witness element: the preimage.
1537                    let mut bytes = vec![1];
1538
1539                    let length = VarInt::from(preimage.len() as u64);
1540
1541                    length
1542                        .consensus_encode(&mut bytes)
1543                        .expect("valid length encoding");
1544
1545                    bytes.write_all(&preimage).expect("valid preimage encoding");
1546
1547                    input.unknown.insert(
1548                        psbt::raw::Key {
1549                            type_value: 222,
1550                            key: VTXO_CONDITION_KEY.to_vec(),
1551                        },
1552                        bytes,
1553                    );
1554                }
1555
1556                let sig = Secp256k1::new().sign_schnorr_no_aux_rand(&msg, &kp);
1557                let pk = kp.x_only_public_key().0;
1558
1559                Ok(vec![(sig, pk)])
1560            };
1561
1562        sign_ark_transaction(sign_fn, &mut ark_tx, 0)
1563            .map_err(Error::from)
1564            .context("failed to sign Ark TX")?;
1565
1566        let ark_txid = ark_tx.unsigned_tx.compute_txid();
1567
1568        let res = self
1569            .network_client()
1570            .submit_offchain_transaction_request(ark_tx, checkpoint_txs)
1571            .await
1572            .map_err(Error::from)
1573            .context("failed to submit offchain TXs")?;
1574
1575        let mut checkpoint_psbt = res
1576            .signed_checkpoint_txs
1577            .first()
1578            .ok_or_else(|| Error::ad_hoc("no checkpoint PSBTs found"))?
1579            .clone();
1580
1581        sign_checkpoint_transaction(sign_fn, &mut checkpoint_psbt)
1582            .map_err(Error::from)
1583            .context("failed to sign checkpoint TX")?;
1584
1585        timeout_op(
1586            self.inner.timeout,
1587            self.network_client()
1588                .finalize_offchain_transaction(ark_txid, vec![checkpoint_psbt]),
1589        )
1590        .await
1591        .context("failed to finalize offchain transaction")?
1592        .map_err(Error::ark_server)
1593        .context("failed to finalize offchain transaction")?;
1594
1595        tracing::info!(swap_id, txid = %ark_txid, "Claimed VHTLC");
1596
1597        // Update storage to persist the preimage
1598        let mut updated_swap = swap.clone();
1599        updated_swap.preimage = Some(preimage);
1600        self.swap_storage()
1601            .update_reverse(swap_id, updated_swap)
1602            .await
1603            .context("failed to update swap data with preimage")?;
1604
1605        Ok(ClaimVhtlcResult {
1606            swap_id: swap_id.to_string(),
1607            claim_txid: ark_txid,
1608            claim_amount,
1609            preimage,
1610        })
1611    }
1612
1613    /// Wait for the VHTLC associated with a reverse submarine swap to be funded, then claim it.
1614    ///
1615    /// # Note
1616    ///
1617    /// This method requires that the preimage was stored when creating the reverse swap (i.e., via
1618    /// [`Self::get_ln_invoice`]). If the swap was created with [`Self::get_ln_invoice_from_hash`],
1619    /// use [`Self::wait_for_vhtlc_funding`] followed by [`Self::claim_vhtlc`] instead.
1620    pub async fn wait_for_vhtlc(&self, swap_id: &str) -> Result<ClaimVhtlcResult, Error> {
1621        use futures::StreamExt;
1622
1623        let swap = self
1624            .swap_storage()
1625            .get_reverse(swap_id)
1626            .await
1627            .context("failed to get reverse swap data")?
1628            .ok_or_else(|| Error::ad_hoc(format!("reverse swap data not found: {swap_id}")))?;
1629
1630        // Ensure the preimage is available in storage
1631        let preimage = swap.preimage.ok_or_else(|| {
1632            Error::ad_hoc(format!(
1633                "preimage not found in storage for swap {swap_id}. \
1634                 Use wait_for_vhtlc_funding and claim_vhtlc instead."
1635            ))
1636        })?;
1637
1638        let stream = self.subscribe_to_swap_updates(swap_id.to_string());
1639        tokio::pin!(stream);
1640
1641        while let Some(status_result) = stream.next().await {
1642            match status_result {
1643                Ok(status) => {
1644                    tracing::debug!(current = ?status, "Swap status");
1645
1646                    match status {
1647                        SwapStatus::TransactionMempool | SwapStatus::TransactionConfirmed => break,
1648                        SwapStatus::InvoiceExpired => {
1649                            return Err(Error::ad_hoc(format!(
1650                                "invoice expired for swap {swap_id}"
1651                            )));
1652                        }
1653                        SwapStatus::Error { error } => {
1654                            tracing::error!(
1655                                swap_id,
1656                                "Got error from swap updates subscription: {error}"
1657                            );
1658                        }
1659                        // TODO: We may still need to handle some of these explicitly.
1660                        SwapStatus::Created
1661                        | SwapStatus::TransactionRefunded
1662                        | SwapStatus::TransactionFailed
1663                        | SwapStatus::TransactionClaimed
1664                        | SwapStatus::TransactionLockupFailed
1665                        | SwapStatus::TransactionServerMempool
1666                        | SwapStatus::TransactionServerConfirmed
1667                        | SwapStatus::InvoiceSet
1668                        | SwapStatus::InvoicePending
1669                        | SwapStatus::InvoicePaid
1670                        | SwapStatus::InvoiceFailedToPay
1671                        | SwapStatus::SwapExpired
1672                        | SwapStatus::Other(_) => {}
1673                    }
1674                }
1675                Err(e) => return Err(e),
1676            }
1677        }
1678
1679        tracing::debug!("Ark transaction for swap found");
1680
1681        let timeout_block_heights = swap.timeout_block_heights;
1682        let server_info = self.server_info().await?;
1683
1684        let vhtlc = self.reconstruct_vhtlc_for_address(
1685            &server_info,
1686            |server| {
1687                Ok(VhtlcOptions {
1688                    sender: swap.refund_public_key.into(),
1689                    receiver: swap.claim_public_key.into(),
1690                    server,
1691                    preimage_hash: swap.preimage_hash,
1692                    refund_locktime: timeout_block_heights.refund,
1693                    unilateral_claim_delay: parse_sequence_number(
1694                        timeout_block_heights.unilateral_claim as i64,
1695                    )
1696                    .map_err(|e| Error::ad_hoc(format!("invalid unilateral claim timeout: {e}")))?,
1697                    unilateral_refund_delay: parse_sequence_number(
1698                        timeout_block_heights.unilateral_refund as i64,
1699                    )
1700                    .map_err(|e| {
1701                        Error::ad_hoc(format!("invalid unilateral refund timeout: {e}"))
1702                    })?,
1703                    unilateral_refund_without_receiver_delay: parse_sequence_number(
1704                        timeout_block_heights.unilateral_refund_without_receiver as i64,
1705                    )
1706                    .map_err(|e| {
1707                        Error::ad_hoc(format!("invalid refund without receiver timeout: {e}"))
1708                    })?,
1709                })
1710            },
1711            &swap.vhtlc_address,
1712        )?;
1713        let vhtlc_address = vhtlc.address();
1714
1715        // TODO: Ideally we can skip this if the vout is always the same (probably 0).
1716        let vhtlc_outpoint = {
1717            let virtual_tx_outpoints = self
1718                .get_virtual_tx_outpoints(std::iter::once(vhtlc_address))
1719                .await?;
1720
1721            let vtxo_list = VtxoList::new(server_info.dust, virtual_tx_outpoints);
1722
1723            // We expect a single outpoint.
1724            let mut unspent = vtxo_list.all_unspent();
1725            let vhtlc_outpoint = unspent.next().ok_or_else(|| {
1726                Error::ad_hoc(format!("no outpoint found for address {vhtlc_address}"))
1727            })?;
1728
1729            vhtlc_outpoint.clone()
1730        };
1731
1732        let claim_address = self.reverse_claim_address(&swap).await?;
1733        let claim_amount = swap.amount;
1734
1735        let outputs = vec![SendReceiver {
1736            address: claim_address,
1737            amount: claim_amount,
1738            assets: Vec::new(),
1739        }];
1740
1741        let spend_info = vhtlc.taproot_spend_info();
1742        let script_ver = (vhtlc.claim_script(), LeafVersion::TapScript);
1743        let control_block = spend_info
1744            .control_block(&script_ver)
1745            .ok_or(Error::ad_hoc("control block not found for claim script"))?;
1746
1747        let script_pubkey = vhtlc.script_pubkey();
1748
1749        let claimer_pk = swap.claim_public_key.inner.x_only_public_key().0;
1750        let vhtlc_input = VtxoInput::new(
1751            script_ver.0,
1752            None,
1753            control_block,
1754            vhtlc.tapscripts(),
1755            script_pubkey,
1756            claim_amount,
1757            vhtlc_outpoint.outpoint,
1758            vhtlc_outpoint.assets,
1759        );
1760
1761        // The change address is superfluous because we are _draining_ the VHTLC.
1762        let change_address = &claim_address;
1763
1764        let OffchainTransactions {
1765            mut ark_tx,
1766            checkpoint_txs,
1767        } = build_offchain_transactions(
1768            &outputs,
1769            change_address,
1770            std::slice::from_ref(&vhtlc_input),
1771            &server_info,
1772        )
1773        .map_err(Error::from)
1774        .context("failed to build offchain TXs")?;
1775
1776        let kp = self.keypair_by_pk(&claimer_pk)?;
1777        let sign_fn =
1778            |input: &mut psbt::Input,
1779             msg: secp256k1::Message|
1780             -> Result<Vec<(schnorr::Signature, XOnlyPublicKey)>, ark_core::Error> {
1781                // Add preimage to PSBT input.
1782                {
1783                    // Initialized with a 1, because we only have one witness element: the preimage.
1784                    let mut bytes = vec![1];
1785
1786                    let length = VarInt::from(preimage.len() as u64);
1787
1788                    length
1789                        .consensus_encode(&mut bytes)
1790                        .expect("valid length encoding");
1791
1792                    bytes.write_all(&preimage).expect("valid preimage encoding");
1793
1794                    input.unknown.insert(
1795                        psbt::raw::Key {
1796                            type_value: 222,
1797                            key: VTXO_CONDITION_KEY.to_vec(),
1798                        },
1799                        bytes,
1800                    );
1801                }
1802
1803                let sig = Secp256k1::new().sign_schnorr_no_aux_rand(&msg, &kp);
1804                let pk = kp.x_only_public_key().0;
1805
1806                Ok(vec![(sig, pk)])
1807            };
1808
1809        sign_ark_transaction(sign_fn, &mut ark_tx, 0)
1810            .map_err(Error::from)
1811            .context("failed to sign Ark TX")?;
1812
1813        let ark_txid = ark_tx.unsigned_tx.compute_txid();
1814
1815        let res = self
1816            .network_client()
1817            .submit_offchain_transaction_request(ark_tx, checkpoint_txs)
1818            .await
1819            .map_err(Error::from)
1820            .context("failed to submit offchain TXs")?;
1821
1822        let mut checkpoint_psbt = res
1823            .signed_checkpoint_txs
1824            .first()
1825            .ok_or_else(|| Error::ad_hoc("no checkpoint PSBTs found"))?
1826            .clone();
1827
1828        sign_checkpoint_transaction(sign_fn, &mut checkpoint_psbt)
1829            .map_err(Error::from)
1830            .context("failed to sign checkpoint TX")?;
1831
1832        timeout_op(
1833            self.inner.timeout,
1834            self.network_client()
1835                .finalize_offchain_transaction(ark_txid, vec![checkpoint_psbt]),
1836        )
1837        .await
1838        .context("failed to finalize offchain transaction")?
1839        .map_err(Error::ark_server)
1840        .context("failed to finalize offchain transaction")?;
1841
1842        tracing::info!(txid = %ark_txid, "Spent VHTLC");
1843
1844        Ok(ClaimVhtlcResult {
1845            swap_id: swap_id.to_string(),
1846            claim_txid: ark_txid,
1847            claim_amount,
1848            preimage,
1849        })
1850    }
1851
1852    // Chain swap.
1853
1854    /// Create a chain swap via Boltz for swapping between ARK and on-chain BTC.
1855    ///
1856    /// Returns a [`ChainSwapResult`] containing the swap ID and the address the user must
1857    /// fund to initiate the swap. For [`ChainSwapDirection::ArkToBtc`], the user should send
1858    /// Ark VTXOs to the `user_lockup_address` using [`Client::send_vtxo`]. For
1859    /// [`ChainSwapDirection::BtcToArk`], the user should send BTC to the `user_lockup_address`.
1860    ///
1861    /// After funding, use [`Self::wait_for_chain_swap_server_lockup`] to wait for Boltz to
1862    /// lock their side, then [`Self::claim_chain_swap`] to claim.
1863    pub async fn create_chain_swap(
1864        &self,
1865        direction: ChainSwapDirection,
1866        amount: ChainSwapAmount,
1867    ) -> Result<ChainSwapResult, Error> {
1868        let preimage: [u8; 32] = rand::random();
1869        let preimage_hash = sha256::Hash::hash(&preimage);
1870
1871        let claim_keypair = self.next_keypair(crate::key_provider::KeypairIndex::New)?;
1872        let claim_public_key = claim_keypair.public_key();
1873        let claim_key_derivation_index =
1874            self.derivation_index_for_pk(&claim_keypair.x_only_public_key().0);
1875
1876        let refund_keypair = self.next_keypair(crate::key_provider::KeypairIndex::New)?;
1877        let refund_public_key = refund_keypair.public_key();
1878        let refund_key_derivation_index =
1879            self.derivation_index_for_pk(&refund_keypair.x_only_public_key().0);
1880
1881        let (from, to) = match &direction {
1882            ChainSwapDirection::ArkToBtc => (Asset::Ark, Asset::Btc),
1883            ChainSwapDirection::BtcToArk => (Asset::Btc, Asset::Ark),
1884        };
1885
1886        let (user_lock_amount, server_lock_amount) = match &amount {
1887            ChainSwapAmount::UserLock(a) => (Some(*a), None),
1888            ChainSwapAmount::ServerLock(a) => (None, Some(*a)),
1889        };
1890
1891        let request = CreateChainSwapRequest {
1892            from,
1893            to,
1894            user_lock_amount,
1895            server_lock_amount,
1896            claim_public_key: claim_public_key.into(),
1897            refund_public_key: refund_public_key.into(),
1898            preimage_hash,
1899            referral_id: self.inner.boltz_referral_id.clone(),
1900        };
1901
1902        let url = format!("{}/v2/swap/chain", self.inner.boltz_url);
1903
1904        let client = reqwest::Client::new();
1905        let response = client
1906            .post(&url)
1907            .json(&request)
1908            .send()
1909            .await
1910            .map_err(|e| Error::ad_hoc(e.to_string()))
1911            .context("failed to send chain swap request")?;
1912
1913        if !response.status().is_success() {
1914            let error_text = response
1915                .text()
1916                .await
1917                .map_err(|e| Error::ad_hoc(e.to_string()))
1918                .context("failed to read error text")?;
1919
1920            return Err(Error::ad_hoc(format!(
1921                "failed to create chain swap: {error_text}"
1922            )));
1923        }
1924
1925        let swap_response: CreateChainSwapResponse = response
1926            .json()
1927            .await
1928            .map_err(|e| Error::ad_hoc(e.to_string()))
1929            .context("failed to deserialize chain swap response")?;
1930
1931        let created_at = SystemTime::now()
1932            .duration_since(UNIX_EPOCH)
1933            .map_err(Error::ad_hoc)
1934            .context("failed to compute created_at")?;
1935
1936        // lockup_details = user's side (where user locks funds)
1937        // claim_details  = server's side (where user claims funds)
1938        // The ARK side carries `timeouts` (full VHTLC timelocks).
1939        // The BTC side carries `swap_tree` and optionally `bip21`.
1940        let bip21 = swap_response
1941            .lockup_details
1942            .bip21
1943            .or(swap_response.claim_details.bip21.clone());
1944
1945        let swap_tree = swap_response
1946            .lockup_details
1947            .swap_tree
1948            .or(swap_response.claim_details.swap_tree.clone());
1949
1950        let data = ChainSwapData {
1951            id: swap_response.id.clone(),
1952            status: SwapStatus::Created,
1953            direction,
1954            preimage: Some(preimage),
1955            preimage_hash,
1956            claim_public_key: claim_public_key.into(),
1957            refund_public_key: refund_public_key.into(),
1958            server_claim_public_key: swap_response.lockup_details.server_public_key,
1959            server_refund_public_key: swap_response.claim_details.server_public_key,
1960            user_lockup_address: swap_response.lockup_details.lockup_address,
1961            server_lockup_address: swap_response.claim_details.lockup_address,
1962            user_lockup_amount: swap_response.lockup_details.amount,
1963            server_lockup_amount: swap_response.claim_details.amount,
1964            user_timeout_block_height: swap_response.lockup_details.timeout_block_height,
1965            server_timeout_block_height: swap_response.claim_details.timeout_block_height,
1966            user_timeout_block_heights: swap_response.lockup_details.timeouts,
1967            server_timeout_block_heights: swap_response.claim_details.timeouts,
1968            bip21,
1969            swap_tree,
1970            created_at: created_at.as_secs(),
1971            claim_key_derivation_index,
1972            refund_key_derivation_index,
1973        };
1974
1975        self.swap_storage()
1976            .insert_chain(swap_response.id.clone(), data.clone())
1977            .await?;
1978
1979        tracing::info!(
1980            swap_id = swap_response.id,
1981            direction = ?data.direction,
1982            user_lockup_address = %data.user_lockup_address,
1983            user_lockup_amount = %data.user_lockup_amount,
1984            server_lockup_amount = %data.server_lockup_amount,
1985            "Created chain swap"
1986        );
1987
1988        Ok(ChainSwapResult {
1989            swap_id: swap_response.id,
1990            user_lockup_address: data.user_lockup_address,
1991            user_lockup_amount: data.user_lockup_amount,
1992            server_lockup_amount: data.server_lockup_amount,
1993            bip21: data.bip21,
1994        })
1995    }
1996
1997    /// Wait for Boltz to lock funds on their side of the chain swap.
1998    ///
1999    /// Returns when the server's lockup transaction is detected in the mempool or confirmed.
2000    /// After this returns, use [`Self::claim_chain_swap`] to claim the funds.
2001    ///
2002    /// Returns the server's lockup transaction ID if available.
2003    pub async fn wait_for_chain_swap_server_lockup(
2004        &self,
2005        swap_id: &str,
2006    ) -> Result<Option<String>, Error> {
2007        use futures::StreamExt;
2008
2009        let stream = self.subscribe_to_swap_updates(swap_id.to_string());
2010        tokio::pin!(stream);
2011
2012        while let Some(status_result) = stream.next().await {
2013            match status_result {
2014                Ok(status) => {
2015                    tracing::debug!(swap_id, current = ?status, "Chain swap status");
2016                    match status {
2017                        SwapStatus::TransactionServerMempool
2018                        | SwapStatus::TransactionServerConfirmed => {
2019                            // Fetch the full status to get the server's lockup txid.
2020                            let url = format!("{}/v2/swap/{swap_id}", self.inner.boltz_url);
2021                            let txid = async {
2022                                reqwest::Client::new()
2023                                    .get(&url)
2024                                    .send()
2025                                    .await
2026                                    .ok()?
2027                                    .json::<GetSwapStatusResponse>()
2028                                    .await
2029                                    .ok()?
2030                                    .transaction
2031                                    .map(|t| t.id)
2032                            }
2033                            .await;
2034
2035                            tracing::info!(
2036                                swap_id,
2037                                server_lockup_txid = txid.as_deref().unwrap_or("unknown"),
2038                                "Server lockup detected"
2039                            );
2040                            return Ok(txid);
2041                        }
2042                        SwapStatus::SwapExpired => {
2043                            return Err(Error::ad_hoc(format!("chain swap expired: {swap_id}")));
2044                        }
2045                        SwapStatus::TransactionRefunded | SwapStatus::TransactionFailed => {
2046                            return Err(Error::ad_hoc(format!(
2047                                "chain swap failed or refunded: {swap_id}"
2048                            )));
2049                        }
2050                        SwapStatus::Error { error } => {
2051                            tracing::error!(swap_id, "Got error from chain swap updates: {error}");
2052                        }
2053                        // User lockup detected — still waiting for server side.
2054                        SwapStatus::Created
2055                        | SwapStatus::TransactionMempool
2056                        | SwapStatus::TransactionConfirmed
2057                        | SwapStatus::TransactionClaimed
2058                        | SwapStatus::TransactionLockupFailed
2059                        | SwapStatus::InvoiceSet
2060                        | SwapStatus::InvoicePending
2061                        | SwapStatus::InvoicePaid
2062                        | SwapStatus::InvoiceFailedToPay
2063                        | SwapStatus::InvoiceExpired
2064                        | SwapStatus::Other(_) => {}
2065                    }
2066                }
2067                Err(e) => return Err(e),
2068            }
2069        }
2070
2071        Err(Error::ad_hoc("Chain swap status stream ended unexpectedly"))
2072    }
2073
2074    /// Claim the Ark VHTLC from a chain swap after Boltz has locked funds.
2075    ///
2076    /// This claims the server's Ark VHTLC lockup using the stored preimage. It is intended
2077    /// for [`ChainSwapDirection::BtcToArk`] swaps where the server locks an Ark VHTLC.
2078    ///
2079    /// Call this after [`Self::wait_for_chain_swap_server_lockup`] returns.
2080    pub async fn claim_chain_swap(&self, swap_id: &str) -> Result<Txid, Error> {
2081        let swap = self
2082            .swap_storage()
2083            .get_chain(swap_id)
2084            .await
2085            .context("failed to get chain swap data")?
2086            .ok_or_else(|| Error::ad_hoc(format!("chain swap data not found: {swap_id}")))?;
2087
2088        let preimage = swap
2089            .preimage
2090            .ok_or_else(|| Error::ad_hoc(format!("preimage not found for chain swap {swap_id}")))?;
2091
2092        let preimage_hash = ripemd160::Hash::hash(swap.preimage_hash.as_byte_array());
2093
2094        let timeout_block_heights = swap.server_timeout_block_heights.ok_or_else(|| {
2095            Error::ad_hoc(format!(
2096                "chain swap {swap_id} has no ARK-side VHTLC timeouts on server lockup \
2097                 (this swap's server lockup is on-chain BTC, not an Ark VHTLC)"
2098            ))
2099        })?;
2100        let server_info = self.server_info().await?;
2101
2102        let expected_address = ArkAddress::decode(&swap.server_lockup_address)
2103            .map_err(|e| Error::ad_hoc(format!("invalid server lockup address: {e}")))?;
2104
2105        let vhtlc = self.reconstruct_vhtlc_for_address(
2106            &server_info,
2107            |server| {
2108                Ok(VhtlcOptions {
2109                    sender: swap.server_refund_public_key.into(),
2110                    receiver: swap.claim_public_key.into(),
2111                    server,
2112                    preimage_hash,
2113                    refund_locktime: timeout_block_heights.refund,
2114                    unilateral_claim_delay: parse_sequence_number(
2115                        timeout_block_heights.unilateral_claim as i64,
2116                    )
2117                    .map_err(|e| Error::ad_hoc(format!("invalid unilateral claim timeout: {e}")))?,
2118                    unilateral_refund_delay: parse_sequence_number(
2119                        timeout_block_heights.unilateral_refund as i64,
2120                    )
2121                    .map_err(|e| {
2122                        Error::ad_hoc(format!("invalid unilateral refund timeout: {e}"))
2123                    })?,
2124                    unilateral_refund_without_receiver_delay: parse_sequence_number(
2125                        timeout_block_heights.unilateral_refund_without_receiver as i64,
2126                    )
2127                    .map_err(|e| {
2128                        Error::ad_hoc(format!("invalid refund without receiver timeout: {e}"))
2129                    })?,
2130                })
2131            },
2132            &expected_address,
2133        )?;
2134        let vhtlc_address = vhtlc.address();
2135
2136        let vhtlc_outpoint = {
2137            let virtual_tx_outpoints = self
2138                .get_virtual_tx_outpoints(std::iter::once(vhtlc_address))
2139                .await?;
2140
2141            let vtxo_list = VtxoList::new(server_info.dust, virtual_tx_outpoints);
2142
2143            let mut unspent = vtxo_list.all_unspent();
2144            let vhtlc_outpoint = unspent.next().ok_or_else(|| {
2145                Error::ad_hoc(format!("no outpoint found for address {vhtlc_address}"))
2146            })?;
2147
2148            vhtlc_outpoint.clone()
2149        };
2150
2151        let (claim_address, _) = self
2152            .get_offchain_address()
2153            .await
2154            .context("failed to get offchain address")?;
2155        let claim_amount = swap.server_lockup_amount;
2156
2157        let outputs = vec![SendReceiver::bitcoin(claim_address, claim_amount)];
2158
2159        let spend_info = vhtlc.taproot_spend_info();
2160        let script_ver = (vhtlc.claim_script(), LeafVersion::TapScript);
2161        let control_block = spend_info
2162            .control_block(&script_ver)
2163            .ok_or(Error::ad_hoc("control block not found for claim script"))?;
2164
2165        let script_pubkey = vhtlc.script_pubkey();
2166
2167        let claimer_pk = swap.claim_public_key.inner.x_only_public_key().0;
2168        let vhtlc_input = VtxoInput::new(
2169            script_ver.0,
2170            None,
2171            control_block,
2172            vhtlc.tapscripts(),
2173            script_pubkey,
2174            claim_amount,
2175            vhtlc_outpoint.outpoint,
2176            vhtlc_outpoint.assets,
2177        );
2178
2179        // The change address is superfluous because we are _draining_ the VHTLC.
2180        let change_address = &claim_address;
2181
2182        let OffchainTransactions {
2183            mut ark_tx,
2184            checkpoint_txs,
2185        } = build_offchain_transactions(
2186            &outputs,
2187            change_address,
2188            std::slice::from_ref(&vhtlc_input),
2189            &server_info,
2190        )
2191        .map_err(Error::from)
2192        .context("failed to build offchain TXs")?;
2193
2194        let kp = self.keypair_by_pk(&claimer_pk)?;
2195        let sign_fn =
2196            |input: &mut psbt::Input,
2197             msg: secp256k1::Message|
2198             -> Result<Vec<(schnorr::Signature, XOnlyPublicKey)>, ark_core::Error> {
2199                // Add preimage to PSBT input.
2200                {
2201                    let mut bytes = vec![1];
2202
2203                    let length = VarInt::from(preimage.len() as u64);
2204
2205                    length
2206                        .consensus_encode(&mut bytes)
2207                        .expect("valid length encoding");
2208
2209                    bytes.write_all(&preimage).expect("valid preimage encoding");
2210
2211                    input.unknown.insert(
2212                        psbt::raw::Key {
2213                            type_value: 222,
2214                            key: VTXO_CONDITION_KEY.to_vec(),
2215                        },
2216                        bytes,
2217                    );
2218                }
2219
2220                let sig = Secp256k1::new().sign_schnorr_no_aux_rand(&msg, &kp);
2221                let pk = kp.x_only_public_key().0;
2222
2223                Ok(vec![(sig, pk)])
2224            };
2225
2226        sign_ark_transaction(sign_fn, &mut ark_tx, 0)
2227            .map_err(Error::from)
2228            .context("failed to sign Ark TX")?;
2229
2230        let ark_txid = ark_tx.unsigned_tx.compute_txid();
2231
2232        let res = self
2233            .network_client()
2234            .submit_offchain_transaction_request(ark_tx, checkpoint_txs)
2235            .await
2236            .map_err(Error::from)
2237            .context("failed to submit offchain TXs")?;
2238
2239        let mut checkpoint_psbt = res
2240            .signed_checkpoint_txs
2241            .first()
2242            .ok_or_else(|| Error::ad_hoc("no checkpoint PSBTs found"))?
2243            .clone();
2244
2245        sign_checkpoint_transaction(sign_fn, &mut checkpoint_psbt)
2246            .map_err(Error::from)
2247            .context("failed to sign checkpoint TX")?;
2248
2249        timeout_op(
2250            self.inner.timeout,
2251            self.network_client()
2252                .finalize_offchain_transaction(ark_txid, vec![checkpoint_psbt]),
2253        )
2254        .await
2255        .context("failed to finalize offchain transaction")?
2256        .map_err(Error::ark_server)
2257        .context("failed to finalize offchain transaction")?;
2258
2259        tracing::info!(swap_id, txid = %ark_txid, "Claimed chain swap VHTLC");
2260
2261        let mut updated_swap = swap.clone();
2262        updated_swap.status = SwapStatus::TransactionClaimed;
2263        self.swap_storage()
2264            .update_chain(swap_id, updated_swap)
2265            .await
2266            .context("failed to update chain swap data")?;
2267
2268        Ok(ark_txid)
2269    }
2270
2271    /// Claim on-chain BTC from a chain swap after Boltz has locked funds.
2272    ///
2273    /// This claims the server's on-chain BTC HTLC using the stored preimage. It is intended
2274    /// for [`ChainSwapDirection::ArkToBtc`] swaps where the server locks on-chain BTC.
2275    ///
2276    /// Call this after [`Self::wait_for_chain_swap_server_lockup`] returns.
2277    pub async fn claim_chain_swap_btc(
2278        &self,
2279        swap_id: &str,
2280        destination_address: bitcoin::Address,
2281        fee_rate_sat_vb: f64,
2282    ) -> Result<Txid, Error> {
2283        let swap = self
2284            .swap_storage()
2285            .get_chain(swap_id)
2286            .await
2287            .context("failed to get chain swap data")?
2288            .ok_or_else(|| Error::ad_hoc(format!("chain swap data not found: {swap_id}")))?;
2289
2290        let preimage = swap
2291            .preimage
2292            .ok_or_else(|| Error::ad_hoc(format!("preimage not found for chain swap {swap_id}")))?;
2293
2294        let swap_tree = swap.swap_tree.clone().ok_or_else(|| {
2295            Error::ad_hoc("no swap tree found (this swap has no on-chain BTC HTLC)")
2296        })?;
2297
2298        // The BTC lockup is server-side for ArkToBtc
2299        let btc_address_str = &swap.server_lockup_address;
2300
2301        // Reconstruct the taproot tree. For ArkToBtc, the server's key on the BTC
2302        // side is server_refund_public_key and the user's key is claim_public_key.
2303        let taproot_spend_info = reconstruct_btc_htlc(
2304            swap.server_refund_public_key,
2305            swap.claim_public_key,
2306            &swap_tree,
2307        )?;
2308
2309        let secp = Secp256k1::new();
2310
2311        // Verify the reconstructed address matches the lockup address.
2312        let expected_spk = ScriptBuf::new_p2tr(
2313            &secp,
2314            taproot_spend_info.internal_key(),
2315            taproot_spend_info.merkle_root(),
2316        );
2317
2318        let parsed_address: bitcoin::Address<bitcoin::address::NetworkUnchecked> = btc_address_str
2319            .parse()
2320            .map_err(|e| Error::ad_hoc(format!("invalid BTC lockup address: {e}")))?;
2321        let parsed_address = parsed_address.assume_checked();
2322        let target_spk = parsed_address.script_pubkey();
2323
2324        if expected_spk != target_spk {
2325            return Err(Error::ad_hoc(format!(
2326                "taproot address mismatch for BTC lockup {btc_address_str}"
2327            )));
2328        }
2329
2330        let claim_script_bytes: Vec<u8> =
2331            bitcoin::hex::FromHex::from_hex(&swap_tree.claim_leaf.output)
2332                .map_err(|e| Error::ad_hoc(format!("invalid claim leaf hex: {e}")))?;
2333        let claim_script = ScriptBuf::from_bytes(claim_script_bytes);
2334        let claim_ver = (claim_script.clone(), LeafVersion::TapScript);
2335
2336        // Find the unspent UTXO at the BTC lockup address
2337        let utxos = self
2338            .inner
2339            .blockchain
2340            .find_outpoints(&parsed_address)
2341            .await
2342            .context("failed to find UTXOs at BTC lockup address")?;
2343
2344        let utxo = utxos.iter().find(|u| !u.is_spent).ok_or_else(|| {
2345            Error::ad_hoc(format!(
2346                "no unspent UTXO found at BTC lockup address {btc_address_str}"
2347            ))
2348        })?;
2349
2350        // Get the control block for the claim leaf
2351        let control_block = taproot_spend_info
2352            .control_block(&claim_ver)
2353            .ok_or(Error::ad_hoc("control block not found for claim leaf"))?;
2354
2355        let cb_bytes = control_block.serialize();
2356        // Weight: 4 * (overhead 10.5 + input ~41 + output ~43) + witness items
2357        let witness_weight = 1 + 1 + 64 + 1 + 32 + 1 + claim_script.len() + 1 + cb_bytes.len() + 1;
2358        let weight = 4 * (11 + 41 + 43) + witness_weight;
2359        let vsize = weight.div_ceil(4);
2360        let fee = Amount::from_sat((vsize as f64 * fee_rate_sat_vb).ceil() as u64);
2361
2362        let claim_amount = utxo.amount.checked_sub(fee).ok_or_else(|| {
2363            Error::ad_hoc(format!(
2364                "UTXO amount {} is less than estimated fee {}",
2365                utxo.amount, fee
2366            ))
2367        })?;
2368
2369        // Build the unsigned transaction
2370        let mut tx = bitcoin::Transaction {
2371            version: bitcoin::transaction::Version::TWO,
2372            lock_time: absolute::LockTime::ZERO,
2373            input: vec![bitcoin::TxIn {
2374                previous_output: utxo.outpoint,
2375                script_sig: ScriptBuf::new(),
2376                sequence: bitcoin::Sequence::ENABLE_RBF_NO_LOCKTIME,
2377                witness: bitcoin::Witness::new(),
2378            }],
2379            output: vec![TxOut {
2380                value: claim_amount,
2381                script_pubkey: destination_address.script_pubkey(),
2382            }],
2383        };
2384
2385        // Compute the taproot script-path sighash
2386        let leaf_hash =
2387            bitcoin::taproot::TapLeafHash::from_script(&claim_script, LeafVersion::TapScript);
2388
2389        let prevouts = [TxOut {
2390            value: utxo.amount,
2391            script_pubkey: target_spk.clone(),
2392        }];
2393
2394        let sighash = bitcoin::sighash::SighashCache::new(&tx)
2395            .taproot_script_spend_signature_hash(
2396                0,
2397                &bitcoin::sighash::Prevouts::All(&prevouts),
2398                leaf_hash,
2399                bitcoin::TapSighashType::Default,
2400            )
2401            .map_err(|e| Error::ad_hoc(format!("failed to compute sighash: {e}")))?;
2402
2403        let msg = secp256k1::Message::from_digest(sighash.to_byte_array());
2404        let claim_kp = self.keypair_by_pk(&swap.claim_public_key.inner.x_only_public_key().0)?;
2405        let signature = secp.sign_schnorr_no_aux_rand(&msg, &claim_kp);
2406
2407        // Build witness: <signature> <preimage> <claim_script> <control_block>
2408        let mut witness = bitcoin::Witness::new();
2409        witness.push(signature.serialize());
2410        witness.push(preimage);
2411        witness.push(claim_script.as_bytes());
2412        witness.push(cb_bytes);
2413
2414        tx.input[0].witness = witness;
2415
2416        // Broadcast
2417        self.inner
2418            .blockchain
2419            .broadcast(&tx)
2420            .await
2421            .context("failed to broadcast BTC claim transaction")?;
2422
2423        let txid = tx.compute_txid();
2424
2425        tracing::info!(swap_id, %txid, %claim_amount, "Claimed on-chain BTC from chain swap");
2426
2427        let mut updated_swap = swap.clone();
2428        updated_swap.status = SwapStatus::TransactionClaimed;
2429        self.swap_storage()
2430            .update_chain(swap_id, updated_swap)
2431            .await
2432            .context("failed to update chain swap data")?;
2433
2434        Ok(txid)
2435    }
2436
2437    /// Refund the Ark VHTLC from a chain swap after the timelock has expired.
2438    ///
2439    /// This is for [`ChainSwapDirection::ArkToBtc`] swaps where the user locked an Ark VHTLC
2440    /// and needs to reclaim it (e.g. if Boltz never locked BTC or the swap expired).
2441    ///
2442    /// This path does not require a signature from Boltz.
2443    pub async fn refund_chain_swap(&self, swap_id: &str) -> Result<Txid, Error> {
2444        let swap = self
2445            .swap_storage()
2446            .get_chain(swap_id)
2447            .await
2448            .context("failed to get chain swap data")?
2449            .ok_or_else(|| Error::ad_hoc(format!("chain swap data not found: {swap_id}")))?;
2450
2451        let timeout_block_heights = swap.user_timeout_block_heights.ok_or_else(|| {
2452            Error::ad_hoc(
2453                "chain swap has no ARK-side VHTLC timeouts on user lockup \
2454                 (user lockup is on-chain BTC, use refund_chain_swap_btc instead)",
2455            )
2456        })?;
2457
2458        let preimage_hash = ripemd160::Hash::hash(swap.preimage_hash.as_byte_array());
2459        let server_info = self.server_info().await?;
2460
2461        // User's lockup VHTLC: sender=user(refund), receiver=server(claim)
2462        let expected_address = ArkAddress::decode(&swap.user_lockup_address)
2463            .map_err(|e| Error::ad_hoc(format!("invalid user lockup address: {e}")))?;
2464
2465        let vhtlc = self.reconstruct_vhtlc_for_address(
2466            &server_info,
2467            |server| {
2468                Ok(VhtlcOptions {
2469                    sender: swap.refund_public_key.into(),
2470                    receiver: swap.server_claim_public_key.into(),
2471                    server,
2472                    preimage_hash,
2473                    refund_locktime: timeout_block_heights.refund,
2474                    unilateral_claim_delay: parse_sequence_number(
2475                        timeout_block_heights.unilateral_claim as i64,
2476                    )
2477                    .map_err(|e| Error::ad_hoc(format!("invalid unilateral claim timeout: {e}")))?,
2478                    unilateral_refund_delay: parse_sequence_number(
2479                        timeout_block_heights.unilateral_refund as i64,
2480                    )
2481                    .map_err(|e| {
2482                        Error::ad_hoc(format!("invalid unilateral refund timeout: {e}"))
2483                    })?,
2484                    unilateral_refund_without_receiver_delay: parse_sequence_number(
2485                        timeout_block_heights.unilateral_refund_without_receiver as i64,
2486                    )
2487                    .map_err(|e| {
2488                        Error::ad_hoc(format!("invalid refund without receiver timeout: {e}"))
2489                    })?,
2490                })
2491            },
2492            &expected_address,
2493        )?;
2494        let vhtlc_address = vhtlc.address();
2495
2496        let vhtlc_outpoint = {
2497            let virtual_tx_outpoints = self
2498                .get_virtual_tx_outpoints(std::iter::once(vhtlc_address))
2499                .await?;
2500
2501            let vtxo_list = VtxoList::new(server_info.dust, virtual_tx_outpoints);
2502
2503            let mut unspent = vtxo_list.all_unspent();
2504            unspent
2505                .next()
2506                .ok_or_else(|| {
2507                    Error::ad_hoc(format!("no outpoint found for address {vhtlc_address}"))
2508                })?
2509                .clone()
2510        };
2511
2512        let (refund_address, _) = self.get_offchain_address().await?;
2513        let refund_amount = swap.user_lockup_amount;
2514
2515        let outputs = vec![SendReceiver::bitcoin(refund_address, refund_amount)];
2516
2517        let refund_script = vhtlc.refund_without_receiver_script();
2518        let spend_info = vhtlc.taproot_spend_info();
2519        let script_ver = (refund_script, LeafVersion::TapScript);
2520        let control_block = spend_info
2521            .control_block(&script_ver)
2522            .ok_or(Error::ad_hoc("control block not found for refund script"))?;
2523
2524        let script_pubkey = vhtlc.script_pubkey();
2525        let refunder_pk = swap.refund_public_key.inner.x_only_public_key().0;
2526
2527        // The change address is superfluous because we are _draining_ the VHTLC.
2528        let change_address = &refund_address;
2529
2530        let vhtlc_input = VtxoInput::new(
2531            script_ver.0,
2532            Some(absolute::LockTime::from_consensus(
2533                timeout_block_heights.refund,
2534            )),
2535            control_block,
2536            vhtlc.tapscripts(),
2537            script_pubkey,
2538            refund_amount,
2539            vhtlc_outpoint.outpoint,
2540            vhtlc_outpoint.assets,
2541        );
2542
2543        let OffchainTransactions {
2544            mut ark_tx,
2545            checkpoint_txs,
2546        } = build_offchain_transactions(
2547            &outputs,
2548            change_address,
2549            std::slice::from_ref(&vhtlc_input),
2550            &server_info,
2551        )?;
2552
2553        let kp = self.keypair_by_pk(&refunder_pk)?;
2554        let sign_fn =
2555            |_: &mut psbt::Input,
2556             msg: secp256k1::Message|
2557             -> Result<Vec<(schnorr::Signature, XOnlyPublicKey)>, ark_core::Error> {
2558                let sig = Secp256k1::new().sign_schnorr_no_aux_rand(&msg, &kp);
2559                let pk = kp.x_only_public_key().0;
2560                Ok(vec![(sig, pk)])
2561            };
2562
2563        sign_ark_transaction(sign_fn, &mut ark_tx, 0)?;
2564
2565        let ark_txid = ark_tx.unsigned_tx.compute_txid();
2566
2567        let res = self
2568            .network_client()
2569            .submit_offchain_transaction_request(ark_tx, checkpoint_txs)
2570            .await?;
2571
2572        let mut checkpoint_psbt = res
2573            .signed_checkpoint_txs
2574            .first()
2575            .ok_or_else(|| Error::ad_hoc("no checkpoint PSBTs found"))?
2576            .clone();
2577
2578        let kp = self.keypair_by_pk(&refunder_pk)?;
2579        let sign_fn =
2580            |_: &mut psbt::Input,
2581             msg: secp256k1::Message|
2582             -> Result<Vec<(schnorr::Signature, XOnlyPublicKey)>, ark_core::Error> {
2583                let sig = Secp256k1::new().sign_schnorr_no_aux_rand(&msg, &kp);
2584                let pk = kp.x_only_public_key().0;
2585                Ok(vec![(sig, pk)])
2586            };
2587
2588        sign_checkpoint_transaction(sign_fn, &mut checkpoint_psbt)?;
2589
2590        timeout_op(
2591            self.inner.timeout,
2592            self.network_client()
2593                .finalize_offchain_transaction(ark_txid, vec![checkpoint_psbt]),
2594        )
2595        .await?
2596        .map_err(Error::ark_server)
2597        .context("failed to finalize offchain transaction")?;
2598
2599        tracing::info!(swap_id, txid = %ark_txid, "Refunded chain swap Ark VHTLC");
2600
2601        let mut updated_swap = swap.clone();
2602        updated_swap.status = SwapStatus::TransactionRefunded;
2603        self.swap_storage()
2604            .update_chain(swap_id, updated_swap)
2605            .await
2606            .context("failed to update chain swap data")?;
2607
2608        Ok(ark_txid)
2609    }
2610
2611    /// Refund on-chain BTC from a chain swap after the timelock has expired.
2612    ///
2613    /// This is for [`ChainSwapDirection::BtcToArk`] swaps where the user locked on-chain BTC
2614    /// and needs to reclaim it (e.g. if Boltz never locked the Ark VHTLC or the swap expired).
2615    pub async fn refund_chain_swap_btc(
2616        &self,
2617        swap_id: &str,
2618        destination_address: bitcoin::Address,
2619        fee_rate_sat_vb: f64,
2620    ) -> Result<Txid, Error> {
2621        let swap = self
2622            .swap_storage()
2623            .get_chain(swap_id)
2624            .await
2625            .context("failed to get chain swap data")?
2626            .ok_or_else(|| Error::ad_hoc(format!("chain swap data not found: {swap_id}")))?;
2627
2628        let swap_tree = swap.swap_tree.clone().ok_or_else(|| {
2629            Error::ad_hoc("no swap tree found (this swap has no on-chain BTC lockup)")
2630        })?;
2631
2632        // The user's BTC lockup address
2633        let btc_address_str = &swap.user_lockup_address;
2634
2635        // Reconstruct the taproot tree. For BtcToArk, the server's key on the BTC
2636        // side is server_claim_public_key and the user's key is refund_public_key.
2637        let taproot_spend_info = reconstruct_btc_htlc(
2638            swap.server_claim_public_key,
2639            swap.refund_public_key,
2640            &swap_tree,
2641        )?;
2642
2643        let secp = Secp256k1::new();
2644
2645        let refund_script_bytes: Vec<u8> =
2646            bitcoin::hex::FromHex::from_hex(&swap_tree.refund_leaf.output)
2647                .map_err(|e| Error::ad_hoc(format!("invalid refund leaf hex: {e}")))?;
2648        let refund_script = ScriptBuf::from_bytes(refund_script_bytes);
2649        let refund_ver = (refund_script.clone(), LeafVersion::TapScript);
2650
2651        // Verify address
2652        let expected_spk = ScriptBuf::new_p2tr(
2653            &secp,
2654            taproot_spend_info.internal_key(),
2655            taproot_spend_info.merkle_root(),
2656        );
2657
2658        let parsed_address: bitcoin::Address<bitcoin::address::NetworkUnchecked> = btc_address_str
2659            .parse()
2660            .map_err(|e| Error::ad_hoc(format!("invalid BTC lockup address: {e}")))?;
2661        let parsed_address = parsed_address.assume_checked();
2662        let target_spk = parsed_address.script_pubkey();
2663
2664        if expected_spk != target_spk {
2665            return Err(Error::ad_hoc(format!(
2666                "taproot address mismatch for BTC lockup {btc_address_str}"
2667            )));
2668        }
2669
2670        // Find the unspent UTXO
2671        let utxos = self
2672            .inner
2673            .blockchain
2674            .find_outpoints(&parsed_address)
2675            .await
2676            .context("failed to find UTXOs at BTC lockup address")?;
2677
2678        let utxo = utxos.iter().find(|u| !u.is_spent).ok_or_else(|| {
2679            Error::ad_hoc(format!(
2680                "no unspent UTXO found at BTC lockup address {btc_address_str}"
2681            ))
2682        })?;
2683
2684        let control_block = taproot_spend_info
2685            .control_block(&refund_ver)
2686            .ok_or(Error::ad_hoc("control block not found for refund leaf"))?;
2687
2688        let cb_bytes = control_block.serialize();
2689        let witness_weight = 1 + 1 + 64 + 1 + refund_script.len() + 1 + cb_bytes.len() + 1;
2690        let weight = 4 * (11 + 41 + 43) + witness_weight;
2691        let vsize = weight.div_ceil(4);
2692        let fee = Amount::from_sat((vsize as f64 * fee_rate_sat_vb).ceil() as u64);
2693
2694        let refund_amount = utxo.amount.checked_sub(fee).ok_or_else(|| {
2695            Error::ad_hoc(format!(
2696                "UTXO amount {} is less than estimated fee {}",
2697                utxo.amount, fee
2698            ))
2699        })?;
2700
2701        // Use the user's timeout block height as nLockTime
2702        let lock_time = absolute::LockTime::from_consensus(swap.user_timeout_block_height);
2703
2704        let mut tx = bitcoin::Transaction {
2705            version: bitcoin::transaction::Version::TWO,
2706            lock_time,
2707            input: vec![bitcoin::TxIn {
2708                previous_output: utxo.outpoint,
2709                script_sig: ScriptBuf::new(),
2710                sequence: bitcoin::Sequence::ENABLE_LOCKTIME_NO_RBF,
2711                witness: bitcoin::Witness::new(),
2712            }],
2713            output: vec![TxOut {
2714                value: refund_amount,
2715                script_pubkey: destination_address.script_pubkey(),
2716            }],
2717        };
2718
2719        // Sign with the refund key
2720        let leaf_hash =
2721            bitcoin::taproot::TapLeafHash::from_script(&refund_script, LeafVersion::TapScript);
2722
2723        let prevouts = [TxOut {
2724            value: utxo.amount,
2725            script_pubkey: target_spk,
2726        }];
2727
2728        let sighash = bitcoin::sighash::SighashCache::new(&tx)
2729            .taproot_script_spend_signature_hash(
2730                0,
2731                &bitcoin::sighash::Prevouts::All(&prevouts),
2732                leaf_hash,
2733                bitcoin::TapSighashType::Default,
2734            )
2735            .map_err(|e| Error::ad_hoc(format!("failed to compute sighash: {e}")))?;
2736
2737        let msg = secp256k1::Message::from_digest(sighash.to_byte_array());
2738        let refund_kp = self.keypair_by_pk(&swap.refund_public_key.inner.x_only_public_key().0)?;
2739        let signature = secp.sign_schnorr_no_aux_rand(&msg, &refund_kp);
2740
2741        // Witness for refund: <signature> <refund_script> <control_block>
2742        let mut witness = bitcoin::Witness::new();
2743        witness.push(signature.serialize());
2744        witness.push(refund_script.as_bytes());
2745        witness.push(cb_bytes);
2746
2747        tx.input[0].witness = witness;
2748
2749        self.inner
2750            .blockchain
2751            .broadcast(&tx)
2752            .await
2753            .context("failed to broadcast BTC refund transaction")?;
2754
2755        let txid = tx.compute_txid();
2756
2757        tracing::info!(swap_id, %txid, %refund_amount, "Refunded on-chain BTC from chain swap");
2758
2759        let mut updated_swap = swap.clone();
2760        updated_swap.status = SwapStatus::TransactionRefunded;
2761        self.swap_storage()
2762            .update_chain(swap_id, updated_swap)
2763            .await
2764            .context("failed to update chain swap data")?;
2765
2766        Ok(txid)
2767    }
2768
2769    /// Query the current status of any Boltz swap by ID.
2770    ///
2771    /// Checks local swap storage to determine the swap type, then queries the Boltz API
2772    /// for the live status.
2773    pub async fn get_swap_status(&self, swap_id: &str) -> Result<SwapStatusInfo, Error> {
2774        // Determine swap type from local storage.
2775        let swap_type = if self.swap_storage().get_submarine(swap_id).await?.is_some() {
2776            SwapType::Submarine
2777        } else if self.swap_storage().get_reverse(swap_id).await?.is_some() {
2778            SwapType::Reverse
2779        } else if self.swap_storage().get_chain(swap_id).await?.is_some() {
2780            SwapType::Chain
2781        } else {
2782            SwapType::Unknown
2783        };
2784
2785        // Query the Boltz API for live status.
2786        let url = format!("{}/v2/swap/{swap_id}", self.inner.boltz_url);
2787        let client = reqwest::Client::new();
2788        let response = client
2789            .get(&url)
2790            .send()
2791            .await
2792            .map_err(|e| Error::ad_hoc(e.to_string()))
2793            .context("failed to query swap status")?;
2794
2795        if !response.status().is_success() {
2796            let error_text = response
2797                .text()
2798                .await
2799                .map_err(|e| Error::ad_hoc(e.to_string()))?;
2800            return Err(Error::ad_hoc(format!(
2801                "failed to get swap status: {error_text}"
2802            )));
2803        }
2804
2805        let status_response: GetSwapStatusResponse = response
2806            .json()
2807            .await
2808            .map_err(|e| Error::ad_hoc(e.to_string()))
2809            .context("failed to deserialize swap status response")?;
2810
2811        Ok(SwapStatusInfo {
2812            swap_id: swap_id.to_string(),
2813            swap_type,
2814            status: status_response.status,
2815        })
2816    }
2817
2818    /// Fetch fee information from Boltz for both submarine and reverse swaps.
2819    ///
2820    /// # Returns
2821    ///
2822    /// - A [`BoltzFees`] struct containing fee information for both swap types.
2823    pub async fn get_fees(&self) -> Result<BoltzFees, Error> {
2824        let client = reqwest::Client::builder()
2825            .timeout(self.inner.timeout)
2826            .build()
2827            .map_err(|e| Error::ad_hoc(e.to_string()))?;
2828
2829        // Fetch submarine swap fees (ARK -> BTC)
2830        let submarine_url = format!("{}/v2/swap/submarine", &self.inner.boltz_url);
2831        let submarine_response = client
2832            .get(&submarine_url)
2833            .send()
2834            .await
2835            .map_err(|e| Error::ad_hoc(e.to_string()))
2836            .context("failed to fetch submarine swap fees")?;
2837
2838        if !submarine_response.status().is_success() {
2839            let error_text = submarine_response
2840                .text()
2841                .await
2842                .map_err(|e| Error::ad_hoc(e.to_string()))?;
2843            return Err(Error::ad_hoc(format!(
2844                "failed to fetch submarine swap fees: {error_text}"
2845            )));
2846        }
2847
2848        let submarine_pairs: SubmarinePairsResponse = submarine_response
2849            .json()
2850            .await
2851            .map_err(|e| Error::ad_hoc(e.to_string()))
2852            .context("failed to deserialize submarine swap fees response")?;
2853
2854        let submarine_pair_fees = &submarine_pairs.ark.btc.fees;
2855        let submarine_fees = SubmarineSwapFees {
2856            percentage: submarine_pair_fees.percentage,
2857            miner_fees: submarine_pair_fees.miner_fees,
2858        };
2859
2860        // Fetch reverse swap fees (BTC -> ARK)
2861        let reverse_url = format!("{}/v2/swap/reverse", self.inner.boltz_url);
2862        let reverse_response = client
2863            .get(&reverse_url)
2864            .send()
2865            .await
2866            .map_err(|e| Error::ad_hoc(e.to_string()))
2867            .context("failed to fetch reverse swap fees")?;
2868
2869        if !reverse_response.status().is_success() {
2870            let error_text = reverse_response
2871                .text()
2872                .await
2873                .map_err(|e| Error::ad_hoc(e.to_string()))?;
2874            return Err(Error::ad_hoc(format!(
2875                "failed to fetch reverse swap fees: {error_text}"
2876            )));
2877        }
2878
2879        let reverse_pairs: ReversePairsResponse = reverse_response
2880            .json()
2881            .await
2882            .map_err(|e| Error::ad_hoc(e.to_string()))
2883            .context("failed to deserialize reverse swap fees response")?;
2884
2885        let reverse_pair_fees = &reverse_pairs.btc.ark.fees;
2886        let reverse_fees = ReverseSwapFees {
2887            percentage: reverse_pair_fees.percentage,
2888            miner_fees: ReverseMinerFees {
2889                lockup: reverse_pair_fees.miner_fees.lockup,
2890                claim: reverse_pair_fees.miner_fees.claim,
2891            },
2892        };
2893
2894        Ok(BoltzFees {
2895            submarine: submarine_fees,
2896            reverse: reverse_fees,
2897        })
2898    }
2899
2900    /// Fetch swap amount limits from Boltz for submarine swaps.
2901    ///
2902    /// # Returns
2903    ///
2904    /// - A [`SwapLimits`] struct containing minimum and maximum swap amounts in satoshis.
2905    pub async fn get_limits(&self) -> Result<SwapLimits, Error> {
2906        let client = reqwest::Client::builder()
2907            .timeout(self.inner.timeout)
2908            .build()
2909            .map_err(|e| Error::ad_hoc(e.to_string()))?;
2910
2911        let url = format!("{}/v2/swap/submarine", self.inner.boltz_url);
2912        let response = client
2913            .get(&url)
2914            .send()
2915            .await
2916            .map_err(|e| Error::ad_hoc(e.to_string()))
2917            .context("failed to fetch swap limits")?;
2918
2919        if !response.status().is_success() {
2920            let error_text = response
2921                .text()
2922                .await
2923                .map_err(|e| Error::ad_hoc(e.to_string()))?;
2924            return Err(Error::ad_hoc(format!(
2925                "failed to fetch swap limits: {error_text}"
2926            )));
2927        }
2928
2929        let pairs: SubmarinePairsResponse = response
2930            .json()
2931            .await
2932            .map_err(|e| Error::ad_hoc(e.to_string()))
2933            .context("failed to deserialize swap limits response")?;
2934
2935        Ok(SwapLimits {
2936            min: pairs.ark.btc.limits.minimal,
2937            max: pairs.ark.btc.limits.maximal,
2938        })
2939    }
2940
2941    /// Use Boltz's API to learn about updates for a particular swap.
2942    // TODO: Make sure this is WASM-compatible.
2943    pub fn subscribe_to_swap_updates(
2944        &self,
2945        swap_id: String,
2946    ) -> impl futures::Stream<Item = Result<SwapStatus, Error>> + '_ {
2947        async_stream::stream! {
2948            let mut last_status: Option<SwapStatus> = None;
2949            let url = format!("{}/v2/swap/{swap_id}", self.inner.boltz_url);
2950
2951            loop {
2952                let client = reqwest::Client::new();
2953                let response = client
2954                    .get(&url)
2955                    .send()
2956                    .await;
2957
2958                match response {
2959                    Ok(resp) if resp.status().is_success() => {
2960                        let status_response = resp
2961                            .json::<GetSwapStatusResponse>()
2962                            .await
2963                            .map_err(|e| Error::ad_hoc(e.to_string()));
2964
2965                        match status_response {
2966                            Ok(current_status) => {
2967                                let current_status = current_status.status;
2968
2969                                // Only yield if status has changed
2970                                if last_status.as_ref() != Some(&current_status) {
2971                                    last_status = Some(current_status.clone());
2972                                    yield Ok(current_status);
2973                                }
2974                            }
2975                            Err(e) => {
2976                                yield Err(Error::ad_hoc(format!(
2977                                            "failed to deserialize swap status response: {e}"
2978                                        )));
2979                                break;
2980                            }
2981                        }
2982                    }
2983                    Ok(resp) => {
2984                        let error_text = resp
2985                            .text()
2986                            .await
2987                            .unwrap_or_else(|_| "Unknown error".to_string());
2988
2989                        yield Err(Error::ad_hoc(format!(
2990                            "failed to check swap status: {error_text}"
2991                        )));
2992                        break;
2993                    }
2994                    Err(e) => {
2995                        yield Err(Error::ad_hoc(e.to_string())
2996                            .context("failed to send swap status request"));
2997                        break;
2998                    }
2999                }
3000
3001                // Poll every second
3002                tokio::time::sleep(std::time::Duration::from_secs(1)).await;
3003            }
3004        }
3005    }
3006
3007    // Pending VHTLC spend recovery.
3008
3009    /// List pending (submitted but not finalized) VHTLC spend transactions.
3010    ///
3011    /// This checks all non-terminal swaps in storage, queries the server for pending VTXOs
3012    /// on their VHTLC addresses, and determines the spend type from the PSBT data.
3013    pub async fn list_pending_vhtlc_spend_txs(&self) -> Result<Vec<PendingVhtlcSpendTx>, Error> {
3014        let vhtlc_infos = self.collect_active_vhtlc_infos().await?;
3015
3016        if vhtlc_infos.is_empty() {
3017            return Ok(vec![]);
3018        }
3019
3020        let addresses = vhtlc_infos.iter().map(|info| info.address);
3021        let request = ark_core::server::GetVtxosRequest::new_for_addresses(addresses)
3022            .pending_only()
3023            .map_err(Error::from)?;
3024
3025        let vtxos = self
3026            .fetch_all_vtxos(request)
3027            .await
3028            .context("failed to fetch pending VHTLC VTXOs")?;
3029
3030        tracing::debug!(
3031            num_pending_vtxos = vtxos.len(),
3032            "Fetched pending VHTLC VTXOs"
3033        );
3034
3035        if vtxos.is_empty() {
3036            return Ok(vec![]);
3037        }
3038
3039        // Map script_pubkey → VhtlcInfo for lookup.
3040        let info_by_script: std::collections::HashMap<_, _> = vhtlc_infos
3041            .iter()
3042            .map(|info| (info.script_pubkey.clone(), info))
3043            .collect();
3044
3045        let secp = Secp256k1::new();
3046        let mut results = Vec::new();
3047        let mut seen_ark_txids = std::collections::HashSet::new();
3048
3049        for vtxo in &vtxos {
3050            let info = match info_by_script.get(&vtxo.script) {
3051                Some(info) => info,
3052                None => {
3053                    tracing::warn!(
3054                        outpoint = %vtxo.outpoint,
3055                        "Skipping pending VHTLC VTXO with unknown script"
3056                    );
3057                    continue;
3058                }
3059            };
3060
3061            // Build an intent to fetch the pending tx from the server.
3062            // We prove ownership using the forfeit-like spend path that we can sign.
3063            // If we have a preimage (reverse swap claim path), include it as extra
3064            // witness so the server can verify the intent proof for the claim script.
3065            let intent_input = match info.preimage {
3066                Some(preimage) => intent::Input::new_with_extra_witness(
3067                    vtxo.outpoint,
3068                    bitcoin::Sequence::ZERO,
3069                    None,
3070                    TxOut {
3071                        value: vtxo.amount,
3072                        script_pubkey: info.script_pubkey.clone(),
3073                    },
3074                    vhtlc_tapscripts(&info.vhtlc),
3075                    info.intent_spend_info.clone(),
3076                    false,
3077                    vtxo.is_swept,
3078                    vtxo.assets.clone(),
3079                    vec![preimage.to_vec()],
3080                ),
3081                None => intent::Input::new(
3082                    vtxo.outpoint,
3083                    bitcoin::Sequence::ZERO,
3084                    None,
3085                    TxOut {
3086                        value: vtxo.amount,
3087                        script_pubkey: info.script_pubkey.clone(),
3088                    },
3089                    vhtlc_tapscripts(&info.vhtlc),
3090                    info.intent_spend_info.clone(),
3091                    false,
3092                    vtxo.is_swept,
3093                    vtxo.assets.clone(),
3094                ),
3095            };
3096
3097            let sign_for_vtxo_fn = |input: &mut psbt::Input,
3098                                    msg: secp256k1::Message|
3099             -> Result<
3100                Vec<(schnorr::Signature, XOnlyPublicKey)>,
3101                ark_core::Error,
3102            > {
3103                match &input.witness_script {
3104                    None => Err(ark_core::Error::ad_hoc(
3105                        "Missing witness script when signing get-pending-tx intent for VHTLC",
3106                    )),
3107                    Some(script) => {
3108                        let pks = extract_checksig_pubkeys(script);
3109                        let mut res = vec![];
3110                        for pk in &pks {
3111                            if let Ok(keypair) = self.keypair_by_pk(pk) {
3112                                let sig = secp.sign_schnorr_no_aux_rand(&msg, &keypair);
3113                                res.push((sig, keypair.x_only_public_key().0));
3114                            }
3115                        }
3116                        Ok(res)
3117                    }
3118                }
3119            };
3120
3121            let sign_for_onchain_fn =
3122                |_: &mut psbt::Input,
3123                 _: secp256k1::Message|
3124                 -> Result<(schnorr::Signature, XOnlyPublicKey), ark_core::Error> {
3125                    Err(ark_core::Error::ad_hoc(
3126                        "unexpected onchain input in get-pending-tx intent",
3127                    ))
3128                };
3129
3130            let message = intent::IntentMessage::GetPendingTx { expire_at: 0 };
3131            let get_pending_intent = intent::make_intent(
3132                sign_for_vtxo_fn,
3133                sign_for_onchain_fn,
3134                vec![intent_input],
3135                vec![],
3136                message,
3137            )?;
3138
3139            let pending_txs = self
3140                .network_client()
3141                .get_pending_tx(get_pending_intent)
3142                .await
3143                .map_err(Error::ark_server)
3144                .context("failed to get pending VHTLC transactions")?;
3145
3146            for pending_tx in pending_txs {
3147                if !seen_ark_txids.insert(pending_tx.ark_txid) {
3148                    continue;
3149                }
3150
3151                let spend_type = Self::identify_vhtlc_spend_type(info, &pending_tx)?;
3152
3153                tracing::info!(
3154                    ark_txid = %pending_tx.ark_txid,
3155                    swap_id = spend_type.swap_id(),
3156                    spend_type = spend_type.name(),
3157                    "Found pending VHTLC spend transaction"
3158                );
3159
3160                results.push(PendingVhtlcSpendTx {
3161                    spend_type,
3162                    pending_tx,
3163                });
3164            }
3165        }
3166
3167        Ok(results)
3168    }
3169
3170    /// Continue (finalize) a pending VHTLC spend transaction.
3171    ///
3172    /// Handles the different spend types appropriately:
3173    /// - **Claim**: signs the checkpoint with the claim key and injects the preimage.
3174    /// - **CollaborativeRefund**: re-requests Boltz's signature, then signs with the refund key.
3175    /// - **ExpiredRefund**: signs the checkpoint with the refund key (no Boltz needed).
3176    pub async fn continue_pending_vhtlc_spend_tx(
3177        &self,
3178        pending: &PendingVhtlcSpendTx,
3179    ) -> Result<Txid, Error> {
3180        let ark_txid = pending.pending_tx.ark_txid;
3181
3182        match &pending.spend_type {
3183            PendingVhtlcSpendType::Claim { preimage, .. } => {
3184                self.continue_pending_claim(ark_txid, &pending.pending_tx, *preimage)
3185                    .await
3186            }
3187            PendingVhtlcSpendType::CollaborativeRefund { swap_id } => {
3188                self.continue_pending_collaborative_refund(ark_txid, &pending.pending_tx, swap_id)
3189                    .await
3190            }
3191            PendingVhtlcSpendType::ExpiredRefund { .. } => {
3192                self.continue_pending_expired_refund(ark_txid, &pending.pending_tx)
3193                    .await
3194            }
3195        }
3196    }
3197
3198    /// Sign and finalize all pending VHTLC spend transactions.
3199    pub async fn continue_pending_vhtlc_spend_txs(&self) -> Result<Vec<Txid>, Error> {
3200        let pending = self.list_pending_vhtlc_spend_txs().await?;
3201
3202        let mut finalized = Vec::new();
3203        for tx in &pending {
3204            match self.continue_pending_vhtlc_spend_tx(tx).await {
3205                Ok(txid) => finalized.push(txid),
3206                Err(e) => {
3207                    tracing::warn!(
3208                        ark_txid = %tx.pending_tx.ark_txid,
3209                        swap_id = tx.spend_type.swap_id(),
3210                        ?e,
3211                        "Failed to finalize pending VHTLC spend tx"
3212                    );
3213                }
3214            }
3215        }
3216
3217        Ok(finalized)
3218    }
3219
3220    /// Sign and finalize a pending claim VHTLC checkpoint.
3221    async fn continue_pending_claim(
3222        &self,
3223        ark_txid: Txid,
3224        pending_tx: &PendingTx,
3225        preimage: [u8; 32],
3226    ) -> Result<Txid, Error> {
3227        let mut signed_checkpoint_txs = pending_tx.signed_checkpoint_txs.clone();
3228
3229        for checkpoint_psbt in signed_checkpoint_txs.iter_mut() {
3230            Self::restore_witness_script_if_needed(checkpoint_psbt, &pending_tx.signed_ark_tx)?;
3231
3232            // Inject preimage into checkpoint inputs before signing.
3233            Self::inject_preimage_into_psbt(checkpoint_psbt, preimage);
3234
3235            self.sign_checkpoint_with_own_keys(checkpoint_psbt)?;
3236        }
3237
3238        timeout_op(
3239            self.inner.timeout,
3240            self.network_client()
3241                .finalize_offchain_transaction(ark_txid, signed_checkpoint_txs),
3242        )
3243        .await?
3244        .map_err(Error::ark_server)
3245        .context("failed to finalize pending claim transaction")?;
3246
3247        tracing::info!(txid = %ark_txid, "Finalized pending VHTLC claim");
3248        Ok(ark_txid)
3249    }
3250
3251    /// Re-request Boltz's signature and finalize a pending collaborative refund.
3252    async fn continue_pending_collaborative_refund(
3253        &self,
3254        ark_txid: Txid,
3255        pending_tx: &PendingTx,
3256        swap_id: &str,
3257    ) -> Result<Txid, Error> {
3258        // For collaborative refunds, the server stripped Boltz's signatures when we
3259        // submitted. We need to re-request them from Boltz.
3260        //
3261        // Re-send the ark tx and each checkpoint to Boltz's refund endpoint to get fresh
3262        // signatures from them.
3263        let url = format!(
3264            "{}/v2/swap/submarine/{swap_id}/refund/ark",
3265            self.inner.boltz_url
3266        );
3267        let client = reqwest::Client::new();
3268
3269        let mut signed_checkpoint_txs = Vec::new();
3270
3271        for checkpoint_psbt in &pending_tx.signed_checkpoint_txs {
3272            let response = client
3273                .post(&url)
3274                .json(&RefundSwapRequest {
3275                    transaction: pending_tx.signed_ark_tx.to_string(),
3276                    checkpoint: checkpoint_psbt.to_string(),
3277                })
3278                .send()
3279                .await
3280                .map_err(Error::ad_hoc)
3281                .context("failed to re-request Boltz refund signature")?;
3282
3283            if !response.status().is_success() {
3284                let error_text = response
3285                    .text()
3286                    .await
3287                    .map_err(|e| Error::ad_hoc(e.to_string()))
3288                    .context("failed to read Boltz error text")?;
3289
3290                return Err(Error::ad_hoc(format!(
3291                    "Boltz refund re-sign request failed: {error_text}"
3292                )));
3293            }
3294
3295            let refund_response: RefundSwapResponse = response
3296                .json()
3297                .await
3298                .map_err(Error::ad_hoc)
3299                .context("failed to deserialize Boltz refund response")?;
3300
3301            if let Some(err) = refund_response.error.as_deref() {
3302                return Err(Error::ad_hoc(format!("Boltz refund re-sign failed: {err}")));
3303            }
3304
3305            let boltz_signed_checkpoint = Psbt::from_str(&refund_response.checkpoint)
3306                .map_err(Error::ad_hoc)
3307                .context("could not parse Boltz-signed checkpoint PSBT")?;
3308
3309            // Extract Boltz's tap_script_sigs.
3310            let boltz_tap_script_sigs = boltz_signed_checkpoint
3311                .inputs
3312                .first()
3313                .ok_or_else(|| Error::ad_hoc("Boltz checkpoint has no inputs"))?
3314                .tap_script_sigs
3315                .clone();
3316
3317            // Start from the server's checkpoint (which has the server's signature).
3318            let mut final_checkpoint = checkpoint_psbt.clone();
3319            Self::restore_witness_script_if_needed(
3320                &mut final_checkpoint,
3321                &pending_tx.signed_ark_tx,
3322            )?;
3323
3324            // Merge Boltz's signatures.
3325            final_checkpoint
3326                .inputs
3327                .first_mut()
3328                .ok_or_else(|| Error::ad_hoc("checkpoint has no inputs"))?
3329                .tap_script_sigs
3330                .extend(boltz_tap_script_sigs);
3331
3332            // Add our (sender) signature.
3333            self.sign_checkpoint_with_own_keys(&mut final_checkpoint)?;
3334
3335            signed_checkpoint_txs.push(final_checkpoint);
3336        }
3337
3338        timeout_op(
3339            self.inner.timeout,
3340            self.network_client()
3341                .finalize_offchain_transaction(ark_txid, signed_checkpoint_txs),
3342        )
3343        .await?
3344        .map_err(Error::ark_server)
3345        .context("failed to finalize pending collaborative refund")?;
3346
3347        tracing::info!(txid = %ark_txid, swap_id, "Finalized pending collaborative refund");
3348        Ok(ark_txid)
3349    }
3350
3351    /// Sign and finalize a pending expired refund checkpoint.
3352    async fn continue_pending_expired_refund(
3353        &self,
3354        ark_txid: Txid,
3355        pending_tx: &PendingTx,
3356    ) -> Result<Txid, Error> {
3357        let mut signed_checkpoint_txs = pending_tx.signed_checkpoint_txs.clone();
3358
3359        for checkpoint_psbt in signed_checkpoint_txs.iter_mut() {
3360            Self::restore_witness_script_if_needed(checkpoint_psbt, &pending_tx.signed_ark_tx)?;
3361            self.sign_checkpoint_with_own_keys(checkpoint_psbt)?;
3362        }
3363
3364        timeout_op(
3365            self.inner.timeout,
3366            self.network_client()
3367                .finalize_offchain_transaction(ark_txid, signed_checkpoint_txs),
3368        )
3369        .await?
3370        .map_err(Error::ark_server)
3371        .context("failed to finalize pending expired refund")?;
3372
3373        tracing::info!(txid = %ark_txid, "Finalized pending expired VHTLC refund");
3374        Ok(ark_txid)
3375    }
3376
3377    // Private helpers for pending VHTLC recovery.
3378
3379    /// Try to reconstruct a [`VhtlcScript`] that matches `expected_address` by trying the current
3380    /// server signer and all deprecated signers in order. Returns the first match.
3381    ///
3382    /// This handles the case where the server rotated its signing key after a swap was created:
3383    /// the VHTLC was built with the old key, so we must try deprecated keys to find the right one.
3384    fn reconstruct_vhtlc_for_address(
3385        &self,
3386        server_info: &Info,
3387        mk_opts: impl Fn(XOnlyPublicKey) -> Result<VhtlcOptions, Error>,
3388        expected_address: &ArkAddress,
3389    ) -> Result<VhtlcScript, Error> {
3390        reconstruct_vhtlc_from_keys(
3391            server_info.all_server_keys(),
3392            server_info.network,
3393            mk_opts,
3394            expected_address,
3395        )
3396    }
3397
3398    /// Reconstruct a [`VhtlcScript`] from swap data fields, trying current + deprecated signers.
3399    fn build_vhtlc_script(
3400        &self,
3401        server_info: &Info,
3402        claim_public_key: PublicKey,
3403        refund_public_key: PublicKey,
3404        preimage_hash: ripemd160::Hash,
3405        timeout_block_heights: &TimeoutBlockHeights,
3406        expected_address: &ArkAddress,
3407    ) -> Result<VhtlcScript, Error> {
3408        let unilateral_claim_delay =
3409            parse_sequence_number(timeout_block_heights.unilateral_claim as i64)
3410                .map_err(|e| Error::ad_hoc(format!("invalid unilateral claim timeout: {e}")))?;
3411        let unilateral_refund_delay =
3412            parse_sequence_number(timeout_block_heights.unilateral_refund as i64)
3413                .map_err(|e| Error::ad_hoc(format!("invalid unilateral refund timeout: {e}")))?;
3414        let unilateral_refund_without_receiver_delay =
3415            parse_sequence_number(timeout_block_heights.unilateral_refund_without_receiver as i64)
3416                .map_err(|e| {
3417                    Error::ad_hoc(format!("invalid refund without receiver timeout: {e}"))
3418                })?;
3419
3420        self.reconstruct_vhtlc_for_address(
3421            server_info,
3422            |server| {
3423                Ok(VhtlcOptions {
3424                    sender: refund_public_key.inner.x_only_public_key().0,
3425                    receiver: claim_public_key.inner.x_only_public_key().0,
3426                    server,
3427                    preimage_hash,
3428                    refund_locktime: timeout_block_heights.refund,
3429                    unilateral_claim_delay,
3430                    unilateral_refund_delay,
3431                    unilateral_refund_without_receiver_delay,
3432                })
3433            },
3434            expected_address,
3435        )
3436    }
3437
3438    /// Collect info about all active (non-terminal) VHTLCs from swap storage.
3439    /// Ensure a swap key is loaded into the key provider's cache so
3440    /// `keypair_by_pk` can find it during intent signing.
3441    ///
3442    /// Returns `true` if the key is available (already cached or successfully derived).
3443    /// Returns `false` for legacy swap data without a stored derivation index.
3444    fn ensure_swap_key_cached(
3445        &self,
3446        pk: &XOnlyPublicKey,
3447        key_derivation_index: Option<u32>,
3448        swap_id: &str,
3449    ) -> bool {
3450        // Already in cache — nothing to do.
3451        if self.keypair_by_pk(pk).is_ok() {
3452            return true;
3453        }
3454
3455        let Some(index) = key_derivation_index else {
3456            tracing::warn!(
3457                swap_id,
3458                "Legacy swap data without derivation index, skipping recovery"
3459            );
3460            return false;
3461        };
3462
3463        let Some(key_provider) = self.inner.discoverable_key_provider.as_ref() else {
3464            return false;
3465        };
3466
3467        match key_provider.derive_at_discovery_index(index) {
3468            Ok(Some(kp)) if kp.x_only_public_key().0 == *pk => {
3469                if let Err(e) = key_provider.cache_discovered_keypair(index, kp) {
3470                    tracing::warn!(swap_id, %e, "Failed to cache swap key");
3471                    return false;
3472                }
3473                true
3474            }
3475            Ok(_) => {
3476                tracing::warn!(
3477                    swap_id,
3478                    index,
3479                    "Key at stored derivation index does not match swap pubkey"
3480                );
3481                false
3482            }
3483            Err(e) => {
3484                tracing::warn!(swap_id, index, %e, "Failed to derive key at stored index");
3485                false
3486            }
3487        }
3488    }
3489
3490    async fn collect_active_vhtlc_infos(&self) -> Result<Vec<VhtlcInfo>, Error> {
3491        let submarine_swaps = self
3492            .swap_storage()
3493            .list_all_submarine()
3494            .await
3495            .context("failed to list submarine swaps")?;
3496
3497        let reverse_swaps = self
3498            .swap_storage()
3499            .list_all_reverse()
3500            .await
3501            .context("failed to list reverse swaps")?;
3502
3503        let server_info = self.server_info().await?;
3504        let mut infos = Vec::new();
3505
3506        for swap in &submarine_swaps {
3507            if swap.status.is_terminal() {
3508                continue;
3509            }
3510
3511            // Ensure the refund key (sender) is in the key cache.
3512            if !self.ensure_swap_key_cached(
3513                &swap.refund_public_key.inner.x_only_public_key().0,
3514                swap.key_derivation_index,
3515                &swap.id,
3516            ) {
3517                continue;
3518            }
3519
3520            let vhtlc = self.build_vhtlc_script(
3521                &server_info,
3522                swap.claim_public_key,
3523                swap.refund_public_key,
3524                swap.preimage_hash,
3525                &swap.timeout_block_heights,
3526                &swap.vhtlc_address,
3527            )?;
3528
3529            // For submarine swaps, the user is the sender (refund key).
3530            // Use refund_without_receiver_script as the intent proof — it only requires
3531            // sender + server, and we can always sign for sender.
3532            let refund_script = vhtlc.refund_without_receiver_script();
3533            let spend_info = vhtlc.taproot_spend_info();
3534            let control_block = spend_info
3535                .control_block(&(refund_script.clone(), LeafVersion::TapScript))
3536                .ok_or_else(|| {
3537                    Error::ad_hoc("control block not found for refund_without_receiver script")
3538                })?;
3539
3540            infos.push(VhtlcInfo {
3541                swap_id: swap.id.clone(),
3542                address: swap.vhtlc_address,
3543                script_pubkey: vhtlc.script_pubkey(),
3544                vhtlc,
3545                intent_spend_info: (refund_script, control_block),
3546                preimage: swap.preimage,
3547            });
3548        }
3549
3550        for swap in &reverse_swaps {
3551            if swap.status.is_terminal() {
3552                continue;
3553            }
3554
3555            // Ensure the claim key (receiver) is in the key cache.
3556            if !self.ensure_swap_key_cached(
3557                &swap.claim_public_key.inner.x_only_public_key().0,
3558                swap.key_derivation_index,
3559                &swap.id,
3560            ) {
3561                continue;
3562            }
3563
3564            let vhtlc = self.build_vhtlc_script(
3565                &server_info,
3566                swap.claim_public_key,
3567                swap.refund_public_key,
3568                swap.preimage_hash,
3569                &swap.timeout_block_heights,
3570                &swap.vhtlc_address,
3571            )?;
3572
3573            // For reverse swaps, the user is the receiver (claim key).
3574            // Use claim_script as the intent proof — we need to sign with the receiver key.
3575            let claim_script = vhtlc.claim_script();
3576            let spend_info = vhtlc.taproot_spend_info();
3577            let control_block = spend_info
3578                .control_block(&(claim_script.clone(), LeafVersion::TapScript))
3579                .ok_or_else(|| Error::ad_hoc("control block not found for claim script"))?;
3580
3581            infos.push(VhtlcInfo {
3582                swap_id: swap.id.clone(),
3583                address: swap.vhtlc_address,
3584                script_pubkey: vhtlc.script_pubkey(),
3585                vhtlc,
3586                intent_spend_info: (claim_script, control_block),
3587                preimage: swap.preimage,
3588            });
3589        }
3590
3591        Ok(infos)
3592    }
3593
3594    /// Determine the spend type by comparing the PSBT's spend script against known VHTLC scripts.
3595    fn identify_vhtlc_spend_type(
3596        info: &VhtlcInfo,
3597        pending_tx: &PendingTx,
3598    ) -> Result<PendingVhtlcSpendType, Error> {
3599        // Extract the spend script from the ark tx's PSBT input tap_scripts.
3600        let spend_script = pending_tx
3601            .signed_ark_tx
3602            .inputs
3603            .iter()
3604            .find_map(|input| {
3605                input.tap_scripts.values().find_map(|(script, _)| {
3606                    // Match against this VHTLC's known scripts.
3607                    let claim = info.vhtlc.claim_script();
3608                    let refund = info.vhtlc.refund_script();
3609                    let refund_no_recv = info.vhtlc.refund_without_receiver_script();
3610
3611                    if *script == claim || *script == refund || *script == refund_no_recv {
3612                        Some(script.clone())
3613                    } else {
3614                        None
3615                    }
3616                })
3617            })
3618            .ok_or_else(|| {
3619                Error::ad_hoc(format!(
3620                    "could not identify spend script in pending tx {} for swap {}",
3621                    pending_tx.ark_txid, info.swap_id
3622                ))
3623            })?;
3624
3625        let claim_script = info.vhtlc.claim_script();
3626        let refund_script = info.vhtlc.refund_script();
3627
3628        if spend_script == claim_script {
3629            // Claim — we need the preimage. Try to extract it from the ark tx PSBT
3630            // (it was injected as extra witness data when the tx was originally signed),
3631            // falling back to what's stored in swap data.
3632            let preimage = extract_preimage_from_psbt(&pending_tx.signed_ark_tx)
3633                .ok()
3634                .or(info.preimage)
3635                .ok_or_else(|| {
3636                    Error::ad_hoc(format!(
3637                        "cannot recover preimage for pending claim of swap {}",
3638                        info.swap_id
3639                    ))
3640                })?;
3641
3642            Ok(PendingVhtlcSpendType::Claim {
3643                swap_id: info.swap_id.clone(),
3644                preimage,
3645            })
3646        } else if spend_script == refund_script {
3647            Ok(PendingVhtlcSpendType::CollaborativeRefund {
3648                swap_id: info.swap_id.clone(),
3649            })
3650        } else {
3651            Ok(PendingVhtlcSpendType::ExpiredRefund {
3652                swap_id: info.swap_id.clone(),
3653            })
3654        }
3655    }
3656
3657    /// Inject a preimage into all inputs of a PSBT via the `VTXO_CONDITION_KEY` unknown field.
3658    fn inject_preimage_into_psbt(psbt: &mut Psbt, preimage: [u8; 32]) {
3659        let mut bytes = vec![1];
3660        let length = VarInt::from(preimage.len() as u64);
3661        length
3662            .consensus_encode(&mut bytes)
3663            .expect("valid length encoding");
3664        bytes.write_all(&preimage).expect("valid preimage encoding");
3665
3666        let key = psbt::raw::Key {
3667            type_value: 222,
3668            key: VTXO_CONDITION_KEY.to_vec(),
3669        };
3670
3671        for input in &mut psbt.inputs {
3672            input.unknown.insert(key.clone(), bytes.clone());
3673        }
3674    }
3675
3676    /// Sign a checkpoint PSBT by matching pubkeys in the witness script against our keys.
3677    fn sign_checkpoint_with_own_keys(&self, checkpoint_psbt: &mut Psbt) -> Result<(), Error> {
3678        let sign_fn =
3679            |input: &mut psbt::Input,
3680             msg: secp256k1::Message|
3681             -> Result<Vec<(schnorr::Signature, XOnlyPublicKey)>, ark_core::Error> {
3682                let script = input.witness_script.as_ref().ok_or_else(|| {
3683                    ark_core::Error::ad_hoc("missing witness script for checkpoint signing")
3684                })?;
3685                let pks = extract_checksig_pubkeys(script);
3686                let mut res = vec![];
3687                for pk in pks {
3688                    if let Ok(keypair) = self.keypair_by_pk(&pk) {
3689                        let sig = Secp256k1::new().sign_schnorr_no_aux_rand(&msg, &keypair);
3690                        res.push((sig, keypair.x_only_public_key().0));
3691                    }
3692                }
3693                Ok(res)
3694            };
3695
3696        sign_checkpoint_transaction(sign_fn, checkpoint_psbt)?;
3697        Ok(())
3698    }
3699
3700    /// Restore the witness_script on a checkpoint PSBT if the server stripped it.
3701    ///
3702    /// This is the same logic used by [`Client::continue_pending_offchain_txs`].
3703    fn restore_witness_script_if_needed(
3704        checkpoint_psbt: &mut Psbt,
3705        signed_ark_tx: &Psbt,
3706    ) -> Result<(), Error> {
3707        if checkpoint_psbt
3708            .inputs
3709            .first()
3710            .ok_or_else(|| Error::ad_hoc("checkpoint PSBT has no inputs"))?
3711            .witness_script
3712            .is_some()
3713        {
3714            return Ok(());
3715        }
3716
3717        let checkpoint_txid = checkpoint_psbt.unsigned_tx.compute_txid();
3718
3719        let ark_input_idx = signed_ark_tx
3720            .unsigned_tx
3721            .input
3722            .iter()
3723            .position(|inp| inp.previous_output.txid == checkpoint_txid)
3724            .ok_or_else(|| {
3725                Error::ad_hoc(format!(
3726                    "checkpoint txid {checkpoint_txid} not found in ark tx inputs"
3727                ))
3728            })?;
3729
3730        let witness_script = signed_ark_tx
3731            .inputs
3732            .get(ark_input_idx)
3733            .and_then(|input| input.witness_script.clone())
3734            .ok_or_else(|| {
3735                Error::ad_hoc(format!(
3736                    "missing witness script on ark tx input {ark_input_idx}"
3737                ))
3738            })?;
3739
3740        checkpoint_psbt
3741            .inputs
3742            .first_mut()
3743            .ok_or_else(|| Error::ad_hoc("checkpoint PSBT has no inputs"))?
3744            .witness_script = Some(witness_script);
3745        Ok(())
3746    }
3747}
3748
3749/// Internal info about an active VHTLC, used during pending tx recovery.
3750struct VhtlcInfo {
3751    swap_id: String,
3752    address: ArkAddress,
3753    script_pubkey: ScriptBuf,
3754    vhtlc: VhtlcScript,
3755    /// The spend path and control block used to prove ownership in the GetPendingTx intent.
3756    intent_spend_info: (ScriptBuf, bitcoin::taproot::ControlBlock),
3757    preimage: Option<[u8; 32]>,
3758}
3759
3760/// Reconstruct the taproot spend info for a Boltz on-chain BTC HTLC.
3761///
3762/// Boltz uses `MuSig2(serverKey, userKey)` as the internal key.
3763/// The tree has two leaves: claim and refund, from the [`SwapTree`].
3764fn reconstruct_btc_htlc(
3765    server_pk: PublicKey,
3766    user_pk: PublicKey,
3767    swap_tree: &SwapTree,
3768) -> Result<bitcoin::taproot::TaprootSpendInfo, Error> {
3769    let claim_script_bytes: Vec<u8> = bitcoin::hex::FromHex::from_hex(&swap_tree.claim_leaf.output)
3770        .map_err(|e| Error::ad_hoc(format!("invalid claim leaf hex: {e}")))?;
3771    let claim_script = ScriptBuf::from_bytes(claim_script_bytes);
3772
3773    let refund_script_bytes: Vec<u8> =
3774        bitcoin::hex::FromHex::from_hex(&swap_tree.refund_leaf.output)
3775            .map_err(|e| Error::ad_hoc(format!("invalid refund leaf hex: {e}")))?;
3776    let refund_script = ScriptBuf::from_bytes(refund_script_bytes);
3777
3778    let musig_server_pk = musig::PublicKey::from_slice(&server_pk.to_bytes())
3779        .map_err(|e| Error::ad_hoc(format!("invalid server key for musig: {e}")))?;
3780    let musig_user_pk = musig::PublicKey::from_slice(&user_pk.to_bytes())
3781        .map_err(|e| Error::ad_hoc(format!("invalid user key for musig: {e}")))?;
3782
3783    let key_agg = musig::musig::KeyAggCache::new(&[&musig_server_pk, &musig_user_pk]);
3784    let internal_key = XOnlyPublicKey::from_slice(&key_agg.agg_pk().serialize())
3785        .map_err(|e| Error::ad_hoc(format!("invalid aggregated key: {e}")))?;
3786
3787    let secp = Secp256k1::new();
3788    bitcoin::taproot::TaprootBuilder::new()
3789        .add_leaf(1, claim_script)
3790        .map_err(|e| Error::ad_hoc(format!("failed to add claim leaf: {e}")))?
3791        .add_leaf(1, refund_script)
3792        .map_err(|e| Error::ad_hoc(format!("failed to add refund leaf: {e}")))?
3793        .finalize(&secp, internal_key)
3794        .map_err(|_| Error::ad_hoc("failed to finalize taproot tree"))
3795}
3796
3797/// Collect all tapscripts from a [`VhtlcScript`].
3798fn vhtlc_tapscripts(vhtlc: &VhtlcScript) -> Vec<ScriptBuf> {
3799    vec![
3800        vhtlc.claim_script(),
3801        vhtlc.refund_script(),
3802        vhtlc.refund_without_receiver_script(),
3803        vhtlc.unilateral_claim_script(),
3804        vhtlc.unilateral_refund_script(),
3805        vhtlc.unilateral_refund_without_receiver_script(),
3806    ]
3807}
3808
3809/// Extract the preimage from a PSBT's `VTXO_CONDITION_KEY` unknown field.
3810///
3811/// The condition data is encoded as: `[num_elements] [varint_length] [preimage_bytes]`.
3812/// For VHTLC claims, there is exactly one element: the 32-byte preimage.
3813fn extract_preimage_from_psbt(psbt: &Psbt) -> Result<[u8; 32], Error> {
3814    let condition_key = psbt::raw::Key {
3815        type_value: 222,
3816        key: VTXO_CONDITION_KEY.to_vec(),
3817    };
3818
3819    for input in &psbt.inputs {
3820        if let Some(condition_data) = input.unknown.get(&condition_key) {
3821            if condition_data.is_empty() {
3822                continue;
3823            }
3824
3825            // First byte is the number of witness elements.
3826            let num_elements = condition_data[0] as usize;
3827            if num_elements == 0 {
3828                continue;
3829            }
3830
3831            // Parse the first element: varint length followed by the preimage bytes.
3832            let mut cursor = std::io::Cursor::new(&condition_data[1..]);
3833            let length = bitcoin::consensus::Decodable::consensus_decode(&mut cursor)
3834                .map_err(|e| Error::ad_hoc(format!("failed to decode varint length: {e}")))?;
3835            let length: VarInt = length;
3836            let offset = cursor.position() as usize;
3837            let remaining = &condition_data[1 + offset..];
3838
3839            if remaining.len() < length.0 as usize {
3840                return Err(Error::ad_hoc(format!(
3841                    "condition data too short: expected {} bytes, got {}",
3842                    length.0,
3843                    remaining.len()
3844                )));
3845            }
3846
3847            let preimage_bytes = &remaining[..length.0 as usize];
3848
3849            let preimage: [u8; 32] = preimage_bytes.try_into().map_err(|_| {
3850                Error::ad_hoc(format!(
3851                    "preimage has unexpected length: {} (expected 32)",
3852                    preimage_bytes.len()
3853                ))
3854            })?;
3855
3856            return Ok(preimage);
3857        }
3858    }
3859
3860    Err(Error::ad_hoc(
3861        "no VTXO_CONDITION_KEY found in any PSBT input",
3862    ))
3863}
3864
3865/// The amount to be shared with Boltz when creating a reverse submarine swap.
3866pub enum SwapAmount {
3867    /// Use this value if you need to set the value to be sent by the payer on Lightning.
3868    Invoice(Amount),
3869    /// Use this value if you need to set the value to be received by the payee on Arkade.
3870    Vhtlc(Amount),
3871}
3872
3873impl SwapAmount {
3874    pub fn invoice(amount: Amount) -> Self {
3875        Self::Invoice(amount)
3876    }
3877
3878    pub fn vhtlc(amount: Amount) -> Self {
3879        Self::Vhtlc(amount)
3880    }
3881}
3882
3883/// The amount specification for a chain swap.
3884pub enum ChainSwapAmount {
3885    /// The amount the user will lock up.
3886    UserLock(Amount),
3887    /// The amount the user wants to receive (server lock amount).
3888    ServerLock(Amount),
3889}
3890
3891/// Data related to a submarine swap.
3892#[serde_as]
3893#[derive(Debug, Clone, Serialize, Deserialize)]
3894pub struct SubmarineSwapData {
3895    /// Unique swap identifier.
3896    pub id: String,
3897    /// Preimage for the swap (learned when Boltz claims the VHTLC).
3898    pub preimage: Option<[u8; 32]>,
3899    /// The preimage hash of the BOLT11 invoice.
3900    pub preimage_hash: ripemd160::Hash,
3901    /// Public key of the receiving party.
3902    pub claim_public_key: PublicKey,
3903    /// Public key of the sending party.
3904    pub refund_public_key: PublicKey,
3905    /// Amount locked up in the VHTLC.
3906    pub amount: Amount,
3907    /// All the timelocks for this swap.
3908    pub timeout_block_heights: TimeoutBlockHeights,
3909    /// Address where funds are locked.
3910    #[serde_as(as = "DisplayFromStr")]
3911    pub vhtlc_address: ArkAddress,
3912    /// BOLT11 invoice associated with the swap.
3913    pub invoice: Bolt11Invoice,
3914    /// Current swap status.
3915    pub status: SwapStatus,
3916    /// UNIX timestamp when swap was created.
3917    pub created_at: u64,
3918    /// BIP32 derivation index of the refund key (sender).
3919    ///
3920    /// `None` for legacy swap data created before this field was added.
3921    #[serde(default)]
3922    pub key_derivation_index: Option<u32>,
3923}
3924
3925/// Data related to a reverse submarine swap.
3926#[serde_as]
3927#[derive(Debug, Clone, Serialize, Deserialize)]
3928pub struct ReverseSwapData {
3929    /// Unique swap identifier.
3930    pub id: String,
3931    /// Preimage for the swap (optional, may not be known at creation time).
3932    pub preimage: Option<[u8; 32]>,
3933    /// The preimage hash of the BOLT11 invoice.
3934    pub preimage_hash: ripemd160::Hash,
3935    /// Public key of the receiving party.
3936    pub claim_public_key: PublicKey,
3937    /// Public key of the sending party.
3938    pub refund_public_key: PublicKey,
3939    /// Amount locked up in the VHTLC.
3940    pub amount: Amount,
3941    /// All the timelocks for this swap.
3942    pub timeout_block_heights: TimeoutBlockHeights,
3943    /// Address where funds are locked.
3944    #[serde_as(as = "DisplayFromStr")]
3945    pub vhtlc_address: ArkAddress,
3946    /// Current swap status.
3947    pub status: SwapStatus,
3948    /// UNIX timestamp when swap was created.
3949    pub created_at: u64,
3950    /// BIP32 derivation index of the claim key (receiver).
3951    ///
3952    /// `None` for legacy swap data created before this field was added.
3953    #[serde(default)]
3954    pub key_derivation_index: Option<u32>,
3955    /// BOLT11 invoice string for this swap.
3956    pub bolt11: String,
3957    /// Invoice expiry in seconds, derived from the BOLT11 invoice itself.
3958    pub invoice_expiry: u64,
3959    /// Arkade address that receives the claimed VHTLC output.
3960    ///
3961    /// `None` for normal receives and legacy swap data, where the client claims into a fresh local
3962    /// offchain address.
3963    #[serde_as(as = "Option<DisplayFromStr>")]
3964    #[serde(default)]
3965    pub claim_address: Option<ArkAddress>,
3966}
3967
3968/// All possible states of a Boltz swap.
3969///
3970/// Swaps progress through these states during their lifecycle.
3971#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
3972pub enum SwapStatus {
3973    /// Initial state when swap is created.
3974    #[serde(rename = "swap.created")]
3975    Created,
3976    /// Lockup transaction detected in mempool.
3977    #[serde(rename = "transaction.mempool")]
3978    TransactionMempool,
3979    /// Lockup transaction confirmed on-chain.
3980    #[serde(rename = "transaction.confirmed")]
3981    TransactionConfirmed,
3982    /// Transaction refunded.
3983    #[serde(rename = "transaction.refunded")]
3984    TransactionRefunded,
3985    /// Transaction failed.
3986    #[serde(rename = "transaction.failed")]
3987    TransactionFailed,
3988    /// Transaction claimed.
3989    #[serde(rename = "transaction.claimed")]
3990    TransactionClaimed,
3991    /// Server lockup transaction detected in mempool (chain swaps).
3992    #[serde(rename = "transaction.server.mempool")]
3993    TransactionServerMempool,
3994    /// Server lockup transaction confirmed (chain swaps).
3995    #[serde(rename = "transaction.server.confirmed")]
3996    TransactionServerConfirmed,
3997    /// Lightning invoice has been set.
3998    #[serde(rename = "invoice.set")]
3999    InvoiceSet,
4000    /// Waiting for Lightning invoice payment.
4001    #[serde(rename = "invoice.pending")]
4002    InvoicePending,
4003    /// Lightning invoice successfully paid.
4004    #[serde(rename = "invoice.paid")]
4005    InvoicePaid,
4006    /// Lightning invoice payment failed.
4007    #[serde(rename = "invoice.failedToPay")]
4008    InvoiceFailedToPay,
4009    /// Invoice expired.
4010    #[serde(rename = "invoice.expired")]
4011    InvoiceExpired,
4012    /// Lockup amount was insufficient (chain swaps).
4013    #[serde(rename = "transaction.lockupFailed")]
4014    TransactionLockupFailed,
4015    /// Swap expired - can be refunded.
4016    #[serde(rename = "swap.expired")]
4017    SwapExpired,
4018    /// Swap failed with error.
4019    #[serde(rename = "error")]
4020    Error { error: String },
4021    /// An unrecognized status from the Boltz API.
4022    #[serde(untagged)]
4023    Other(String),
4024}
4025
4026impl SwapStatus {
4027    /// Whether this status represents a terminal state (swap is done, no further action needed).
4028    pub fn is_terminal(&self) -> bool {
4029        matches!(
4030            self,
4031            Self::TransactionRefunded
4032                | Self::TransactionFailed
4033                | Self::TransactionClaimed
4034                | Self::TransactionLockupFailed
4035                | Self::InvoicePaid
4036                | Self::InvoiceFailedToPay
4037                | Self::InvoiceExpired
4038                | Self::SwapExpired
4039                | Self::Error { .. }
4040        )
4041    }
4042}
4043
4044#[derive(Debug, Clone, Serialize, Deserialize, Copy)]
4045#[serde(rename_all = "camelCase")]
4046pub struct TimeoutBlockHeights {
4047    pub refund: u32,
4048    pub unilateral_claim: u32,
4049    pub unilateral_refund: u32,
4050    pub unilateral_refund_without_receiver: u32,
4051}
4052
4053#[derive(Debug, Clone, Serialize, Deserialize)]
4054#[serde(rename_all = "UPPERCASE")]
4055enum Asset {
4056    Btc,
4057    Ark,
4058}
4059
4060#[derive(Debug, Clone, Serialize, Deserialize)]
4061#[serde(rename_all = "camelCase")]
4062struct CreateReverseSwapRequest {
4063    from: Asset,
4064    to: Asset,
4065    #[serde(skip_serializing_if = "Option::is_none")]
4066    invoice_amount: Option<Amount>,
4067    #[serde(skip_serializing_if = "Option::is_none")]
4068    onchain_amount: Option<Amount>,
4069    claim_public_key: PublicKey,
4070    preimage_hash: sha256::Hash,
4071    /// The expiry will be this number of seconds in the future.
4072    ///
4073    /// If not provided, the generated invoice will have the default expiry set by Boltz.
4074    #[serde(skip_serializing_if = "Option::is_none")]
4075    invoice_expiry: Option<u64>,
4076    #[serde(skip_serializing_if = "Option::is_none")]
4077    referral_id: Option<String>,
4078    #[serde(skip_serializing_if = "Option::is_none")]
4079    description: Option<String>,
4080}
4081
4082#[serde_as]
4083#[derive(Debug, Clone, Serialize, Deserialize)]
4084#[serde(rename_all = "camelCase")]
4085struct CreateReverseSwapResponse {
4086    id: String,
4087    #[serde_as(as = "DisplayFromStr")]
4088    lockup_address: ArkAddress,
4089    refund_public_key: PublicKey,
4090    timeout_block_heights: TimeoutBlockHeights,
4091    invoice: Bolt11Invoice,
4092    onchain_amount: Option<Amount>,
4093}
4094
4095#[derive(Debug, Clone, Serialize, Deserialize)]
4096struct CreateSubmarineSwapRequest {
4097    from: Asset,
4098    to: Asset,
4099    invoice: Bolt11Invoice,
4100    #[serde(rename = "refundPublicKey")]
4101    refund_public_key: PublicKey,
4102    #[serde(rename = "referralId", skip_serializing_if = "Option::is_none")]
4103    referral_id: Option<String>,
4104}
4105
4106#[serde_as]
4107#[derive(Debug, Clone, Serialize, Deserialize)]
4108#[serde(rename_all = "camelCase")]
4109struct CreateSubmarineSwapResponse {
4110    id: String,
4111    #[serde_as(as = "DisplayFromStr")]
4112    address: ArkAddress,
4113    expected_amount: Amount,
4114    claim_public_key: PublicKey,
4115    timeout_block_heights: TimeoutBlockHeights,
4116}
4117
4118#[derive(Debug, Clone, Serialize, Deserialize)]
4119struct GetSwapStatusResponse {
4120    status: SwapStatus,
4121    #[serde(default)]
4122    transaction: Option<SwapStatusTransaction>,
4123}
4124
4125#[derive(Debug, Clone, Serialize, Deserialize)]
4126struct SwapStatusTransaction {
4127    id: String,
4128}
4129
4130#[derive(Debug, Clone, Serialize, Deserialize)]
4131struct RefundSwapRequest {
4132    transaction: String,
4133    checkpoint: String,
4134}
4135
4136#[derive(Debug, Clone, Serialize, Deserialize)]
4137struct RefundSwapResponse {
4138    transaction: String,
4139    checkpoint: String,
4140    #[serde(skip_serializing_if = "Option::is_none")]
4141    error: Option<String>,
4142}
4143
4144/// Fee information for submarine swaps (Ark -> Lightning).
4145#[derive(Debug, Clone, Serialize, Deserialize)]
4146#[serde(rename_all = "camelCase")]
4147pub struct SubmarineSwapFees {
4148    /// Percentage fee charged by Boltz (e.g., 0.25 = 0.25%).
4149    pub percentage: f64,
4150    /// Fixed miner fee in satoshis.
4151    pub miner_fees: u64,
4152}
4153
4154/// Miner fees for reverse swaps, broken down by operation.
4155#[derive(Debug, Clone, Serialize, Deserialize)]
4156pub struct ReverseMinerFees {
4157    /// Miner fee for lockup transaction in satoshis.
4158    pub lockup: u64,
4159    /// Miner fee for claim transaction in satoshis.
4160    pub claim: u64,
4161}
4162
4163/// Fee information for reverse swaps (Lightning -> Ark).
4164#[derive(Debug, Clone, Serialize, Deserialize)]
4165#[serde(rename_all = "camelCase")]
4166pub struct ReverseSwapFees {
4167    /// Percentage fee charged by Boltz (e.g., 0.25 = 0.25%).
4168    pub percentage: f64,
4169    /// Miner fees broken down by operation.
4170    pub miner_fees: ReverseMinerFees,
4171}
4172
4173/// Combined fee information for both swap types.
4174#[derive(Debug, Clone, Serialize, Deserialize)]
4175pub struct BoltzFees {
4176    /// Fees for submarine swaps (Ark -> Lightning).
4177    pub submarine: SubmarineSwapFees,
4178    /// Fees for reverse swaps (Lightning -> Ark).
4179    pub reverse: ReverseSwapFees,
4180}
4181
4182/// Limits for swap amounts.
4183#[derive(Debug, Clone, Serialize, Deserialize)]
4184pub struct SwapLimits {
4185    /// Minimum amount in satoshis.
4186    pub min: u64,
4187    /// Maximum amount in satoshis.
4188    pub max: u64,
4189}
4190
4191// Internal structs for deserializing the Boltz API response.
4192
4193#[derive(Debug, Clone, Deserialize)]
4194struct PairLimits {
4195    minimal: u64,
4196    maximal: u64,
4197}
4198
4199// Submarine swap: { "ARK": { "BTC": { ... } } }
4200#[derive(Debug, Clone, Deserialize)]
4201#[serde(rename_all = "camelCase")]
4202struct SubmarinePairFees {
4203    percentage: f64,
4204    miner_fees: u64,
4205}
4206
4207#[derive(Debug, Clone, Deserialize)]
4208struct SubmarinePairInfo {
4209    fees: SubmarinePairFees,
4210    limits: PairLimits,
4211}
4212
4213#[derive(Debug, Clone, Deserialize)]
4214#[serde(rename_all = "UPPERCASE")]
4215struct SubmarineArkPairs {
4216    btc: SubmarinePairInfo,
4217}
4218
4219#[derive(Debug, Clone, Deserialize)]
4220#[serde(rename_all = "UPPERCASE")]
4221struct SubmarinePairsResponse {
4222    ark: SubmarineArkPairs,
4223}
4224
4225// Reverse swap: { "BTC": { "ARK": { ... } } }
4226#[derive(Debug, Clone, Deserialize)]
4227#[serde(rename_all = "camelCase")]
4228struct ReverseMinerFeesResponse {
4229    claim: u64,
4230    lockup: u64,
4231}
4232
4233#[derive(Debug, Clone, Deserialize)]
4234#[serde(rename_all = "camelCase")]
4235struct ReversePairFees {
4236    percentage: f64,
4237    miner_fees: ReverseMinerFeesResponse,
4238}
4239
4240#[derive(Debug, Clone, Deserialize)]
4241struct ReversePairInfo {
4242    fees: ReversePairFees,
4243}
4244
4245#[derive(Debug, Clone, Deserialize)]
4246#[serde(rename_all = "UPPERCASE")]
4247struct ReverseBtcPairs {
4248    ark: ReversePairInfo,
4249}
4250
4251#[derive(Debug, Clone, Deserialize)]
4252#[serde(rename_all = "UPPERCASE")]
4253struct ReversePairsResponse {
4254    btc: ReverseBtcPairs,
4255}
4256
4257// ── Chain swap types ──────────────────────────────────────────────────
4258
4259/// Direction of a chain swap.
4260#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
4261pub enum ChainSwapDirection {
4262    /// User locks Ark VHTLC, claims on-chain BTC.
4263    ArkToBtc,
4264    /// User sends on-chain BTC, claims Ark VHTLC.
4265    BtcToArk,
4266}
4267
4268/// Data for a pending chain swap (ARK ↔ BTC).
4269#[serde_as]
4270#[derive(Debug, Clone, Serialize, Deserialize)]
4271pub struct ChainSwapData {
4272    /// Unique swap identifier.
4273    pub id: String,
4274    /// Current swap status.
4275    pub status: SwapStatus,
4276    /// Direction of the swap.
4277    pub direction: ChainSwapDirection,
4278    /// Preimage for the swap.
4279    pub preimage: Option<[u8; 32]>,
4280    /// The preimage hash.
4281    pub preimage_hash: sha256::Hash,
4282    /// User's claim public key (for claiming Boltz's VHTLC).
4283    pub claim_public_key: PublicKey,
4284    /// User's refund public key (for refunding user's VHTLC).
4285    pub refund_public_key: PublicKey,
4286    /// Boltz's claim public key (on user's VHTLC).
4287    pub server_claim_public_key: PublicKey,
4288    /// Boltz's refund public key (on Boltz's VHTLC).
4289    pub server_refund_public_key: PublicKey,
4290    /// Address where user locks funds.
4291    pub user_lockup_address: String,
4292    /// Address where Boltz locks funds.
4293    pub server_lockup_address: String,
4294    /// Amount user locks up.
4295    pub user_lockup_amount: Amount,
4296    /// Amount Boltz locks up (what user receives).
4297    pub server_lockup_amount: Amount,
4298    /// Timeout block height for user's lockup.
4299    pub user_timeout_block_height: u32,
4300    /// Timeout block height for Boltz's lockup.
4301    pub server_timeout_block_height: u32,
4302    /// Full VHTLC timelocks for user's lockup (present when user locks on ARK side).
4303    #[serde(default)]
4304    pub user_timeout_block_heights: Option<TimeoutBlockHeights>,
4305    /// Full VHTLC timelocks for Boltz's lockup (present when server locks on ARK side).
4306    #[serde(default)]
4307    pub server_timeout_block_heights: Option<TimeoutBlockHeights>,
4308    /// BIP21 payment URI for funding (present for on-chain BTC lockup).
4309    #[serde(default)]
4310    pub bip21: Option<String>,
4311    /// Swap tree for the on-chain BTC HTLC (present for the BTC side of chain swaps).
4312    #[serde(default)]
4313    pub swap_tree: Option<SwapTree>,
4314    /// UNIX timestamp when swap was created.
4315    pub created_at: u64,
4316    /// BIP32 derivation index for the claim key.
4317    #[serde(default)]
4318    pub claim_key_derivation_index: Option<u32>,
4319    /// BIP32 derivation index for the refund key.
4320    #[serde(default)]
4321    pub refund_key_derivation_index: Option<u32>,
4322}
4323
4324/// Result of creating a chain swap.
4325#[derive(Clone, Debug)]
4326pub struct ChainSwapResult {
4327    /// Unique swap identifier.
4328    pub swap_id: String,
4329    /// Address the user must fund to initiate the swap.
4330    pub user_lockup_address: String,
4331    /// Amount the user must send.
4332    pub user_lockup_amount: Amount,
4333    /// Amount the user will receive after fees.
4334    pub server_lockup_amount: Amount,
4335    /// BIP21 payment URI for on-chain BTC funding (when the user lockup is BTC).
4336    pub bip21: Option<String>,
4337}
4338
4339// ── Chain swap Boltz API types ───────────────────────────────────────
4340
4341/// Tapscript tree for an on-chain BTC HTLC used in chain swaps.
4342#[derive(Debug, Clone, Serialize, Deserialize)]
4343#[serde(rename_all = "camelCase")]
4344pub struct SwapTree {
4345    /// Leaf used to claim (requires preimage + claim key signature).
4346    pub claim_leaf: SwapTreeLeaf,
4347    /// Leaf used to refund (requires timelock + refund key signature).
4348    pub refund_leaf: SwapTreeLeaf,
4349}
4350
4351/// A single leaf in a [`SwapTree`].
4352#[derive(Debug, Clone, Serialize, Deserialize)]
4353pub struct SwapTreeLeaf {
4354    /// Tapscript leaf version (192 = TapScript).
4355    pub version: u8,
4356    /// Hex-encoded Bitcoin script.
4357    pub output: String,
4358}
4359
4360#[derive(Debug, Clone, Serialize, Deserialize)]
4361#[serde(rename_all = "camelCase")]
4362struct CreateChainSwapRequest {
4363    from: Asset,
4364    to: Asset,
4365    #[serde(skip_serializing_if = "Option::is_none")]
4366    user_lock_amount: Option<Amount>,
4367    #[serde(skip_serializing_if = "Option::is_none")]
4368    server_lock_amount: Option<Amount>,
4369    claim_public_key: PublicKey,
4370    refund_public_key: PublicKey,
4371    preimage_hash: sha256::Hash,
4372    #[serde(skip_serializing_if = "Option::is_none")]
4373    referral_id: Option<String>,
4374}
4375
4376#[serde_as]
4377#[derive(Debug, Clone, Serialize, Deserialize)]
4378#[serde(rename_all = "camelCase")]
4379struct CreateChainSwapResponse {
4380    id: String,
4381    claim_details: ChainSwapSideDetails,
4382    lockup_details: ChainSwapSideDetails,
4383}
4384
4385#[serde_as]
4386#[derive(Debug, Clone, Serialize, Deserialize)]
4387#[serde(rename_all = "camelCase")]
4388struct ChainSwapSideDetails {
4389    lockup_address: String,
4390    server_public_key: PublicKey,
4391    timeout_block_height: u32,
4392    #[serde(default)]
4393    timeouts: Option<TimeoutBlockHeights>,
4394    amount: Amount,
4395    #[serde(default)]
4396    swap_tree: Option<SwapTree>,
4397    #[serde(default)]
4398    bip21: Option<String>,
4399}
4400
4401// VHTLC timeouts come from the stored swap data/Boltz response, not from the server's current
4402// unilateral-exit delay. The legacy exit-delay probe is therefore only needed for regular
4403// VTXO/boarding script discovery.
4404
4405/// Iterate `server_keys` in order, building a [`VhtlcScript`] for each one, and return the
4406/// first whose address matches `expected_address`.
4407///
4408/// Extracted from [`Client::reconstruct_vhtlc_for_address`] so the key-iteration logic can be
4409/// tested without a full [`Client`] instance.
4410pub(crate) fn reconstruct_vhtlc_from_keys(
4411    server_keys: impl Iterator<Item = XOnlyPublicKey>,
4412    network: bitcoin::Network,
4413    mk_opts: impl Fn(XOnlyPublicKey) -> Result<VhtlcOptions, Error>,
4414    expected_address: &ArkAddress,
4415) -> Result<VhtlcScript, Error> {
4416    for server_key in server_keys {
4417        let opts = mk_opts(server_key)?;
4418        let vhtlc = VhtlcScript::new(opts, network).map_err(Error::ad_hoc)?;
4419        if &vhtlc.address() == expected_address {
4420            return Ok(vhtlc);
4421        }
4422    }
4423    Err(Error::ad_hoc(format!(
4424        "VHTLC script could not be reconstructed for address {expected_address}: \
4425         does not match current or any deprecated server key"
4426    )))
4427}
4428
4429#[cfg(test)]
4430mod tests {
4431    use super::*;
4432
4433    #[test]
4434    fn test_deserialize_create_reverse_swap_response() {
4435        let json = r#"{
4436  "id": "vqhG2fJtNY4H",
4437  "lockupAddress": "tark1qra883hysahlkt0ujcwhv0x2n278849c3m7t3a08l7fdc40f4f2nmw3f7kn37vvq0hqazxtqgtvhwp3z83zfgr7qc82t9mty8vk95ynpx3l43d",
4438  "refundPublicKey": "0206988651c7fbe41747bb21b54ced0a183f4d658e007ee8fdb23fbbfccb8e0c55",
4439  "timeoutBlockHeights": {
4440    "refund": 1760508054,
4441    "unilateralClaim": 9728,
4442    "unilateralRefund": 86528,
4443    "unilateralRefundWithoutReceiver": 86528
4444  },
4445  "invoice": "lntbs10u1p5wmeeepp56ms94rkev7tdrwqyus5a63lny2mqzq9vh2rq3u4ym3v4lxv6xl4qdql2djkuepqw3hjqs2jfvsxzerywfjhxuccqz95xqztfsp5ckaskagag554na8d56tlrfdxasstqrmmpkvswqqqx6y386jcfq9s9qxpqysgqt7z0vkdwkqamydae7ctgkh7l8q75w7q9394ce3lda2mkfxrpfdtj5gmltuctav7jdgatkflhztrjjzutdla5e4xp0uhxxy7sluzll4qpkkh6wv",
4446  "onchainAmount": 996
4447}"#;
4448
4449        let response: CreateReverseSwapResponse =
4450            serde_json::from_str(json).expect("Failed to deserialize CreateReverseSwapResponse");
4451
4452        // Verify the deserialized fields
4453        assert_eq!(response.id, "vqhG2fJtNY4H");
4454        assert_eq!(response.onchain_amount, Some(Amount::from_sat(996)));
4455        assert_eq!(
4456            response.refund_public_key,
4457            PublicKey::from_str(
4458                "0206988651c7fbe41747bb21b54ced0a183f4d658e007ee8fdb23fbbfccb8e0c55"
4459            )
4460            .expect("valid public key")
4461        );
4462        assert_eq!(
4463            response.lockup_address.to_string(),
4464            "tark1qra883hysahlkt0ujcwhv0x2n278849c3m7t3a08l7fdc40f4f2nmw3f7kn37vvq0hqazxtqgtvhwp3z83zfgr7qc82t9mty8vk95ynpx3l43d"
4465        );
4466        assert_eq!(response.timeout_block_heights.refund, 1760508054);
4467        assert_eq!(response.timeout_block_heights.unilateral_claim, 9728);
4468        assert_eq!(response.timeout_block_heights.unilateral_refund, 86528);
4469        assert_eq!(
4470            response
4471                .timeout_block_heights
4472                .unilateral_refund_without_receiver,
4473            86528
4474        );
4475    }
4476
4477    #[test]
4478    fn test_btc_htlc_address_reconstruction_btc_to_ark() {
4479        // Real BtcToArk chain swap response from Boltz mutinynet.
4480        // lockupDetails = BTC side (user locks): serverPublicKey = server's claim key.
4481        // User's key is refundPublicKey from the request.
4482        let server_pk = PublicKey::from_str(
4483            "03ce9f5a57218103d5fe07b9d7ecf4b28ad60a960f0fbfd86dd090013020617389",
4484        )
4485        .unwrap();
4486        let user_pk = PublicKey::from_str(
4487            "02c6047f9441ed7d6d3045406e95c07cd85c778e4b8cef3ca7abac09b95c709ee5",
4488        )
4489        .unwrap();
4490        let swap_tree = SwapTree {
4491            claim_leaf: SwapTreeLeaf {
4492                version: 192,
4493                output: "82012088a914b472a266d0bd89c13706a4132ccfb16f7c3b9fcb8820ce9f5a57218103d5fe07b9d7ecf4b28ad60a960f0fbfd86dd090013020617389ac".into(),
4494            },
4495            refund_leaf: SwapTreeLeaf {
4496                version: 192,
4497                output: "20c6047f9441ed7d6d3045406e95c07cd85c778e4b8cef3ca7abac09b95c709ee5ad03f9832db1".into(),
4498            },
4499        };
4500
4501        let spend_info = reconstruct_btc_htlc(server_pk, user_pk, &swap_tree).unwrap();
4502
4503        let secp = Secp256k1::new();
4504        let spk = ScriptBuf::new_p2tr(&secp, spend_info.internal_key(), spend_info.merkle_root());
4505        let addr = bitcoin::Address::from_script(&spk, bitcoin::Network::Testnet).unwrap();
4506
4507        assert_eq!(
4508            addr.to_string(),
4509            "tb1ptf632fkczflsjn4356ra4x2s6qp6vvk8e7pplprpwnkvcsd8tpwqkw92c7"
4510        );
4511    }
4512
4513    #[test]
4514    fn submarine_swap_request_serializes_referral_id_when_set() {
4515        let request = CreateSubmarineSwapRequest {
4516            from: Asset::Ark,
4517            to: Asset::Btc,
4518            invoice: Bolt11Invoice::from_str(
4519                "lntbs10u1p5wmeeepp56ms94rkev7tdrwqyus5a63lny2mqzq9vh2rq3u4ym3v4lxv6xl4qdql2djkuepqw3hjqs2jfvsxzerywfjhxuccqz95xqztfsp5ckaskagag554na8d56tlrfdxasstqrmmpkvswqqqx6y386jcfq9s9qxpqysgqt7z0vkdwkqamydae7ctgkh7l8q75w7q9394ce3lda2mkfxrpfdtj5gmltuctav7jdgatkflhztrjjzutdla5e4xp0uhxxy7sluzll4qpkkh6wv",
4520            )
4521            .unwrap(),
4522            refund_public_key: PublicKey::from_str(
4523                "02c6047f9441ed7d6d3045406e95c07cd85c778e4b8cef3ca7abac09b95c709ee5",
4524            )
4525            .unwrap(),
4526            referral_id: Some("partner-xyz".to_string()),
4527        };
4528
4529        let json: serde_json::Value = serde_json::to_value(&request).unwrap();
4530        assert_eq!(json["referralId"], "partner-xyz");
4531    }
4532
4533    #[test]
4534    fn submarine_swap_request_omits_referral_id_when_none() {
4535        let request = CreateSubmarineSwapRequest {
4536            from: Asset::Ark,
4537            to: Asset::Btc,
4538            invoice: Bolt11Invoice::from_str(
4539                "lntbs10u1p5wmeeepp56ms94rkev7tdrwqyus5a63lny2mqzq9vh2rq3u4ym3v4lxv6xl4qdql2djkuepqw3hjqs2jfvsxzerywfjhxuccqz95xqztfsp5ckaskagag554na8d56tlrfdxasstqrmmpkvswqqqx6y386jcfq9s9qxpqysgqt7z0vkdwkqamydae7ctgkh7l8q75w7q9394ce3lda2mkfxrpfdtj5gmltuctav7jdgatkflhztrjjzutdla5e4xp0uhxxy7sluzll4qpkkh6wv",
4540            )
4541            .unwrap(),
4542            refund_public_key: PublicKey::from_str(
4543                "02c6047f9441ed7d6d3045406e95c07cd85c778e4b8cef3ca7abac09b95c709ee5",
4544            )
4545            .unwrap(),
4546            referral_id: None,
4547        };
4548
4549        let json: serde_json::Value = serde_json::to_value(&request).unwrap();
4550        assert!(json.get("referralId").is_none());
4551        assert!(json.get("referral_id").is_none());
4552    }
4553
4554    #[test]
4555    fn reverse_swap_request_serializes_referral_id_when_set() {
4556        let request = CreateReverseSwapRequest {
4557            from: Asset::Btc,
4558            to: Asset::Ark,
4559            invoice_amount: Some(Amount::from_sat(1000)),
4560            onchain_amount: None,
4561            claim_public_key: PublicKey::from_str(
4562                "02c6047f9441ed7d6d3045406e95c07cd85c778e4b8cef3ca7abac09b95c709ee5",
4563            )
4564            .unwrap(),
4565            preimage_hash: sha256::Hash::from_byte_array([1u8; 32]),
4566            invoice_expiry: Some(3600),
4567            referral_id: Some("partner-xyz".to_string()),
4568            description: None,
4569        };
4570
4571        let json: serde_json::Value = serde_json::to_value(&request).unwrap();
4572        assert_eq!(json["referralId"], "partner-xyz");
4573    }
4574
4575    #[test]
4576    fn reverse_swap_request_omits_referral_id_when_none() {
4577        let request = CreateReverseSwapRequest {
4578            from: Asset::Btc,
4579            to: Asset::Ark,
4580            invoice_amount: Some(Amount::from_sat(1000)),
4581            onchain_amount: None,
4582            claim_public_key: PublicKey::from_str(
4583                "02c6047f9441ed7d6d3045406e95c07cd85c778e4b8cef3ca7abac09b95c709ee5",
4584            )
4585            .unwrap(),
4586            preimage_hash: sha256::Hash::from_byte_array([1u8; 32]),
4587            invoice_expiry: Some(3600),
4588            referral_id: None,
4589            description: None,
4590        };
4591
4592        let json: serde_json::Value = serde_json::to_value(&request).unwrap();
4593        assert!(json.get("referralId").is_none());
4594        assert!(json.get("referral_id").is_none());
4595    }
4596
4597    #[test]
4598    fn chain_swap_request_serializes_referral_id_when_set() {
4599        let request = CreateChainSwapRequest {
4600            from: Asset::Ark,
4601            to: Asset::Btc,
4602            user_lock_amount: Some(Amount::from_sat(1000)),
4603            server_lock_amount: None,
4604            claim_public_key: PublicKey::from_str(
4605                "02c6047f9441ed7d6d3045406e95c07cd85c778e4b8cef3ca7abac09b95c709ee5",
4606            )
4607            .unwrap(),
4608            refund_public_key: PublicKey::from_str(
4609                "0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798",
4610            )
4611            .unwrap(),
4612            preimage_hash: sha256::Hash::from_byte_array([1u8; 32]),
4613            referral_id: Some("partner-xyz".to_string()),
4614        };
4615
4616        let json: serde_json::Value = serde_json::to_value(&request).unwrap();
4617        assert_eq!(json["referralId"], "partner-xyz");
4618    }
4619
4620    #[test]
4621    fn chain_swap_request_omits_referral_id_when_none() {
4622        let request = CreateChainSwapRequest {
4623            from: Asset::Ark,
4624            to: Asset::Btc,
4625            user_lock_amount: Some(Amount::from_sat(1000)),
4626            server_lock_amount: None,
4627            claim_public_key: PublicKey::from_str(
4628                "02c6047f9441ed7d6d3045406e95c07cd85c778e4b8cef3ca7abac09b95c709ee5",
4629            )
4630            .unwrap(),
4631            refund_public_key: PublicKey::from_str(
4632                "0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798",
4633            )
4634            .unwrap(),
4635            preimage_hash: sha256::Hash::from_byte_array([1u8; 32]),
4636            referral_id: None,
4637        };
4638
4639        let json: serde_json::Value = serde_json::to_value(&request).unwrap();
4640        assert!(json.get("referralId").is_none());
4641        assert!(json.get("referral_id").is_none());
4642    }
4643
4644    #[test]
4645    fn test_btc_htlc_address_reconstruction_ark_to_btc() {
4646        // Real ArkToBtc chain swap response from Boltz mutinynet.
4647        // claimDetails = BTC side (user claims): serverPublicKey = server's refund key.
4648        // User's key is claimPublicKey from the request.
4649        let server_pk = PublicKey::from_str(
4650            "0207364dc5853e630be83439fde62b531e3c11db34ce8c4f454a56782555c58ed6",
4651        )
4652        .unwrap();
4653        let user_pk = PublicKey::from_str(
4654            "0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798",
4655        )
4656        .unwrap();
4657        let swap_tree = SwapTree {
4658            claim_leaf: SwapTreeLeaf {
4659                version: 192,
4660                output: "82012088a914cf7ff51392e9a37bc72c7284841db669c82e2c14882079be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798ac".into(),
4661            },
4662            refund_leaf: SwapTreeLeaf {
4663                version: 192,
4664                output: "2007364dc5853e630be83439fde62b531e3c11db34ce8c4f454a56782555c58ed6ad036b832db1".into(),
4665            },
4666        };
4667
4668        let spend_info = reconstruct_btc_htlc(server_pk, user_pk, &swap_tree).unwrap();
4669
4670        let secp = Secp256k1::new();
4671        let spk = ScriptBuf::new_p2tr(&secp, spend_info.internal_key(), spend_info.merkle_root());
4672        let addr = bitcoin::Address::from_script(&spk, bitcoin::Network::Testnet).unwrap();
4673
4674        assert_eq!(
4675            addr.to_string(),
4676            "tb1pxa78pf55g0aaurrd8c76fyax4df9e8y38fzps8sw2vkrecf9k3ss36a78m"
4677        );
4678    }
4679
4680    #[test]
4681    fn validate_invoice_description_accepts_none_empty_and_max_length() {
4682        assert!(validate_invoice_description(None).is_ok());
4683        assert!(validate_invoice_description(Some("")).is_ok());
4684        let at_limit = "a".repeat(MAX_BOLT11_DESCRIPTION_BYTES);
4685        assert!(validate_invoice_description(Some(&at_limit)).is_ok());
4686    }
4687
4688    #[test]
4689    fn validate_invoice_description_rejects_over_limit() {
4690        let too_long = "a".repeat(MAX_BOLT11_DESCRIPTION_BYTES + 1);
4691        let err = validate_invoice_description(Some(&too_long)).unwrap_err();
4692        let msg = err.to_string();
4693        assert!(msg.contains("640"), "unexpected error message: {msg}");
4694        assert!(msg.contains("639"), "unexpected error message: {msg}");
4695    }
4696
4697    // ── reconstruct_vhtlc_from_keys ─────────────────────────────────────────
4698
4699    /// Build a [`VhtlcOptions`] from the first fixture in vhtlc.json (CSV > 16).
4700    /// Keys and expected address are taken verbatim from the JSON fixture so the test
4701    /// is independent of any client-side logic.
4702    fn fixture_opts(server: XOnlyPublicKey) -> VhtlcOptions {
4703        let sender = XOnlyPublicKey::from(
4704            PublicKey::from_str(
4705                "030192e796452d6df9697c280542e1560557bcf79a347d925895043136225c7cb4",
4706            )
4707            .unwrap()
4708            .inner,
4709        );
4710        let receiver = XOnlyPublicKey::from(
4711            PublicKey::from_str(
4712                "021e1bb85455fe3f5aed60d101aa4dbdb9e7714f6226769a97a17a5331dadcd53b",
4713            )
4714            .unwrap()
4715            .inner,
4716        );
4717        VhtlcOptions {
4718            sender,
4719            receiver,
4720            server,
4721            preimage_hash: ripemd160::Hash::from_str("4d487dd3753a89bc9fe98401d1196523058251fc")
4722                .unwrap(),
4723            refund_locktime: 265,
4724            unilateral_claim_delay: bitcoin::Sequence::from_height(17),
4725            unilateral_refund_delay: bitcoin::Sequence::from_height(144),
4726            unilateral_refund_without_receiver_delay: bitcoin::Sequence::from_height(144),
4727        }
4728    }
4729
4730    fn fixture_server_xonly() -> XOnlyPublicKey {
4731        XOnlyPublicKey::from(
4732            PublicKey::from_str(
4733                "03aad52d58162e9eefeafc7ad8a1cdca8060b5f01df1e7583362d052e266208f88",
4734            )
4735            .unwrap()
4736            .inner,
4737        )
4738    }
4739
4740    // Expected Ark address from the fixture (vhtlc.json CSV > 16 case, testnet).
4741    const FIXTURE_ADDRESS: &str = "tark1qz4d2t2czchfaml2l3ad3gwde2qxpd0srhc7wkpnvtg99cnxyz8c3pnvvhnhumhwhqthmlxmdryakwx99s6508y8dunj9sty2p5mr7unh5re63";
4742
4743    // A second server key that produces a different address for the same other params.
4744    fn wrong_server_xonly() -> XOnlyPublicKey {
4745        XOnlyPublicKey::from(
4746            PublicKey::from_str(
4747                "0206988651c7fbe41747bb21b54ced0a183f4d658e007ee8fdb23fbbfccb8e0c55",
4748            )
4749            .unwrap()
4750            .inner,
4751        )
4752    }
4753
4754    #[test]
4755    fn reconstruct_matches_with_single_current_key() {
4756        let server = fixture_server_xonly();
4757        let expected = ArkAddress::decode(FIXTURE_ADDRESS).unwrap();
4758
4759        let vhtlc = reconstruct_vhtlc_from_keys(
4760            std::iter::once(server),
4761            bitcoin::Network::Testnet,
4762            |sk| Ok(fixture_opts(sk)),
4763            &expected,
4764        )
4765        .unwrap();
4766
4767        assert_eq!(vhtlc.address(), expected);
4768    }
4769
4770    #[test]
4771    fn reconstruct_skips_wrong_key_and_finds_deprecated() {
4772        let wrong = wrong_server_xonly();
4773        let correct = fixture_server_xonly();
4774        let expected = ArkAddress::decode(FIXTURE_ADDRESS).unwrap();
4775
4776        // Iterator: wrong key first, correct key second (simulates signer rotation).
4777        let keys = [wrong, correct].into_iter();
4778        let vhtlc = reconstruct_vhtlc_from_keys(
4779            keys,
4780            bitcoin::Network::Testnet,
4781            |sk| Ok(fixture_opts(sk)),
4782            &expected,
4783        )
4784        .unwrap();
4785
4786        assert_eq!(vhtlc.address(), expected);
4787    }
4788
4789    #[test]
4790    fn reconstruct_errors_when_no_key_matches() {
4791        let wrong = wrong_server_xonly();
4792        let expected = ArkAddress::decode(FIXTURE_ADDRESS).unwrap();
4793
4794        let err = reconstruct_vhtlc_from_keys(
4795            std::iter::once(wrong),
4796            bitcoin::Network::Testnet,
4797            |sk| Ok(fixture_opts(sk)),
4798            &expected,
4799        )
4800        .err()
4801        .expect("should have failed");
4802
4803        assert!(
4804            err.to_string()
4805                .contains("does not match current or any deprecated server key"),
4806            "unexpected error: {err}"
4807        );
4808    }
4809
4810    #[test]
4811    fn reconstruct_propagates_mk_opts_error() {
4812        let server = fixture_server_xonly();
4813        let expected = ArkAddress::decode(FIXTURE_ADDRESS).unwrap();
4814
4815        let err = reconstruct_vhtlc_from_keys(
4816            std::iter::once(server),
4817            bitcoin::Network::Testnet,
4818            |_| Err(Error::ad_hoc("options error")),
4819            &expected,
4820        )
4821        .err()
4822        .expect("should have failed");
4823
4824        assert!(
4825            err.to_string().contains("options error"),
4826            "unexpected: {err}"
4827        );
4828    }
4829
4830    #[test]
4831    fn build_vhtlc_script_sender_is_refund_receiver_is_claim() {
4832        // Verify the key-role mapping: build_vhtlc_script(claim, refund, ...) must produce the
4833        // same address as a manually-constructed VhtlcOptions{sender=refund, receiver=claim}.
4834        let claim_pk = PublicKey::from_str(
4835            "021e1bb85455fe3f5aed60d101aa4dbdb9e7714f6226769a97a17a5331dadcd53b",
4836        )
4837        .unwrap();
4838        let refund_pk = PublicKey::from_str(
4839            "030192e796452d6df9697c280542e1560557bcf79a347d925895043136225c7cb4",
4840        )
4841        .unwrap();
4842        let server = fixture_server_xonly();
4843        let expected = ArkAddress::decode(FIXTURE_ADDRESS).unwrap();
4844
4845        let opts = VhtlcOptions {
4846            sender: refund_pk.inner.x_only_public_key().0,
4847            receiver: claim_pk.inner.x_only_public_key().0,
4848            server,
4849            preimage_hash: ripemd160::Hash::from_str("4d487dd3753a89bc9fe98401d1196523058251fc")
4850                .unwrap(),
4851            refund_locktime: 265,
4852            unilateral_claim_delay: bitcoin::Sequence::from_height(17),
4853            unilateral_refund_delay: bitcoin::Sequence::from_height(144),
4854            unilateral_refund_without_receiver_delay: bitcoin::Sequence::from_height(144),
4855        };
4856        let manual_vhtlc =
4857            VhtlcScript::new(opts, bitcoin::Network::Testnet).expect("valid options");
4858
4859        // The manual construction produces the expected fixture address.
4860        assert_eq!(manual_vhtlc.address(), expected);
4861    }
4862}