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
55const 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#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
76pub enum SwapType {
77 Submarine,
78 Reverse,
79 Chain,
80 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#[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#[derive(Clone, Debug)]
130pub enum PendingVhtlcSpendType {
131 Claim { swap_id: String, preimage: [u8; 32] },
135 CollaborativeRefund { swap_id: String },
139 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#[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 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 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 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 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 let Some(preimage) = swap_data.preimage {
470 return Ok(preimage);
471 }
472
473 let vhtlc_address = swap_data.vhtlc_address;
474
475 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 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 let preimage = extract_preimage_from_psbt(claim_psbt)?;
507
508 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 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 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 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 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 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 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 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 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 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, control_block,
902 vhtlc.tapscripts(),
903 script_pubkey,
904 refund_amount,
905 vhtlc_outpoint.outpoint,
906 vhtlc_outpoint.assets,
907 );
908
909 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 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 let checkpoint_psbt = checkpoint_txs
938 .first()
939 .ok_or_else(|| Error::ad_hoc("no checkpoint PSBTs found"))?
940 .clone();
941
942 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 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 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 let res = self
1004 .network_client()
1005 .submit_offchain_transaction_request(boltz_signed_ark_tx, vec![boltz_signed_checkpoint])
1006 .await?;
1007
1008 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 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 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 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, 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 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 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 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 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 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 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 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 {
1496 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 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 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 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 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 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 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 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 {
1748 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 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 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 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 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 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 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 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 {
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 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 let btc_address_str = &swap.server_lockup_address;
2266
2267 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 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 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 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 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 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 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 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 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 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 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 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 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 let btc_address_str = &swap.user_lockup_address;
2601
2602 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 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 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 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 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 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 pub async fn get_swap_status(&self, swap_id: &str) -> Result<SwapStatusInfo, Error> {
2741 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 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 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 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 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 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 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 if last_status.as_ref() != Some(¤t_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 tokio::time::sleep(std::time::Duration::from_secs(1)).await;
2970 }
2971 }
2972 }
2973
2974 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 fn ensure_swap_key_cached(
3388 &self,
3389 pk: &XOnlyPublicKey,
3390 key_derivation_index: Option<u32>,
3391 swap_id: &str,
3392 ) -> bool {
3393 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 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 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 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 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 fn identify_vhtlc_spend_type(
3546 info: &VhtlcInfo,
3547 pending_tx: &PendingTx,
3548 ) -> Result<PendingVhtlcSpendType, Error> {
3549 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 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 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 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 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 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
3699struct VhtlcInfo {
3701 swap_id: String,
3702 address: ArkAddress,
3703 script_pubkey: ScriptBuf,
3704 vhtlc: VhtlcScript,
3705 intent_spend_info: (ScriptBuf, bitcoin::taproot::ControlBlock),
3707 preimage: Option<[u8; 32]>,
3708}
3709
3710fn 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
3747fn 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
3759fn 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 let num_elements = condition_data[0] as usize;
3777 if num_elements == 0 {
3778 continue;
3779 }
3780
3781 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
3815pub enum SwapAmount {
3817 Invoice(Amount),
3819 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
3833pub enum ChainSwapAmount {
3835 UserLock(Amount),
3837 ServerLock(Amount),
3839}
3840
3841#[serde_as]
3843#[derive(Debug, Clone, Serialize, Deserialize)]
3844pub struct SubmarineSwapData {
3845 pub id: String,
3847 pub preimage: Option<[u8; 32]>,
3849 pub preimage_hash: ripemd160::Hash,
3851 pub claim_public_key: PublicKey,
3853 pub refund_public_key: PublicKey,
3855 pub amount: Amount,
3857 pub timeout_block_heights: TimeoutBlockHeights,
3859 #[serde_as(as = "DisplayFromStr")]
3861 pub vhtlc_address: ArkAddress,
3862 pub invoice: Bolt11Invoice,
3864 pub status: SwapStatus,
3866 pub created_at: u64,
3868 #[serde(default)]
3872 pub key_derivation_index: Option<u32>,
3873}
3874
3875#[serde_as]
3877#[derive(Debug, Clone, Serialize, Deserialize)]
3878pub struct ReverseSwapData {
3879 pub id: String,
3881 pub preimage: Option<[u8; 32]>,
3883 pub preimage_hash: ripemd160::Hash,
3885 pub claim_public_key: PublicKey,
3887 pub refund_public_key: PublicKey,
3889 pub amount: Amount,
3891 pub timeout_block_heights: TimeoutBlockHeights,
3893 #[serde_as(as = "DisplayFromStr")]
3895 pub vhtlc_address: ArkAddress,
3896 pub status: SwapStatus,
3898 pub created_at: u64,
3900 #[serde(default)]
3904 pub key_derivation_index: Option<u32>,
3905 pub bolt11: String,
3907 pub invoice_expiry: u64,
3909}
3910
3911#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
3915pub enum SwapStatus {
3916 #[serde(rename = "swap.created")]
3918 Created,
3919 #[serde(rename = "transaction.mempool")]
3921 TransactionMempool,
3922 #[serde(rename = "transaction.confirmed")]
3924 TransactionConfirmed,
3925 #[serde(rename = "transaction.refunded")]
3927 TransactionRefunded,
3928 #[serde(rename = "transaction.failed")]
3930 TransactionFailed,
3931 #[serde(rename = "transaction.claimed")]
3933 TransactionClaimed,
3934 #[serde(rename = "transaction.server.mempool")]
3936 TransactionServerMempool,
3937 #[serde(rename = "transaction.server.confirmed")]
3939 TransactionServerConfirmed,
3940 #[serde(rename = "invoice.set")]
3942 InvoiceSet,
3943 #[serde(rename = "invoice.pending")]
3945 InvoicePending,
3946 #[serde(rename = "invoice.paid")]
3948 InvoicePaid,
3949 #[serde(rename = "invoice.failedToPay")]
3951 InvoiceFailedToPay,
3952 #[serde(rename = "invoice.expired")]
3954 InvoiceExpired,
3955 #[serde(rename = "transaction.lockupFailed")]
3957 TransactionLockupFailed,
3958 #[serde(rename = "swap.expired")]
3960 SwapExpired,
3961 #[serde(rename = "error")]
3963 Error { error: String },
3964 #[serde(untagged)]
3966 Other(String),
3967}
3968
3969impl SwapStatus {
3970 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 #[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#[derive(Debug, Clone, Serialize, Deserialize)]
4089#[serde(rename_all = "camelCase")]
4090pub struct SubmarineSwapFees {
4091 pub percentage: f64,
4093 pub miner_fees: u64,
4095}
4096
4097#[derive(Debug, Clone, Serialize, Deserialize)]
4099pub struct ReverseMinerFees {
4100 pub lockup: u64,
4102 pub claim: u64,
4104}
4105
4106#[derive(Debug, Clone, Serialize, Deserialize)]
4108#[serde(rename_all = "camelCase")]
4109pub struct ReverseSwapFees {
4110 pub percentage: f64,
4112 pub miner_fees: ReverseMinerFees,
4114}
4115
4116#[derive(Debug, Clone, Serialize, Deserialize)]
4118pub struct BoltzFees {
4119 pub submarine: SubmarineSwapFees,
4121 pub reverse: ReverseSwapFees,
4123}
4124
4125#[derive(Debug, Clone, Serialize, Deserialize)]
4127pub struct SwapLimits {
4128 pub min: u64,
4130 pub max: u64,
4132}
4133
4134#[derive(Debug, Clone, Deserialize)]
4137struct PairLimits {
4138 minimal: u64,
4139 maximal: u64,
4140}
4141
4142#[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#[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#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
4204pub enum ChainSwapDirection {
4205 ArkToBtc,
4207 BtcToArk,
4209}
4210
4211#[serde_as]
4213#[derive(Debug, Clone, Serialize, Deserialize)]
4214pub struct ChainSwapData {
4215 pub id: String,
4217 pub status: SwapStatus,
4219 pub direction: ChainSwapDirection,
4221 pub preimage: Option<[u8; 32]>,
4223 pub preimage_hash: sha256::Hash,
4225 pub claim_public_key: PublicKey,
4227 pub refund_public_key: PublicKey,
4229 pub server_claim_public_key: PublicKey,
4231 pub server_refund_public_key: PublicKey,
4233 pub user_lockup_address: String,
4235 pub server_lockup_address: String,
4237 pub user_lockup_amount: Amount,
4239 pub server_lockup_amount: Amount,
4241 pub user_timeout_block_height: u32,
4243 pub server_timeout_block_height: u32,
4245 #[serde(default)]
4247 pub user_timeout_block_heights: Option<TimeoutBlockHeights>,
4248 #[serde(default)]
4250 pub server_timeout_block_heights: Option<TimeoutBlockHeights>,
4251 #[serde(default)]
4253 pub bip21: Option<String>,
4254 #[serde(default)]
4256 pub swap_tree: Option<SwapTree>,
4257 pub created_at: u64,
4259 #[serde(default)]
4261 pub claim_key_derivation_index: Option<u32>,
4262 #[serde(default)]
4264 pub refund_key_derivation_index: Option<u32>,
4265}
4266
4267#[derive(Clone, Debug)]
4269pub struct ChainSwapResult {
4270 pub swap_id: String,
4272 pub user_lockup_address: String,
4274 pub user_lockup_amount: Amount,
4276 pub server_lockup_amount: Amount,
4278 pub bip21: Option<String>,
4280}
4281
4282#[derive(Debug, Clone, Serialize, Deserialize)]
4286#[serde(rename_all = "camelCase")]
4287pub struct SwapTree {
4288 pub claim_leaf: SwapTreeLeaf,
4290 pub refund_leaf: SwapTreeLeaf,
4292}
4293
4294#[derive(Debug, Clone, Serialize, Deserialize)]
4296pub struct SwapTreeLeaf {
4297 pub version: u8,
4299 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 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 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 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}