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