use crate::batch::BatchOutputType;
use crate::error::ErrorContext as _;
use crate::swap_storage::SwapStorage;
use crate::timeout_op;
use crate::wallet::BoardingWallet;
use crate::wallet::OnchainWallet;
use crate::Blockchain;
use crate::Client;
use crate::Error;
use ark_core::intent;
use ark_core::script::extract_checksig_pubkeys;
use ark_core::send::build_offchain_transactions;
use ark_core::send::sign_ark_transaction;
use ark_core::send::sign_checkpoint_transaction;
use ark_core::send::OffchainTransactions;
use ark_core::send::SendReceiver;
use ark_core::send::VtxoInput;
use ark_core::server::parse_sequence_number;
use ark_core::server::PendingTx;
use ark_core::vhtlc::VhtlcOptions;
use ark_core::vhtlc::VhtlcScript;
use ark_core::ArkAddress;
use ark_core::VtxoList;
use ark_core::VTXO_CONDITION_KEY;
use bitcoin::absolute;
use bitcoin::consensus::Encodable;
use bitcoin::hashes::ripemd160;
use bitcoin::hashes::sha256;
use bitcoin::hashes::Hash;
use bitcoin::io::Write;
use bitcoin::key::Secp256k1;
use bitcoin::psbt;
use bitcoin::secp256k1;
use bitcoin::secp256k1::schnorr;
use bitcoin::taproot::LeafVersion;
use bitcoin::Amount;
use bitcoin::Psbt;
use bitcoin::PublicKey;
use bitcoin::ScriptBuf;
use bitcoin::TxOut;
use bitcoin::Txid;
use bitcoin::VarInt;
use bitcoin::XOnlyPublicKey;
use lightning_invoice::Bolt11Invoice;
use rand::CryptoRng;
use rand::Rng;
use serde::Deserialize;
use serde::Serialize;
use serde_with::serde_as;
use serde_with::DisplayFromStr;
use std::str::FromStr;
use std::time::SystemTime;
use std::time::UNIX_EPOCH;
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
pub enum SwapType {
Submarine,
Reverse,
Chain,
Unknown,
}
impl std::fmt::Display for SwapType {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Submarine => write!(f, "submarine"),
Self::Reverse => write!(f, "reverse"),
Self::Chain => write!(f, "chain"),
Self::Unknown => write!(f, "unknown"),
}
}
}
#[derive(Clone, Debug)]
pub struct SwapStatusInfo {
pub swap_id: String,
pub swap_type: SwapType,
pub status: SwapStatus,
}
#[derive(Clone, Debug)]
pub struct SubmarineSwapResult {
pub swap_id: String,
pub txid: Txid,
pub amount: Amount,
}
#[derive(Clone, Debug)]
pub struct ReverseSwapResult {
pub swap_id: String,
pub amount: Amount,
pub invoice: Bolt11Invoice,
}
#[derive(Clone, Debug)]
pub struct ClaimVhtlcResult {
pub swap_id: String,
pub claim_txid: Txid,
pub claim_amount: Amount,
pub preimage: [u8; 32],
}
#[derive(Clone, Debug)]
pub enum PendingVhtlcSpendType {
Claim { swap_id: String, preimage: [u8; 32] },
CollaborativeRefund { swap_id: String },
ExpiredRefund { swap_id: String },
}
impl PendingVhtlcSpendType {
pub fn swap_id(&self) -> &str {
match self {
Self::Claim { swap_id, .. }
| Self::CollaborativeRefund { swap_id }
| Self::ExpiredRefund { swap_id } => swap_id,
}
}
pub fn name(&self) -> &'static str {
match self {
Self::Claim { .. } => "Claim",
Self::CollaborativeRefund { .. } => "CollaborativeRefund",
Self::ExpiredRefund { .. } => "ExpiredRefund",
}
}
}
#[derive(Clone, Debug)]
pub struct PendingVhtlcSpendTx {
pub spend_type: PendingVhtlcSpendType,
pub pending_tx: PendingTx,
}
impl<B, W, S, K> Client<B, W, S, K>
where
B: Blockchain,
W: BoardingWallet + OnchainWallet,
S: SwapStorage + 'static,
K: crate::KeyProvider,
{
pub async fn prepare_ln_invoice_payment(
&self,
invoice: Bolt11Invoice,
) -> Result<SubmarineSwapData, Error> {
let refund_keypair = self.next_keypair(crate::key_provider::KeypairIndex::New)?;
let refund_public_key = refund_keypair.public_key();
let key_derivation_index =
self.derivation_index_for_pk(&refund_keypair.x_only_public_key().0);
let preimage_hash = invoice.payment_hash();
let preimage_hash = ripemd160::Hash::hash(preimage_hash.as_byte_array());
let request = CreateSubmarineSwapRequest {
from: Asset::Ark,
to: Asset::Btc,
invoice,
refund_public_key: refund_public_key.into(),
};
let url = format!("{}/v2/swap/submarine", self.inner.boltz_url);
let client = reqwest::Client::new();
let response = client
.post(&url)
.json(&request)
.send()
.await
.map_err(|e| Error::ad_hoc(e.to_string()))
.context("failed to send submarine swap request")?;
if !response.status().is_success() {
let error_text = response
.text()
.await
.map_err(|e| Error::ad_hoc(e.to_string()))
.context("failed to read error text")?;
return Err(Error::ad_hoc(format!(
"failed to create submarine swap: {error_text}"
)));
}
let swap_response: CreateSubmarineSwapResponse = response
.json()
.await
.map_err(|e| Error::ad_hoc(e.to_string()))
.context("failed to deserialize submarine swap response")?;
let created_at = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_err(Error::ad_hoc)
.context("failed to compute created_at")?;
let data = SubmarineSwapData {
id: swap_response.id.clone(),
status: SwapStatus::Created,
preimage: None,
preimage_hash,
refund_public_key: refund_public_key.into(),
claim_public_key: swap_response.claim_public_key,
vhtlc_address: swap_response.address,
timeout_block_heights: swap_response.timeout_block_heights,
amount: swap_response.expected_amount,
invoice: request.invoice.clone(),
created_at: created_at.as_secs(),
key_derivation_index,
};
self.swap_storage()
.insert_submarine(swap_response.id.clone(), data.clone())
.await?;
tracing::info!(
swap_id = swap_response.id,
vhtlc_address = %data.vhtlc_address,
expected_amount = %data.amount,
"Prepared Lightning invoice payment"
);
Ok(data)
}
pub async fn pay_ln_invoice(
&self,
invoice: Bolt11Invoice,
) -> Result<SubmarineSwapResult, Error> {
let refund_keypair = self.next_keypair(crate::key_provider::KeypairIndex::New)?;
let refund_public_key = refund_keypair.public_key();
let key_derivation_index =
self.derivation_index_for_pk(&refund_keypair.x_only_public_key().0);
let preimage_hash = invoice.payment_hash();
let preimage_hash = ripemd160::Hash::hash(preimage_hash.as_byte_array());
let request = CreateSubmarineSwapRequest {
from: Asset::Ark,
to: Asset::Btc,
invoice,
refund_public_key: refund_public_key.into(),
};
let url = format!("{}/v2/swap/submarine", self.inner.boltz_url);
let client = reqwest::Client::new();
let response = client
.post(&url)
.json(&request)
.send()
.await
.map_err(|e| Error::ad_hoc(e.to_string()))
.context("failed to send submarine swap request")?;
if !response.status().is_success() {
let error_text = response
.text()
.await
.map_err(|e| Error::ad_hoc(e.to_string()))
.context("failed to read error text")?;
return Err(Error::ad_hoc(format!(
"failed to create submarine swap: {error_text}"
)));
}
let swap_response: CreateSubmarineSwapResponse = response
.json()
.await
.map_err(|e| Error::ad_hoc(e.to_string()))
.context("failed to deserialize submarine swap response")?;
let created_at = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_err(Error::ad_hoc)
.context("failed to compute created_at")?;
self.swap_storage()
.insert_submarine(
swap_response.id.clone(),
SubmarineSwapData {
id: swap_response.id.clone(),
status: SwapStatus::Created,
preimage: None,
preimage_hash,
refund_public_key: refund_public_key.into(),
claim_public_key: swap_response.claim_public_key,
vhtlc_address: swap_response.address,
timeout_block_heights: swap_response.timeout_block_heights,
amount: swap_response.expected_amount,
invoice: request.invoice.clone(),
created_at: created_at.as_secs(),
key_derivation_index,
},
)
.await?;
let vhtlc_address = swap_response.address;
let amount = swap_response.expected_amount;
let txid = self
.send(vec![SendReceiver::bitcoin(vhtlc_address, amount)])
.await?;
tracing::info!(swap_id = swap_response.id, %amount, "Funded VHTLC");
Ok(SubmarineSwapResult {
swap_id: swap_response.id,
txid,
amount,
})
}
pub async fn wait_for_invoice_paid(&self, swap_id: &str) -> Result<[u8; 32], Error> {
use futures::StreamExt;
let stream = self.subscribe_to_swap_updates(swap_id.to_string());
tokio::pin!(stream);
while let Some(status_result) = stream.next().await {
match status_result {
Ok(status) => {
tracing::debug!(swap_id, current = ?status, "Swap status");
match status {
SwapStatus::InvoicePaid => {
let deadline = tokio::time::Instant::now() + self.inner.timeout;
loop {
match self.extract_submarine_swap_preimage(swap_id).await {
Ok(preimage) => return Ok(preimage),
Err(e) => {
if tokio::time::Instant::now() >= deadline {
return Err(e.context(
"invoice paid but failed to extract preimage from claim tx",
));
}
tracing::debug!(
swap_id,
"Preimage not available yet, retrying: {e}"
);
}
}
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
}
}
SwapStatus::InvoiceExpired => {
return Err(Error::ad_hoc(format!(
"invoice expired for swap {swap_id}"
)));
}
SwapStatus::Error { error } => {
tracing::error!(
swap_id,
"Got error from swap updates subscription: {error}"
);
}
SwapStatus::InvoiceSet
| SwapStatus::InvoicePending
| SwapStatus::Created
| SwapStatus::TransactionMempool
| SwapStatus::TransactionConfirmed
| SwapStatus::TransactionServerMempool
| SwapStatus::TransactionServerConfirmed
| SwapStatus::TransactionRefunded
| SwapStatus::TransactionFailed
| SwapStatus::TransactionClaimed
| SwapStatus::TransactionLockupFailed
| SwapStatus::InvoiceFailedToPay
| SwapStatus::SwapExpired
| SwapStatus::Other(_) => {}
}
}
Err(e) => return Err(e),
}
}
Err(Error::ad_hoc("Status stream ended unexpectedly"))
}
pub async fn extract_submarine_swap_preimage(&self, swap_id: &str) -> Result<[u8; 32], Error> {
let mut swap_data = self
.swap_storage()
.get_submarine(swap_id)
.await?
.ok_or(Error::ad_hoc("submarine swap not found"))?;
if let Some(preimage) = swap_data.preimage {
return Ok(preimage);
}
let vhtlc_address = swap_data.vhtlc_address;
let virtual_tx_outpoints = self
.get_virtual_tx_outpoints(std::iter::once(vhtlc_address))
.await
.context("failed to get virtual tx outpoints for VHTLC address")?;
let vhtlc_outpoint = virtual_tx_outpoints
.iter()
.find(|o| o.is_spent)
.ok_or_else(|| Error::ad_hoc("VHTLC outpoint not found or not yet spent (claimed)"))?;
let claim_txid = vhtlc_outpoint.ark_txid.ok_or_else(|| {
Error::ad_hoc("VHTLC is spent but has no ark_txid (claim transaction)")
})?;
let claim_txs = timeout_op(
self.inner.timeout,
self.network_client()
.get_virtual_txs(vec![claim_txid.to_string()], None),
)
.await?
.map_err(|e| Error::ad_hoc(e.to_string()))
.context("failed to fetch claim transaction")?;
let claim_psbt = claim_txs
.txs
.first()
.ok_or_else(|| Error::ad_hoc("claim transaction not found"))?;
let preimage = extract_preimage_from_psbt(claim_psbt)?;
let computed_hash = ripemd160::Hash::hash(sha256::Hash::hash(&preimage).as_byte_array());
if computed_hash != swap_data.preimage_hash {
return Err(Error::ad_hoc(format!(
"extracted preimage does not match stored hash: expected {}, got {}",
swap_data.preimage_hash, computed_hash
)));
}
swap_data.preimage = Some(preimage);
self.swap_storage()
.update_submarine(swap_id, swap_data)
.await
.context("failed to persist preimage to swap storage")?;
tracing::info!(
swap_id,
"Extracted and persisted preimage from claim transaction"
);
Ok(preimage)
}
pub async fn refund_expired_vhtlc(&self, swap_id: &str) -> Result<Txid, Error> {
let swap_data = self
.swap_storage()
.get_submarine(swap_id)
.await?
.ok_or(Error::ad_hoc("Submarine swap not found"))?;
let timeout_block_heights = swap_data.timeout_block_heights;
let vhtlc = VhtlcScript::new(
VhtlcOptions {
sender: swap_data.refund_public_key.into(),
receiver: swap_data.claim_public_key.into(),
server: self.server_info.signer_pk.into(),
preimage_hash: swap_data.preimage_hash,
refund_locktime: timeout_block_heights.refund,
unilateral_claim_delay: parse_sequence_number(
timeout_block_heights.unilateral_claim as i64,
)
.map_err(|e| Error::ad_hoc(format!("invalid unilateral claim timeout: {e}")))?,
unilateral_refund_delay: parse_sequence_number(
timeout_block_heights.unilateral_refund as i64,
)
.map_err(|e| Error::ad_hoc(format!("invalid unilateral refund timeout: {e}")))?,
unilateral_refund_without_receiver_delay: parse_sequence_number(
timeout_block_heights.unilateral_refund_without_receiver as i64,
)
.map_err(|e| {
Error::ad_hoc(format!("invalid refund without receiver timeout: {e}"))
})?,
},
self.server_info.network,
)
.map_err(Error::ad_hoc)?;
let vhtlc_address = vhtlc.address();
if vhtlc_address != swap_data.vhtlc_address {
return Err(Error::ad_hoc(format!(
"VHTLC address ({vhtlc_address}) does not match swap address ({})",
swap_data.vhtlc_address
)));
}
let vhtlc_outpoint = {
let virtual_tx_outpoints = self
.get_virtual_tx_outpoints(std::iter::once(vhtlc_address))
.await?;
let vtxo_list = VtxoList::new(self.server_info.dust, virtual_tx_outpoints);
let mut unspent = vtxo_list.all_unspent();
let vhtlc_outpoint = unspent.next().ok_or_else(|| {
Error::ad_hoc(format!("no outpoint found for address {vhtlc_address}"))
})?;
vhtlc_outpoint.clone()
};
let (refund_address, _) = self.get_offchain_address()?;
let refund_amount = swap_data.amount;
let outputs = vec![SendReceiver {
address: refund_address,
amount: refund_amount,
assets: Vec::new(),
}];
let refund_script = vhtlc.refund_without_receiver_script();
let spend_info = vhtlc.taproot_spend_info();
let script_ver = (refund_script, LeafVersion::TapScript);
let control_block = spend_info
.control_block(&script_ver)
.ok_or(Error::ad_hoc("control block not found for refund script"))?;
let script_pubkey = vhtlc.script_pubkey();
let refunder_pk = swap_data.refund_public_key.inner.x_only_public_key().0;
let vhtlc_input = VtxoInput::new(
script_ver.0,
Some(absolute::LockTime::from_consensus(
swap_data.timeout_block_heights.refund,
)),
control_block,
vhtlc.tapscripts(),
script_pubkey,
refund_amount,
vhtlc_outpoint.outpoint,
vhtlc_outpoint.assets,
);
let change_address = &refund_address;
let OffchainTransactions {
mut ark_tx,
checkpoint_txs,
} = build_offchain_transactions(
&outputs,
change_address,
std::slice::from_ref(&vhtlc_input),
&self.server_info,
)?;
let kp = self.keypair_by_pk(&refunder_pk)?;
let sign_fn =
|_: &mut psbt::Input,
msg: secp256k1::Message|
-> Result<Vec<(schnorr::Signature, XOnlyPublicKey)>, ark_core::Error> {
let sig = Secp256k1::new().sign_schnorr_no_aux_rand(&msg, &kp);
let pk = kp.x_only_public_key().0;
Ok(vec![(sig, pk)])
};
sign_ark_transaction(sign_fn, &mut ark_tx, 0)?;
let ark_txid = ark_tx.unsigned_tx.compute_txid();
let res = self
.network_client()
.submit_offchain_transaction_request(ark_tx, checkpoint_txs)
.await?;
let mut checkpoint_psbt = res
.signed_checkpoint_txs
.first()
.ok_or_else(|| Error::ad_hoc("no checkpoint PSBTs found"))?
.clone();
let kp = self.keypair_by_pk(&refunder_pk)?;
let sign_fn =
|_: &mut psbt::Input,
msg: secp256k1::Message|
-> Result<Vec<(schnorr::Signature, XOnlyPublicKey)>, ark_core::Error> {
let sig = Secp256k1::new().sign_schnorr_no_aux_rand(&msg, &kp);
let pk = kp.x_only_public_key().0;
Ok(vec![(sig, pk)])
};
sign_checkpoint_transaction(sign_fn, &mut checkpoint_psbt)?;
timeout_op(
self.inner.timeout,
self.network_client()
.finalize_offchain_transaction(ark_txid, vec![checkpoint_psbt]),
)
.await?
.map_err(Error::ark_server)
.context("failed to finalize offchain transaction")?;
tracing::info!(txid = %ark_txid, "Refunded VHTLC");
Ok(ark_txid)
}
pub async fn refund_expired_vhtlc_via_settlement<R>(
&self,
rng: &mut R,
swap_id: &str,
) -> Result<Txid, Error>
where
R: Rng + CryptoRng,
{
let swap_data = self
.swap_storage()
.get_submarine(swap_id)
.await?
.ok_or(Error::ad_hoc("Submarine swap not found"))?;
let timeout_block_heights = swap_data.timeout_block_heights;
let vhtlc = VhtlcScript::new(
VhtlcOptions {
sender: swap_data.refund_public_key.into(),
receiver: swap_data.claim_public_key.into(),
server: self.server_info.signer_pk.into(),
preimage_hash: swap_data.preimage_hash,
refund_locktime: timeout_block_heights.refund,
unilateral_claim_delay: parse_sequence_number(
timeout_block_heights.unilateral_claim as i64,
)
.map_err(|e| Error::ad_hoc(format!("invalid unilateral claim timeout: {e}")))?,
unilateral_refund_delay: parse_sequence_number(
timeout_block_heights.unilateral_refund as i64,
)
.map_err(|e| Error::ad_hoc(format!("invalid unilateral refund timeout: {e}")))?,
unilateral_refund_without_receiver_delay: parse_sequence_number(
timeout_block_heights.unilateral_refund_without_receiver as i64,
)
.map_err(|e| {
Error::ad_hoc(format!("invalid refund without receiver timeout: {e}"))
})?,
},
self.server_info.network,
)
.map_err(Error::ad_hoc)?;
let vhtlc_address = vhtlc.address();
if vhtlc_address != swap_data.vhtlc_address {
return Err(Error::ad_hoc(format!(
"VHTLC address ({vhtlc_address}) does not match swap address ({})",
swap_data.vhtlc_address
)));
}
let vhtlc_outpoint = {
let virtual_tx_outpoints = self
.get_virtual_tx_outpoints(std::iter::once(vhtlc_address))
.await?;
let vtxo_list = VtxoList::new(self.server_info.dust, virtual_tx_outpoints);
let mut recoverable = vtxo_list.recoverable();
recoverable
.next()
.ok_or_else(|| {
Error::ad_hoc(format!("no outpoint found for address {vhtlc_address}"))
})?
.clone()
};
let refund_script = vhtlc.refund_without_receiver_script();
let spend_info = vhtlc.taproot_spend_info();
let script_ver = (refund_script, LeafVersion::TapScript);
let control_block = spend_info
.control_block(&script_ver)
.ok_or(Error::ad_hoc("control block not found for refund script"))?;
let script_pubkey = vhtlc.script_pubkey();
let (refund_address, _) = self.get_offchain_address()?;
let refund_amount = swap_data.amount;
let vhtlc_input = intent::Input::new(
vhtlc_outpoint.outpoint,
parse_sequence_number(timeout_block_heights.unilateral_refund as i64)
.map_err(|e| Error::ad_hoc(format!("invalid unilateral refund timeout: {e}")))?,
Some(absolute::LockTime::from_consensus(
timeout_block_heights.refund,
)),
TxOut {
value: refund_amount,
script_pubkey,
},
vhtlc.tapscripts(),
(script_ver.0, control_block),
false,
true,
vhtlc_outpoint.assets,
);
let commitment_txid = self
.join_next_batch(
rng,
Vec::new(),
vec![vhtlc_input],
BatchOutputType::Board {
to_address: refund_address,
to_amount: refund_amount,
},
)
.await
.context("failed to join batch")?;
tracing::info!(txid = %commitment_txid, "Refunded VHTLC via settlement");
Ok(commitment_txid)
}
pub async fn refund_vhtlc(&self, swap_id: &str) -> Result<Txid, Error> {
let swap_data = self
.swap_storage()
.get_submarine(swap_id)
.await?
.ok_or(Error::ad_hoc("submarine swap not found"))?;
let timeout_block_heights = swap_data.timeout_block_heights;
let vhtlc = VhtlcScript::new(
VhtlcOptions {
sender: swap_data.refund_public_key.into(),
receiver: swap_data.claim_public_key.into(),
server: self.server_info.signer_pk.into(),
preimage_hash: swap_data.preimage_hash,
refund_locktime: timeout_block_heights.refund,
unilateral_claim_delay: parse_sequence_number(
timeout_block_heights.unilateral_claim as i64,
)
.map_err(|e| Error::ad_hoc(format!("invalid unilateral claim timeout: {e}")))?,
unilateral_refund_delay: parse_sequence_number(
timeout_block_heights.unilateral_refund as i64,
)
.map_err(|e| Error::ad_hoc(format!("invalid unilateral refund timeout: {e}")))?,
unilateral_refund_without_receiver_delay: parse_sequence_number(
timeout_block_heights.unilateral_refund_without_receiver as i64,
)
.map_err(|e| {
Error::ad_hoc(format!("invalid refund without receiver timeout: {e}"))
})?,
},
self.server_info.network,
)
.map_err(Error::ad_hoc)?;
let vhtlc_address = vhtlc.address();
if vhtlc_address != swap_data.vhtlc_address {
return Err(Error::ad_hoc(format!(
"VHTLC address ({vhtlc_address}) does not match swap address ({})",
swap_data.vhtlc_address
)));
}
let vhtlc_outpoint = {
let virtual_tx_outpoints = self
.get_virtual_tx_outpoints(std::iter::once(vhtlc_address))
.await?;
let vtxo_list = VtxoList::new(self.server_info.dust, virtual_tx_outpoints);
let mut unspent = vtxo_list.all_unspent();
let vhtlc_outpoint = unspent.next().ok_or_else(|| {
Error::ad_hoc(format!("no outpoint found for address {vhtlc_address}"))
})?;
vhtlc_outpoint.clone()
};
let (refund_address, _) = self.get_offchain_address()?;
let refund_amount = swap_data.amount;
let outputs = vec![SendReceiver {
address: refund_address,
amount: refund_amount,
assets: Vec::new(),
}];
let refund_script = vhtlc.refund_script();
let spend_info = vhtlc.taproot_spend_info();
let script_ver = (refund_script, LeafVersion::TapScript);
let control_block = spend_info
.control_block(&script_ver)
.ok_or(Error::ad_hoc("control block not found for refund script"))?;
let script_pubkey = vhtlc.script_pubkey();
let refunder_pk = swap_data.refund_public_key.inner.x_only_public_key().0;
let vhtlc_input = VtxoInput::new(
script_ver.0,
None, control_block,
vhtlc.tapscripts(),
script_pubkey,
refund_amount,
vhtlc_outpoint.outpoint,
vhtlc_outpoint.assets,
);
let change_address = &refund_address;
let OffchainTransactions {
mut ark_tx,
checkpoint_txs,
} = build_offchain_transactions(
&outputs,
change_address,
std::slice::from_ref(&vhtlc_input),
&self.server_info,
)?;
let kp = self.keypair_by_pk(&refunder_pk)?;
let sign_fn =
|_: &mut psbt::Input,
msg: secp256k1::Message|
-> Result<Vec<(schnorr::Signature, XOnlyPublicKey)>, ark_core::Error> {
let sig = Secp256k1::new().sign_schnorr_no_aux_rand(&msg, &kp);
let pk = kp.x_only_public_key().0;
Ok(vec![(sig, pk)])
};
sign_ark_transaction(sign_fn, &mut ark_tx, 0)?;
let checkpoint_psbt = checkpoint_txs
.first()
.ok_or_else(|| Error::ad_hoc("no checkpoint PSBTs found"))?
.clone();
let url = format!(
"{}/v2/swap/submarine/{swap_id}/refund/ark",
self.inner.boltz_url
);
let client = reqwest::Client::new();
let response = client
.post(&url)
.json(&RefundSwapRequest {
transaction: ark_tx.to_string(),
checkpoint: checkpoint_psbt.to_string(),
})
.send()
.await
.map_err(Error::ad_hoc)
.context("failed to send refund request to Boltz")?;
if !response.status().is_success() {
let error_text = response
.text()
.await
.map_err(|e| Error::ad_hoc(e.to_string()))
.context("failed to read error text")?;
return Err(Error::ad_hoc(format!(
"Boltz refund request failed: {error_text}"
)));
}
let refund_response: RefundSwapResponse = response
.json()
.await
.map_err(Error::ad_hoc)
.context("failed to deserialize refund response")?;
if let Some(err) = refund_response.error.as_deref() {
return Err(Error::ad_hoc(format!("Boltz refund request failed: {err}")));
}
let boltz_signed_ark_tx = Psbt::from_str(&refund_response.transaction)
.map_err(Error::ad_hoc)
.context("could not parse refund transaction PSBT")?;
let boltz_signed_checkpoint = Psbt::from_str(&refund_response.checkpoint)
.map_err(Error::ad_hoc)
.context("could not parse refund checkpoint PSBT")?;
let ark_txid = boltz_signed_ark_tx.unsigned_tx.compute_txid();
let boltz_tap_script_sigs = boltz_signed_checkpoint
.inputs
.first()
.ok_or_else(|| Error::ad_hoc("boltz checkpoint has no inputs"))?
.tap_script_sigs
.clone();
let res = self
.network_client()
.submit_offchain_transaction_request(boltz_signed_ark_tx, vec![boltz_signed_checkpoint])
.await?;
let mut server_signed_checkpoint = res
.signed_checkpoint_txs
.first()
.ok_or_else(|| Error::ad_hoc("no signed checkpoint PSBTs returned"))?
.clone();
let kp = self.keypair_by_pk(&refunder_pk)?;
let sign_fn =
|_: &mut psbt::Input,
msg: secp256k1::Message|
-> Result<Vec<(schnorr::Signature, XOnlyPublicKey)>, ark_core::Error> {
let sig = Secp256k1::new().sign_schnorr_no_aux_rand(&msg, &kp);
let pk = kp.x_only_public_key().0;
Ok(vec![(sig, pk)])
};
server_signed_checkpoint
.inputs
.first_mut()
.ok_or_else(|| Error::ad_hoc("server checkpoint has no inputs"))?
.tap_script_sigs
.extend(boltz_tap_script_sigs);
sign_checkpoint_transaction(sign_fn, &mut server_signed_checkpoint)?;
timeout_op(
self.inner.timeout,
self.network_client()
.finalize_offchain_transaction(ark_txid, vec![server_signed_checkpoint]),
)
.await?
.map_err(Error::ark_server)
.context("failed to finalize offchain transaction")?;
tracing::info!(swap_id, txid = %ark_txid, "Refunded VHTLC via collaborative refund");
Ok(ark_txid)
}
pub async fn get_ln_invoice(
&self,
amount: SwapAmount,
expiry_secs: Option<u64>,
) -> Result<ReverseSwapResult, Error> {
let preimage: [u8; 32] = rand::random();
let preimage_hash_sha256 = sha256::Hash::hash(&preimage);
let preimage_hash = ripemd160::Hash::hash(preimage_hash_sha256.as_byte_array());
let claim_keypair = self.next_keypair(crate::key_provider::KeypairIndex::New)?;
let claim_public_key = claim_keypair.public_key();
let key_derivation_index =
self.derivation_index_for_pk(&claim_keypair.x_only_public_key().0);
let (invoice_amount, onchain_amount) = match amount {
SwapAmount::Invoice(amount) => (Some(amount), None),
SwapAmount::Vhtlc(amount) => (None, Some(amount)),
};
let request = CreateReverseSwapRequest {
from: Asset::Btc,
to: Asset::Ark,
invoice_amount,
onchain_amount,
claim_public_key: claim_public_key.into(),
preimage_hash: preimage_hash_sha256,
invoice_expiry: expiry_secs,
};
let url = format!("{}/v2/swap/reverse", self.inner.boltz_url);
let client = reqwest::Client::new();
let response = client
.post(&url)
.json(&request)
.send()
.await
.map_err(|e| Error::ad_hoc(e.to_string()))
.context("failed to send reverse swap request")?;
if !response.status().is_success() {
let error_text = response
.text()
.await
.map_err(|e| Error::ad_hoc(e.to_string()))
.context("failed to read error text")?;
return Err(Error::ad_hoc(format!(
"failed to create reverse swap: {error_text}"
)));
}
let response: CreateReverseSwapResponse = response
.json()
.await
.map_err(|e| Error::ad_hoc(e.to_string()))
.context("failed to deserialize reverse swap response")?;
let created_at = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_err(Error::ad_hoc)
.context("failed to compute created_at")?;
let swap_amount = response.onchain_amount.or(onchain_amount).ok_or_else(|| {
Error::ad_hoc("onchain_amount not provided by Boltz and not specified in request")
})?;
let swap = ReverseSwapData {
id: response.id.clone(),
status: SwapStatus::Created,
preimage: Some(preimage),
vhtlc_address: response.lockup_address,
preimage_hash,
refund_public_key: response.refund_public_key,
amount: swap_amount,
claim_public_key: claim_public_key.into(),
timeout_block_heights: response.timeout_block_heights,
created_at: created_at.as_secs(),
key_derivation_index,
bolt11: response.invoice.to_string(),
invoice_expiry: response.invoice.expiry_time().as_secs(),
};
self.swap_storage()
.insert_reverse(response.id.clone(), swap.clone())
.await
.context("failed to persist swap data")?;
Ok(ReverseSwapResult {
swap_id: swap.id,
invoice: response.invoice,
amount: swap_amount,
})
}
pub async fn get_ln_invoice_from_hash(
&self,
amount: SwapAmount,
expiry_secs: Option<u64>,
preimage_hash_sha256: sha256::Hash,
) -> Result<ReverseSwapResult, Error> {
let preimage_hash = ripemd160::Hash::hash(preimage_hash_sha256.as_byte_array());
let claim_keypair = self.next_keypair(crate::key_provider::KeypairIndex::New)?;
let claim_public_key = claim_keypair.public_key();
let key_derivation_index =
self.derivation_index_for_pk(&claim_keypair.x_only_public_key().0);
let (invoice_amount, onchain_amount) = match amount {
SwapAmount::Invoice(amount) => (Some(amount), None),
SwapAmount::Vhtlc(amount) => (None, Some(amount)),
};
let request = CreateReverseSwapRequest {
from: Asset::Btc,
to: Asset::Ark,
invoice_amount,
onchain_amount,
claim_public_key: claim_public_key.into(),
preimage_hash: preimage_hash_sha256,
invoice_expiry: expiry_secs,
};
let url = format!("{}/v2/swap/reverse", self.inner.boltz_url);
let client = reqwest::Client::new();
let response = client
.post(&url)
.json(&request)
.send()
.await
.map_err(|e| Error::ad_hoc(e.to_string()))
.context("failed to send reverse swap request")?;
if !response.status().is_success() {
let error_text = response
.text()
.await
.map_err(|e| Error::ad_hoc(e.to_string()))
.context("failed to read error text")?;
return Err(Error::ad_hoc(format!(
"failed to create reverse swap: {error_text}"
)));
}
let response: CreateReverseSwapResponse = response
.json()
.await
.map_err(|e| Error::ad_hoc(e.to_string()))
.context("failed to deserialize reverse swap response")?;
let created_at = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_err(Error::ad_hoc)
.context("failed to compute created_at")?;
let swap_amount = response.onchain_amount.or(onchain_amount).ok_or_else(|| {
Error::ad_hoc("onchain_amount not provided by Boltz and not specified in request")
})?;
let swap = ReverseSwapData {
id: response.id.clone(),
status: SwapStatus::Created,
preimage: None, vhtlc_address: response.lockup_address,
preimage_hash,
refund_public_key: response.refund_public_key,
amount: swap_amount,
claim_public_key: claim_public_key.into(),
timeout_block_heights: response.timeout_block_heights,
created_at: created_at.as_secs(),
key_derivation_index,
bolt11: response.invoice.to_string(),
invoice_expiry: response.invoice.expiry_time().as_secs(),
};
self.swap_storage()
.insert_reverse(response.id.clone(), swap.clone())
.await
.context("failed to persist swap data")?;
Ok(ReverseSwapResult {
swap_id: swap.id,
invoice: response.invoice,
amount: swap_amount,
})
}
pub async fn wait_for_vhtlc_funding(&self, swap_id: &str) -> Result<(), Error> {
use futures::StreamExt;
let stream = self.subscribe_to_swap_updates(swap_id.to_string());
tokio::pin!(stream);
while let Some(status_result) = stream.next().await {
match status_result {
Ok(status) => {
tracing::debug!(swap_id, current = ?status, "Swap status");
match status {
SwapStatus::TransactionMempool | SwapStatus::TransactionConfirmed => {
tracing::debug!(swap_id, "VHTLC funding detected");
return Ok(());
}
SwapStatus::InvoiceExpired => {
return Err(Error::ad_hoc(format!(
"invoice expired for swap {swap_id}"
)));
}
SwapStatus::Error { error } => {
tracing::error!(
swap_id,
"Got error from swap updates subscription: {error}"
);
}
SwapStatus::Created
| SwapStatus::TransactionRefunded
| SwapStatus::TransactionFailed
| SwapStatus::TransactionClaimed
| SwapStatus::TransactionLockupFailed
| SwapStatus::TransactionServerMempool
| SwapStatus::TransactionServerConfirmed
| SwapStatus::InvoiceSet
| SwapStatus::InvoicePending
| SwapStatus::InvoicePaid
| SwapStatus::InvoiceFailedToPay
| SwapStatus::SwapExpired
| SwapStatus::Other(_) => {}
}
}
Err(e) => return Err(e),
}
}
Err(Error::ad_hoc("Status stream ended unexpectedly"))
}
pub async fn claim_vhtlc(
&self,
swap_id: &str,
preimage: [u8; 32],
) -> Result<ClaimVhtlcResult, Error> {
let swap = self
.swap_storage()
.get_reverse(swap_id)
.await
.context("failed to get reverse swap data")?
.ok_or_else(|| Error::ad_hoc(format!("reverse swap data not found: {swap_id}")))?;
let preimage_hash_sha256 = sha256::Hash::hash(&preimage);
let preimage_hash = ripemd160::Hash::hash(preimage_hash_sha256.as_byte_array());
if preimage_hash != swap.preimage_hash {
return Err(Error::ad_hoc(format!(
"preimage does not match stored hash for swap {swap_id}"
)));
}
tracing::debug!(swap_id, "Claiming VHTLC with verified preimage");
let timeout_block_heights = swap.timeout_block_heights;
let vhtlc = VhtlcScript::new(
VhtlcOptions {
sender: swap.refund_public_key.into(),
receiver: swap.claim_public_key.into(),
server: self.server_info.signer_pk.into(),
preimage_hash: swap.preimage_hash,
refund_locktime: timeout_block_heights.refund,
unilateral_claim_delay: parse_sequence_number(
timeout_block_heights.unilateral_claim as i64,
)
.map_err(|e| Error::ad_hoc(format!("invalid unilateral claim timeout: {e}")))?,
unilateral_refund_delay: parse_sequence_number(
timeout_block_heights.unilateral_refund as i64,
)
.map_err(|e| Error::ad_hoc(format!("invalid unilateral refund timeout: {e}")))?,
unilateral_refund_without_receiver_delay: parse_sequence_number(
timeout_block_heights.unilateral_refund_without_receiver as i64,
)
.map_err(|e| {
Error::ad_hoc(format!("invalid refund without receiver timeout: {e}"))
})?,
},
self.server_info.network,
)
.map_err(Error::ad_hoc)
.context("failed to build VHTLC script")?;
let vhtlc_address = vhtlc.address();
if vhtlc_address != swap.vhtlc_address {
return Err(Error::ad_hoc(format!(
"VHTLC address ({vhtlc_address}) does not match swap address ({})",
swap.vhtlc_address
)));
}
let vhtlc_outpoint = {
let virtual_tx_outpoints = self
.get_virtual_tx_outpoints(std::iter::once(vhtlc_address))
.await?;
let vtxo_list = VtxoList::new(self.server_info.dust, virtual_tx_outpoints);
let mut unspent = vtxo_list.all_unspent();
let vhtlc_outpoint = unspent.next().ok_or_else(|| {
Error::ad_hoc(format!("no outpoint found for address {vhtlc_address}"))
})?;
vhtlc_outpoint.clone()
};
let (claim_address, _) = self
.get_offchain_address()
.context("failed to get offchain address")?;
let claim_amount = swap.amount;
let outputs = vec![SendReceiver {
address: claim_address,
amount: claim_amount,
assets: Vec::new(),
}];
let spend_info = vhtlc.taproot_spend_info();
let script_ver = (vhtlc.claim_script(), LeafVersion::TapScript);
let control_block = spend_info
.control_block(&script_ver)
.ok_or(Error::ad_hoc("control block not found for claim script"))?;
let script_pubkey = vhtlc.script_pubkey();
let claimer_pk = swap.claim_public_key.inner.x_only_public_key().0;
let vhtlc_input = VtxoInput::new(
script_ver.0,
None,
control_block,
vhtlc.tapscripts(),
script_pubkey,
claim_amount,
vhtlc_outpoint.outpoint,
vhtlc_outpoint.assets,
);
let change_address = &claim_address;
let OffchainTransactions {
mut ark_tx,
checkpoint_txs,
} = build_offchain_transactions(
&outputs,
change_address,
std::slice::from_ref(&vhtlc_input),
&self.server_info,
)
.map_err(Error::from)
.context("failed to build offchain TXs")?;
let kp = self.keypair_by_pk(&claimer_pk)?;
let sign_fn =
|input: &mut psbt::Input,
msg: secp256k1::Message|
-> Result<Vec<(schnorr::Signature, XOnlyPublicKey)>, ark_core::Error> {
{
let mut bytes = vec![1];
let length = VarInt::from(preimage.len() as u64);
length
.consensus_encode(&mut bytes)
.expect("valid length encoding");
bytes.write_all(&preimage).expect("valid preimage encoding");
input.unknown.insert(
psbt::raw::Key {
type_value: 222,
key: VTXO_CONDITION_KEY.to_vec(),
},
bytes,
);
}
let sig = Secp256k1::new().sign_schnorr_no_aux_rand(&msg, &kp);
let pk = kp.x_only_public_key().0;
Ok(vec![(sig, pk)])
};
sign_ark_transaction(sign_fn, &mut ark_tx, 0)
.map_err(Error::from)
.context("failed to sign Ark TX")?;
let ark_txid = ark_tx.unsigned_tx.compute_txid();
let res = self
.network_client()
.submit_offchain_transaction_request(ark_tx, checkpoint_txs)
.await
.map_err(Error::from)
.context("failed to submit offchain TXs")?;
let mut checkpoint_psbt = res
.signed_checkpoint_txs
.first()
.ok_or_else(|| Error::ad_hoc("no checkpoint PSBTs found"))?
.clone();
sign_checkpoint_transaction(sign_fn, &mut checkpoint_psbt)
.map_err(Error::from)
.context("failed to sign checkpoint TX")?;
timeout_op(
self.inner.timeout,
self.network_client()
.finalize_offchain_transaction(ark_txid, vec![checkpoint_psbt]),
)
.await
.context("failed to finalize offchain transaction")?
.map_err(Error::ark_server)
.context("failed to finalize offchain transaction")?;
tracing::info!(swap_id, txid = %ark_txid, "Claimed VHTLC");
let mut updated_swap = swap.clone();
updated_swap.preimage = Some(preimage);
self.swap_storage()
.update_reverse(swap_id, updated_swap)
.await
.context("failed to update swap data with preimage")?;
Ok(ClaimVhtlcResult {
swap_id: swap_id.to_string(),
claim_txid: ark_txid,
claim_amount,
preimage,
})
}
pub async fn wait_for_vhtlc(&self, swap_id: &str) -> Result<ClaimVhtlcResult, Error> {
use futures::StreamExt;
let swap = self
.swap_storage()
.get_reverse(swap_id)
.await
.context("failed to get reverse swap data")?
.ok_or_else(|| Error::ad_hoc(format!("reverse swap data not found: {swap_id}")))?;
let preimage = swap.preimage.ok_or_else(|| {
Error::ad_hoc(format!(
"preimage not found in storage for swap {swap_id}. \
Use wait_for_vhtlc_funding and claim_vhtlc instead."
))
})?;
let stream = self.subscribe_to_swap_updates(swap_id.to_string());
tokio::pin!(stream);
while let Some(status_result) = stream.next().await {
match status_result {
Ok(status) => {
tracing::debug!(current = ?status, "Swap status");
match status {
SwapStatus::TransactionMempool | SwapStatus::TransactionConfirmed => break,
SwapStatus::InvoiceExpired => {
return Err(Error::ad_hoc(format!(
"invoice expired for swap {swap_id}"
)));
}
SwapStatus::Error { error } => {
tracing::error!(
swap_id,
"Got error from swap updates subscription: {error}"
);
}
SwapStatus::Created
| SwapStatus::TransactionRefunded
| SwapStatus::TransactionFailed
| SwapStatus::TransactionClaimed
| SwapStatus::TransactionLockupFailed
| SwapStatus::TransactionServerMempool
| SwapStatus::TransactionServerConfirmed
| SwapStatus::InvoiceSet
| SwapStatus::InvoicePending
| SwapStatus::InvoicePaid
| SwapStatus::InvoiceFailedToPay
| SwapStatus::SwapExpired
| SwapStatus::Other(_) => {}
}
}
Err(e) => return Err(e),
}
}
tracing::debug!("Ark transaction for swap found");
let timeout_block_heights = swap.timeout_block_heights;
let vhtlc = VhtlcScript::new(
VhtlcOptions {
sender: swap.refund_public_key.into(),
receiver: swap.claim_public_key.into(),
server: self.server_info.signer_pk.into(),
preimage_hash: swap.preimage_hash,
refund_locktime: timeout_block_heights.refund,
unilateral_claim_delay: parse_sequence_number(
timeout_block_heights.unilateral_claim as i64,
)
.map_err(|e| Error::ad_hoc(format!("invalid unilateral claim timeout: {e}")))?,
unilateral_refund_delay: parse_sequence_number(
timeout_block_heights.unilateral_refund as i64,
)
.map_err(|e| Error::ad_hoc(format!("invalid unilateral refund timeout: {e}")))?,
unilateral_refund_without_receiver_delay: parse_sequence_number(
timeout_block_heights.unilateral_refund_without_receiver as i64,
)
.map_err(|e| {
Error::ad_hoc(format!("invalid refund without receiver timeout: {e}"))
})?,
},
self.server_info.network,
)
.map_err(Error::ad_hoc)
.context("failed to build VHTLC script")?;
let vhtlc_address = vhtlc.address();
if vhtlc_address != swap.vhtlc_address {
return Err(Error::ad_hoc(format!(
"VHTLC address ({vhtlc_address}) does not match swap address ({})",
swap.vhtlc_address
)));
}
let vhtlc_outpoint = {
let virtual_tx_outpoints = self
.get_virtual_tx_outpoints(std::iter::once(vhtlc_address))
.await?;
let vtxo_list = VtxoList::new(self.server_info.dust, virtual_tx_outpoints);
let mut unspent = vtxo_list.all_unspent();
let vhtlc_outpoint = unspent.next().ok_or_else(|| {
Error::ad_hoc(format!("no outpoint found for address {vhtlc_address}"))
})?;
vhtlc_outpoint.clone()
};
let (claim_address, _) = self
.get_offchain_address()
.context("failed to get offchain address")?;
let claim_amount = swap.amount;
let outputs = vec![SendReceiver {
address: claim_address,
amount: claim_amount,
assets: Vec::new(),
}];
let spend_info = vhtlc.taproot_spend_info();
let script_ver = (vhtlc.claim_script(), LeafVersion::TapScript);
let control_block = spend_info
.control_block(&script_ver)
.ok_or(Error::ad_hoc("control block not found for claim script"))?;
let script_pubkey = vhtlc.script_pubkey();
let claimer_pk = swap.claim_public_key.inner.x_only_public_key().0;
let vhtlc_input = VtxoInput::new(
script_ver.0,
None,
control_block,
vhtlc.tapscripts(),
script_pubkey,
claim_amount,
vhtlc_outpoint.outpoint,
vhtlc_outpoint.assets,
);
let change_address = &claim_address;
let OffchainTransactions {
mut ark_tx,
checkpoint_txs,
} = build_offchain_transactions(
&outputs,
change_address,
std::slice::from_ref(&vhtlc_input),
&self.server_info,
)
.map_err(Error::from)
.context("failed to build offchain TXs")?;
let kp = self.keypair_by_pk(&claimer_pk)?;
let sign_fn =
|input: &mut psbt::Input,
msg: secp256k1::Message|
-> Result<Vec<(schnorr::Signature, XOnlyPublicKey)>, ark_core::Error> {
{
let mut bytes = vec![1];
let length = VarInt::from(preimage.len() as u64);
length
.consensus_encode(&mut bytes)
.expect("valid length encoding");
bytes.write_all(&preimage).expect("valid preimage encoding");
input.unknown.insert(
psbt::raw::Key {
type_value: 222,
key: VTXO_CONDITION_KEY.to_vec(),
},
bytes,
);
}
let sig = Secp256k1::new().sign_schnorr_no_aux_rand(&msg, &kp);
let pk = kp.x_only_public_key().0;
Ok(vec![(sig, pk)])
};
sign_ark_transaction(sign_fn, &mut ark_tx, 0)
.map_err(Error::from)
.context("failed to sign Ark TX")?;
let ark_txid = ark_tx.unsigned_tx.compute_txid();
let res = self
.network_client()
.submit_offchain_transaction_request(ark_tx, checkpoint_txs)
.await
.map_err(Error::from)
.context("failed to submit offchain TXs")?;
let mut checkpoint_psbt = res
.signed_checkpoint_txs
.first()
.ok_or_else(|| Error::ad_hoc("no checkpoint PSBTs found"))?
.clone();
sign_checkpoint_transaction(sign_fn, &mut checkpoint_psbt)
.map_err(Error::from)
.context("failed to sign checkpoint TX")?;
timeout_op(
self.inner.timeout,
self.network_client()
.finalize_offchain_transaction(ark_txid, vec![checkpoint_psbt]),
)
.await
.context("failed to finalize offchain transaction")?
.map_err(Error::ark_server)
.context("failed to finalize offchain transaction")?;
tracing::info!(txid = %ark_txid, "Spent VHTLC");
Ok(ClaimVhtlcResult {
swap_id: swap_id.to_string(),
claim_txid: ark_txid,
claim_amount,
preimage,
})
}
pub async fn create_chain_swap(
&self,
direction: ChainSwapDirection,
amount: ChainSwapAmount,
) -> Result<ChainSwapResult, Error> {
let preimage: [u8; 32] = rand::random();
let preimage_hash = sha256::Hash::hash(&preimage);
let claim_keypair = self.next_keypair(crate::key_provider::KeypairIndex::New)?;
let claim_public_key = claim_keypair.public_key();
let claim_key_derivation_index =
self.derivation_index_for_pk(&claim_keypair.x_only_public_key().0);
let refund_keypair = self.next_keypair(crate::key_provider::KeypairIndex::New)?;
let refund_public_key = refund_keypair.public_key();
let refund_key_derivation_index =
self.derivation_index_for_pk(&refund_keypair.x_only_public_key().0);
let (from, to) = match &direction {
ChainSwapDirection::ArkToBtc => (Asset::Ark, Asset::Btc),
ChainSwapDirection::BtcToArk => (Asset::Btc, Asset::Ark),
};
let (user_lock_amount, server_lock_amount) = match &amount {
ChainSwapAmount::UserLock(a) => (Some(*a), None),
ChainSwapAmount::ServerLock(a) => (None, Some(*a)),
};
let request = CreateChainSwapRequest {
from,
to,
user_lock_amount,
server_lock_amount,
claim_public_key: claim_public_key.into(),
refund_public_key: refund_public_key.into(),
preimage_hash,
};
let url = format!("{}/v2/swap/chain", self.inner.boltz_url);
let client = reqwest::Client::new();
let response = client
.post(&url)
.json(&request)
.send()
.await
.map_err(|e| Error::ad_hoc(e.to_string()))
.context("failed to send chain swap request")?;
if !response.status().is_success() {
let error_text = response
.text()
.await
.map_err(|e| Error::ad_hoc(e.to_string()))
.context("failed to read error text")?;
return Err(Error::ad_hoc(format!(
"failed to create chain swap: {error_text}"
)));
}
let swap_response: CreateChainSwapResponse = response
.json()
.await
.map_err(|e| Error::ad_hoc(e.to_string()))
.context("failed to deserialize chain swap response")?;
let created_at = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_err(Error::ad_hoc)
.context("failed to compute created_at")?;
let bip21 = swap_response
.lockup_details
.bip21
.or(swap_response.claim_details.bip21.clone());
let swap_tree = swap_response
.lockup_details
.swap_tree
.or(swap_response.claim_details.swap_tree.clone());
let data = ChainSwapData {
id: swap_response.id.clone(),
status: SwapStatus::Created,
direction,
preimage: Some(preimage),
preimage_hash,
claim_public_key: claim_public_key.into(),
refund_public_key: refund_public_key.into(),
server_claim_public_key: swap_response.lockup_details.server_public_key,
server_refund_public_key: swap_response.claim_details.server_public_key,
user_lockup_address: swap_response.lockup_details.lockup_address,
server_lockup_address: swap_response.claim_details.lockup_address,
user_lockup_amount: swap_response.lockup_details.amount,
server_lockup_amount: swap_response.claim_details.amount,
user_timeout_block_height: swap_response.lockup_details.timeout_block_height,
server_timeout_block_height: swap_response.claim_details.timeout_block_height,
user_timeout_block_heights: swap_response.lockup_details.timeouts,
server_timeout_block_heights: swap_response.claim_details.timeouts,
bip21,
swap_tree,
created_at: created_at.as_secs(),
claim_key_derivation_index,
refund_key_derivation_index,
};
self.swap_storage()
.insert_chain(swap_response.id.clone(), data.clone())
.await?;
tracing::info!(
swap_id = swap_response.id,
direction = ?data.direction,
user_lockup_address = %data.user_lockup_address,
user_lockup_amount = %data.user_lockup_amount,
server_lockup_amount = %data.server_lockup_amount,
"Created chain swap"
);
Ok(ChainSwapResult {
swap_id: swap_response.id,
user_lockup_address: data.user_lockup_address,
user_lockup_amount: data.user_lockup_amount,
server_lockup_amount: data.server_lockup_amount,
bip21: data.bip21,
})
}
pub async fn wait_for_chain_swap_server_lockup(
&self,
swap_id: &str,
) -> Result<Option<String>, Error> {
use futures::StreamExt;
let stream = self.subscribe_to_swap_updates(swap_id.to_string());
tokio::pin!(stream);
while let Some(status_result) = stream.next().await {
match status_result {
Ok(status) => {
tracing::debug!(swap_id, current = ?status, "Chain swap status");
match status {
SwapStatus::TransactionServerMempool
| SwapStatus::TransactionServerConfirmed => {
let url = format!("{}/v2/swap/{swap_id}", self.inner.boltz_url);
let txid = async {
reqwest::Client::new()
.get(&url)
.send()
.await
.ok()?
.json::<GetSwapStatusResponse>()
.await
.ok()?
.transaction
.map(|t| t.id)
}
.await;
tracing::info!(
swap_id,
server_lockup_txid = txid.as_deref().unwrap_or("unknown"),
"Server lockup detected"
);
return Ok(txid);
}
SwapStatus::SwapExpired => {
return Err(Error::ad_hoc(format!("chain swap expired: {swap_id}")));
}
SwapStatus::TransactionRefunded | SwapStatus::TransactionFailed => {
return Err(Error::ad_hoc(format!(
"chain swap failed or refunded: {swap_id}"
)));
}
SwapStatus::Error { error } => {
tracing::error!(swap_id, "Got error from chain swap updates: {error}");
}
SwapStatus::Created
| SwapStatus::TransactionMempool
| SwapStatus::TransactionConfirmed
| SwapStatus::TransactionClaimed
| SwapStatus::TransactionLockupFailed
| SwapStatus::InvoiceSet
| SwapStatus::InvoicePending
| SwapStatus::InvoicePaid
| SwapStatus::InvoiceFailedToPay
| SwapStatus::InvoiceExpired
| SwapStatus::Other(_) => {}
}
}
Err(e) => return Err(e),
}
}
Err(Error::ad_hoc("Chain swap status stream ended unexpectedly"))
}
pub async fn claim_chain_swap(&self, swap_id: &str) -> Result<Txid, Error> {
let swap = self
.swap_storage()
.get_chain(swap_id)
.await
.context("failed to get chain swap data")?
.ok_or_else(|| Error::ad_hoc(format!("chain swap data not found: {swap_id}")))?;
let preimage = swap
.preimage
.ok_or_else(|| Error::ad_hoc(format!("preimage not found for chain swap {swap_id}")))?;
let preimage_hash = ripemd160::Hash::hash(swap.preimage_hash.as_byte_array());
let timeout_block_heights = swap.server_timeout_block_heights.ok_or_else(|| {
Error::ad_hoc(format!(
"chain swap {swap_id} has no ARK-side VHTLC timeouts on server lockup \
(this swap's server lockup is on-chain BTC, not an Ark VHTLC)"
))
})?;
let vhtlc = VhtlcScript::new(
VhtlcOptions {
sender: swap.server_refund_public_key.into(),
receiver: swap.claim_public_key.into(),
server: self.server_info.signer_pk.into(),
preimage_hash,
refund_locktime: timeout_block_heights.refund,
unilateral_claim_delay: parse_sequence_number(
timeout_block_heights.unilateral_claim as i64,
)
.map_err(|e| Error::ad_hoc(format!("invalid unilateral claim timeout: {e}")))?,
unilateral_refund_delay: parse_sequence_number(
timeout_block_heights.unilateral_refund as i64,
)
.map_err(|e| Error::ad_hoc(format!("invalid unilateral refund timeout: {e}")))?,
unilateral_refund_without_receiver_delay: parse_sequence_number(
timeout_block_heights.unilateral_refund_without_receiver as i64,
)
.map_err(|e| {
Error::ad_hoc(format!("invalid refund without receiver timeout: {e}"))
})?,
},
self.server_info.network,
)
.map_err(Error::ad_hoc)
.context("failed to build VHTLC script")?;
let vhtlc_address = vhtlc.address();
let expected_address = ArkAddress::decode(&swap.server_lockup_address)
.map_err(|e| Error::ad_hoc(format!("invalid server lockup address: {e}")))?;
if vhtlc_address != expected_address {
return Err(Error::ad_hoc(format!(
"VHTLC address ({vhtlc_address}) does not match server lockup address ({expected_address})"
)));
}
let vhtlc_outpoint = {
let virtual_tx_outpoints = self
.get_virtual_tx_outpoints(std::iter::once(vhtlc_address))
.await?;
let vtxo_list = VtxoList::new(self.server_info.dust, virtual_tx_outpoints);
let mut unspent = vtxo_list.all_unspent();
let vhtlc_outpoint = unspent.next().ok_or_else(|| {
Error::ad_hoc(format!("no outpoint found for address {vhtlc_address}"))
})?;
vhtlc_outpoint.clone()
};
let (claim_address, _) = self
.get_offchain_address()
.context("failed to get offchain address")?;
let claim_amount = swap.server_lockup_amount;
let outputs = vec![SendReceiver::bitcoin(claim_address, claim_amount)];
let spend_info = vhtlc.taproot_spend_info();
let script_ver = (vhtlc.claim_script(), LeafVersion::TapScript);
let control_block = spend_info
.control_block(&script_ver)
.ok_or(Error::ad_hoc("control block not found for claim script"))?;
let script_pubkey = vhtlc.script_pubkey();
let claimer_pk = swap.claim_public_key.inner.x_only_public_key().0;
let vhtlc_input = VtxoInput::new(
script_ver.0,
None,
control_block,
vhtlc.tapscripts(),
script_pubkey,
claim_amount,
vhtlc_outpoint.outpoint,
vhtlc_outpoint.assets,
);
let change_address = &claim_address;
let OffchainTransactions {
mut ark_tx,
checkpoint_txs,
} = build_offchain_transactions(
&outputs,
change_address,
std::slice::from_ref(&vhtlc_input),
&self.server_info,
)
.map_err(Error::from)
.context("failed to build offchain TXs")?;
let kp = self.keypair_by_pk(&claimer_pk)?;
let sign_fn =
|input: &mut psbt::Input,
msg: secp256k1::Message|
-> Result<Vec<(schnorr::Signature, XOnlyPublicKey)>, ark_core::Error> {
{
let mut bytes = vec![1];
let length = VarInt::from(preimage.len() as u64);
length
.consensus_encode(&mut bytes)
.expect("valid length encoding");
bytes.write_all(&preimage).expect("valid preimage encoding");
input.unknown.insert(
psbt::raw::Key {
type_value: 222,
key: VTXO_CONDITION_KEY.to_vec(),
},
bytes,
);
}
let sig = Secp256k1::new().sign_schnorr_no_aux_rand(&msg, &kp);
let pk = kp.x_only_public_key().0;
Ok(vec![(sig, pk)])
};
sign_ark_transaction(sign_fn, &mut ark_tx, 0)
.map_err(Error::from)
.context("failed to sign Ark TX")?;
let ark_txid = ark_tx.unsigned_tx.compute_txid();
let res = self
.network_client()
.submit_offchain_transaction_request(ark_tx, checkpoint_txs)
.await
.map_err(Error::from)
.context("failed to submit offchain TXs")?;
let mut checkpoint_psbt = res
.signed_checkpoint_txs
.first()
.ok_or_else(|| Error::ad_hoc("no checkpoint PSBTs found"))?
.clone();
sign_checkpoint_transaction(sign_fn, &mut checkpoint_psbt)
.map_err(Error::from)
.context("failed to sign checkpoint TX")?;
timeout_op(
self.inner.timeout,
self.network_client()
.finalize_offchain_transaction(ark_txid, vec![checkpoint_psbt]),
)
.await
.context("failed to finalize offchain transaction")?
.map_err(Error::ark_server)
.context("failed to finalize offchain transaction")?;
tracing::info!(swap_id, txid = %ark_txid, "Claimed chain swap VHTLC");
let mut updated_swap = swap.clone();
updated_swap.status = SwapStatus::TransactionClaimed;
self.swap_storage()
.update_chain(swap_id, updated_swap)
.await
.context("failed to update chain swap data")?;
Ok(ark_txid)
}
pub async fn claim_chain_swap_btc(
&self,
swap_id: &str,
destination_address: bitcoin::Address,
fee_rate_sat_vb: f64,
) -> Result<Txid, Error> {
let swap = self
.swap_storage()
.get_chain(swap_id)
.await
.context("failed to get chain swap data")?
.ok_or_else(|| Error::ad_hoc(format!("chain swap data not found: {swap_id}")))?;
let preimage = swap
.preimage
.ok_or_else(|| Error::ad_hoc(format!("preimage not found for chain swap {swap_id}")))?;
let swap_tree = swap.swap_tree.clone().ok_or_else(|| {
Error::ad_hoc("no swap tree found (this swap has no on-chain BTC HTLC)")
})?;
let btc_address_str = &swap.server_lockup_address;
let taproot_spend_info = reconstruct_btc_htlc(
swap.server_refund_public_key,
swap.claim_public_key,
&swap_tree,
)?;
let secp = Secp256k1::new();
let expected_spk = ScriptBuf::new_p2tr(
&secp,
taproot_spend_info.internal_key(),
taproot_spend_info.merkle_root(),
);
let parsed_address: bitcoin::Address<bitcoin::address::NetworkUnchecked> = btc_address_str
.parse()
.map_err(|e| Error::ad_hoc(format!("invalid BTC lockup address: {e}")))?;
let parsed_address = parsed_address.assume_checked();
let target_spk = parsed_address.script_pubkey();
if expected_spk != target_spk {
return Err(Error::ad_hoc(format!(
"taproot address mismatch for BTC lockup {btc_address_str}"
)));
}
let claim_script_bytes: Vec<u8> =
bitcoin::hex::FromHex::from_hex(&swap_tree.claim_leaf.output)
.map_err(|e| Error::ad_hoc(format!("invalid claim leaf hex: {e}")))?;
let claim_script = ScriptBuf::from_bytes(claim_script_bytes);
let claim_ver = (claim_script.clone(), LeafVersion::TapScript);
let utxos = self
.inner
.blockchain
.find_outpoints(&parsed_address)
.await
.context("failed to find UTXOs at BTC lockup address")?;
let utxo = utxos.iter().find(|u| !u.is_spent).ok_or_else(|| {
Error::ad_hoc(format!(
"no unspent UTXO found at BTC lockup address {btc_address_str}"
))
})?;
let control_block = taproot_spend_info
.control_block(&claim_ver)
.ok_or(Error::ad_hoc("control block not found for claim leaf"))?;
let cb_bytes = control_block.serialize();
let witness_weight = 1 + 1 + 64 + 1 + 32 + 1 + claim_script.len() + 1 + cb_bytes.len() + 1;
let weight = 4 * (11 + 41 + 43) + witness_weight;
let vsize = weight.div_ceil(4);
let fee = Amount::from_sat((vsize as f64 * fee_rate_sat_vb).ceil() as u64);
let claim_amount = utxo.amount.checked_sub(fee).ok_or_else(|| {
Error::ad_hoc(format!(
"UTXO amount {} is less than estimated fee {}",
utxo.amount, fee
))
})?;
let mut tx = bitcoin::Transaction {
version: bitcoin::transaction::Version::TWO,
lock_time: absolute::LockTime::ZERO,
input: vec![bitcoin::TxIn {
previous_output: utxo.outpoint,
script_sig: ScriptBuf::new(),
sequence: bitcoin::Sequence::ENABLE_RBF_NO_LOCKTIME,
witness: bitcoin::Witness::new(),
}],
output: vec![TxOut {
value: claim_amount,
script_pubkey: destination_address.script_pubkey(),
}],
};
let leaf_hash =
bitcoin::taproot::TapLeafHash::from_script(&claim_script, LeafVersion::TapScript);
let prevouts = [TxOut {
value: utxo.amount,
script_pubkey: target_spk.clone(),
}];
let sighash = bitcoin::sighash::SighashCache::new(&tx)
.taproot_script_spend_signature_hash(
0,
&bitcoin::sighash::Prevouts::All(&prevouts),
leaf_hash,
bitcoin::TapSighashType::Default,
)
.map_err(|e| Error::ad_hoc(format!("failed to compute sighash: {e}")))?;
let msg = secp256k1::Message::from_digest(sighash.to_byte_array());
let claim_kp = self.keypair_by_pk(&swap.claim_public_key.inner.x_only_public_key().0)?;
let signature = secp.sign_schnorr_no_aux_rand(&msg, &claim_kp);
let mut witness = bitcoin::Witness::new();
witness.push(signature.serialize());
witness.push(preimage);
witness.push(claim_script.as_bytes());
witness.push(cb_bytes);
tx.input[0].witness = witness;
self.inner
.blockchain
.broadcast(&tx)
.await
.context("failed to broadcast BTC claim transaction")?;
let txid = tx.compute_txid();
tracing::info!(swap_id, %txid, %claim_amount, "Claimed on-chain BTC from chain swap");
let mut updated_swap = swap.clone();
updated_swap.status = SwapStatus::TransactionClaimed;
self.swap_storage()
.update_chain(swap_id, updated_swap)
.await
.context("failed to update chain swap data")?;
Ok(txid)
}
pub async fn refund_chain_swap(&self, swap_id: &str) -> Result<Txid, Error> {
let swap = self
.swap_storage()
.get_chain(swap_id)
.await
.context("failed to get chain swap data")?
.ok_or_else(|| Error::ad_hoc(format!("chain swap data not found: {swap_id}")))?;
let timeout_block_heights = swap.user_timeout_block_heights.ok_or_else(|| {
Error::ad_hoc(
"chain swap has no ARK-side VHTLC timeouts on user lockup \
(user lockup is on-chain BTC, use refund_chain_swap_btc instead)",
)
})?;
let preimage_hash = ripemd160::Hash::hash(swap.preimage_hash.as_byte_array());
let vhtlc = VhtlcScript::new(
VhtlcOptions {
sender: swap.refund_public_key.into(),
receiver: swap.server_claim_public_key.into(),
server: self.server_info.signer_pk.into(),
preimage_hash,
refund_locktime: timeout_block_heights.refund,
unilateral_claim_delay: parse_sequence_number(
timeout_block_heights.unilateral_claim as i64,
)
.map_err(|e| Error::ad_hoc(format!("invalid unilateral claim timeout: {e}")))?,
unilateral_refund_delay: parse_sequence_number(
timeout_block_heights.unilateral_refund as i64,
)
.map_err(|e| Error::ad_hoc(format!("invalid unilateral refund timeout: {e}")))?,
unilateral_refund_without_receiver_delay: parse_sequence_number(
timeout_block_heights.unilateral_refund_without_receiver as i64,
)
.map_err(|e| {
Error::ad_hoc(format!("invalid refund without receiver timeout: {e}"))
})?,
},
self.server_info.network,
)
.map_err(Error::ad_hoc)?;
let vhtlc_address = vhtlc.address();
let expected_address = ArkAddress::decode(&swap.user_lockup_address)
.map_err(|e| Error::ad_hoc(format!("invalid user lockup address: {e}")))?;
if vhtlc_address != expected_address {
return Err(Error::ad_hoc(format!(
"VHTLC address ({vhtlc_address}) does not match user lockup address ({expected_address})"
)));
}
let vhtlc_outpoint = {
let virtual_tx_outpoints = self
.get_virtual_tx_outpoints(std::iter::once(vhtlc_address))
.await?;
let vtxo_list = VtxoList::new(self.server_info.dust, virtual_tx_outpoints);
let mut unspent = vtxo_list.all_unspent();
unspent
.next()
.ok_or_else(|| {
Error::ad_hoc(format!("no outpoint found for address {vhtlc_address}"))
})?
.clone()
};
let (refund_address, _) = self.get_offchain_address()?;
let refund_amount = swap.user_lockup_amount;
let outputs = vec![SendReceiver::bitcoin(refund_address, refund_amount)];
let refund_script = vhtlc.refund_without_receiver_script();
let spend_info = vhtlc.taproot_spend_info();
let script_ver = (refund_script, LeafVersion::TapScript);
let control_block = spend_info
.control_block(&script_ver)
.ok_or(Error::ad_hoc("control block not found for refund script"))?;
let script_pubkey = vhtlc.script_pubkey();
let refunder_pk = swap.refund_public_key.inner.x_only_public_key().0;
let change_address = &refund_address;
let vhtlc_input = VtxoInput::new(
script_ver.0,
Some(absolute::LockTime::from_consensus(
timeout_block_heights.refund,
)),
control_block,
vhtlc.tapscripts(),
script_pubkey,
refund_amount,
vhtlc_outpoint.outpoint,
vhtlc_outpoint.assets,
);
let OffchainTransactions {
mut ark_tx,
checkpoint_txs,
} = build_offchain_transactions(
&outputs,
change_address,
std::slice::from_ref(&vhtlc_input),
&self.server_info,
)?;
let kp = self.keypair_by_pk(&refunder_pk)?;
let sign_fn =
|_: &mut psbt::Input,
msg: secp256k1::Message|
-> Result<Vec<(schnorr::Signature, XOnlyPublicKey)>, ark_core::Error> {
let sig = Secp256k1::new().sign_schnorr_no_aux_rand(&msg, &kp);
let pk = kp.x_only_public_key().0;
Ok(vec![(sig, pk)])
};
sign_ark_transaction(sign_fn, &mut ark_tx, 0)?;
let ark_txid = ark_tx.unsigned_tx.compute_txid();
let res = self
.network_client()
.submit_offchain_transaction_request(ark_tx, checkpoint_txs)
.await?;
let mut checkpoint_psbt = res
.signed_checkpoint_txs
.first()
.ok_or_else(|| Error::ad_hoc("no checkpoint PSBTs found"))?
.clone();
let kp = self.keypair_by_pk(&refunder_pk)?;
let sign_fn =
|_: &mut psbt::Input,
msg: secp256k1::Message|
-> Result<Vec<(schnorr::Signature, XOnlyPublicKey)>, ark_core::Error> {
let sig = Secp256k1::new().sign_schnorr_no_aux_rand(&msg, &kp);
let pk = kp.x_only_public_key().0;
Ok(vec![(sig, pk)])
};
sign_checkpoint_transaction(sign_fn, &mut checkpoint_psbt)?;
timeout_op(
self.inner.timeout,
self.network_client()
.finalize_offchain_transaction(ark_txid, vec![checkpoint_psbt]),
)
.await?
.map_err(Error::ark_server)
.context("failed to finalize offchain transaction")?;
tracing::info!(swap_id, txid = %ark_txid, "Refunded chain swap Ark VHTLC");
let mut updated_swap = swap.clone();
updated_swap.status = SwapStatus::TransactionRefunded;
self.swap_storage()
.update_chain(swap_id, updated_swap)
.await
.context("failed to update chain swap data")?;
Ok(ark_txid)
}
pub async fn refund_chain_swap_btc(
&self,
swap_id: &str,
destination_address: bitcoin::Address,
fee_rate_sat_vb: f64,
) -> Result<Txid, Error> {
let swap = self
.swap_storage()
.get_chain(swap_id)
.await
.context("failed to get chain swap data")?
.ok_or_else(|| Error::ad_hoc(format!("chain swap data not found: {swap_id}")))?;
let swap_tree = swap.swap_tree.clone().ok_or_else(|| {
Error::ad_hoc("no swap tree found (this swap has no on-chain BTC lockup)")
})?;
let btc_address_str = &swap.user_lockup_address;
let taproot_spend_info = reconstruct_btc_htlc(
swap.server_claim_public_key,
swap.refund_public_key,
&swap_tree,
)?;
let secp = Secp256k1::new();
let refund_script_bytes: Vec<u8> =
bitcoin::hex::FromHex::from_hex(&swap_tree.refund_leaf.output)
.map_err(|e| Error::ad_hoc(format!("invalid refund leaf hex: {e}")))?;
let refund_script = ScriptBuf::from_bytes(refund_script_bytes);
let refund_ver = (refund_script.clone(), LeafVersion::TapScript);
let expected_spk = ScriptBuf::new_p2tr(
&secp,
taproot_spend_info.internal_key(),
taproot_spend_info.merkle_root(),
);
let parsed_address: bitcoin::Address<bitcoin::address::NetworkUnchecked> = btc_address_str
.parse()
.map_err(|e| Error::ad_hoc(format!("invalid BTC lockup address: {e}")))?;
let parsed_address = parsed_address.assume_checked();
let target_spk = parsed_address.script_pubkey();
if expected_spk != target_spk {
return Err(Error::ad_hoc(format!(
"taproot address mismatch for BTC lockup {btc_address_str}"
)));
}
let utxos = self
.inner
.blockchain
.find_outpoints(&parsed_address)
.await
.context("failed to find UTXOs at BTC lockup address")?;
let utxo = utxos.iter().find(|u| !u.is_spent).ok_or_else(|| {
Error::ad_hoc(format!(
"no unspent UTXO found at BTC lockup address {btc_address_str}"
))
})?;
let control_block = taproot_spend_info
.control_block(&refund_ver)
.ok_or(Error::ad_hoc("control block not found for refund leaf"))?;
let cb_bytes = control_block.serialize();
let witness_weight = 1 + 1 + 64 + 1 + refund_script.len() + 1 + cb_bytes.len() + 1;
let weight = 4 * (11 + 41 + 43) + witness_weight;
let vsize = weight.div_ceil(4);
let fee = Amount::from_sat((vsize as f64 * fee_rate_sat_vb).ceil() as u64);
let refund_amount = utxo.amount.checked_sub(fee).ok_or_else(|| {
Error::ad_hoc(format!(
"UTXO amount {} is less than estimated fee {}",
utxo.amount, fee
))
})?;
let lock_time = absolute::LockTime::from_consensus(swap.user_timeout_block_height);
let mut tx = bitcoin::Transaction {
version: bitcoin::transaction::Version::TWO,
lock_time,
input: vec![bitcoin::TxIn {
previous_output: utxo.outpoint,
script_sig: ScriptBuf::new(),
sequence: bitcoin::Sequence::ENABLE_LOCKTIME_NO_RBF,
witness: bitcoin::Witness::new(),
}],
output: vec![TxOut {
value: refund_amount,
script_pubkey: destination_address.script_pubkey(),
}],
};
let leaf_hash =
bitcoin::taproot::TapLeafHash::from_script(&refund_script, LeafVersion::TapScript);
let prevouts = [TxOut {
value: utxo.amount,
script_pubkey: target_spk,
}];
let sighash = bitcoin::sighash::SighashCache::new(&tx)
.taproot_script_spend_signature_hash(
0,
&bitcoin::sighash::Prevouts::All(&prevouts),
leaf_hash,
bitcoin::TapSighashType::Default,
)
.map_err(|e| Error::ad_hoc(format!("failed to compute sighash: {e}")))?;
let msg = secp256k1::Message::from_digest(sighash.to_byte_array());
let refund_kp = self.keypair_by_pk(&swap.refund_public_key.inner.x_only_public_key().0)?;
let signature = secp.sign_schnorr_no_aux_rand(&msg, &refund_kp);
let mut witness = bitcoin::Witness::new();
witness.push(signature.serialize());
witness.push(refund_script.as_bytes());
witness.push(cb_bytes);
tx.input[0].witness = witness;
self.inner
.blockchain
.broadcast(&tx)
.await
.context("failed to broadcast BTC refund transaction")?;
let txid = tx.compute_txid();
tracing::info!(swap_id, %txid, %refund_amount, "Refunded on-chain BTC from chain swap");
let mut updated_swap = swap.clone();
updated_swap.status = SwapStatus::TransactionRefunded;
self.swap_storage()
.update_chain(swap_id, updated_swap)
.await
.context("failed to update chain swap data")?;
Ok(txid)
}
pub async fn get_swap_status(&self, swap_id: &str) -> Result<SwapStatusInfo, Error> {
let swap_type = if self.swap_storage().get_submarine(swap_id).await?.is_some() {
SwapType::Submarine
} else if self.swap_storage().get_reverse(swap_id).await?.is_some() {
SwapType::Reverse
} else if self.swap_storage().get_chain(swap_id).await?.is_some() {
SwapType::Chain
} else {
SwapType::Unknown
};
let url = format!("{}/v2/swap/{swap_id}", self.inner.boltz_url);
let client = reqwest::Client::new();
let response = client
.get(&url)
.send()
.await
.map_err(|e| Error::ad_hoc(e.to_string()))
.context("failed to query swap status")?;
if !response.status().is_success() {
let error_text = response
.text()
.await
.map_err(|e| Error::ad_hoc(e.to_string()))?;
return Err(Error::ad_hoc(format!(
"failed to get swap status: {error_text}"
)));
}
let status_response: GetSwapStatusResponse = response
.json()
.await
.map_err(|e| Error::ad_hoc(e.to_string()))
.context("failed to deserialize swap status response")?;
Ok(SwapStatusInfo {
swap_id: swap_id.to_string(),
swap_type,
status: status_response.status,
})
}
pub async fn get_fees(&self) -> Result<BoltzFees, Error> {
let client = reqwest::Client::builder()
.timeout(self.inner.timeout)
.build()
.map_err(|e| Error::ad_hoc(e.to_string()))?;
let submarine_url = format!("{}/v2/swap/submarine", &self.inner.boltz_url);
let submarine_response = client
.get(&submarine_url)
.send()
.await
.map_err(|e| Error::ad_hoc(e.to_string()))
.context("failed to fetch submarine swap fees")?;
if !submarine_response.status().is_success() {
let error_text = submarine_response
.text()
.await
.map_err(|e| Error::ad_hoc(e.to_string()))?;
return Err(Error::ad_hoc(format!(
"failed to fetch submarine swap fees: {error_text}"
)));
}
let submarine_pairs: SubmarinePairsResponse = submarine_response
.json()
.await
.map_err(|e| Error::ad_hoc(e.to_string()))
.context("failed to deserialize submarine swap fees response")?;
let submarine_pair_fees = &submarine_pairs.ark.btc.fees;
let submarine_fees = SubmarineSwapFees {
percentage: submarine_pair_fees.percentage,
miner_fees: submarine_pair_fees.miner_fees,
};
let reverse_url = format!("{}/v2/swap/reverse", self.inner.boltz_url);
let reverse_response = client
.get(&reverse_url)
.send()
.await
.map_err(|e| Error::ad_hoc(e.to_string()))
.context("failed to fetch reverse swap fees")?;
if !reverse_response.status().is_success() {
let error_text = reverse_response
.text()
.await
.map_err(|e| Error::ad_hoc(e.to_string()))?;
return Err(Error::ad_hoc(format!(
"failed to fetch reverse swap fees: {error_text}"
)));
}
let reverse_pairs: ReversePairsResponse = reverse_response
.json()
.await
.map_err(|e| Error::ad_hoc(e.to_string()))
.context("failed to deserialize reverse swap fees response")?;
let reverse_pair_fees = &reverse_pairs.btc.ark.fees;
let reverse_fees = ReverseSwapFees {
percentage: reverse_pair_fees.percentage,
miner_fees: ReverseMinerFees {
lockup: reverse_pair_fees.miner_fees.lockup,
claim: reverse_pair_fees.miner_fees.claim,
},
};
Ok(BoltzFees {
submarine: submarine_fees,
reverse: reverse_fees,
})
}
pub async fn get_limits(&self) -> Result<SwapLimits, Error> {
let client = reqwest::Client::builder()
.timeout(self.inner.timeout)
.build()
.map_err(|e| Error::ad_hoc(e.to_string()))?;
let url = format!("{}/v2/swap/submarine", self.inner.boltz_url);
let response = client
.get(&url)
.send()
.await
.map_err(|e| Error::ad_hoc(e.to_string()))
.context("failed to fetch swap limits")?;
if !response.status().is_success() {
let error_text = response
.text()
.await
.map_err(|e| Error::ad_hoc(e.to_string()))?;
return Err(Error::ad_hoc(format!(
"failed to fetch swap limits: {error_text}"
)));
}
let pairs: SubmarinePairsResponse = response
.json()
.await
.map_err(|e| Error::ad_hoc(e.to_string()))
.context("failed to deserialize swap limits response")?;
Ok(SwapLimits {
min: pairs.ark.btc.limits.minimal,
max: pairs.ark.btc.limits.maximal,
})
}
pub fn subscribe_to_swap_updates(
&self,
swap_id: String,
) -> impl futures::Stream<Item = Result<SwapStatus, Error>> + '_ {
async_stream::stream! {
let mut last_status: Option<SwapStatus> = None;
let url = format!("{}/v2/swap/{swap_id}", self.inner.boltz_url);
loop {
let client = reqwest::Client::new();
let response = client
.get(&url)
.send()
.await;
match response {
Ok(resp) if resp.status().is_success() => {
let status_response = resp
.json::<GetSwapStatusResponse>()
.await
.map_err(|e| Error::ad_hoc(e.to_string()));
match status_response {
Ok(current_status) => {
let current_status = current_status.status;
if last_status.as_ref() != Some(¤t_status) {
last_status = Some(current_status.clone());
yield Ok(current_status);
}
}
Err(e) => {
yield Err(Error::ad_hoc(format!(
"failed to deserialize swap status response: {e}"
)));
break;
}
}
}
Ok(resp) => {
let error_text = resp
.text()
.await
.unwrap_or_else(|_| "Unknown error".to_string());
yield Err(Error::ad_hoc(format!(
"failed to check swap status: {error_text}"
)));
break;
}
Err(e) => {
yield Err(Error::ad_hoc(e.to_string())
.context("failed to send swap status request"));
break;
}
}
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
}
}
}
pub async fn list_pending_vhtlc_spend_txs(&self) -> Result<Vec<PendingVhtlcSpendTx>, Error> {
let vhtlc_infos = self.collect_active_vhtlc_infos().await?;
if vhtlc_infos.is_empty() {
return Ok(vec![]);
}
let addresses = vhtlc_infos.iter().map(|info| info.address);
let request = ark_core::server::GetVtxosRequest::new_for_addresses(addresses)
.pending_only()
.map_err(Error::from)?;
let vtxos = self
.fetch_all_vtxos(request)
.await
.context("failed to fetch pending VHTLC VTXOs")?;
tracing::debug!(
num_pending_vtxos = vtxos.len(),
"Fetched pending VHTLC VTXOs"
);
if vtxos.is_empty() {
return Ok(vec![]);
}
let info_by_script: std::collections::HashMap<_, _> = vhtlc_infos
.iter()
.map(|info| (info.script_pubkey.clone(), info))
.collect();
let secp = Secp256k1::new();
let mut results = Vec::new();
let mut seen_ark_txids = std::collections::HashSet::new();
for vtxo in &vtxos {
let info = match info_by_script.get(&vtxo.script) {
Some(info) => info,
None => {
tracing::warn!(
outpoint = %vtxo.outpoint,
"Skipping pending VHTLC VTXO with unknown script"
);
continue;
}
};
let intent_input = match info.preimage {
Some(preimage) => intent::Input::new_with_extra_witness(
vtxo.outpoint,
bitcoin::Sequence::ZERO,
None,
TxOut {
value: vtxo.amount,
script_pubkey: info.script_pubkey.clone(),
},
vhtlc_tapscripts(&info.vhtlc),
info.intent_spend_info.clone(),
false,
vtxo.is_swept,
vtxo.assets.clone(),
vec![preimage.to_vec()],
),
None => intent::Input::new(
vtxo.outpoint,
bitcoin::Sequence::ZERO,
None,
TxOut {
value: vtxo.amount,
script_pubkey: info.script_pubkey.clone(),
},
vhtlc_tapscripts(&info.vhtlc),
info.intent_spend_info.clone(),
false,
vtxo.is_swept,
vtxo.assets.clone(),
),
};
let sign_for_vtxo_fn = |input: &mut psbt::Input,
msg: secp256k1::Message|
-> Result<
Vec<(schnorr::Signature, XOnlyPublicKey)>,
ark_core::Error,
> {
match &input.witness_script {
None => Err(ark_core::Error::ad_hoc(
"Missing witness script when signing get-pending-tx intent for VHTLC",
)),
Some(script) => {
let pks = extract_checksig_pubkeys(script);
let mut res = vec![];
for pk in &pks {
if let Ok(keypair) = self.keypair_by_pk(pk) {
let sig = secp.sign_schnorr_no_aux_rand(&msg, &keypair);
res.push((sig, keypair.x_only_public_key().0));
}
}
Ok(res)
}
}
};
let sign_for_onchain_fn =
|_: &mut psbt::Input,
_: secp256k1::Message|
-> Result<(schnorr::Signature, XOnlyPublicKey), ark_core::Error> {
Err(ark_core::Error::ad_hoc(
"unexpected onchain input in get-pending-tx intent",
))
};
let message = intent::IntentMessage::GetPendingTx { expire_at: 0 };
let get_pending_intent = intent::make_intent(
sign_for_vtxo_fn,
sign_for_onchain_fn,
vec![intent_input],
vec![],
message,
)?;
let pending_txs = self
.network_client()
.get_pending_tx(get_pending_intent)
.await
.map_err(Error::ark_server)
.context("failed to get pending VHTLC transactions")?;
for pending_tx in pending_txs {
if !seen_ark_txids.insert(pending_tx.ark_txid) {
continue;
}
let spend_type = Self::identify_vhtlc_spend_type(info, &pending_tx)?;
tracing::info!(
ark_txid = %pending_tx.ark_txid,
swap_id = spend_type.swap_id(),
spend_type = spend_type.name(),
"Found pending VHTLC spend transaction"
);
results.push(PendingVhtlcSpendTx {
spend_type,
pending_tx,
});
}
}
Ok(results)
}
pub async fn continue_pending_vhtlc_spend_tx(
&self,
pending: &PendingVhtlcSpendTx,
) -> Result<Txid, Error> {
let ark_txid = pending.pending_tx.ark_txid;
match &pending.spend_type {
PendingVhtlcSpendType::Claim { preimage, .. } => {
self.continue_pending_claim(ark_txid, &pending.pending_tx, *preimage)
.await
}
PendingVhtlcSpendType::CollaborativeRefund { swap_id } => {
self.continue_pending_collaborative_refund(ark_txid, &pending.pending_tx, swap_id)
.await
}
PendingVhtlcSpendType::ExpiredRefund { .. } => {
self.continue_pending_expired_refund(ark_txid, &pending.pending_tx)
.await
}
}
}
pub async fn continue_pending_vhtlc_spend_txs(&self) -> Result<Vec<Txid>, Error> {
let pending = self.list_pending_vhtlc_spend_txs().await?;
let mut finalized = Vec::new();
for tx in &pending {
match self.continue_pending_vhtlc_spend_tx(tx).await {
Ok(txid) => finalized.push(txid),
Err(e) => {
tracing::warn!(
ark_txid = %tx.pending_tx.ark_txid,
swap_id = tx.spend_type.swap_id(),
?e,
"Failed to finalize pending VHTLC spend tx"
);
}
}
}
Ok(finalized)
}
async fn continue_pending_claim(
&self,
ark_txid: Txid,
pending_tx: &PendingTx,
preimage: [u8; 32],
) -> Result<Txid, Error> {
let mut signed_checkpoint_txs = pending_tx.signed_checkpoint_txs.clone();
for checkpoint_psbt in signed_checkpoint_txs.iter_mut() {
Self::restore_witness_script_if_needed(checkpoint_psbt, &pending_tx.signed_ark_tx)?;
Self::inject_preimage_into_psbt(checkpoint_psbt, preimage);
self.sign_checkpoint_with_own_keys(checkpoint_psbt)?;
}
timeout_op(
self.inner.timeout,
self.network_client()
.finalize_offchain_transaction(ark_txid, signed_checkpoint_txs),
)
.await?
.map_err(Error::ark_server)
.context("failed to finalize pending claim transaction")?;
tracing::info!(txid = %ark_txid, "Finalized pending VHTLC claim");
Ok(ark_txid)
}
async fn continue_pending_collaborative_refund(
&self,
ark_txid: Txid,
pending_tx: &PendingTx,
swap_id: &str,
) -> Result<Txid, Error> {
let url = format!(
"{}/v2/swap/submarine/{swap_id}/refund/ark",
self.inner.boltz_url
);
let client = reqwest::Client::new();
let mut signed_checkpoint_txs = Vec::new();
for checkpoint_psbt in &pending_tx.signed_checkpoint_txs {
let response = client
.post(&url)
.json(&RefundSwapRequest {
transaction: pending_tx.signed_ark_tx.to_string(),
checkpoint: checkpoint_psbt.to_string(),
})
.send()
.await
.map_err(Error::ad_hoc)
.context("failed to re-request Boltz refund signature")?;
if !response.status().is_success() {
let error_text = response
.text()
.await
.map_err(|e| Error::ad_hoc(e.to_string()))
.context("failed to read Boltz error text")?;
return Err(Error::ad_hoc(format!(
"Boltz refund re-sign request failed: {error_text}"
)));
}
let refund_response: RefundSwapResponse = response
.json()
.await
.map_err(Error::ad_hoc)
.context("failed to deserialize Boltz refund response")?;
if let Some(err) = refund_response.error.as_deref() {
return Err(Error::ad_hoc(format!("Boltz refund re-sign failed: {err}")));
}
let boltz_signed_checkpoint = Psbt::from_str(&refund_response.checkpoint)
.map_err(Error::ad_hoc)
.context("could not parse Boltz-signed checkpoint PSBT")?;
let boltz_tap_script_sigs = boltz_signed_checkpoint
.inputs
.first()
.ok_or_else(|| Error::ad_hoc("Boltz checkpoint has no inputs"))?
.tap_script_sigs
.clone();
let mut final_checkpoint = checkpoint_psbt.clone();
Self::restore_witness_script_if_needed(
&mut final_checkpoint,
&pending_tx.signed_ark_tx,
)?;
final_checkpoint
.inputs
.first_mut()
.ok_or_else(|| Error::ad_hoc("checkpoint has no inputs"))?
.tap_script_sigs
.extend(boltz_tap_script_sigs);
self.sign_checkpoint_with_own_keys(&mut final_checkpoint)?;
signed_checkpoint_txs.push(final_checkpoint);
}
timeout_op(
self.inner.timeout,
self.network_client()
.finalize_offchain_transaction(ark_txid, signed_checkpoint_txs),
)
.await?
.map_err(Error::ark_server)
.context("failed to finalize pending collaborative refund")?;
tracing::info!(txid = %ark_txid, swap_id, "Finalized pending collaborative refund");
Ok(ark_txid)
}
async fn continue_pending_expired_refund(
&self,
ark_txid: Txid,
pending_tx: &PendingTx,
) -> Result<Txid, Error> {
let mut signed_checkpoint_txs = pending_tx.signed_checkpoint_txs.clone();
for checkpoint_psbt in signed_checkpoint_txs.iter_mut() {
Self::restore_witness_script_if_needed(checkpoint_psbt, &pending_tx.signed_ark_tx)?;
self.sign_checkpoint_with_own_keys(checkpoint_psbt)?;
}
timeout_op(
self.inner.timeout,
self.network_client()
.finalize_offchain_transaction(ark_txid, signed_checkpoint_txs),
)
.await?
.map_err(Error::ark_server)
.context("failed to finalize pending expired refund")?;
tracing::info!(txid = %ark_txid, "Finalized pending expired VHTLC refund");
Ok(ark_txid)
}
fn build_vhtlc_script(
&self,
claim_public_key: PublicKey,
refund_public_key: PublicKey,
preimage_hash: ripemd160::Hash,
timeout_block_heights: &TimeoutBlockHeights,
) -> Result<VhtlcScript, Error> {
VhtlcScript::new(
VhtlcOptions {
sender: refund_public_key.inner.x_only_public_key().0,
receiver: claim_public_key.inner.x_only_public_key().0,
server: self.server_info.signer_pk.into(),
preimage_hash,
refund_locktime: timeout_block_heights.refund,
unilateral_claim_delay: parse_sequence_number(
timeout_block_heights.unilateral_claim as i64,
)
.map_err(|e| Error::ad_hoc(format!("invalid unilateral claim timeout: {e}")))?,
unilateral_refund_delay: parse_sequence_number(
timeout_block_heights.unilateral_refund as i64,
)
.map_err(|e| Error::ad_hoc(format!("invalid unilateral refund timeout: {e}")))?,
unilateral_refund_without_receiver_delay: parse_sequence_number(
timeout_block_heights.unilateral_refund_without_receiver as i64,
)
.map_err(|e| {
Error::ad_hoc(format!("invalid refund without receiver timeout: {e}"))
})?,
},
self.server_info.network,
)
.map_err(Error::ad_hoc)
}
fn ensure_swap_key_cached(
&self,
pk: &XOnlyPublicKey,
key_derivation_index: Option<u32>,
swap_id: &str,
) -> bool {
if self.keypair_by_pk(pk).is_ok() {
return true;
}
let Some(index) = key_derivation_index else {
tracing::warn!(
swap_id,
"Legacy swap data without derivation index, skipping recovery"
);
return false;
};
match self.inner.key_provider.derive_at_discovery_index(index) {
Ok(Some(kp)) if kp.x_only_public_key().0 == *pk => {
if let Err(e) = self.inner.key_provider.cache_discovered_keypair(index, kp) {
tracing::warn!(swap_id, %e, "Failed to cache swap key");
return false;
}
true
}
Ok(_) => {
tracing::warn!(
swap_id,
index,
"Key at stored derivation index does not match swap pubkey"
);
false
}
Err(e) => {
tracing::warn!(swap_id, index, %e, "Failed to derive key at stored index");
false
}
}
}
async fn collect_active_vhtlc_infos(&self) -> Result<Vec<VhtlcInfo>, Error> {
let submarine_swaps = self
.swap_storage()
.list_all_submarine()
.await
.context("failed to list submarine swaps")?;
let reverse_swaps = self
.swap_storage()
.list_all_reverse()
.await
.context("failed to list reverse swaps")?;
let mut infos = Vec::new();
for swap in &submarine_swaps {
if swap.status.is_terminal() {
continue;
}
if !self.ensure_swap_key_cached(
&swap.refund_public_key.inner.x_only_public_key().0,
swap.key_derivation_index,
&swap.id,
) {
continue;
}
let vhtlc = self.build_vhtlc_script(
swap.claim_public_key,
swap.refund_public_key,
swap.preimage_hash,
&swap.timeout_block_heights,
)?;
if vhtlc.address() != swap.vhtlc_address {
tracing::warn!(
swap_id = swap.id,
"VHTLC address mismatch for submarine swap, skipping"
);
continue;
}
let refund_script = vhtlc.refund_without_receiver_script();
let spend_info = vhtlc.taproot_spend_info();
let control_block = spend_info
.control_block(&(refund_script.clone(), LeafVersion::TapScript))
.ok_or_else(|| {
Error::ad_hoc("control block not found for refund_without_receiver script")
})?;
infos.push(VhtlcInfo {
swap_id: swap.id.clone(),
address: swap.vhtlc_address,
script_pubkey: vhtlc.script_pubkey(),
vhtlc,
intent_spend_info: (refund_script, control_block),
preimage: swap.preimage,
});
}
for swap in &reverse_swaps {
if swap.status.is_terminal() {
continue;
}
if !self.ensure_swap_key_cached(
&swap.claim_public_key.inner.x_only_public_key().0,
swap.key_derivation_index,
&swap.id,
) {
continue;
}
let vhtlc = self.build_vhtlc_script(
swap.claim_public_key,
swap.refund_public_key,
swap.preimage_hash,
&swap.timeout_block_heights,
)?;
if vhtlc.address() != swap.vhtlc_address {
tracing::warn!(
swap_id = swap.id,
"VHTLC address mismatch for reverse swap, skipping"
);
continue;
}
let claim_script = vhtlc.claim_script();
let spend_info = vhtlc.taproot_spend_info();
let control_block = spend_info
.control_block(&(claim_script.clone(), LeafVersion::TapScript))
.ok_or_else(|| Error::ad_hoc("control block not found for claim script"))?;
infos.push(VhtlcInfo {
swap_id: swap.id.clone(),
address: swap.vhtlc_address,
script_pubkey: vhtlc.script_pubkey(),
vhtlc,
intent_spend_info: (claim_script, control_block),
preimage: swap.preimage,
});
}
Ok(infos)
}
fn identify_vhtlc_spend_type(
info: &VhtlcInfo,
pending_tx: &PendingTx,
) -> Result<PendingVhtlcSpendType, Error> {
let spend_script = pending_tx
.signed_ark_tx
.inputs
.iter()
.find_map(|input| {
input.tap_scripts.values().find_map(|(script, _)| {
let claim = info.vhtlc.claim_script();
let refund = info.vhtlc.refund_script();
let refund_no_recv = info.vhtlc.refund_without_receiver_script();
if *script == claim || *script == refund || *script == refund_no_recv {
Some(script.clone())
} else {
None
}
})
})
.ok_or_else(|| {
Error::ad_hoc(format!(
"could not identify spend script in pending tx {} for swap {}",
pending_tx.ark_txid, info.swap_id
))
})?;
let claim_script = info.vhtlc.claim_script();
let refund_script = info.vhtlc.refund_script();
if spend_script == claim_script {
let preimage = extract_preimage_from_psbt(&pending_tx.signed_ark_tx)
.ok()
.or(info.preimage)
.ok_or_else(|| {
Error::ad_hoc(format!(
"cannot recover preimage for pending claim of swap {}",
info.swap_id
))
})?;
Ok(PendingVhtlcSpendType::Claim {
swap_id: info.swap_id.clone(),
preimage,
})
} else if spend_script == refund_script {
Ok(PendingVhtlcSpendType::CollaborativeRefund {
swap_id: info.swap_id.clone(),
})
} else {
Ok(PendingVhtlcSpendType::ExpiredRefund {
swap_id: info.swap_id.clone(),
})
}
}
fn inject_preimage_into_psbt(psbt: &mut Psbt, preimage: [u8; 32]) {
let mut bytes = vec![1];
let length = VarInt::from(preimage.len() as u64);
length
.consensus_encode(&mut bytes)
.expect("valid length encoding");
bytes.write_all(&preimage).expect("valid preimage encoding");
let key = psbt::raw::Key {
type_value: 222,
key: VTXO_CONDITION_KEY.to_vec(),
};
for input in &mut psbt.inputs {
input.unknown.insert(key.clone(), bytes.clone());
}
}
fn sign_checkpoint_with_own_keys(&self, checkpoint_psbt: &mut Psbt) -> Result<(), Error> {
let sign_fn =
|input: &mut psbt::Input,
msg: secp256k1::Message|
-> Result<Vec<(schnorr::Signature, XOnlyPublicKey)>, ark_core::Error> {
let script = input.witness_script.as_ref().ok_or_else(|| {
ark_core::Error::ad_hoc("missing witness script for checkpoint signing")
})?;
let pks = extract_checksig_pubkeys(script);
let mut res = vec![];
for pk in pks {
if let Ok(keypair) = self.keypair_by_pk(&pk) {
let sig = Secp256k1::new().sign_schnorr_no_aux_rand(&msg, &keypair);
res.push((sig, keypair.x_only_public_key().0));
}
}
Ok(res)
};
sign_checkpoint_transaction(sign_fn, checkpoint_psbt)?;
Ok(())
}
fn restore_witness_script_if_needed(
checkpoint_psbt: &mut Psbt,
signed_ark_tx: &Psbt,
) -> Result<(), Error> {
if checkpoint_psbt
.inputs
.first()
.ok_or_else(|| Error::ad_hoc("checkpoint PSBT has no inputs"))?
.witness_script
.is_some()
{
return Ok(());
}
let checkpoint_txid = checkpoint_psbt.unsigned_tx.compute_txid();
let ark_input_idx = signed_ark_tx
.unsigned_tx
.input
.iter()
.position(|inp| inp.previous_output.txid == checkpoint_txid)
.ok_or_else(|| {
Error::ad_hoc(format!(
"checkpoint txid {checkpoint_txid} not found in ark tx inputs"
))
})?;
let witness_script = signed_ark_tx
.inputs
.get(ark_input_idx)
.and_then(|input| input.witness_script.clone())
.ok_or_else(|| {
Error::ad_hoc(format!(
"missing witness script on ark tx input {ark_input_idx}"
))
})?;
checkpoint_psbt
.inputs
.first_mut()
.ok_or_else(|| Error::ad_hoc("checkpoint PSBT has no inputs"))?
.witness_script = Some(witness_script);
Ok(())
}
}
struct VhtlcInfo {
swap_id: String,
address: ArkAddress,
script_pubkey: ScriptBuf,
vhtlc: VhtlcScript,
intent_spend_info: (ScriptBuf, bitcoin::taproot::ControlBlock),
preimage: Option<[u8; 32]>,
}
fn reconstruct_btc_htlc(
server_pk: PublicKey,
user_pk: PublicKey,
swap_tree: &SwapTree,
) -> Result<bitcoin::taproot::TaprootSpendInfo, Error> {
let claim_script_bytes: Vec<u8> = bitcoin::hex::FromHex::from_hex(&swap_tree.claim_leaf.output)
.map_err(|e| Error::ad_hoc(format!("invalid claim leaf hex: {e}")))?;
let claim_script = ScriptBuf::from_bytes(claim_script_bytes);
let refund_script_bytes: Vec<u8> =
bitcoin::hex::FromHex::from_hex(&swap_tree.refund_leaf.output)
.map_err(|e| Error::ad_hoc(format!("invalid refund leaf hex: {e}")))?;
let refund_script = ScriptBuf::from_bytes(refund_script_bytes);
let musig_server_pk = musig::PublicKey::from_slice(&server_pk.to_bytes())
.map_err(|e| Error::ad_hoc(format!("invalid server key for musig: {e}")))?;
let musig_user_pk = musig::PublicKey::from_slice(&user_pk.to_bytes())
.map_err(|e| Error::ad_hoc(format!("invalid user key for musig: {e}")))?;
let key_agg = musig::musig::KeyAggCache::new(&[&musig_server_pk, &musig_user_pk]);
let internal_key = XOnlyPublicKey::from_slice(&key_agg.agg_pk().serialize())
.map_err(|e| Error::ad_hoc(format!("invalid aggregated key: {e}")))?;
let secp = Secp256k1::new();
bitcoin::taproot::TaprootBuilder::new()
.add_leaf(1, claim_script)
.map_err(|e| Error::ad_hoc(format!("failed to add claim leaf: {e}")))?
.add_leaf(1, refund_script)
.map_err(|e| Error::ad_hoc(format!("failed to add refund leaf: {e}")))?
.finalize(&secp, internal_key)
.map_err(|_| Error::ad_hoc("failed to finalize taproot tree"))
}
fn vhtlc_tapscripts(vhtlc: &VhtlcScript) -> Vec<ScriptBuf> {
vec![
vhtlc.claim_script(),
vhtlc.refund_script(),
vhtlc.refund_without_receiver_script(),
vhtlc.unilateral_claim_script(),
vhtlc.unilateral_refund_script(),
vhtlc.unilateral_refund_without_receiver_script(),
]
}
fn extract_preimage_from_psbt(psbt: &Psbt) -> Result<[u8; 32], Error> {
let condition_key = psbt::raw::Key {
type_value: 222,
key: VTXO_CONDITION_KEY.to_vec(),
};
for input in &psbt.inputs {
if let Some(condition_data) = input.unknown.get(&condition_key) {
if condition_data.is_empty() {
continue;
}
let num_elements = condition_data[0] as usize;
if num_elements == 0 {
continue;
}
let mut cursor = std::io::Cursor::new(&condition_data[1..]);
let length = bitcoin::consensus::Decodable::consensus_decode(&mut cursor)
.map_err(|e| Error::ad_hoc(format!("failed to decode varint length: {e}")))?;
let length: VarInt = length;
let offset = cursor.position() as usize;
let remaining = &condition_data[1 + offset..];
if remaining.len() < length.0 as usize {
return Err(Error::ad_hoc(format!(
"condition data too short: expected {} bytes, got {}",
length.0,
remaining.len()
)));
}
let preimage_bytes = &remaining[..length.0 as usize];
let preimage: [u8; 32] = preimage_bytes.try_into().map_err(|_| {
Error::ad_hoc(format!(
"preimage has unexpected length: {} (expected 32)",
preimage_bytes.len()
))
})?;
return Ok(preimage);
}
}
Err(Error::ad_hoc(
"no VTXO_CONDITION_KEY found in any PSBT input",
))
}
pub enum SwapAmount {
Invoice(Amount),
Vhtlc(Amount),
}
impl SwapAmount {
pub fn invoice(amount: Amount) -> Self {
Self::Invoice(amount)
}
pub fn vhtlc(amount: Amount) -> Self {
Self::Vhtlc(amount)
}
}
pub enum ChainSwapAmount {
UserLock(Amount),
ServerLock(Amount),
}
#[serde_as]
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SubmarineSwapData {
pub id: String,
pub preimage: Option<[u8; 32]>,
pub preimage_hash: ripemd160::Hash,
pub claim_public_key: PublicKey,
pub refund_public_key: PublicKey,
pub amount: Amount,
pub timeout_block_heights: TimeoutBlockHeights,
#[serde_as(as = "DisplayFromStr")]
pub vhtlc_address: ArkAddress,
pub invoice: Bolt11Invoice,
pub status: SwapStatus,
pub created_at: u64,
#[serde(default)]
pub key_derivation_index: Option<u32>,
}
#[serde_as]
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ReverseSwapData {
pub id: String,
pub preimage: Option<[u8; 32]>,
pub preimage_hash: ripemd160::Hash,
pub claim_public_key: PublicKey,
pub refund_public_key: PublicKey,
pub amount: Amount,
pub timeout_block_heights: TimeoutBlockHeights,
#[serde_as(as = "DisplayFromStr")]
pub vhtlc_address: ArkAddress,
pub status: SwapStatus,
pub created_at: u64,
#[serde(default)]
pub key_derivation_index: Option<u32>,
pub bolt11: String,
pub invoice_expiry: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub enum SwapStatus {
#[serde(rename = "swap.created")]
Created,
#[serde(rename = "transaction.mempool")]
TransactionMempool,
#[serde(rename = "transaction.confirmed")]
TransactionConfirmed,
#[serde(rename = "transaction.refunded")]
TransactionRefunded,
#[serde(rename = "transaction.failed")]
TransactionFailed,
#[serde(rename = "transaction.claimed")]
TransactionClaimed,
#[serde(rename = "transaction.server.mempool")]
TransactionServerMempool,
#[serde(rename = "transaction.server.confirmed")]
TransactionServerConfirmed,
#[serde(rename = "invoice.set")]
InvoiceSet,
#[serde(rename = "invoice.pending")]
InvoicePending,
#[serde(rename = "invoice.paid")]
InvoicePaid,
#[serde(rename = "invoice.failedToPay")]
InvoiceFailedToPay,
#[serde(rename = "invoice.expired")]
InvoiceExpired,
#[serde(rename = "transaction.lockupFailed")]
TransactionLockupFailed,
#[serde(rename = "swap.expired")]
SwapExpired,
#[serde(rename = "error")]
Error { error: String },
#[serde(untagged)]
Other(String),
}
impl SwapStatus {
pub fn is_terminal(&self) -> bool {
matches!(
self,
Self::TransactionRefunded
| Self::TransactionFailed
| Self::TransactionClaimed
| Self::TransactionLockupFailed
| Self::InvoicePaid
| Self::InvoiceFailedToPay
| Self::InvoiceExpired
| Self::SwapExpired
| Self::Error { .. }
)
}
}
#[derive(Debug, Clone, Serialize, Deserialize, Copy)]
#[serde(rename_all = "camelCase")]
pub struct TimeoutBlockHeights {
pub refund: u32,
pub unilateral_claim: u32,
pub unilateral_refund: u32,
pub unilateral_refund_without_receiver: u32,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "UPPERCASE")]
enum Asset {
Btc,
Ark,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
struct CreateReverseSwapRequest {
from: Asset,
to: Asset,
#[serde(skip_serializing_if = "Option::is_none")]
invoice_amount: Option<Amount>,
#[serde(skip_serializing_if = "Option::is_none")]
onchain_amount: Option<Amount>,
claim_public_key: PublicKey,
preimage_hash: sha256::Hash,
#[serde(skip_serializing_if = "Option::is_none")]
invoice_expiry: Option<u64>,
}
#[serde_as]
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
struct CreateReverseSwapResponse {
id: String,
#[serde_as(as = "DisplayFromStr")]
lockup_address: ArkAddress,
refund_public_key: PublicKey,
timeout_block_heights: TimeoutBlockHeights,
invoice: Bolt11Invoice,
onchain_amount: Option<Amount>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct CreateSubmarineSwapRequest {
from: Asset,
to: Asset,
invoice: Bolt11Invoice,
#[serde(rename = "refundPublicKey")]
refund_public_key: PublicKey,
}
#[serde_as]
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
struct CreateSubmarineSwapResponse {
id: String,
#[serde_as(as = "DisplayFromStr")]
address: ArkAddress,
expected_amount: Amount,
claim_public_key: PublicKey,
timeout_block_heights: TimeoutBlockHeights,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct GetSwapStatusResponse {
status: SwapStatus,
#[serde(default)]
transaction: Option<SwapStatusTransaction>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct SwapStatusTransaction {
id: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct RefundSwapRequest {
transaction: String,
checkpoint: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct RefundSwapResponse {
transaction: String,
checkpoint: String,
#[serde(skip_serializing_if = "Option::is_none")]
error: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SubmarineSwapFees {
pub percentage: f64,
pub miner_fees: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ReverseMinerFees {
pub lockup: u64,
pub claim: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ReverseSwapFees {
pub percentage: f64,
pub miner_fees: ReverseMinerFees,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BoltzFees {
pub submarine: SubmarineSwapFees,
pub reverse: ReverseSwapFees,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SwapLimits {
pub min: u64,
pub max: u64,
}
#[derive(Debug, Clone, Deserialize)]
struct PairLimits {
minimal: u64,
maximal: u64,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
struct SubmarinePairFees {
percentage: f64,
miner_fees: u64,
}
#[derive(Debug, Clone, Deserialize)]
struct SubmarinePairInfo {
fees: SubmarinePairFees,
limits: PairLimits,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "UPPERCASE")]
struct SubmarineArkPairs {
btc: SubmarinePairInfo,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "UPPERCASE")]
struct SubmarinePairsResponse {
ark: SubmarineArkPairs,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
struct ReverseMinerFeesResponse {
claim: u64,
lockup: u64,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
struct ReversePairFees {
percentage: f64,
miner_fees: ReverseMinerFeesResponse,
}
#[derive(Debug, Clone, Deserialize)]
struct ReversePairInfo {
fees: ReversePairFees,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "UPPERCASE")]
struct ReverseBtcPairs {
ark: ReversePairInfo,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "UPPERCASE")]
struct ReversePairsResponse {
btc: ReverseBtcPairs,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub enum ChainSwapDirection {
ArkToBtc,
BtcToArk,
}
#[serde_as]
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ChainSwapData {
pub id: String,
pub status: SwapStatus,
pub direction: ChainSwapDirection,
pub preimage: Option<[u8; 32]>,
pub preimage_hash: sha256::Hash,
pub claim_public_key: PublicKey,
pub refund_public_key: PublicKey,
pub server_claim_public_key: PublicKey,
pub server_refund_public_key: PublicKey,
pub user_lockup_address: String,
pub server_lockup_address: String,
pub user_lockup_amount: Amount,
pub server_lockup_amount: Amount,
pub user_timeout_block_height: u32,
pub server_timeout_block_height: u32,
#[serde(default)]
pub user_timeout_block_heights: Option<TimeoutBlockHeights>,
#[serde(default)]
pub server_timeout_block_heights: Option<TimeoutBlockHeights>,
#[serde(default)]
pub bip21: Option<String>,
#[serde(default)]
pub swap_tree: Option<SwapTree>,
pub created_at: u64,
#[serde(default)]
pub claim_key_derivation_index: Option<u32>,
#[serde(default)]
pub refund_key_derivation_index: Option<u32>,
}
#[derive(Clone, Debug)]
pub struct ChainSwapResult {
pub swap_id: String,
pub user_lockup_address: String,
pub user_lockup_amount: Amount,
pub server_lockup_amount: Amount,
pub bip21: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SwapTree {
pub claim_leaf: SwapTreeLeaf,
pub refund_leaf: SwapTreeLeaf,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SwapTreeLeaf {
pub version: u8,
pub output: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
struct CreateChainSwapRequest {
from: Asset,
to: Asset,
#[serde(skip_serializing_if = "Option::is_none")]
user_lock_amount: Option<Amount>,
#[serde(skip_serializing_if = "Option::is_none")]
server_lock_amount: Option<Amount>,
claim_public_key: PublicKey,
refund_public_key: PublicKey,
preimage_hash: sha256::Hash,
}
#[serde_as]
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
struct CreateChainSwapResponse {
id: String,
claim_details: ChainSwapSideDetails,
lockup_details: ChainSwapSideDetails,
}
#[serde_as]
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
struct ChainSwapSideDetails {
lockup_address: String,
server_public_key: PublicKey,
timeout_block_height: u32,
#[serde(default)]
timeouts: Option<TimeoutBlockHeights>,
amount: Amount,
#[serde(default)]
swap_tree: Option<SwapTree>,
#[serde(default)]
bip21: Option<String>,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_deserialize_create_reverse_swap_response() {
let json = r#"{
"id": "vqhG2fJtNY4H",
"lockupAddress": "tark1qra883hysahlkt0ujcwhv0x2n278849c3m7t3a08l7fdc40f4f2nmw3f7kn37vvq0hqazxtqgtvhwp3z83zfgr7qc82t9mty8vk95ynpx3l43d",
"refundPublicKey": "0206988651c7fbe41747bb21b54ced0a183f4d658e007ee8fdb23fbbfccb8e0c55",
"timeoutBlockHeights": {
"refund": 1760508054,
"unilateralClaim": 9728,
"unilateralRefund": 86528,
"unilateralRefundWithoutReceiver": 86528
},
"invoice": "lntbs10u1p5wmeeepp56ms94rkev7tdrwqyus5a63lny2mqzq9vh2rq3u4ym3v4lxv6xl4qdql2djkuepqw3hjqs2jfvsxzerywfjhxuccqz95xqztfsp5ckaskagag554na8d56tlrfdxasstqrmmpkvswqqqx6y386jcfq9s9qxpqysgqt7z0vkdwkqamydae7ctgkh7l8q75w7q9394ce3lda2mkfxrpfdtj5gmltuctav7jdgatkflhztrjjzutdla5e4xp0uhxxy7sluzll4qpkkh6wv",
"onchainAmount": 996
}"#;
let response: CreateReverseSwapResponse =
serde_json::from_str(json).expect("Failed to deserialize CreateReverseSwapResponse");
assert_eq!(response.id, "vqhG2fJtNY4H");
assert_eq!(response.onchain_amount, Some(Amount::from_sat(996)));
assert_eq!(
response.refund_public_key,
PublicKey::from_str(
"0206988651c7fbe41747bb21b54ced0a183f4d658e007ee8fdb23fbbfccb8e0c55"
)
.expect("valid public key")
);
assert_eq!(
response.lockup_address.to_string(),
"tark1qra883hysahlkt0ujcwhv0x2n278849c3m7t3a08l7fdc40f4f2nmw3f7kn37vvq0hqazxtqgtvhwp3z83zfgr7qc82t9mty8vk95ynpx3l43d"
);
assert_eq!(response.timeout_block_heights.refund, 1760508054);
assert_eq!(response.timeout_block_heights.unilateral_claim, 9728);
assert_eq!(response.timeout_block_heights.unilateral_refund, 86528);
assert_eq!(
response
.timeout_block_heights
.unilateral_refund_without_receiver,
86528
);
}
#[test]
fn test_btc_htlc_address_reconstruction_btc_to_ark() {
let server_pk = PublicKey::from_str(
"03ce9f5a57218103d5fe07b9d7ecf4b28ad60a960f0fbfd86dd090013020617389",
)
.unwrap();
let user_pk = PublicKey::from_str(
"02c6047f9441ed7d6d3045406e95c07cd85c778e4b8cef3ca7abac09b95c709ee5",
)
.unwrap();
let swap_tree = SwapTree {
claim_leaf: SwapTreeLeaf {
version: 192,
output: "82012088a914b472a266d0bd89c13706a4132ccfb16f7c3b9fcb8820ce9f5a57218103d5fe07b9d7ecf4b28ad60a960f0fbfd86dd090013020617389ac".into(),
},
refund_leaf: SwapTreeLeaf {
version: 192,
output: "20c6047f9441ed7d6d3045406e95c07cd85c778e4b8cef3ca7abac09b95c709ee5ad03f9832db1".into(),
},
};
let spend_info = reconstruct_btc_htlc(server_pk, user_pk, &swap_tree).unwrap();
let secp = Secp256k1::new();
let spk = ScriptBuf::new_p2tr(&secp, spend_info.internal_key(), spend_info.merkle_root());
let addr = bitcoin::Address::from_script(&spk, bitcoin::Network::Testnet).unwrap();
assert_eq!(
addr.to_string(),
"tb1ptf632fkczflsjn4356ra4x2s6qp6vvk8e7pplprpwnkvcsd8tpwqkw92c7"
);
}
#[test]
fn test_btc_htlc_address_reconstruction_ark_to_btc() {
let server_pk = PublicKey::from_str(
"0207364dc5853e630be83439fde62b531e3c11db34ce8c4f454a56782555c58ed6",
)
.unwrap();
let user_pk = PublicKey::from_str(
"0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798",
)
.unwrap();
let swap_tree = SwapTree {
claim_leaf: SwapTreeLeaf {
version: 192,
output: "82012088a914cf7ff51392e9a37bc72c7284841db669c82e2c14882079be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798ac".into(),
},
refund_leaf: SwapTreeLeaf {
version: 192,
output: "2007364dc5853e630be83439fde62b531e3c11db34ce8c4f454a56782555c58ed6ad036b832db1".into(),
},
};
let spend_info = reconstruct_btc_htlc(server_pk, user_pk, &swap_tree).unwrap();
let secp = Secp256k1::new();
let spk = ScriptBuf::new_p2tr(&secp, spend_info.internal_key(), spend_info.merkle_root());
let addr = bitcoin::Address::from_script(&spk, bitcoin::Network::Testnet).unwrap();
assert_eq!(
addr.to_string(),
"tb1pxa78pf55g0aaurrd8c76fyax4df9e8y38fzps8sw2vkrecf9k3ss36a78m"
);
}
}