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::send::build_offchain_transactions;
12use ark_core::send::sign_ark_transaction;
13use ark_core::send::sign_checkpoint_transaction;
14use ark_core::send::OffchainTransactions;
15use ark_core::send::VtxoInput;
16use ark_core::server::parse_sequence_number;
17use ark_core::vhtlc::VhtlcOptions;
18use ark_core::vhtlc::VhtlcScript;
19use ark_core::ArkAddress;
20use ark_core::VtxoList;
21use ark_core::VTXO_CONDITION_KEY;
22use bitcoin::absolute;
23use bitcoin::consensus::Encodable;
24use bitcoin::hashes::ripemd160;
25use bitcoin::hashes::sha256;
26use bitcoin::hashes::Hash;
27use bitcoin::io::Write;
28use bitcoin::key::Secp256k1;
29use bitcoin::psbt;
30use bitcoin::secp256k1;
31use bitcoin::secp256k1::schnorr;
32use bitcoin::taproot::LeafVersion;
33use bitcoin::Amount;
34use bitcoin::Psbt;
35use bitcoin::PublicKey;
36use bitcoin::TxOut;
37use bitcoin::Txid;
38use bitcoin::VarInt;
39use bitcoin::XOnlyPublicKey;
40use lightning_invoice::Bolt11Invoice;
41use rand::CryptoRng;
42use rand::Rng;
43use serde::Deserialize;
44use serde::Serialize;
45use serde_with::serde_as;
46use serde_with::DisplayFromStr;
47use std::str::FromStr;
48use std::time::SystemTime;
49use std::time::UNIX_EPOCH;
50
51#[derive(Clone, Debug)]
52pub struct SubmarineSwapResult {
53    pub swap_id: String,
54    pub txid: Txid,
55    pub amount: Amount,
56}
57
58#[derive(Clone, Debug)]
59pub struct ReverseSwapResult {
60    pub swap_id: String,
61    pub amount: Amount,
62    pub invoice: Bolt11Invoice,
63}
64
65#[derive(Clone, Debug)]
66pub struct ClaimVhtlcResult {
67    pub swap_id: String,
68    pub claim_txid: Txid,
69    pub claim_amount: Amount,
70    pub preimage: [u8; 32],
71}
72
73impl<B, W, S, K> Client<B, W, S, K>
74where
75    B: Blockchain,
76    W: BoardingWallet + OnchainWallet,
77    S: SwapStorage + 'static,
78    K: crate::KeyProvider,
79{
80    // Submarine swap.
81
82    /// Prepare the payment of a BOLT11 invoice by setting up a submarine swap via Boltz.
83    ///
84    /// This function does not execute the payment itself. Once you are ready for payment you
85    /// will have to send the required `amount` to the `vhtlc_address`.
86    ///
87    /// If you are looking for a function which pays the invoice immediately, consider using
88    /// [`Client::pay_ln_invoice`] instead.
89    ///
90    /// # Arguments
91    ///
92    /// - `invoice`: a [`Bolt11Invoice`] to be paid.
93    ///
94    /// # Returns
95    ///
96    /// - A [`SubmarineSwapData`] object, including an identifier for the swap.
97    pub async fn prepare_ln_invoice_payment(
98        &self,
99        invoice: Bolt11Invoice,
100    ) -> Result<SubmarineSwapData, Error> {
101        let refund_public_key = self
102            .next_keypair(crate::key_provider::KeypairIndex::New)?
103            .public_key();
104
105        let preimage_hash = invoice.payment_hash();
106        let preimage_hash = ripemd160::Hash::hash(preimage_hash.as_byte_array());
107
108        let request = CreateSubmarineSwapRequest {
109            from: Asset::Ark,
110            to: Asset::Btc,
111            invoice,
112            refund_public_key: refund_public_key.into(),
113        };
114        let url = format!("{}/v2/swap/submarine", self.inner.boltz_url);
115
116        let client = reqwest::Client::new();
117        let response = client
118            .post(&url)
119            .json(&request)
120            .send()
121            .await
122            .map_err(|e| Error::ad_hoc(e.to_string()))
123            .context("failed to send submarine swap request")?;
124
125        if !response.status().is_success() {
126            let error_text = response
127                .text()
128                .await
129                .map_err(|e| Error::ad_hoc(e.to_string()))
130                .context("failed to read error text")?;
131
132            return Err(Error::ad_hoc(format!(
133                "failed to create submarine swap: {error_text}"
134            )));
135        }
136
137        let swap_response: CreateSubmarineSwapResponse = response
138            .json()
139            .await
140            .map_err(|e| Error::ad_hoc(e.to_string()))
141            .context("failed to deserialize submarine swap response")?;
142
143        let created_at = SystemTime::now()
144            .duration_since(UNIX_EPOCH)
145            .map_err(Error::ad_hoc)
146            .context("failed to compute created_at")?;
147
148        let data = SubmarineSwapData {
149            id: swap_response.id.clone(),
150            status: SwapStatus::Created,
151            preimage_hash,
152            refund_public_key: refund_public_key.into(),
153            claim_public_key: swap_response.claim_public_key,
154            vhtlc_address: swap_response.address,
155            timeout_block_heights: swap_response.timeout_block_heights,
156            amount: swap_response.expected_amount,
157            invoice: request.invoice.clone(),
158            created_at: created_at.as_secs(),
159        };
160
161        self.swap_storage()
162            .insert_submarine(swap_response.id.clone(), data.clone())
163            .await?;
164
165        tracing::info!(
166            swap_id = swap_response.id,
167            vhtlc_address = %data.vhtlc_address,
168            expected_amount = %data.amount,
169            "Prepared Lightning invoice payment"
170        );
171
172        Ok(data)
173    }
174
175    /// Pay a BOLT11 invoice by performing a submarine swap via Boltz. This allows to make Lightning
176    /// payments with an Ark wallet.
177    ///
178    /// # Arguments
179    ///
180    /// - `invoice`: a [`Bolt11Invoice`] to be paid.
181    ///
182    /// # Returns
183    ///
184    /// - A [`SubmarineSwapResult`], including an identifier for the swap and the TXID of the Ark
185    ///   transaction that funds the VHTLC.
186    pub async fn pay_ln_invoice(
187        &self,
188        invoice: Bolt11Invoice,
189    ) -> Result<SubmarineSwapResult, Error> {
190        let keypair = self.next_keypair(crate::key_provider::KeypairIndex::New)?;
191        let refund_public_key = keypair.public_key();
192
193        let preimage_hash = invoice.payment_hash();
194        let preimage_hash = ripemd160::Hash::hash(preimage_hash.as_byte_array());
195
196        let request = CreateSubmarineSwapRequest {
197            from: Asset::Ark,
198            to: Asset::Btc,
199            invoice,
200            refund_public_key: refund_public_key.into(),
201        };
202        let url = format!("{}/v2/swap/submarine", self.inner.boltz_url);
203
204        let client = reqwest::Client::new();
205        let response = client
206            .post(&url)
207            .json(&request)
208            .send()
209            .await
210            .map_err(|e| Error::ad_hoc(e.to_string()))
211            .context("failed to send submarine swap request")?;
212
213        if !response.status().is_success() {
214            let error_text = response
215                .text()
216                .await
217                .map_err(|e| Error::ad_hoc(e.to_string()))
218                .context("failed to read error text")?;
219
220            return Err(Error::ad_hoc(format!(
221                "failed to create submarine swap: {error_text}"
222            )));
223        }
224
225        let swap_response: CreateSubmarineSwapResponse = response
226            .json()
227            .await
228            .map_err(|e| Error::ad_hoc(e.to_string()))
229            .context("failed to deserialize submarine swap response")?;
230
231        let created_at = SystemTime::now()
232            .duration_since(UNIX_EPOCH)
233            .map_err(Error::ad_hoc)
234            .context("failed to compute created_at")?;
235
236        self.swap_storage()
237            .insert_submarine(
238                swap_response.id.clone(),
239                SubmarineSwapData {
240                    id: swap_response.id.clone(),
241                    status: SwapStatus::Created,
242                    preimage_hash,
243                    refund_public_key: refund_public_key.into(),
244                    claim_public_key: swap_response.claim_public_key,
245                    vhtlc_address: swap_response.address,
246                    timeout_block_heights: swap_response.timeout_block_heights,
247                    amount: swap_response.expected_amount,
248                    invoice: request.invoice.clone(),
249                    created_at: created_at.as_secs(),
250                },
251            )
252            .await?;
253
254        let vhtlc_address = swap_response.address;
255        let amount = swap_response.expected_amount;
256        let txid = self.send_vtxo(vhtlc_address, amount).await?;
257
258        tracing::info!(swap_id = swap_response.id, %amount, "Funded VHTLC");
259
260        Ok(SubmarineSwapResult {
261            swap_id: swap_response.id,
262            txid,
263            amount,
264        })
265    }
266
267    /// Wait for the Lightning invoice associated with a submarine swap to be paid by Boltz.
268    ///
269    /// Boltz will first need to claim our VHTLC before paying the invoice.
270    pub async fn wait_for_invoice_paid(&self, swap_id: &str) -> Result<(), Error> {
271        use futures::StreamExt;
272
273        let stream = self.subscribe_to_swap_updates(swap_id.to_string());
274        tokio::pin!(stream);
275
276        while let Some(status_result) = stream.next().await {
277            match status_result {
278                Ok(status) => {
279                    tracing::debug!(swap_id, current = ?status, "Swap status");
280                    match status {
281                        SwapStatus::InvoicePaid => {
282                            return Ok(());
283                        }
284                        SwapStatus::InvoiceExpired => {
285                            return Err(Error::ad_hoc(format!(
286                                "invoice expired for swap {swap_id}"
287                            )));
288                        }
289                        SwapStatus::Error { error } => {
290                            tracing::error!(
291                                swap_id,
292                                "Got error from swap updates subscription: {error}"
293                            );
294                        }
295                        // TODO: We may still need to handle some of these explicitly.
296                        SwapStatus::InvoiceSet
297                        | SwapStatus::InvoicePending
298                        | SwapStatus::Created
299                        | SwapStatus::TransactionMempool
300                        | SwapStatus::TransactionConfirmed
301                        | SwapStatus::TransactionRefunded
302                        | SwapStatus::TransactionFailed
303                        | SwapStatus::TransactionClaimed
304                        | SwapStatus::InvoiceFailedToPay
305                        | SwapStatus::SwapExpired => {}
306                    }
307                }
308                Err(e) => return Err(e),
309            }
310        }
311
312        Err(Error::ad_hoc("Status stream ended unexpectedly"))
313    }
314
315    /// Refund a VHTLC after the timelock has expired.
316    ///
317    /// This path does not require a signature from Boltz.
318    pub async fn refund_expired_vhtlc(&self, swap_id: &str) -> Result<Txid, Error> {
319        let swap_data = self
320            .swap_storage()
321            .get_submarine(swap_id)
322            .await?
323            .ok_or(Error::ad_hoc("Submarine swap not found"))?;
324
325        let timeout_block_heights = swap_data.timeout_block_heights;
326
327        let vhtlc = VhtlcScript::new(
328            VhtlcOptions {
329                sender: swap_data.refund_public_key.into(),
330                receiver: swap_data.claim_public_key.into(),
331                server: self.server_info.signer_pk.into(),
332                preimage_hash: swap_data.preimage_hash,
333                refund_locktime: timeout_block_heights.refund,
334                unilateral_claim_delay: parse_sequence_number(
335                    timeout_block_heights.unilateral_claim as i64,
336                )
337                .map_err(|e| Error::ad_hoc(format!("invalid unilateral claim timeout: {e}")))?,
338                unilateral_refund_delay: parse_sequence_number(
339                    timeout_block_heights.unilateral_refund as i64,
340                )
341                .map_err(|e| Error::ad_hoc(format!("invalid unilateral refund timeout: {e}")))?,
342                unilateral_refund_without_receiver_delay: parse_sequence_number(
343                    timeout_block_heights.unilateral_refund_without_receiver as i64,
344                )
345                .map_err(|e| {
346                    Error::ad_hoc(format!("invalid refund without receiver timeout: {e}"))
347                })?,
348            },
349            self.server_info.network,
350        )
351        .map_err(Error::ad_hoc)?;
352
353        let vhtlc_address = vhtlc.address();
354        if vhtlc_address != swap_data.vhtlc_address {
355            return Err(Error::ad_hoc(format!(
356                "VHTLC address ({vhtlc_address}) does not match swap address ({})",
357                swap_data.vhtlc_address
358            )));
359        }
360
361        let vhtlc_outpoint = {
362            let virtual_tx_outpoints = self
363                .get_virtual_tx_outpoints(std::iter::once(vhtlc_address))
364                .await?;
365
366            let vtxo_list = VtxoList::new(self.server_info.dust, virtual_tx_outpoints);
367
368            // We expect a single outpoint.
369            let mut unspent = vtxo_list.all_unspent();
370            let vhtlc_outpoint = unspent.next().ok_or_else(|| {
371                Error::ad_hoc(format!("no outpoint found for address {vhtlc_address}"))
372            })?;
373
374            vhtlc_outpoint.clone()
375        };
376
377        let (refund_address, _) = self.get_offchain_address()?;
378        let refund_amount = swap_data.amount;
379
380        let outputs = vec![(&refund_address, refund_amount)];
381
382        let refund_script = vhtlc.refund_without_receiver_script();
383
384        let spend_info = vhtlc.taproot_spend_info();
385        let script_ver = (refund_script, LeafVersion::TapScript);
386        let control_block = spend_info
387            .control_block(&script_ver)
388            .ok_or(Error::ad_hoc("control block not found for refund script"))?;
389
390        let script_pubkey = vhtlc.script_pubkey();
391
392        let refunder_pk = swap_data.refund_public_key.inner.x_only_public_key().0;
393        let vhtlc_input = VtxoInput::new(
394            script_ver.0,
395            Some(absolute::LockTime::from_consensus(
396                swap_data.timeout_block_heights.refund,
397            )),
398            control_block,
399            vhtlc.tapscripts(),
400            script_pubkey,
401            refund_amount,
402            vhtlc_outpoint.outpoint,
403        );
404
405        let OffchainTransactions {
406            mut ark_tx,
407            checkpoint_txs,
408        } = build_offchain_transactions(
409            &outputs,
410            None,
411            std::slice::from_ref(&vhtlc_input),
412            &self.server_info,
413        )?;
414
415        let kp = self.keypair_by_pk(&refunder_pk)?;
416        let sign_fn =
417            |_: &mut psbt::Input,
418             msg: secp256k1::Message|
419             -> Result<Vec<(schnorr::Signature, XOnlyPublicKey)>, ark_core::Error> {
420                let sig = Secp256k1::new().sign_schnorr_no_aux_rand(&msg, &kp);
421                let pk = kp.x_only_public_key().0;
422
423                Ok(vec![(sig, pk)])
424            };
425
426        sign_ark_transaction(sign_fn, &mut ark_tx, 0)?;
427
428        let ark_txid = ark_tx.unsigned_tx.compute_txid();
429
430        let res = self
431            .network_client()
432            .submit_offchain_transaction_request(ark_tx, checkpoint_txs)
433            .await?;
434
435        let mut checkpoint_psbt = res
436            .signed_checkpoint_txs
437            .first()
438            .ok_or_else(|| Error::ad_hoc("no checkpoint PSBTs found"))?
439            .clone();
440
441        let kp = self.keypair_by_pk(&refunder_pk)?;
442        let sign_fn =
443            |_: &mut psbt::Input,
444             msg: secp256k1::Message|
445             -> Result<Vec<(schnorr::Signature, XOnlyPublicKey)>, ark_core::Error> {
446                let sig = Secp256k1::new().sign_schnorr_no_aux_rand(&msg, &kp);
447                let pk = kp.x_only_public_key().0;
448
449                Ok(vec![(sig, pk)])
450            };
451
452        sign_checkpoint_transaction(sign_fn, &mut checkpoint_psbt)?;
453
454        timeout_op(
455            self.inner.timeout,
456            self.network_client()
457                .finalize_offchain_transaction(ark_txid, vec![checkpoint_psbt]),
458        )
459        .await?
460        .map_err(Error::ark_server)
461        .context("failed to finalize offchain transaction")?;
462
463        tracing::info!(txid = %ark_txid, "Refunded VHTLC");
464
465        Ok(ark_txid)
466    }
467
468    /// Refund a VHTLC after the timelock has expired via settlement.
469    ///
470    /// This path does not require a signature from Boltz.
471    pub async fn refund_expired_vhtlc_via_settlement<R>(
472        &self,
473        rng: &mut R,
474        swap_id: &str,
475    ) -> Result<Txid, Error>
476    where
477        R: Rng + CryptoRng,
478    {
479        let swap_data = self
480            .swap_storage()
481            .get_submarine(swap_id)
482            .await?
483            .ok_or(Error::ad_hoc("Submarine swap not found"))?;
484
485        let timeout_block_heights = swap_data.timeout_block_heights;
486
487        let vhtlc = VhtlcScript::new(
488            VhtlcOptions {
489                sender: swap_data.refund_public_key.into(),
490                receiver: swap_data.claim_public_key.into(),
491                server: self.server_info.signer_pk.into(),
492                preimage_hash: swap_data.preimage_hash,
493                refund_locktime: timeout_block_heights.refund,
494                unilateral_claim_delay: parse_sequence_number(
495                    timeout_block_heights.unilateral_claim as i64,
496                )
497                .map_err(|e| Error::ad_hoc(format!("invalid unilateral claim timeout: {e}")))?,
498                unilateral_refund_delay: parse_sequence_number(
499                    timeout_block_heights.unilateral_refund as i64,
500                )
501                .map_err(|e| Error::ad_hoc(format!("invalid unilateral refund timeout: {e}")))?,
502                unilateral_refund_without_receiver_delay: parse_sequence_number(
503                    timeout_block_heights.unilateral_refund_without_receiver as i64,
504                )
505                .map_err(|e| {
506                    Error::ad_hoc(format!("invalid refund without receiver timeout: {e}"))
507                })?,
508            },
509            self.server_info.network,
510        )
511        .map_err(Error::ad_hoc)?;
512
513        let vhtlc_address = vhtlc.address();
514        if vhtlc_address != swap_data.vhtlc_address {
515            return Err(Error::ad_hoc(format!(
516                "VHTLC address ({vhtlc_address}) does not match swap address ({})",
517                swap_data.vhtlc_address
518            )));
519        }
520
521        let vhtlc_outpoint = {
522            let virtual_tx_outpoints = self
523                .get_virtual_tx_outpoints(std::iter::once(vhtlc_address))
524                .await?;
525
526            let vtxo_list = VtxoList::new(self.server_info.dust, virtual_tx_outpoints);
527
528            // We expect a single outpoint.
529            let mut recoverable = vtxo_list.recoverable();
530
531            recoverable
532                .next()
533                .ok_or_else(|| {
534                    Error::ad_hoc(format!("no outpoint found for address {vhtlc_address}"))
535                })?
536                .clone()
537        };
538
539        let refund_script = vhtlc.refund_without_receiver_script();
540
541        let spend_info = vhtlc.taproot_spend_info();
542        let script_ver = (refund_script, LeafVersion::TapScript);
543        let control_block = spend_info
544            .control_block(&script_ver)
545            .ok_or(Error::ad_hoc("control block not found for refund script"))?;
546
547        let script_pubkey = vhtlc.script_pubkey();
548
549        let (refund_address, _) = self.get_offchain_address()?;
550        let refund_amount = swap_data.amount;
551
552        let vhtlc_input = intent::Input::new(
553            vhtlc_outpoint.outpoint,
554            parse_sequence_number(timeout_block_heights.unilateral_refund as i64)
555                .map_err(|e| Error::ad_hoc(format!("invalid unilateral refund timeout: {e}")))?,
556            Some(absolute::LockTime::from_consensus(
557                timeout_block_heights.refund,
558            )),
559            TxOut {
560                value: refund_amount,
561                script_pubkey,
562            },
563            vhtlc.tapscripts(),
564            (script_ver.0, control_block),
565            false,
566            true,
567        );
568
569        let commitment_txid = self
570            .join_next_batch(
571                rng,
572                Vec::new(),
573                vec![vhtlc_input],
574                BatchOutputType::Board {
575                    to_address: refund_address,
576                    to_amount: refund_amount,
577                },
578            )
579            .await
580            .context("failed to join batch")?;
581
582        tracing::info!(txid = %commitment_txid, "Refunded VHTLC via settlement");
583
584        Ok(commitment_txid)
585    }
586
587    /// Refund a VHTLC with collaboration from Boltz.
588    ///
589    /// This path requires Boltz's cooperation to sign the refund transaction. It allows refunding
590    /// a submarine swap before the timelock expires. For refunds after timelock expiry without
591    /// Boltz cooperation, use [`Client::refund_expired_vhtlc`] instead.
592    pub async fn refund_vhtlc(&self, swap_id: &str) -> Result<Txid, Error> {
593        let swap_data = self
594            .swap_storage()
595            .get_submarine(swap_id)
596            .await?
597            .ok_or(Error::ad_hoc("submarine swap not found"))?;
598
599        let timeout_block_heights = swap_data.timeout_block_heights;
600
601        let vhtlc = VhtlcScript::new(
602            VhtlcOptions {
603                sender: swap_data.refund_public_key.into(),
604                receiver: swap_data.claim_public_key.into(),
605                server: self.server_info.signer_pk.into(),
606                preimage_hash: swap_data.preimage_hash,
607                refund_locktime: timeout_block_heights.refund,
608                unilateral_claim_delay: parse_sequence_number(
609                    timeout_block_heights.unilateral_claim as i64,
610                )
611                .map_err(|e| Error::ad_hoc(format!("invalid unilateral claim timeout: {e}")))?,
612                unilateral_refund_delay: parse_sequence_number(
613                    timeout_block_heights.unilateral_refund as i64,
614                )
615                .map_err(|e| Error::ad_hoc(format!("invalid unilateral refund timeout: {e}")))?,
616                unilateral_refund_without_receiver_delay: parse_sequence_number(
617                    timeout_block_heights.unilateral_refund_without_receiver as i64,
618                )
619                .map_err(|e| {
620                    Error::ad_hoc(format!("invalid refund without receiver timeout: {e}"))
621                })?,
622            },
623            self.server_info.network,
624        )
625        .map_err(Error::ad_hoc)?;
626
627        let vhtlc_address = vhtlc.address();
628        if vhtlc_address != swap_data.vhtlc_address {
629            return Err(Error::ad_hoc(format!(
630                "VHTLC address ({vhtlc_address}) does not match swap address ({})",
631                swap_data.vhtlc_address
632            )));
633        }
634
635        let vhtlc_outpoint = {
636            let virtual_tx_outpoints = self
637                .get_virtual_tx_outpoints(std::iter::once(vhtlc_address))
638                .await?;
639
640            let vtxo_list = VtxoList::new(self.server_info.dust, virtual_tx_outpoints);
641
642            // We expect a single outpoint.
643            let mut unspent = vtxo_list.all_unspent();
644            let vhtlc_outpoint = unspent.next().ok_or_else(|| {
645                Error::ad_hoc(format!("no outpoint found for address {vhtlc_address}"))
646            })?;
647
648            vhtlc_outpoint.clone()
649        };
650
651        let (refund_address, _) = self.get_offchain_address()?;
652        let refund_amount = swap_data.amount;
653
654        let outputs = vec![(&refund_address, refund_amount)];
655
656        // Use the collaborative refund script which requires sender + receiver + server signatures.
657        let refund_script = vhtlc.refund_script();
658
659        let spend_info = vhtlc.taproot_spend_info();
660        let script_ver = (refund_script, LeafVersion::TapScript);
661        let control_block = spend_info
662            .control_block(&script_ver)
663            .ok_or(Error::ad_hoc("control block not found for refund script"))?;
664
665        let script_pubkey = vhtlc.script_pubkey();
666
667        let refunder_pk = swap_data.refund_public_key.inner.x_only_public_key().0;
668        let vhtlc_input = VtxoInput::new(
669            script_ver.0,
670            None, // No locktime required for collaborative refund
671            control_block,
672            vhtlc.tapscripts(),
673            script_pubkey,
674            refund_amount,
675            vhtlc_outpoint.outpoint,
676        );
677
678        let OffchainTransactions {
679            mut ark_tx,
680            checkpoint_txs,
681        } = build_offchain_transactions(
682            &outputs,
683            None,
684            std::slice::from_ref(&vhtlc_input),
685            &self.server_info,
686        )?;
687
688        // Sign the ark transaction with the sender's (user's) key.
689        let kp = self.keypair_by_pk(&refunder_pk)?;
690        let sign_fn =
691            |_: &mut psbt::Input,
692             msg: secp256k1::Message|
693             -> Result<Vec<(schnorr::Signature, XOnlyPublicKey)>, ark_core::Error> {
694                let sig = Secp256k1::new().sign_schnorr_no_aux_rand(&msg, &kp);
695                let pk = kp.x_only_public_key().0;
696
697                Ok(vec![(sig, pk)])
698            };
699
700        sign_ark_transaction(sign_fn, &mut ark_tx, 0)?;
701
702        // Get the unsigned checkpoint - we'll sign it after arkd adds its signature.
703        let checkpoint_psbt = checkpoint_txs
704            .first()
705            .ok_or_else(|| Error::ad_hoc("no checkpoint PSBTs found"))?
706            .clone();
707
708        // Send ark transaction (with user signature) and unsigned checkpoint to Boltz.
709        // Boltz will add their signature (receiver) to the ark transaction.
710        let url = format!(
711            "{}/v2/swap/submarine/{swap_id}/refund/ark",
712            self.inner.boltz_url
713        );
714        let client = reqwest::Client::new();
715        let response = client
716            .post(&url)
717            .json(&RefundSwapRequest {
718                transaction: ark_tx.to_string(),
719                checkpoint: checkpoint_psbt.to_string(),
720            })
721            .send()
722            .await
723            .map_err(Error::ad_hoc)
724            .context("failed to send refund request to Boltz")?;
725
726        if !response.status().is_success() {
727            let error_text = response
728                .text()
729                .await
730                .map_err(|e| Error::ad_hoc(e.to_string()))
731                .context("failed to read error text")?;
732
733            return Err(Error::ad_hoc(format!(
734                "Boltz refund request failed: {error_text}"
735            )));
736        }
737
738        let refund_response: RefundSwapResponse = response
739            .json()
740            .await
741            .map_err(Error::ad_hoc)
742            .context("failed to deserialize refund response")?;
743
744        if let Some(err) = refund_response.error.as_deref() {
745            return Err(Error::ad_hoc(format!("Boltz refund request failed: {err}")));
746        }
747
748        // Parse the Boltz-signed transactions.
749        let boltz_signed_ark_tx = Psbt::from_str(&refund_response.transaction)
750            .map_err(Error::ad_hoc)
751            .context("could not parse refund transaction PSBT")?;
752
753        let boltz_signed_checkpoint = Psbt::from_str(&refund_response.checkpoint)
754            .map_err(Error::ad_hoc)
755            .context("could not parse refund checkpoint PSBT")?;
756
757        let ark_txid = boltz_signed_ark_tx.unsigned_tx.compute_txid();
758
759        // Extract Boltz's signatures before sending to arkd (server strips incoming sigs).
760        let boltz_tap_script_sigs = boltz_signed_checkpoint
761            .inputs
762            .first()
763            .ok_or_else(|| Error::ad_hoc("boltz checkpoint has no inputs"))?
764            .tap_script_sigs
765            .clone();
766
767        // Submit to arkd for server signature.
768        // We send the Boltz-signed transactions so arkd can add its signature.
769        let res = self
770            .network_client()
771            .submit_offchain_transaction_request(boltz_signed_ark_tx, vec![boltz_signed_checkpoint])
772            .await?;
773
774        // The server returns the checkpoint with its signature added.
775        // Now we need to add our (sender) signature to the checkpoint.
776        let mut server_signed_checkpoint = res
777            .signed_checkpoint_txs
778            .first()
779            .ok_or_else(|| Error::ad_hoc("no signed checkpoint PSBTs returned"))?
780            .clone();
781
782        let kp = self.keypair_by_pk(&refunder_pk)?;
783        let sign_fn =
784            |_: &mut psbt::Input,
785             msg: secp256k1::Message|
786             -> Result<Vec<(schnorr::Signature, XOnlyPublicKey)>, ark_core::Error> {
787                let sig = Secp256k1::new().sign_schnorr_no_aux_rand(&msg, &kp);
788                let pk = kp.x_only_public_key().0;
789
790                Ok(vec![(sig, pk)])
791            };
792
793        server_signed_checkpoint
794            .inputs
795            .first_mut()
796            .ok_or_else(|| Error::ad_hoc("server checkpoint has no inputs"))?
797            .tap_script_sigs
798            .extend(boltz_tap_script_sigs);
799
800        sign_checkpoint_transaction(sign_fn, &mut server_signed_checkpoint)?;
801
802        // Finalize the transaction with the fully-signed checkpoint.
803        timeout_op(
804            self.inner.timeout,
805            self.network_client()
806                .finalize_offchain_transaction(ark_txid, vec![server_signed_checkpoint]),
807        )
808        .await?
809        .map_err(Error::ark_server)
810        .context("failed to finalize offchain transaction")?;
811
812        tracing::info!(swap_id, txid = %ark_txid, "Refunded VHTLC via collaborative refund");
813
814        Ok(ark_txid)
815    }
816
817    // Reverse submarine swap.
818
819    /// Generate a BOLT11 invoice to perform a reverse submarine swap via Boltz. This allows to
820    /// receive Lightning payments into an Ark wallet.
821    ///
822    /// # Arguments
823    ///
824    /// - `amount`: the expected [`Amount`] to be received.
825    ///
826    /// # Returns
827    ///
828    /// - A `ReverseSwapResult`, including an identifier for the reverse swap and the
829    ///   [`Bolt11Invoice`] to be paid.
830    pub async fn get_ln_invoice(
831        &self,
832        amount: SwapAmount,
833        expiry_secs: Option<u64>,
834    ) -> Result<ReverseSwapResult, Error> {
835        let preimage: [u8; 32] = rand::random();
836        let preimage_hash_sha256 = sha256::Hash::hash(&preimage);
837        let preimage_hash = ripemd160::Hash::hash(preimage_hash_sha256.as_byte_array());
838
839        let claim_public_key = self
840            .next_keypair(crate::key_provider::KeypairIndex::New)?
841            .public_key();
842
843        let (invoice_amount, onchain_amount) = match amount {
844            SwapAmount::Invoice(amount) => (Some(amount), None),
845            SwapAmount::Vhtlc(amount) => (None, Some(amount)),
846        };
847
848        let request = CreateReverseSwapRequest {
849            from: Asset::Btc,
850            to: Asset::Ark,
851            invoice_amount,
852            onchain_amount,
853            claim_public_key: claim_public_key.into(),
854            preimage_hash: preimage_hash_sha256,
855            invoice_expiry: expiry_secs,
856        };
857
858        let url = format!("{}/v2/swap/reverse", self.inner.boltz_url);
859
860        let client = reqwest::Client::new();
861        let response = client
862            .post(&url)
863            .json(&request)
864            .send()
865            .await
866            .map_err(|e| Error::ad_hoc(e.to_string()))
867            .context("failed to send reverse swap request")?;
868
869        if !response.status().is_success() {
870            let error_text = response
871                .text()
872                .await
873                .map_err(|e| Error::ad_hoc(e.to_string()))
874                .context("failed to read error text")?;
875
876            return Err(Error::ad_hoc(format!(
877                "failed to create reverse swap: {error_text}"
878            )));
879        }
880
881        let response: CreateReverseSwapResponse = response
882            .json()
883            .await
884            .map_err(|e| Error::ad_hoc(e.to_string()))
885            .context("failed to deserialize reverse swap response")?;
886
887        let created_at = SystemTime::now()
888            .duration_since(UNIX_EPOCH)
889            .map_err(Error::ad_hoc)
890            .context("failed to compute created_at")?;
891
892        let swap_amount = response.onchain_amount.or(onchain_amount).ok_or_else(|| {
893            Error::ad_hoc("onchain_amount not provided by Boltz and not specified in request")
894        })?;
895
896        let swap = ReverseSwapData {
897            id: response.id.clone(),
898            status: SwapStatus::Created,
899            preimage: Some(preimage),
900            vhtlc_address: response.lockup_address,
901            preimage_hash,
902            refund_public_key: response.refund_public_key,
903            amount: swap_amount,
904            claim_public_key: claim_public_key.into(),
905            timeout_block_heights: response.timeout_block_heights,
906            created_at: created_at.as_secs(),
907        };
908
909        self.swap_storage()
910            .insert_reverse(response.id.clone(), swap.clone())
911            .await
912            .context("failed to persist swap data")?;
913
914        Ok(ReverseSwapResult {
915            swap_id: swap.id,
916            invoice: response.invoice,
917            amount: swap_amount,
918        })
919    }
920
921    /// Generate a BOLT11 invoice using a provided SHA256 preimage hash for a reverse submarine
922    /// swap via Boltz. This allows receiving Lightning payments when the preimage is managed
923    /// externally.
924    ///
925    /// # Arguments
926    ///
927    /// - `amount`: the expected [`Amount`] to be received.
928    /// - `preimage_hash_sha256`: the SHA256 hash of the preimage. The preimage itself is not stored
929    ///   and must be provided later when claiming via [`Self::claim_vhtlc`].
930    ///
931    /// # Returns
932    ///
933    /// - A [`ReverseSwapResult`], including an identifier for the reverse swap and the
934    ///   [`Bolt11Invoice`] to be paid.
935    ///
936    /// # Note
937    ///
938    /// After calling this method, use [`Self::wait_for_vhtlc_funding`] to wait for the VHTLC to
939    /// be funded, then [`Self::claim_vhtlc`] with the preimage to claim the funds.
940    pub async fn get_ln_invoice_from_hash(
941        &self,
942        amount: SwapAmount,
943        expiry_secs: Option<u64>,
944        preimage_hash_sha256: sha256::Hash,
945    ) -> Result<ReverseSwapResult, Error> {
946        let preimage_hash = ripemd160::Hash::hash(preimage_hash_sha256.as_byte_array());
947
948        let keypair = self.next_keypair(crate::key_provider::KeypairIndex::New)?;
949        let claim_public_key = keypair.public_key();
950
951        let (invoice_amount, onchain_amount) = match amount {
952            SwapAmount::Invoice(amount) => (Some(amount), None),
953            SwapAmount::Vhtlc(amount) => (None, Some(amount)),
954        };
955
956        let request = CreateReverseSwapRequest {
957            from: Asset::Btc,
958            to: Asset::Ark,
959            invoice_amount,
960            onchain_amount,
961            claim_public_key: claim_public_key.into(),
962            preimage_hash: preimage_hash_sha256,
963            invoice_expiry: expiry_secs,
964        };
965
966        let url = format!("{}/v2/swap/reverse", self.inner.boltz_url);
967
968        let client = reqwest::Client::new();
969        let response = client
970            .post(&url)
971            .json(&request)
972            .send()
973            .await
974            .map_err(|e| Error::ad_hoc(e.to_string()))
975            .context("failed to send reverse swap request")?;
976
977        if !response.status().is_success() {
978            let error_text = response
979                .text()
980                .await
981                .map_err(|e| Error::ad_hoc(e.to_string()))
982                .context("failed to read error text")?;
983
984            return Err(Error::ad_hoc(format!(
985                "failed to create reverse swap: {error_text}"
986            )));
987        }
988
989        let response: CreateReverseSwapResponse = response
990            .json()
991            .await
992            .map_err(|e| Error::ad_hoc(e.to_string()))
993            .context("failed to deserialize reverse swap response")?;
994
995        let created_at = SystemTime::now()
996            .duration_since(UNIX_EPOCH)
997            .map_err(Error::ad_hoc)
998            .context("failed to compute created_at")?;
999
1000        let swap_amount = response.onchain_amount.or(onchain_amount).ok_or_else(|| {
1001            Error::ad_hoc("onchain_amount not provided by Boltz and not specified in request")
1002        })?;
1003
1004        let swap = ReverseSwapData {
1005            id: response.id.clone(),
1006            status: SwapStatus::Created,
1007            preimage: None, // Preimage not known at creation time
1008            vhtlc_address: response.lockup_address,
1009            preimage_hash,
1010            refund_public_key: response.refund_public_key,
1011            amount: swap_amount,
1012            claim_public_key: claim_public_key.into(),
1013            timeout_block_heights: response.timeout_block_heights,
1014            created_at: created_at.as_secs(),
1015        };
1016
1017        self.swap_storage()
1018            .insert_reverse(response.id.clone(), swap.clone())
1019            .await
1020            .context("failed to persist swap data")?;
1021
1022        Ok(ReverseSwapResult {
1023            swap_id: swap.id,
1024            invoice: response.invoice,
1025            amount: swap_amount,
1026        })
1027    }
1028
1029    /// Wait for the VHTLC associated with a reverse submarine swap to be funded.
1030    ///
1031    /// This method only waits for the funding transaction to be detected (in mempool or confirmed).
1032    /// It does not claim the VHTLC. Use [`Self::claim_vhtlc`] to claim after the preimage is known.
1033    ///
1034    /// # Arguments
1035    ///
1036    /// - `swap_id`: The unique identifier for the reverse swap.
1037    ///
1038    /// # Returns
1039    ///
1040    /// Returns `Ok(())` when the VHTLC funding transaction is detected.
1041    pub async fn wait_for_vhtlc_funding(&self, swap_id: &str) -> Result<(), Error> {
1042        use futures::StreamExt;
1043
1044        let stream = self.subscribe_to_swap_updates(swap_id.to_string());
1045        tokio::pin!(stream);
1046
1047        while let Some(status_result) = stream.next().await {
1048            match status_result {
1049                Ok(status) => {
1050                    tracing::debug!(swap_id, current = ?status, "Swap status");
1051
1052                    match status {
1053                        SwapStatus::TransactionMempool | SwapStatus::TransactionConfirmed => {
1054                            tracing::debug!(swap_id, "VHTLC funding detected");
1055                            return Ok(());
1056                        }
1057                        SwapStatus::InvoiceExpired => {
1058                            return Err(Error::ad_hoc(format!(
1059                                "invoice expired for swap {swap_id}"
1060                            )));
1061                        }
1062                        SwapStatus::Error { error } => {
1063                            tracing::error!(
1064                                swap_id,
1065                                "Got error from swap updates subscription: {error}"
1066                            );
1067                        }
1068                        // TODO: We may still need to handle some of these explicitly.
1069                        SwapStatus::Created
1070                        | SwapStatus::TransactionRefunded
1071                        | SwapStatus::TransactionFailed
1072                        | SwapStatus::TransactionClaimed
1073                        | SwapStatus::InvoiceSet
1074                        | SwapStatus::InvoicePending
1075                        | SwapStatus::InvoicePaid
1076                        | SwapStatus::InvoiceFailedToPay
1077                        | SwapStatus::SwapExpired => {}
1078                    }
1079                }
1080                Err(e) => return Err(e),
1081            }
1082        }
1083
1084        Err(Error::ad_hoc("Status stream ended unexpectedly"))
1085    }
1086
1087    /// Claim a funded VHTLC for a reverse submarine swap using the preimage.
1088    ///
1089    /// This method should be called after the VHTLC has been funded (after
1090    /// [`Self::wait_for_vhtlc_funding`] returns) and the preimage is known.
1091    ///
1092    /// # Arguments
1093    ///
1094    /// - `swap_id`: The unique identifier for the reverse swap.
1095    /// - `preimage`: The 32-byte preimage that unlocks the VHTLC.
1096    ///
1097    /// # Returns
1098    ///
1099    /// Returns a [`ClaimVhtlcResult`] with details about the claim transaction.
1100    pub async fn claim_vhtlc(
1101        &self,
1102        swap_id: &str,
1103        preimage: [u8; 32],
1104    ) -> Result<ClaimVhtlcResult, Error> {
1105        let swap = self
1106            .swap_storage()
1107            .get_reverse(swap_id)
1108            .await
1109            .context("failed to get reverse swap data")?
1110            .ok_or_else(|| Error::ad_hoc(format!("reverse swap data not found: {swap_id}")))?;
1111
1112        // Verify the preimage matches the stored hash
1113        let preimage_hash_sha256 = sha256::Hash::hash(&preimage);
1114        let preimage_hash = ripemd160::Hash::hash(preimage_hash_sha256.as_byte_array());
1115
1116        if preimage_hash != swap.preimage_hash {
1117            return Err(Error::ad_hoc(format!(
1118                "preimage does not match stored hash for swap {swap_id}"
1119            )));
1120        }
1121
1122        tracing::debug!(swap_id, "Claiming VHTLC with verified preimage");
1123
1124        let timeout_block_heights = swap.timeout_block_heights;
1125
1126        let vhtlc = VhtlcScript::new(
1127            VhtlcOptions {
1128                sender: swap.refund_public_key.into(),
1129                receiver: swap.claim_public_key.into(),
1130                server: self.server_info.signer_pk.into(),
1131                preimage_hash: swap.preimage_hash,
1132                refund_locktime: timeout_block_heights.refund,
1133                unilateral_claim_delay: parse_sequence_number(
1134                    timeout_block_heights.unilateral_claim as i64,
1135                )
1136                .map_err(|e| Error::ad_hoc(format!("invalid unilateral claim timeout: {e}")))?,
1137                unilateral_refund_delay: parse_sequence_number(
1138                    timeout_block_heights.unilateral_refund as i64,
1139                )
1140                .map_err(|e| Error::ad_hoc(format!("invalid unilateral refund timeout: {e}")))?,
1141                unilateral_refund_without_receiver_delay: parse_sequence_number(
1142                    timeout_block_heights.unilateral_refund_without_receiver as i64,
1143                )
1144                .map_err(|e| {
1145                    Error::ad_hoc(format!("invalid refund without receiver timeout: {e}"))
1146                })?,
1147            },
1148            self.server_info.network,
1149        )
1150        .map_err(Error::ad_hoc)
1151        .context("failed to build VHTLC script")?;
1152
1153        let vhtlc_address = vhtlc.address();
1154        if vhtlc_address != swap.vhtlc_address {
1155            return Err(Error::ad_hoc(format!(
1156                "VHTLC address ({vhtlc_address}) does not match swap address ({})",
1157                swap.vhtlc_address
1158            )));
1159        }
1160
1161        // TODO: Ideally we can skip this if the vout is always the same (probably 0).
1162        let vhtlc_outpoint = {
1163            let virtual_tx_outpoints = self
1164                .get_virtual_tx_outpoints(std::iter::once(vhtlc_address))
1165                .await?;
1166
1167            let vtxo_list = VtxoList::new(self.server_info.dust, virtual_tx_outpoints);
1168
1169            // We expect a single outpoint.
1170            let mut unspent = vtxo_list.all_unspent();
1171            let vhtlc_outpoint = unspent.next().ok_or_else(|| {
1172                Error::ad_hoc(format!("no outpoint found for address {vhtlc_address}"))
1173            })?;
1174
1175            vhtlc_outpoint.clone()
1176        };
1177
1178        let (claim_address, _) = self
1179            .get_offchain_address()
1180            .context("failed to get offchain address")?;
1181        let claim_amount = swap.amount;
1182
1183        let outputs = vec![(&claim_address, claim_amount)];
1184
1185        let spend_info = vhtlc.taproot_spend_info();
1186        let script_ver = (vhtlc.claim_script(), LeafVersion::TapScript);
1187        let control_block = spend_info
1188            .control_block(&script_ver)
1189            .ok_or(Error::ad_hoc("control block not found for claim script"))?;
1190
1191        let script_pubkey = vhtlc.script_pubkey();
1192
1193        let claimer_pk = swap.claim_public_key.inner.x_only_public_key().0;
1194        let vhtlc_input = VtxoInput::new(
1195            script_ver.0,
1196            None,
1197            control_block,
1198            vhtlc.tapscripts(),
1199            script_pubkey,
1200            claim_amount,
1201            vhtlc_outpoint.outpoint,
1202        );
1203
1204        let OffchainTransactions {
1205            mut ark_tx,
1206            checkpoint_txs,
1207        } = build_offchain_transactions(
1208            &outputs,
1209            None,
1210            std::slice::from_ref(&vhtlc_input),
1211            &self.server_info,
1212        )
1213        .map_err(Error::from)
1214        .context("failed to build offchain TXs")?;
1215
1216        let kp = self.keypair_by_pk(&claimer_pk)?;
1217        let sign_fn =
1218            |input: &mut psbt::Input,
1219             msg: secp256k1::Message|
1220             -> Result<Vec<(schnorr::Signature, XOnlyPublicKey)>, ark_core::Error> {
1221                // Add preimage to PSBT input.
1222                {
1223                    // Initialized with a 1, because we only have one witness element: the preimage.
1224                    let mut bytes = vec![1];
1225
1226                    let length = VarInt::from(preimage.len() as u64);
1227
1228                    length
1229                        .consensus_encode(&mut bytes)
1230                        .expect("valid length encoding");
1231
1232                    bytes.write_all(&preimage).expect("valid preimage encoding");
1233
1234                    input.unknown.insert(
1235                        psbt::raw::Key {
1236                            type_value: 222,
1237                            key: VTXO_CONDITION_KEY.to_vec(),
1238                        },
1239                        bytes,
1240                    );
1241                }
1242
1243                let sig = Secp256k1::new().sign_schnorr_no_aux_rand(&msg, &kp);
1244                let pk = kp.x_only_public_key().0;
1245
1246                Ok(vec![(sig, pk)])
1247            };
1248
1249        sign_ark_transaction(sign_fn, &mut ark_tx, 0)
1250            .map_err(Error::from)
1251            .context("failed to sign Ark TX")?;
1252
1253        let ark_txid = ark_tx.unsigned_tx.compute_txid();
1254
1255        let res = self
1256            .network_client()
1257            .submit_offchain_transaction_request(ark_tx, checkpoint_txs)
1258            .await
1259            .map_err(Error::from)
1260            .context("failed to submit offchain TXs")?;
1261
1262        let mut checkpoint_psbt = res
1263            .signed_checkpoint_txs
1264            .first()
1265            .ok_or_else(|| Error::ad_hoc("no checkpoint PSBTs found"))?
1266            .clone();
1267
1268        sign_checkpoint_transaction(sign_fn, &mut checkpoint_psbt)
1269            .map_err(Error::from)
1270            .context("failed to sign checkpoint TX")?;
1271
1272        timeout_op(
1273            self.inner.timeout,
1274            self.network_client()
1275                .finalize_offchain_transaction(ark_txid, vec![checkpoint_psbt]),
1276        )
1277        .await
1278        .context("failed to finalize offchain transaction")?
1279        .map_err(Error::ark_server)
1280        .context("failed to finalize offchain transaction")?;
1281
1282        tracing::info!(swap_id, txid = %ark_txid, "Claimed VHTLC");
1283
1284        // Update storage to persist the preimage
1285        let mut updated_swap = swap.clone();
1286        updated_swap.preimage = Some(preimage);
1287        self.swap_storage()
1288            .update_reverse(swap_id, updated_swap)
1289            .await
1290            .context("failed to update swap data with preimage")?;
1291
1292        Ok(ClaimVhtlcResult {
1293            swap_id: swap_id.to_string(),
1294            claim_txid: ark_txid,
1295            claim_amount,
1296            preimage,
1297        })
1298    }
1299
1300    /// Wait for the VHTLC associated with a reverse submarine swap to be funded, then claim it.
1301    ///
1302    /// # Note
1303    ///
1304    /// This method requires that the preimage was stored when creating the reverse swap (i.e., via
1305    /// [`Self::get_ln_invoice`]). If the swap was created with [`Self::get_ln_invoice_from_hash`],
1306    /// use [`Self::wait_for_vhtlc_funding`] followed by [`Self::claim_vhtlc`] instead.
1307    pub async fn wait_for_vhtlc(&self, swap_id: &str) -> Result<ClaimVhtlcResult, Error> {
1308        use futures::StreamExt;
1309
1310        let swap = self
1311            .swap_storage()
1312            .get_reverse(swap_id)
1313            .await
1314            .context("failed to get reverse swap data")?
1315            .ok_or_else(|| Error::ad_hoc(format!("reverse swap data not found: {swap_id}")))?;
1316
1317        // Ensure the preimage is available in storage
1318        let preimage = swap.preimage.ok_or_else(|| {
1319            Error::ad_hoc(format!(
1320                "preimage not found in storage for swap {swap_id}. \
1321                 Use wait_for_vhtlc_funding and claim_vhtlc instead."
1322            ))
1323        })?;
1324
1325        let stream = self.subscribe_to_swap_updates(swap_id.to_string());
1326        tokio::pin!(stream);
1327
1328        while let Some(status_result) = stream.next().await {
1329            match status_result {
1330                Ok(status) => {
1331                    tracing::debug!(current = ?status, "Swap status");
1332
1333                    match status {
1334                        SwapStatus::TransactionMempool | SwapStatus::TransactionConfirmed => break,
1335                        SwapStatus::InvoiceExpired => {
1336                            return Err(Error::ad_hoc(format!(
1337                                "invoice expired for swap {swap_id}"
1338                            )));
1339                        }
1340                        SwapStatus::Error { error } => {
1341                            tracing::error!(
1342                                swap_id,
1343                                "Got error from swap updates subscription: {error}"
1344                            );
1345                        }
1346                        // TODO: We may still need to handle some of these explicitly.
1347                        SwapStatus::Created
1348                        | SwapStatus::TransactionRefunded
1349                        | SwapStatus::TransactionFailed
1350                        | SwapStatus::TransactionClaimed
1351                        | SwapStatus::InvoiceSet
1352                        | SwapStatus::InvoicePending
1353                        | SwapStatus::InvoicePaid
1354                        | SwapStatus::InvoiceFailedToPay
1355                        | SwapStatus::SwapExpired => {}
1356                    }
1357                }
1358                Err(e) => return Err(e),
1359            }
1360        }
1361
1362        tracing::debug!("Ark transaction for swap found");
1363
1364        let timeout_block_heights = swap.timeout_block_heights;
1365
1366        let vhtlc = VhtlcScript::new(
1367            VhtlcOptions {
1368                sender: swap.refund_public_key.into(),
1369                receiver: swap.claim_public_key.into(),
1370                server: self.server_info.signer_pk.into(),
1371                preimage_hash: swap.preimage_hash,
1372                refund_locktime: timeout_block_heights.refund,
1373                unilateral_claim_delay: parse_sequence_number(
1374                    timeout_block_heights.unilateral_claim as i64,
1375                )
1376                .map_err(|e| Error::ad_hoc(format!("invalid unilateral claim timeout: {e}")))?,
1377                unilateral_refund_delay: parse_sequence_number(
1378                    timeout_block_heights.unilateral_refund as i64,
1379                )
1380                .map_err(|e| Error::ad_hoc(format!("invalid unilateral refund timeout: {e}")))?,
1381                unilateral_refund_without_receiver_delay: parse_sequence_number(
1382                    timeout_block_heights.unilateral_refund_without_receiver as i64,
1383                )
1384                .map_err(|e| {
1385                    Error::ad_hoc(format!("invalid refund without receiver timeout: {e}"))
1386                })?,
1387            },
1388            self.server_info.network,
1389        )
1390        .map_err(Error::ad_hoc)
1391        .context("failed to build VHTLC script")?;
1392
1393        let vhtlc_address = vhtlc.address();
1394        if vhtlc_address != swap.vhtlc_address {
1395            return Err(Error::ad_hoc(format!(
1396                "VHTLC address ({vhtlc_address}) does not match swap address ({})",
1397                swap.vhtlc_address
1398            )));
1399        }
1400
1401        // TODO: Ideally we can skip this if the vout is always the same (probably 0).
1402        let vhtlc_outpoint = {
1403            let virtual_tx_outpoints = self
1404                .get_virtual_tx_outpoints(std::iter::once(vhtlc_address))
1405                .await?;
1406
1407            let vtxo_list = VtxoList::new(self.server_info.dust, virtual_tx_outpoints);
1408
1409            // We expect a single outpoint.
1410            let mut unspent = vtxo_list.all_unspent();
1411            let vhtlc_outpoint = unspent.next().ok_or_else(|| {
1412                Error::ad_hoc(format!("no outpoint found for address {vhtlc_address}"))
1413            })?;
1414
1415            vhtlc_outpoint.clone()
1416        };
1417
1418        let (claim_address, _) = self
1419            .get_offchain_address()
1420            .context("failed to get offchain address")?;
1421        let claim_amount = swap.amount;
1422
1423        let outputs = vec![(&claim_address, claim_amount)];
1424
1425        let spend_info = vhtlc.taproot_spend_info();
1426        let script_ver = (vhtlc.claim_script(), LeafVersion::TapScript);
1427        let control_block = spend_info
1428            .control_block(&script_ver)
1429            .ok_or(Error::ad_hoc("control block not found for claim script"))?;
1430
1431        let script_pubkey = vhtlc.script_pubkey();
1432
1433        let claimer_pk = swap.claim_public_key.inner.x_only_public_key().0;
1434        let vhtlc_input = VtxoInput::new(
1435            script_ver.0,
1436            None,
1437            control_block,
1438            vhtlc.tapscripts(),
1439            script_pubkey,
1440            claim_amount,
1441            vhtlc_outpoint.outpoint,
1442        );
1443
1444        let OffchainTransactions {
1445            mut ark_tx,
1446            checkpoint_txs,
1447        } = build_offchain_transactions(
1448            &outputs,
1449            None,
1450            std::slice::from_ref(&vhtlc_input),
1451            &self.server_info,
1452        )
1453        .map_err(Error::from)
1454        .context("failed to build offchain TXs")?;
1455
1456        let kp = self.keypair_by_pk(&claimer_pk)?;
1457        let sign_fn =
1458            |input: &mut psbt::Input,
1459             msg: secp256k1::Message|
1460             -> Result<Vec<(schnorr::Signature, XOnlyPublicKey)>, ark_core::Error> {
1461                // Add preimage to PSBT input.
1462                {
1463                    // Initialized with a 1, because we only have one witness element: the preimage.
1464                    let mut bytes = vec![1];
1465
1466                    let length = VarInt::from(preimage.len() as u64);
1467
1468                    length
1469                        .consensus_encode(&mut bytes)
1470                        .expect("valid length encoding");
1471
1472                    bytes.write_all(&preimage).expect("valid preimage encoding");
1473
1474                    input.unknown.insert(
1475                        psbt::raw::Key {
1476                            type_value: 222,
1477                            key: VTXO_CONDITION_KEY.to_vec(),
1478                        },
1479                        bytes,
1480                    );
1481                }
1482
1483                let sig = Secp256k1::new().sign_schnorr_no_aux_rand(&msg, &kp);
1484                let pk = kp.x_only_public_key().0;
1485
1486                Ok(vec![(sig, pk)])
1487            };
1488
1489        sign_ark_transaction(sign_fn, &mut ark_tx, 0)
1490            .map_err(Error::from)
1491            .context("failed to sign Ark TX")?;
1492
1493        let ark_txid = ark_tx.unsigned_tx.compute_txid();
1494
1495        let res = self
1496            .network_client()
1497            .submit_offchain_transaction_request(ark_tx, checkpoint_txs)
1498            .await
1499            .map_err(Error::from)
1500            .context("failed to submit offchain TXs")?;
1501
1502        let mut checkpoint_psbt = res
1503            .signed_checkpoint_txs
1504            .first()
1505            .ok_or_else(|| Error::ad_hoc("no checkpoint PSBTs found"))?
1506            .clone();
1507
1508        sign_checkpoint_transaction(sign_fn, &mut checkpoint_psbt)
1509            .map_err(Error::from)
1510            .context("failed to sign checkpoint TX")?;
1511
1512        timeout_op(
1513            self.inner.timeout,
1514            self.network_client()
1515                .finalize_offchain_transaction(ark_txid, vec![checkpoint_psbt]),
1516        )
1517        .await
1518        .context("failed to finalize offchain transaction")?
1519        .map_err(Error::ark_server)
1520        .context("failed to finalize offchain transaction")?;
1521
1522        tracing::info!(txid = %ark_txid, "Spent VHTLC");
1523
1524        Ok(ClaimVhtlcResult {
1525            swap_id: swap_id.to_string(),
1526            claim_txid: ark_txid,
1527            claim_amount,
1528            preimage,
1529        })
1530    }
1531
1532    /// Fetch fee information from Boltz for both submarine and reverse swaps.
1533    ///
1534    /// # Returns
1535    ///
1536    /// - A [`BoltzFees`] struct containing fee information for both swap types.
1537    pub async fn get_fees(&self) -> Result<BoltzFees, Error> {
1538        let client = reqwest::Client::builder()
1539            .timeout(self.inner.timeout)
1540            .build()
1541            .map_err(|e| Error::ad_hoc(e.to_string()))?;
1542
1543        // Fetch submarine swap fees (ARK -> BTC)
1544        let submarine_url = format!("{}/v2/swap/submarine", &self.inner.boltz_url);
1545        let submarine_response = client
1546            .get(&submarine_url)
1547            .send()
1548            .await
1549            .map_err(|e| Error::ad_hoc(e.to_string()))
1550            .context("failed to fetch submarine swap fees")?;
1551
1552        if !submarine_response.status().is_success() {
1553            let error_text = submarine_response
1554                .text()
1555                .await
1556                .map_err(|e| Error::ad_hoc(e.to_string()))?;
1557            return Err(Error::ad_hoc(format!(
1558                "failed to fetch submarine swap fees: {error_text}"
1559            )));
1560        }
1561
1562        let submarine_pairs: SubmarinePairsResponse = submarine_response
1563            .json()
1564            .await
1565            .map_err(|e| Error::ad_hoc(e.to_string()))
1566            .context("failed to deserialize submarine swap fees response")?;
1567
1568        let submarine_pair_fees = &submarine_pairs.ark.btc.fees;
1569        let submarine_fees = SubmarineSwapFees {
1570            percentage: submarine_pair_fees.percentage,
1571            miner_fees: submarine_pair_fees.miner_fees,
1572        };
1573
1574        // Fetch reverse swap fees (BTC -> ARK)
1575        let reverse_url = format!("{}/v2/swap/reverse", self.inner.boltz_url);
1576        let reverse_response = client
1577            .get(&reverse_url)
1578            .send()
1579            .await
1580            .map_err(|e| Error::ad_hoc(e.to_string()))
1581            .context("failed to fetch reverse swap fees")?;
1582
1583        if !reverse_response.status().is_success() {
1584            let error_text = reverse_response
1585                .text()
1586                .await
1587                .map_err(|e| Error::ad_hoc(e.to_string()))?;
1588            return Err(Error::ad_hoc(format!(
1589                "failed to fetch reverse swap fees: {error_text}"
1590            )));
1591        }
1592
1593        let reverse_pairs: ReversePairsResponse = reverse_response
1594            .json()
1595            .await
1596            .map_err(|e| Error::ad_hoc(e.to_string()))
1597            .context("failed to deserialize reverse swap fees response")?;
1598
1599        let reverse_pair_fees = &reverse_pairs.btc.ark.fees;
1600        let reverse_fees = ReverseSwapFees {
1601            percentage: reverse_pair_fees.percentage,
1602            miner_fees: ReverseMinerFees {
1603                lockup: reverse_pair_fees.miner_fees.lockup,
1604                claim: reverse_pair_fees.miner_fees.claim,
1605            },
1606        };
1607
1608        Ok(BoltzFees {
1609            submarine: submarine_fees,
1610            reverse: reverse_fees,
1611        })
1612    }
1613
1614    /// Fetch swap amount limits from Boltz for submarine swaps.
1615    ///
1616    /// # Returns
1617    ///
1618    /// - A [`SwapLimits`] struct containing minimum and maximum swap amounts in satoshis.
1619    pub async fn get_limits(&self) -> Result<SwapLimits, Error> {
1620        let client = reqwest::Client::builder()
1621            .timeout(self.inner.timeout)
1622            .build()
1623            .map_err(|e| Error::ad_hoc(e.to_string()))?;
1624
1625        let url = format!("{}/v2/swap/submarine", self.inner.boltz_url);
1626        let response = client
1627            .get(&url)
1628            .send()
1629            .await
1630            .map_err(|e| Error::ad_hoc(e.to_string()))
1631            .context("failed to fetch swap limits")?;
1632
1633        if !response.status().is_success() {
1634            let error_text = response
1635                .text()
1636                .await
1637                .map_err(|e| Error::ad_hoc(e.to_string()))?;
1638            return Err(Error::ad_hoc(format!(
1639                "failed to fetch swap limits: {error_text}"
1640            )));
1641        }
1642
1643        let pairs: SubmarinePairsResponse = response
1644            .json()
1645            .await
1646            .map_err(|e| Error::ad_hoc(e.to_string()))
1647            .context("failed to deserialize swap limits response")?;
1648
1649        Ok(SwapLimits {
1650            min: pairs.ark.btc.limits.minimal,
1651            max: pairs.ark.btc.limits.maximal,
1652        })
1653    }
1654
1655    /// Use Boltz's API to learn about updates for a particular swap.
1656    // TODO: Make sure this is WASM-compatible.
1657    pub fn subscribe_to_swap_updates(
1658        &self,
1659        swap_id: String,
1660    ) -> impl futures::Stream<Item = Result<SwapStatus, Error>> + '_ {
1661        async_stream::stream! {
1662            let mut last_status: Option<SwapStatus> = None;
1663            let url = format!("{}/v2/swap/{swap_id}", self.inner.boltz_url);
1664
1665            loop {
1666                let client = reqwest::Client::new();
1667                let response = client
1668                    .get(&url)
1669                    .send()
1670                    .await;
1671
1672                match response {
1673                    Ok(resp) if resp.status().is_success() => {
1674                        let status_response = resp
1675                            .json::<GetSwapStatusResponse>()
1676                            .await
1677                            .map_err(|e| Error::ad_hoc(e.to_string()));
1678
1679                        match status_response {
1680                            Ok(current_status) => {
1681                                let current_status = current_status.status;
1682
1683                                // Only yield if status has changed
1684                                if last_status.as_ref() != Some(&current_status) {
1685                                    last_status = Some(current_status.clone());
1686                                    yield Ok(current_status);
1687                                }
1688                            }
1689                            Err(e) => {
1690                                yield Err(Error::ad_hoc(format!(
1691                                            "failed to deserialize swap status response: {e}"
1692                                        )));
1693                                break;
1694                            }
1695                        }
1696                    }
1697                    Ok(resp) => {
1698                        let error_text = resp
1699                            .text()
1700                            .await
1701                            .unwrap_or_else(|_| "Unknown error".to_string());
1702
1703                        yield Err(Error::ad_hoc(format!(
1704                            "failed to check swap status: {error_text}"
1705                        )));
1706                        break;
1707                    }
1708                    Err(e) => {
1709                        yield Err(Error::ad_hoc(e.to_string())
1710                            .context("failed to send swap status request"));
1711                        break;
1712                    }
1713                }
1714
1715                // Poll every second
1716                tokio::time::sleep(std::time::Duration::from_secs(1)).await;
1717            }
1718        }
1719    }
1720}
1721
1722/// The amount to be shared with Boltz when creating a reverse submarine swap.
1723pub enum SwapAmount {
1724    /// Use this value if you need to set the value to be sent by the payer on Lightning.
1725    Invoice(Amount),
1726    /// Use this value if you need to set the value to be received by the payee on Arkade.
1727    Vhtlc(Amount),
1728}
1729
1730impl SwapAmount {
1731    pub fn invoice(amount: Amount) -> Self {
1732        Self::Invoice(amount)
1733    }
1734
1735    pub fn vhtlc(amount: Amount) -> Self {
1736        Self::Vhtlc(amount)
1737    }
1738}
1739
1740/// Data related to a submarine swap.
1741#[serde_as]
1742#[derive(Debug, Clone, Serialize, Deserialize)]
1743pub struct SubmarineSwapData {
1744    /// Unique swap identifier.
1745    pub id: String,
1746    /// The preimage hash of the BOLT11 invoice.
1747    pub preimage_hash: ripemd160::Hash,
1748    /// Public key of the receiving party.
1749    pub claim_public_key: PublicKey,
1750    /// Public key of the sending party.
1751    pub refund_public_key: PublicKey,
1752    /// Amount locked up in the VHTLC.
1753    pub amount: Amount,
1754    /// All the timelocks for this swap.
1755    pub timeout_block_heights: TimeoutBlockHeights,
1756    /// Address where funds are locked.
1757    #[serde_as(as = "DisplayFromStr")]
1758    pub vhtlc_address: ArkAddress,
1759    /// BOLT11 invoice associated with the swap.
1760    pub invoice: Bolt11Invoice,
1761    /// Current swap status.
1762    pub status: SwapStatus,
1763    /// UNIX timestamp when swap was created.
1764    pub created_at: u64,
1765}
1766
1767/// Data related to a reverse submarine swap.
1768#[serde_as]
1769#[derive(Debug, Clone, Serialize, Deserialize)]
1770pub struct ReverseSwapData {
1771    /// Unique swap identifier.
1772    pub id: String,
1773    /// Preimage for the swap (optional, may not be known at creation time).
1774    pub preimage: Option<[u8; 32]>,
1775    /// The preimage hash of the BOLT11 invoice.
1776    pub preimage_hash: ripemd160::Hash,
1777    /// Public key of the receiving party.
1778    pub claim_public_key: PublicKey,
1779    /// Public key of the sending party.
1780    pub refund_public_key: PublicKey,
1781    /// Amount locked up in the VHTLC.
1782    pub amount: Amount,
1783    /// All the timelocks for this swap.
1784    pub timeout_block_heights: TimeoutBlockHeights,
1785    /// Address where funds are locked.
1786    #[serde_as(as = "DisplayFromStr")]
1787    pub vhtlc_address: ArkAddress,
1788    /// Current swap status.
1789    pub status: SwapStatus,
1790    /// UNIX timestamp when swap was created.
1791    pub created_at: u64,
1792}
1793
1794/// All possible states of a Boltz swap.
1795///
1796/// Swaps progress through these states during their lifecycle.
1797#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1798pub enum SwapStatus {
1799    /// Initial state when swap is created.
1800    #[serde(rename = "swap.created")]
1801    Created,
1802    /// Lockup transaction detected in mempool.
1803    #[serde(rename = "transaction.mempool")]
1804    TransactionMempool,
1805    /// Lockup transaction confirmed on-chain.
1806    #[serde(rename = "transaction.confirmed")]
1807    TransactionConfirmed,
1808    /// Transaction refunded.
1809    #[serde(rename = "transaction.refunded")]
1810    TransactionRefunded,
1811    /// Transaction failed.
1812    #[serde(rename = "transaction.failed")]
1813    TransactionFailed,
1814    /// Transaction claimed.
1815    #[serde(rename = "transaction.claimed")]
1816    TransactionClaimed,
1817    /// Lightning invoice has been set.
1818    #[serde(rename = "invoice.set")]
1819    InvoiceSet,
1820    /// Waiting for Lightning invoice payment.
1821    #[serde(rename = "invoice.pending")]
1822    InvoicePending,
1823    /// Lightning invoice successfully paid.
1824    #[serde(rename = "invoice.paid")]
1825    InvoicePaid,
1826    /// Lightning invoice payment failed.
1827    #[serde(rename = "invoice.failedToPay")]
1828    InvoiceFailedToPay,
1829    /// Invoice expired.
1830    #[serde(rename = "invoice.expired")]
1831    InvoiceExpired,
1832    /// Swap expired - can be refunded.
1833    #[serde(rename = "swap.expired")]
1834    SwapExpired,
1835    /// Swap failed with error.
1836    #[serde(rename = "error")]
1837    Error { error: String },
1838}
1839
1840#[derive(Debug, Clone, Serialize, Deserialize, Copy)]
1841#[serde(rename_all = "camelCase")]
1842pub struct TimeoutBlockHeights {
1843    pub refund: u32,
1844    pub unilateral_claim: u32,
1845    pub unilateral_refund: u32,
1846    pub unilateral_refund_without_receiver: u32,
1847}
1848
1849#[derive(Debug, Clone, Serialize, Deserialize)]
1850#[serde(rename_all = "UPPERCASE")]
1851enum Asset {
1852    Btc,
1853    Ark,
1854}
1855
1856#[derive(Debug, Clone, Serialize, Deserialize)]
1857#[serde(rename_all = "camelCase")]
1858struct CreateReverseSwapRequest {
1859    from: Asset,
1860    to: Asset,
1861    #[serde(skip_serializing_if = "Option::is_none")]
1862    invoice_amount: Option<Amount>,
1863    #[serde(skip_serializing_if = "Option::is_none")]
1864    onchain_amount: Option<Amount>,
1865    claim_public_key: PublicKey,
1866    preimage_hash: sha256::Hash,
1867    /// The expiry will be this number of seconds in the future.
1868    ///
1869    /// If not provided, the generated invoice will have the default expiry set by Boltz.
1870    #[serde(skip_serializing_if = "Option::is_none")]
1871    invoice_expiry: Option<u64>,
1872}
1873
1874#[serde_as]
1875#[derive(Debug, Clone, Serialize, Deserialize)]
1876#[serde(rename_all = "camelCase")]
1877struct CreateReverseSwapResponse {
1878    id: String,
1879    #[serde_as(as = "DisplayFromStr")]
1880    lockup_address: ArkAddress,
1881    refund_public_key: PublicKey,
1882    timeout_block_heights: TimeoutBlockHeights,
1883    invoice: Bolt11Invoice,
1884    onchain_amount: Option<Amount>,
1885}
1886
1887#[derive(Debug, Clone, Serialize, Deserialize)]
1888struct CreateSubmarineSwapRequest {
1889    from: Asset,
1890    to: Asset,
1891    invoice: Bolt11Invoice,
1892    #[serde(rename = "refundPublicKey")]
1893    refund_public_key: PublicKey,
1894}
1895
1896#[serde_as]
1897#[derive(Debug, Clone, Serialize, Deserialize)]
1898#[serde(rename_all = "camelCase")]
1899struct CreateSubmarineSwapResponse {
1900    id: String,
1901    #[serde_as(as = "DisplayFromStr")]
1902    address: ArkAddress,
1903    expected_amount: Amount,
1904    claim_public_key: PublicKey,
1905    timeout_block_heights: TimeoutBlockHeights,
1906}
1907
1908#[derive(Debug, Clone, Serialize, Deserialize)]
1909struct GetSwapStatusResponse {
1910    status: SwapStatus,
1911}
1912
1913#[derive(Debug, Clone, Serialize, Deserialize)]
1914struct RefundSwapRequest {
1915    transaction: String,
1916    checkpoint: String,
1917}
1918
1919#[derive(Debug, Clone, Serialize, Deserialize)]
1920struct RefundSwapResponse {
1921    transaction: String,
1922    checkpoint: String,
1923    #[serde(skip_serializing_if = "Option::is_none")]
1924    error: Option<String>,
1925}
1926
1927/// Fee information for submarine swaps (Ark -> Lightning).
1928#[derive(Debug, Clone, Serialize, Deserialize)]
1929#[serde(rename_all = "camelCase")]
1930pub struct SubmarineSwapFees {
1931    /// Percentage fee charged by Boltz (e.g., 0.25 = 0.25%).
1932    pub percentage: f64,
1933    /// Fixed miner fee in satoshis.
1934    pub miner_fees: u64,
1935}
1936
1937/// Miner fees for reverse swaps, broken down by operation.
1938#[derive(Debug, Clone, Serialize, Deserialize)]
1939pub struct ReverseMinerFees {
1940    /// Miner fee for lockup transaction in satoshis.
1941    pub lockup: u64,
1942    /// Miner fee for claim transaction in satoshis.
1943    pub claim: u64,
1944}
1945
1946/// Fee information for reverse swaps (Lightning -> Ark).
1947#[derive(Debug, Clone, Serialize, Deserialize)]
1948#[serde(rename_all = "camelCase")]
1949pub struct ReverseSwapFees {
1950    /// Percentage fee charged by Boltz (e.g., 0.25 = 0.25%).
1951    pub percentage: f64,
1952    /// Miner fees broken down by operation.
1953    pub miner_fees: ReverseMinerFees,
1954}
1955
1956/// Combined fee information for both swap types.
1957#[derive(Debug, Clone, Serialize, Deserialize)]
1958pub struct BoltzFees {
1959    /// Fees for submarine swaps (Ark -> Lightning).
1960    pub submarine: SubmarineSwapFees,
1961    /// Fees for reverse swaps (Lightning -> Ark).
1962    pub reverse: ReverseSwapFees,
1963}
1964
1965/// Limits for swap amounts.
1966#[derive(Debug, Clone, Serialize, Deserialize)]
1967pub struct SwapLimits {
1968    /// Minimum amount in satoshis.
1969    pub min: u64,
1970    /// Maximum amount in satoshis.
1971    pub max: u64,
1972}
1973
1974// Internal structs for deserializing the Boltz API response.
1975
1976#[derive(Debug, Clone, Deserialize)]
1977struct PairLimits {
1978    minimal: u64,
1979    maximal: u64,
1980}
1981
1982// Submarine swap: { "ARK": { "BTC": { ... } } }
1983#[derive(Debug, Clone, Deserialize)]
1984#[serde(rename_all = "camelCase")]
1985struct SubmarinePairFees {
1986    percentage: f64,
1987    miner_fees: u64,
1988}
1989
1990#[derive(Debug, Clone, Deserialize)]
1991struct SubmarinePairInfo {
1992    fees: SubmarinePairFees,
1993    limits: PairLimits,
1994}
1995
1996#[derive(Debug, Clone, Deserialize)]
1997#[serde(rename_all = "UPPERCASE")]
1998struct SubmarineArkPairs {
1999    btc: SubmarinePairInfo,
2000}
2001
2002#[derive(Debug, Clone, Deserialize)]
2003#[serde(rename_all = "UPPERCASE")]
2004struct SubmarinePairsResponse {
2005    ark: SubmarineArkPairs,
2006}
2007
2008// Reverse swap: { "BTC": { "ARK": { ... } } }
2009#[derive(Debug, Clone, Deserialize)]
2010#[serde(rename_all = "camelCase")]
2011struct ReverseMinerFeesResponse {
2012    claim: u64,
2013    lockup: u64,
2014}
2015
2016#[derive(Debug, Clone, Deserialize)]
2017#[serde(rename_all = "camelCase")]
2018struct ReversePairFees {
2019    percentage: f64,
2020    miner_fees: ReverseMinerFeesResponse,
2021}
2022
2023#[derive(Debug, Clone, Deserialize)]
2024struct ReversePairInfo {
2025    fees: ReversePairFees,
2026}
2027
2028#[derive(Debug, Clone, Deserialize)]
2029#[serde(rename_all = "UPPERCASE")]
2030struct ReverseBtcPairs {
2031    ark: ReversePairInfo,
2032}
2033
2034#[derive(Debug, Clone, Deserialize)]
2035#[serde(rename_all = "UPPERCASE")]
2036struct ReversePairsResponse {
2037    btc: ReverseBtcPairs,
2038}
2039
2040#[cfg(test)]
2041mod tests {
2042    use super::*;
2043
2044    #[test]
2045    fn test_deserialize_create_reverse_swap_response() {
2046        let json = r#"{
2047  "id": "vqhG2fJtNY4H",
2048  "lockupAddress": "tark1qra883hysahlkt0ujcwhv0x2n278849c3m7t3a08l7fdc40f4f2nmw3f7kn37vvq0hqazxtqgtvhwp3z83zfgr7qc82t9mty8vk95ynpx3l43d",
2049  "refundPublicKey": "0206988651c7fbe41747bb21b54ced0a183f4d658e007ee8fdb23fbbfccb8e0c55",
2050  "timeoutBlockHeights": {
2051    "refund": 1760508054,
2052    "unilateralClaim": 9728,
2053    "unilateralRefund": 86528,
2054    "unilateralRefundWithoutReceiver": 86528
2055  },
2056  "invoice": "lntbs10u1p5wmeeepp56ms94rkev7tdrwqyus5a63lny2mqzq9vh2rq3u4ym3v4lxv6xl4qdql2djkuepqw3hjqs2jfvsxzerywfjhxuccqz95xqztfsp5ckaskagag554na8d56tlrfdxasstqrmmpkvswqqqx6y386jcfq9s9qxpqysgqt7z0vkdwkqamydae7ctgkh7l8q75w7q9394ce3lda2mkfxrpfdtj5gmltuctav7jdgatkflhztrjjzutdla5e4xp0uhxxy7sluzll4qpkkh6wv",
2057  "onchainAmount": 996
2058}"#;
2059
2060        let response: CreateReverseSwapResponse =
2061            serde_json::from_str(json).expect("Failed to deserialize CreateReverseSwapResponse");
2062
2063        // Verify the deserialized fields
2064        assert_eq!(response.id, "vqhG2fJtNY4H");
2065        assert_eq!(response.onchain_amount, Some(Amount::from_sat(996)));
2066        assert_eq!(
2067            response.refund_public_key,
2068            PublicKey::from_str(
2069                "0206988651c7fbe41747bb21b54ced0a183f4d658e007ee8fdb23fbbfccb8e0c55"
2070            )
2071            .expect("valid public key")
2072        );
2073        assert_eq!(
2074            response.lockup_address.to_string(),
2075            "tark1qra883hysahlkt0ujcwhv0x2n278849c3m7t3a08l7fdc40f4f2nmw3f7kn37vvq0hqazxtqgtvhwp3z83zfgr7qc82t9mty8vk95ynpx3l43d"
2076        );
2077        assert_eq!(response.timeout_block_heights.refund, 1760508054);
2078        assert_eq!(response.timeout_block_heights.unilateral_claim, 9728);
2079        assert_eq!(response.timeout_block_heights.unilateral_refund, 86528);
2080        assert_eq!(
2081            response
2082                .timeout_block_heights
2083                .unilateral_refund_without_receiver,
2084            86528
2085        );
2086    }
2087}