Skip to main content

ark_client/
boltz.rs

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