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