use std::collections::HashMap;
use std::ops::Not as _;
use std::time::Instant;
use std::{fs, path::PathBuf, str::FromStr, sync::Arc, time::Duration};
use anyhow::{anyhow, ensure, Result};
use boltz_client::{swaps::boltz::*, util::secrets::Preimage};
use buy::{BuyBitcoinApi, BuyBitcoinService};
use chain::bitcoin::HybridBitcoinChainService;
use chain::liquid::{HybridLiquidChainService, LiquidChainService};
use chain_swap::ESTIMATED_BTC_CLAIM_TX_VSIZE;
use futures_util::stream::select_all;
use futures_util::{StreamExt, TryFutureExt};
use lnurl::auth::SdkLnurlAuthSigner;
use log::{debug, error, info, warn};
use lwk_wollet::bitcoin::base64::Engine as _;
use lwk_wollet::elements_miniscript::elements::bitcoin::bip32::Xpub;
use lwk_wollet::hashes::{sha256, Hash};
use lwk_wollet::secp256k1::Message;
use lwk_wollet::ElementsNetwork;
use persist::model::PaymentTxDetails;
use recover::recoverer::Recoverer;
use sdk_common::bitcoin::hashes::hex::ToHex;
use sdk_common::input_parser::InputType;
use sdk_common::liquid::LiquidAddressData;
use sdk_common::prelude::{FiatAPI, FiatCurrency, LnUrlPayError, LnUrlWithdrawError, Rate};
use signer::SdkSigner;
use tokio::sync::{watch, Mutex, RwLock};
use tokio::time::MissedTickBehavior;
use tokio_stream::wrappers::BroadcastStream;
use x509_parser::parse_x509_certificate;
use crate::chain::bitcoin::BitcoinChainService;
use crate::chain_swap::ChainSwapHandler;
use crate::ensure_sdk;
use crate::error::SdkError;
use crate::lightning_invoice::{Bolt11Invoice, Bolt11InvoiceDescription};
use crate::model::PaymentState::*;
use crate::model::Signer;
use crate::receive_swap::ReceiveSwapHandler;
use crate::send_swap::SendSwapHandler;
use crate::swapper::{boltz::BoltzSwapper, Swapper, SwapperReconnectHandler, SwapperStatusStream};
use crate::wallet::{LiquidOnchainWallet, OnchainWallet};
use crate::{
error::{PaymentError, SdkResult},
event::EventManager,
model::*,
persist::Persister,
utils, *,
};
use sdk_common::lightning_125::offers::invoice::Bolt12Invoice;
use self::sync::client::BreezSyncerClient;
use self::sync::SyncService;
pub const DEFAULT_DATA_DIR: &str = ".data";
pub const CHAIN_SWAP_MONITORING_PERIOD_BITCOIN_BLOCKS: u32 = 4320;
pub const DEFAULT_EXTERNAL_INPUT_PARSERS: &[(&str, &str, &str)] = &[(
"picknpay",
"(.*)(za.co.electrum.picknpay)(.*)",
"https://cryptoqr.net/.well-known/lnurlp/<input>",
)];
pub struct LiquidSdk {
pub(crate) config: Config,
pub(crate) onchain_wallet: Arc<dyn OnchainWallet>,
pub(crate) signer: Arc<Box<dyn Signer>>,
pub(crate) persister: Arc<Persister>,
pub(crate) event_manager: Arc<EventManager>,
pub(crate) status_stream: Arc<dyn SwapperStatusStream>,
pub(crate) swapper: Arc<dyn Swapper>,
pub(crate) recoverer: Arc<Recoverer>,
pub(crate) liquid_chain_service: Arc<Mutex<dyn LiquidChainService>>,
pub(crate) bitcoin_chain_service: Arc<Mutex<dyn BitcoinChainService>>,
pub(crate) fiat_api: Arc<dyn FiatAPI>,
pub(crate) is_started: RwLock<bool>,
pub(crate) shutdown_sender: watch::Sender<()>,
pub(crate) shutdown_receiver: watch::Receiver<()>,
pub(crate) send_swap_handler: SendSwapHandler,
pub(crate) sync_service: Option<Arc<SyncService>>,
pub(crate) receive_swap_handler: ReceiveSwapHandler,
pub(crate) chain_swap_handler: Arc<ChainSwapHandler>,
pub(crate) buy_bitcoin_service: Arc<dyn BuyBitcoinApi>,
pub(crate) external_input_parsers: Vec<ExternalInputParser>,
}
impl LiquidSdk {
pub async fn connect(req: ConnectRequest) -> Result<Arc<LiquidSdk>> {
let signer = Box::new(SdkSigner::new(
req.mnemonic.as_ref(),
req.config.network == LiquidNetwork::Mainnet,
)?);
Self::connect_with_signer(ConnectWithSignerRequest { config: req.config }, signer)
.inspect_err(|e| error!("Failed to connect: {:?}", e))
.await
}
pub async fn connect_with_signer(
req: ConnectWithSignerRequest,
signer: Box<dyn Signer>,
) -> Result<Arc<LiquidSdk>> {
let maybe_swapper_proxy_url =
match BreezServer::new("https://bs1.breez.technology:443".into(), None) {
Ok(breez_server) => breez_server
.fetch_boltz_swapper_urls()
.await
.ok()
.and_then(|swapper_urls| swapper_urls.first().cloned()),
Err(_) => None,
};
let sdk = LiquidSdk::new(req.config, maybe_swapper_proxy_url, Arc::new(signer))?;
sdk.start()
.inspect_err(|e| error!("Failed to start an SDK instance: {:?}", e))
.await?;
Ok(sdk)
}
fn validate_breez_api_key(api_key: &str) -> Result<()> {
let api_key_decoded = lwk_wollet::bitcoin::base64::engine::general_purpose::STANDARD
.decode(api_key.as_bytes())
.map_err(|err| anyhow!("Could not base64 decode the Breez API key: {err:?}"))?;
let (_rem, cert) = parse_x509_certificate(&api_key_decoded)
.map_err(|err| anyhow!("Invaid certificate for Breez API key: {err:?}"))?;
let issuer = cert
.issuer()
.iter_common_name()
.next()
.and_then(|cn| cn.as_str().ok());
match issuer {
Some(common_name) => ensure_sdk!(
common_name.starts_with("Breez"),
anyhow!("Invalid certificate found for Breez API key: issuer mismatch. Please confirm that the certificate's origin is trusted")
),
_ => {
return Err(anyhow!("Could not parse Breez API key certificate: issuer is invalid or not found."))
}
}
Ok(())
}
fn new(
config: Config,
swapper_proxy_url: Option<String>,
signer: Arc<Box<dyn Signer>>,
) -> Result<Arc<Self>> {
if let Some(breez_api_key) = &config.breez_api_key {
Self::validate_breez_api_key(breez_api_key)?
}
fs::create_dir_all(&config.working_dir)?;
let fingerprint_hex: String =
Xpub::decode(signer.xpub()?.as_slice())?.identifier()[0..4].to_hex();
let working_dir = config.get_wallet_dir(&config.working_dir, &fingerprint_hex)?;
let cache_dir = config.get_wallet_dir(
config.cache_dir.as_ref().unwrap_or(&config.working_dir),
&fingerprint_hex,
)?;
let persister = Arc::new(Persister::new(&working_dir, config.network, None)?);
persister.init()?;
let liquid_chain_service =
Arc::new(Mutex::new(HybridLiquidChainService::new(config.clone())?));
let bitcoin_chain_service =
Arc::new(Mutex::new(HybridBitcoinChainService::new(config.clone())?));
let onchain_wallet = Arc::new(LiquidOnchainWallet::new(
config.clone(),
&cache_dir,
persister.clone(),
signer.clone(),
)?);
let event_manager = Arc::new(EventManager::new());
let (shutdown_sender, shutdown_receiver) = watch::channel::<()>(());
if let Some(swapper_proxy_url) = swapper_proxy_url {
persister.set_swapper_proxy_url(swapper_proxy_url)?;
}
let cached_swapper_proxy_url = persister.get_swapper_proxy_url()?;
let swapper = Arc::new(BoltzSwapper::new(config.clone(), cached_swapper_proxy_url));
let status_stream = Arc::<dyn SwapperStatusStream>::from(swapper.create_status_stream());
let recoverer = Arc::new(Recoverer::new(
signer.slip77_master_blinding_key()?,
swapper.clone(),
onchain_wallet.clone(),
liquid_chain_service.clone(),
bitcoin_chain_service.clone(),
)?);
let mut sync_service = None;
if let Some(sync_service_url) = config.sync_service_url.clone() {
if BREEZ_SYNC_SERVICE_URL == sync_service_url && config.breez_api_key.is_none() {
anyhow::bail!(
"Cannot start the Breez real-time sync service without providing a valid API key. See https://sdk-doc-liquid.breez.technology/guide/getting_started.html#api-key",
);
}
let syncer_client = Box::new(BreezSyncerClient::new(config.breez_api_key.clone()));
sync_service = Some(Arc::new(SyncService::new(
sync_service_url,
persister.clone(),
recoverer.clone(),
signer.clone(),
syncer_client,
)));
}
let send_swap_handler = SendSwapHandler::new(
config.clone(),
onchain_wallet.clone(),
persister.clone(),
swapper.clone(),
liquid_chain_service.clone(),
);
let receive_swap_handler = ReceiveSwapHandler::new(
config.clone(),
onchain_wallet.clone(),
persister.clone(),
swapper.clone(),
liquid_chain_service.clone(),
);
let chain_swap_handler = Arc::new(ChainSwapHandler::new(
config.clone(),
onchain_wallet.clone(),
persister.clone(),
swapper.clone(),
liquid_chain_service.clone(),
bitcoin_chain_service.clone(),
)?);
let breez_server = Arc::new(BreezServer::new(PRODUCTION_BREEZSERVER_URL.into(), None)?);
let buy_bitcoin_service =
Arc::new(BuyBitcoinService::new(config.clone(), breez_server.clone()));
let external_input_parsers = config.get_all_external_input_parsers();
let sdk = Arc::new(LiquidSdk {
config: config.clone(),
onchain_wallet,
signer: signer.clone(),
persister: persister.clone(),
event_manager,
status_stream: status_stream.clone(),
swapper,
recoverer,
bitcoin_chain_service,
liquid_chain_service,
fiat_api: breez_server,
is_started: RwLock::new(false),
shutdown_sender,
shutdown_receiver,
send_swap_handler,
receive_swap_handler,
sync_service,
chain_swap_handler,
buy_bitcoin_service,
external_input_parsers,
});
Ok(sdk)
}
async fn start(self: &Arc<LiquidSdk>) -> SdkResult<()> {
let mut is_started = self.is_started.write().await;
let start_ts = Instant::now();
self.persister
.update_send_swaps_by_state(Created, TimedOut)
.inspect_err(|e| error!("Failed to update send swaps by state: {:?}", e))?;
self.start_background_tasks()
.inspect_err(|e| error!("Failed to start background tasks: {:?}", e))
.await?;
*is_started = true;
let start_duration = start_ts.elapsed();
info!("Liquid SDK initialized in: {start_duration:?}");
Ok(())
}
async fn start_background_tasks(self: &Arc<LiquidSdk>) -> SdkResult<()> {
let reconnect_handler = Box::new(SwapperReconnectHandler::new(
self.persister.clone(),
self.status_stream.clone(),
));
self.status_stream
.clone()
.start(reconnect_handler, self.shutdown_receiver.clone())
.await;
if let Some(sync_service) = self.sync_service.clone() {
sync_service.start(self.shutdown_receiver.clone()).await?;
}
self.track_new_blocks().await;
self.track_swap_updates().await;
Ok(())
}
async fn ensure_is_started(&self) -> SdkResult<()> {
let is_started = self.is_started.read().await;
ensure_sdk!(*is_started, SdkError::NotStarted);
Ok(())
}
pub async fn disconnect(&self) -> SdkResult<()> {
self.ensure_is_started().await?;
let mut is_started = self.is_started.write().await;
self.shutdown_sender
.send(())
.map_err(|e| SdkError::generic(format!("Shutdown failed: {e}")))?;
*is_started = false;
Ok(())
}
async fn track_new_blocks(self: &Arc<LiquidSdk>) {
let cloned = self.clone();
tokio::spawn(async move {
let mut current_liquid_block: u32 = 0;
let mut current_bitcoin_block: u32 = 0;
let mut shutdown_receiver = cloned.shutdown_receiver.clone();
let mut interval = tokio::time::interval(Duration::from_secs(10));
interval.set_missed_tick_behavior(MissedTickBehavior::Skip);
loop {
tokio::select! {
_ = interval.tick() => {
let liquid_tip_res = cloned.liquid_chain_service.lock().await.tip().await;
let is_new_liquid_block = match &liquid_tip_res {
Ok(height) => {
debug!("Got Liquid tip: {height}");
let is_new_liquid_block = *height > current_liquid_block;
current_liquid_block = *height;
is_new_liquid_block
},
Err(e) => {
error!("Failed to fetch Liquid tip {e}");
false
}
};
let bitcoin_tip_res = cloned.bitcoin_chain_service.lock().await.tip().map(|tip| tip.height as u32);
let is_new_bitcoin_block = match &bitcoin_tip_res {
Ok(height) => {
debug!("Got Bitcoin tip: {height}");
let is_new_bitcoin_block = *height > current_bitcoin_block;
current_bitcoin_block = *height;
is_new_bitcoin_block
},
Err(e) => {
error!("Failed to fetch Bitcoin tip {e}");
false
}
};
if let (Ok(liquid_tip), Ok(bitcoin_tip)) = (liquid_tip_res, bitcoin_tip_res) {
cloned.persister.set_blockchain_info(&BlockchainInfo {
liquid_tip,
bitcoin_tip
})
.unwrap_or_else(|err| warn!("Could not update local tips: {err:?}"));
};
let partial_sync = (is_new_liquid_block || is_new_bitcoin_block).not();
_ = cloned.sync(partial_sync).await;
if is_new_liquid_block {
cloned.chain_swap_handler.on_liquid_block(current_liquid_block).await;
cloned.send_swap_handler.on_liquid_block(current_liquid_block).await;
}
if is_new_bitcoin_block {
cloned.chain_swap_handler.on_bitcoin_block(current_bitcoin_block).await;
cloned.send_swap_handler.on_bitcoin_block(current_bitcoin_block).await;
}
}
_ = shutdown_receiver.changed() => {
info!("Received shutdown signal, exiting track blocks loop");
return;
}
}
}
});
}
async fn track_swap_updates(self: &Arc<LiquidSdk>) {
let cloned = self.clone();
tokio::spawn(async move {
let mut shutdown_receiver = cloned.shutdown_receiver.clone();
let mut updates_stream = cloned.status_stream.subscribe_swap_updates();
let swaps_streams = vec![
cloned.send_swap_handler.subscribe_payment_updates(),
cloned.receive_swap_handler.subscribe_payment_updates(),
cloned.chain_swap_handler.subscribe_payment_updates(),
];
let mut combined_swap_streams =
select_all(swaps_streams.into_iter().map(BroadcastStream::new));
loop {
tokio::select! {
payment_id = combined_swap_streams.next() => {
if let Some(payment_id) = payment_id {
match payment_id {
Ok(payment_id) => {
if let Err(e) = cloned.emit_payment_updated(Some(payment_id)).await {
error!("Failed to emit payment update: {e:?}");
}
}
Err(e) => error!("Failed to receive swap state change: {e:?}")
}
}
}
update = updates_stream.recv() => match update {
Ok(update) => {
let id = &update.id;
match cloned.persister.fetch_swap_by_id(id) {
Ok(Swap::Send(_)) => match cloned.send_swap_handler.on_new_status(&update).await {
Ok(_) => info!("Successfully handled Send Swap {id} update"),
Err(e) => error!("Failed to handle Send Swap {id} update: {e}")
},
Ok(Swap::Receive(_)) => match cloned.receive_swap_handler.on_new_status(&update).await {
Ok(_) => info!("Successfully handled Receive Swap {id} update"),
Err(e) => error!("Failed to handle Receive Swap {id} update: {e}")
},
Ok(Swap::Chain(_)) => match cloned.chain_swap_handler.on_new_status(&update).await {
Ok(_) => info!("Successfully handled Chain Swap {id} update"),
Err(e) => error!("Failed to handle Chain Swap {id} update: {e}")
},
_ => {
error!("Could not find Swap {id}");
}
}
}
Err(e) => error!("Received stream error: {e:?}"),
},
_ = shutdown_receiver.changed() => {
info!("Received shutdown signal, exiting swap updates loop");
return;
}
}
}
});
}
async fn notify_event_listeners(&self, e: SdkEvent) -> Result<()> {
self.event_manager.notify(e).await;
Ok(())
}
pub async fn add_event_listener(&self, listener: Box<dyn EventListener>) -> SdkResult<String> {
Ok(self.event_manager.add(listener).await?)
}
pub async fn remove_event_listener(&self, id: String) -> SdkResult<()> {
self.event_manager.remove(id).await;
Ok(())
}
async fn emit_payment_updated(&self, payment_id: Option<String>) -> Result<()> {
if let Some(id) = payment_id {
match self.persister.get_payment(&id)? {
Some(payment) => {
self.update_wallet_info().await?;
match payment.status {
Complete => {
self.notify_event_listeners(SdkEvent::PaymentSucceeded {
details: payment,
})
.await?
}
Pending => {
match &payment.details.get_swap_id() {
Some(swap_id) => match self.persister.fetch_swap_by_id(swap_id)? {
Swap::Chain(ChainSwap { claim_tx_id, .. }) => {
if claim_tx_id.is_some() {
self.notify_event_listeners(
SdkEvent::PaymentWaitingConfirmation {
details: payment,
},
)
.await?
} else {
self.notify_event_listeners(SdkEvent::PaymentPending {
details: payment,
})
.await?
}
}
Swap::Receive(ReceiveSwap {
claim_tx_id,
mrh_tx_id,
..
}) => {
if claim_tx_id.is_some() || mrh_tx_id.is_some() {
self.notify_event_listeners(
SdkEvent::PaymentWaitingConfirmation {
details: payment,
},
)
.await?
} else {
self.notify_event_listeners(SdkEvent::PaymentPending {
details: payment,
})
.await?
}
}
Swap::Send(_) => {
self.notify_event_listeners(SdkEvent::PaymentPending {
details: payment,
})
.await?
}
},
None => {
self.notify_event_listeners(
SdkEvent::PaymentWaitingConfirmation { details: payment },
)
.await?
}
};
}
WaitingFeeAcceptance => {
let swap_id = &payment
.details
.get_swap_id()
.ok_or(anyhow!("Payment WaitingFeeAcceptance must have a swap"))?;
ensure!(
matches!(
self.persister.fetch_swap_by_id(swap_id)?,
Swap::Chain(ChainSwap { .. })
),
"Swap in WaitingFeeAcceptance payment must be chain swap"
);
self.notify_event_listeners(SdkEvent::PaymentWaitingFeeAcceptance {
details: payment,
})
.await?;
}
Refundable => {
self.notify_event_listeners(SdkEvent::PaymentRefundable {
details: payment,
})
.await?
}
RefundPending => {
self.notify_event_listeners(SdkEvent::PaymentRefundPending {
details: payment,
})
.await?
}
Failed => match payment.payment_type {
PaymentType::Receive => {
self.notify_event_listeners(SdkEvent::PaymentFailed {
details: payment,
})
.await?
}
PaymentType::Send => {
self.notify_event_listeners(SdkEvent::PaymentRefunded {
details: payment,
})
.await?
}
},
_ => (),
};
}
None => debug!("Payment not found: {id}"),
}
}
Ok(())
}
pub async fn get_info(&self) -> SdkResult<GetInfoResponse> {
self.ensure_is_started().await?;
let maybe_info = self.persister.get_info()?;
match maybe_info {
Some(info) => Ok(info),
None => {
self.update_wallet_info().await?;
self.persister.get_info()?.ok_or(SdkError::Generic {
err: "Info not found".into(),
})
}
}
}
pub fn sign_message(&self, req: &SignMessageRequest) -> SdkResult<SignMessageResponse> {
let signature = self.onchain_wallet.sign_message(&req.message)?;
Ok(SignMessageResponse { signature })
}
pub fn check_message(&self, req: &CheckMessageRequest) -> SdkResult<CheckMessageResponse> {
let is_valid =
self.onchain_wallet
.check_message(&req.message, &req.pubkey, &req.signature)?;
Ok(CheckMessageResponse { is_valid })
}
async fn validate_bitcoin_address(&self, input: &str) -> Result<String, PaymentError> {
match self.parse(input).await? {
InputType::BitcoinAddress {
address: bitcoin_address_data,
..
} => match bitcoin_address_data.network == self.config.network.into() {
true => Ok(bitcoin_address_data.address),
false => Err(PaymentError::InvalidNetwork {
err: format!(
"Not a {} address",
Into::<Network>::into(self.config.network)
),
}),
},
_ => Err(PaymentError::Generic {
err: "Invalid Bitcoin address".to_string(),
}),
}
}
fn validate_bolt11_invoice(&self, invoice: &str) -> Result<Bolt11Invoice, PaymentError> {
let invoice = invoice
.trim()
.parse::<Bolt11Invoice>()
.map_err(|err| PaymentError::invalid_invoice(&err.to_string()))?;
match (invoice.network().to_string().as_str(), self.config.network) {
("bitcoin", LiquidNetwork::Mainnet) => {}
("testnet", LiquidNetwork::Testnet) => {}
_ => {
return Err(PaymentError::InvalidNetwork {
err: "Invoice cannot be paid on the current network".to_string(),
})
}
}
ensure_sdk!(
!invoice.is_expired(),
PaymentError::invalid_invoice("Invoice has expired")
);
Ok(invoice)
}
fn validate_bolt12_invoice(
&self,
offer: &LNOffer,
user_specified_receiver_amount_sat: u64,
invoice: &str,
) -> Result<Bolt12Invoice, PaymentError> {
let invoice_parsed = utils::parse_bolt12_invoice(invoice)?;
let invoice_signing_pubkey = invoice_parsed.signing_pubkey().to_hex();
match &offer.signing_pubkey {
None => {
ensure_sdk!(
&offer
.paths
.iter()
.filter_map(|path| path.blinded_hops.last())
.any(|last_hop| &invoice_signing_pubkey == last_hop),
PaymentError::invalid_invoice(
"Invalid Bolt12 invoice signing key when using blinded path"
)
);
}
Some(offer_signing_pubkey) => {
ensure_sdk!(
offer_signing_pubkey == &invoice_signing_pubkey,
PaymentError::invalid_invoice("Invalid Bolt12 invoice signing key")
);
}
}
let receiver_amount_sat = invoice_parsed.amount_msats() / 1_000;
ensure_sdk!(
receiver_amount_sat == user_specified_receiver_amount_sat,
PaymentError::invalid_invoice("Invalid Bolt12 invoice amount")
);
Ok(invoice_parsed)
}
fn validate_submarine_pairs(
&self,
receiver_amount_sat: u64,
) -> Result<SubmarinePair, PaymentError> {
let lbtc_pair = self
.swapper
.get_submarine_pairs()?
.ok_or(PaymentError::PairsNotFound)?;
lbtc_pair.limits.within(receiver_amount_sat)?;
let fees_sat = lbtc_pair.fees.total(receiver_amount_sat);
ensure_sdk!(
receiver_amount_sat > fees_sat,
PaymentError::AmountOutOfRange
);
Ok(lbtc_pair)
}
fn get_chain_pair(&self, direction: Direction) -> Result<ChainPair, PaymentError> {
self.swapper
.get_chain_pair(direction)?
.ok_or(PaymentError::PairsNotFound)
}
fn validate_user_lockup_amount_for_chain_pair(
&self,
pair: &ChainPair,
user_lockup_amount_sat: u64,
) -> Result<(), PaymentError> {
pair.limits.within(user_lockup_amount_sat)?;
let fees_sat = pair.fees.total(user_lockup_amount_sat);
ensure_sdk!(
user_lockup_amount_sat > fees_sat,
PaymentError::AmountOutOfRange
);
Ok(())
}
fn get_and_validate_chain_pair(
&self,
direction: Direction,
user_lockup_amount_sat: Option<u64>,
) -> Result<ChainPair, PaymentError> {
let pair = self.get_chain_pair(direction)?;
if let Some(user_lockup_amount_sat) = user_lockup_amount_sat {
self.validate_user_lockup_amount_for_chain_pair(&pair, user_lockup_amount_sat)?;
}
Ok(pair)
}
async fn estimate_onchain_tx_fee(
&self,
amount_sat: u64,
address: &str,
) -> Result<u64, PaymentError> {
let fee_sat = self
.onchain_wallet
.build_tx(Some(LIQUID_FEE_RATE_MSAT_PER_VBYTE), address, amount_sat)
.await?
.all_fees()
.values()
.sum::<u64>();
info!("Estimated tx fee: {fee_sat} sat");
Ok(fee_sat)
}
fn get_temp_p2tr_addr(&self) -> &str {
match self.config.network {
LiquidNetwork::Mainnet => "lq1pqvzxvqhrf54dd4sny4cag7497pe38252qefk46t92frs7us8r80ja9ha8r5me09nn22m4tmdqp5p4wafq3s59cql3v9n45t5trwtxrmxfsyxjnstkctj",
LiquidNetwork::Testnet => "tlq1pq0wqu32e2xacxeyps22x8gjre4qk3u6r70pj4r62hzczxeyz8x3yxucrpn79zy28plc4x37aaf33kwt6dz2nn6gtkya6h02mwpzy4eh69zzexq7cf5y5"
}
}
async fn estimate_lockup_tx_fee(
&self,
user_lockup_amount_sat: u64,
) -> Result<u64, PaymentError> {
let temp_p2tr_addr = self.get_temp_p2tr_addr();
self.estimate_onchain_tx_fee(user_lockup_amount_sat, temp_p2tr_addr)
.await
}
async fn estimate_drain_tx_fee(
&self,
enforce_amount_sat: Option<u64>,
address: Option<&str>,
) -> Result<u64, PaymentError> {
let receipent_address = address.unwrap_or(self.get_temp_p2tr_addr());
let fee_sat = self
.onchain_wallet
.build_drain_tx(
Some(LIQUID_FEE_RATE_MSAT_PER_VBYTE),
receipent_address,
enforce_amount_sat,
)
.await?
.all_fees()
.values()
.sum();
info!("Estimated drain tx fee: {fee_sat} sat");
Ok(fee_sat)
}
async fn estimate_onchain_tx_or_drain_tx_fee(
&self,
amount_sat: u64,
address: &str,
) -> Result<u64, PaymentError> {
match self.estimate_onchain_tx_fee(amount_sat, address).await {
Ok(fees_sat) => Ok(fees_sat),
Err(PaymentError::InsufficientFunds) => self
.estimate_drain_tx_fee(Some(amount_sat), Some(address))
.await
.map_err(|_| PaymentError::InsufficientFunds),
Err(e) => Err(e),
}
}
async fn estimate_lockup_tx_or_drain_tx_fee(
&self,
amount_sat: u64,
) -> Result<u64, PaymentError> {
let temp_p2tr_addr = self.get_temp_p2tr_addr();
self.estimate_onchain_tx_or_drain_tx_fee(amount_sat, temp_p2tr_addr)
.await
}
pub async fn prepare_send_payment(
&self,
req: &PrepareSendRequest,
) -> Result<PrepareSendResponse, PaymentError> {
self.ensure_is_started().await?;
let get_info_res = self.get_info().await?;
let fees_sat;
let receiver_amount_sat;
let payment_destination;
match self.parse(&req.destination).await {
Ok(InputType::LiquidAddress {
address: mut liquid_address_data,
}) => {
let amount = match (liquid_address_data.amount_sat, req.amount.clone()) {
(None, None) => {
return Err(PaymentError::AmountMissing {
err: "Amount must be set when paying to a Liquid address".to_string(),
});
}
(Some(bip21_amount_sat), None) => PayAmount::Receiver {
amount_sat: bip21_amount_sat,
},
(_, Some(amount)) => amount,
};
ensure_sdk!(
liquid_address_data.network == self.config.network.into(),
PaymentError::InvalidNetwork {
err: format!(
"Cannot send payment from {} to {}",
Into::<sdk_common::bitcoin::Network>::into(self.config.network),
liquid_address_data.network
)
}
);
(receiver_amount_sat, fees_sat) = match amount {
PayAmount::Drain => {
ensure_sdk!(
get_info_res.wallet_info.pending_receive_sat == 0
&& get_info_res.wallet_info.pending_send_sat == 0,
PaymentError::Generic {
err: "Cannot drain while there are pending payments".to_string(),
}
);
let drain_fees_sat = self
.estimate_drain_tx_fee(None, Some(&liquid_address_data.address))
.await?;
let drain_amount_sat =
get_info_res.wallet_info.balance_sat - drain_fees_sat;
info!("Drain amount: {drain_amount_sat} sat");
(drain_amount_sat, drain_fees_sat)
}
PayAmount::Receiver { amount_sat } => {
let fees_sat = self
.estimate_onchain_tx_or_drain_tx_fee(
amount_sat,
&liquid_address_data.address,
)
.await?;
(amount_sat, fees_sat)
}
};
liquid_address_data.amount_sat = Some(receiver_amount_sat);
payment_destination = SendDestination::LiquidAddress {
address_data: liquid_address_data,
};
}
Ok(InputType::Bolt11 { invoice }) => {
if let Some(sync_service) = &self.sync_service {
sync_service
.pull()
.await
.map_err(|err| PaymentError::Generic {
err: format!("Could not pull real-time sync changes: {err:?}"),
})?;
}
self.ensure_send_is_not_self_transfer(&invoice.bolt11)?;
self.validate_bolt11_invoice(&invoice.bolt11)?;
receiver_amount_sat = invoice.amount_msat.ok_or(PaymentError::amount_missing(
"Expected invoice with an amount",
))? / 1000;
if let Some(PayAmount::Receiver { amount_sat }) = req.amount {
ensure_sdk!(
receiver_amount_sat == amount_sat,
PaymentError::Generic {
err: "Receiver amount and invoice amount do not match".to_string()
}
);
}
let lbtc_pair = self.validate_submarine_pairs(receiver_amount_sat)?;
fees_sat = match self.swapper.check_for_mrh(&invoice.bolt11)? {
Some((lbtc_address, _)) => {
self.estimate_onchain_tx_or_drain_tx_fee(receiver_amount_sat, &lbtc_address)
.await?
}
None => {
let boltz_fees_total = lbtc_pair.fees.total(receiver_amount_sat);
let user_lockup_amount_sat = receiver_amount_sat + boltz_fees_total;
let lockup_fees_sat = self
.estimate_lockup_tx_or_drain_tx_fee(user_lockup_amount_sat)
.await?;
boltz_fees_total + lockup_fees_sat
}
};
payment_destination = SendDestination::Bolt11 { invoice };
}
Ok(InputType::Bolt12Offer { offer }) => {
receiver_amount_sat = match req.amount {
Some(PayAmount::Receiver { amount_sat }) => Ok(amount_sat),
_ => Err(PaymentError::amount_missing(
"Expected PayAmount of type Receiver when processing a Bolt12 offer",
)),
}?;
if let Some(Amount::Bitcoin { amount_msat }) = &offer.min_amount {
ensure_sdk!(
receiver_amount_sat >= amount_msat / 1_000,
PaymentError::invalid_invoice(
"Invalid receiver amount: below offer minimum"
)
);
}
let lbtc_pair = self.validate_submarine_pairs(receiver_amount_sat)?;
let boltz_fees_total = lbtc_pair.fees.total(receiver_amount_sat);
let lockup_fees_sat = self
.estimate_lockup_tx_or_drain_tx_fee(receiver_amount_sat + boltz_fees_total)
.await?;
fees_sat = boltz_fees_total + lockup_fees_sat;
payment_destination = SendDestination::Bolt12 {
offer,
receiver_amount_sat,
};
}
_ => {
return Err(PaymentError::generic("Destination is not valid"));
}
};
let payer_amount_sat = receiver_amount_sat + fees_sat;
ensure_sdk!(
payer_amount_sat <= get_info_res.wallet_info.balance_sat,
PaymentError::InsufficientFunds
);
Ok(PrepareSendResponse {
destination: payment_destination,
fees_sat,
})
}
fn ensure_send_is_not_self_transfer(&self, invoice: &str) -> Result<(), PaymentError> {
match self.persister.fetch_receive_swap_by_invoice(invoice)? {
None => Ok(()),
Some(_) => Err(PaymentError::SelfTransferNotSupported),
}
}
pub async fn send_payment(
&self,
req: &SendPaymentRequest,
) -> Result<SendPaymentResponse, PaymentError> {
self.ensure_is_started().await?;
let PrepareSendResponse {
fees_sat,
destination: payment_destination,
} = &req.prepare_response;
match payment_destination {
SendDestination::LiquidAddress {
address_data: liquid_address_data,
} => {
let Some(amount_sat) = liquid_address_data.amount_sat else {
return Err(PaymentError::AmountMissing { err: "`amount_sat` must be present when paying to a `SendDestination::LiquidAddress`".to_string() });
};
ensure_sdk!(
liquid_address_data.network == self.config.network.into(),
PaymentError::InvalidNetwork {
err: format!(
"Cannot send payment from {} to {}",
Into::<sdk_common::bitcoin::Network>::into(self.config.network),
liquid_address_data.network
)
}
);
let payer_amount_sat = amount_sat + fees_sat;
ensure_sdk!(
payer_amount_sat <= self.get_info().await?.wallet_info.balance_sat,
PaymentError::InsufficientFunds
);
self.pay_liquid(liquid_address_data.clone(), amount_sat, *fees_sat, true)
.await
}
SendDestination::Bolt11 { invoice } => {
self.pay_bolt11_invoice(&invoice.bolt11, *fees_sat).await
}
SendDestination::Bolt12 {
offer,
receiver_amount_sat,
} => {
let bolt12_invoice = self
.swapper
.get_bolt12_invoice(&offer.offer, *receiver_amount_sat)?;
self.pay_bolt12_invoice(offer, *receiver_amount_sat, &bolt12_invoice, *fees_sat)
.await
}
}
}
async fn pay_bolt11_invoice(
&self,
invoice: &str,
fees_sat: u64,
) -> Result<SendPaymentResponse, PaymentError> {
self.ensure_send_is_not_self_transfer(invoice)?;
let bolt11_invoice = self.validate_bolt11_invoice(invoice)?;
let amount_sat = get_invoice_amount!(invoice);
let payer_amount_sat = amount_sat + fees_sat;
ensure_sdk!(
payer_amount_sat <= self.get_info().await?.wallet_info.balance_sat,
PaymentError::InsufficientFunds
);
let description = match bolt11_invoice.description() {
Bolt11InvoiceDescription::Direct(msg) => Some(msg.to_string()),
Bolt11InvoiceDescription::Hash(_) => None,
};
match self.swapper.check_for_mrh(invoice)? {
Some((address, _)) => {
info!("Found MRH for L-BTC address {address}, invoice amount_sat {amount_sat}");
self.pay_liquid(
LiquidAddressData {
address,
network: self.config.network.into(),
asset_id: None,
amount_sat: None,
label: None,
message: None,
},
amount_sat,
fees_sat,
false,
)
.await
}
None => {
self.send_payment_via_swap(
invoice,
None,
&bolt11_invoice.payment_hash().to_string(),
description,
amount_sat,
fees_sat,
)
.await
}
}
}
async fn pay_bolt12_invoice(
&self,
offer: &LNOffer,
user_specified_receiver_amount_sat: u64,
invoice_str: &str,
fees_sat: u64,
) -> Result<SendPaymentResponse, PaymentError> {
let invoice =
self.validate_bolt12_invoice(offer, user_specified_receiver_amount_sat, invoice_str)?;
let receiver_amount_sat = invoice.amount_msats() / 1_000;
let payer_amount_sat = receiver_amount_sat + fees_sat;
ensure_sdk!(
payer_amount_sat <= self.get_info().await?.wallet_info.balance_sat,
PaymentError::InsufficientFunds
);
self.send_payment_via_swap(
invoice_str,
Some(offer.offer.clone()),
&invoice.payment_hash().to_string(),
invoice.description().map(|desc| desc.to_string()),
receiver_amount_sat,
fees_sat,
)
.await
}
async fn pay_liquid(
&self,
address_data: LiquidAddressData,
receiver_amount_sat: u64,
fees_sat: u64,
skip_already_paid_check: bool,
) -> Result<SendPaymentResponse, PaymentError> {
let destination = address_data
.to_uri()
.unwrap_or(address_data.address.clone());
let payments = self.persister.get_payments(&ListPaymentsRequest {
details: Some(ListPaymentDetails::Liquid {
destination: destination.clone(),
}),
..Default::default()
})?;
ensure_sdk!(
skip_already_paid_check || payments.is_empty(),
PaymentError::AlreadyPaid
);
let tx = self
.onchain_wallet
.build_tx_or_drain_tx(
Some(LIQUID_FEE_RATE_MSAT_PER_VBYTE),
&address_data.address,
receiver_amount_sat,
)
.await?;
let tx_fees_sat = tx.all_fees().values().sum::<u64>();
ensure_sdk!(tx_fees_sat <= fees_sat, PaymentError::InvalidOrExpiredFees);
let tx_id = tx.txid().to_string();
let payer_amount_sat = receiver_amount_sat + tx_fees_sat;
info!(
"Built onchain L-BTC tx with receiver_amount_sat = {receiver_amount_sat}, fees_sat = {fees_sat} and txid = {tx_id}"
);
let liquid_chain_service = self.liquid_chain_service.lock().await;
let tx_id = liquid_chain_service.broadcast(&tx).await?.to_string();
let tx_data = PaymentTxData {
tx_id: tx_id.clone(),
timestamp: Some(utils::now()),
amount_sat: payer_amount_sat,
fees_sat,
payment_type: PaymentType::Send,
is_confirmed: false,
unblinding_data: None,
};
let description = address_data.message;
self.persister.insert_or_update_payment(
tx_data.clone(),
Some(PaymentTxDetails {
tx_id: tx_id.clone(),
destination: destination.clone(),
description: description.clone(),
..Default::default()
}),
false,
)?;
self.emit_payment_updated(Some(tx_id)).await?; let payment_details = PaymentDetails::Liquid {
destination,
description: description.unwrap_or("Liquid transfer".to_string()),
};
Ok(SendPaymentResponse {
payment: Payment::from_tx_data(tx_data, None, payment_details),
})
}
async fn send_payment_via_swap(
&self,
invoice: &str,
bolt12_offer: Option<String>,
payment_hash: &str,
description: Option<String>,
receiver_amount_sat: u64,
fees_sat: u64,
) -> Result<SendPaymentResponse, PaymentError> {
let lbtc_pair = self.validate_submarine_pairs(receiver_amount_sat)?;
let boltz_fees_total = lbtc_pair.fees.total(receiver_amount_sat);
let user_lockup_amount_sat = receiver_amount_sat + boltz_fees_total;
let lockup_tx_fees_sat = self
.estimate_lockup_tx_or_drain_tx_fee(user_lockup_amount_sat)
.await?;
ensure_sdk!(
fees_sat == boltz_fees_total + lockup_tx_fees_sat,
PaymentError::InvalidOrExpiredFees
);
let swap = match self.persister.fetch_send_swap_by_invoice(invoice)? {
Some(swap) => match swap.state {
Created => swap,
TimedOut => {
self.send_swap_handler.update_swap_info(
&swap.id,
PaymentState::Created,
None,
None,
None,
)?;
swap
}
Pending => return Err(PaymentError::PaymentInProgress),
Complete => return Err(PaymentError::AlreadyPaid),
RefundPending | Refundable | Failed => {
return Err(PaymentError::invalid_invoice(
"Payment has already failed. Please try with another invoice",
))
}
WaitingFeeAcceptance => {
return Err(PaymentError::Generic {
err: "Send swap payment cannot be in state WaitingFeeAcceptance"
.to_string(),
})
}
},
None => {
let keypair = utils::generate_keypair();
let refund_public_key = boltz_client::PublicKey {
compressed: true,
inner: keypair.public_key(),
};
let webhook = self.persister.get_webhook_url()?.map(|url| Webhook {
url,
hash_swap_id: Some(true),
status: Some(vec![
SubSwapStates::InvoiceFailedToPay,
SubSwapStates::SwapExpired,
SubSwapStates::TransactionClaimPending,
SubSwapStates::TransactionLockupFailed,
]),
});
let create_response = self.swapper.create_send_swap(CreateSubmarineRequest {
from: "L-BTC".to_string(),
to: "BTC".to_string(),
invoice: invoice.to_string(),
refund_public_key,
pair_hash: Some(lbtc_pair.hash.clone()),
referral_id: None,
webhook,
})?;
let swap_id = &create_response.id;
let create_response_json =
SendSwap::from_boltz_struct_to_json(&create_response, swap_id)?;
let destination_pubkey =
utils::get_invoice_destination_pubkey(invoice, bolt12_offer.is_some())?;
let payer_amount_sat = fees_sat + receiver_amount_sat;
let swap = SendSwap {
id: swap_id.clone(),
invoice: invoice.to_string(),
bolt12_offer,
payment_hash: Some(payment_hash.to_string()),
destination_pubkey: Some(destination_pubkey),
timeout_block_height: create_response.timeout_block_height,
description,
preimage: None,
payer_amount_sat,
receiver_amount_sat,
pair_fees_json: serde_json::to_string(&lbtc_pair).map_err(|e| {
PaymentError::generic(&format!("Failed to serialize SubmarinePair: {e:?}"))
})?,
create_response_json,
lockup_tx_id: None,
refund_tx_id: None,
created_at: utils::now(),
state: PaymentState::Created,
refund_private_key: keypair.display_secret().to_string(),
version: 0,
};
self.persister.insert_or_update_send_swap(&swap)?;
swap
}
};
self.status_stream.track_swap_id(&swap.id)?;
let create_response = swap.get_boltz_create_response()?;
self.send_swap_handler
.try_lockup(&swap, &create_response)
.await?;
self.wait_for_payment_with_timeout(Swap::Send(swap), create_response.accept_zero_conf)
.await
.map(|payment| SendPaymentResponse { payment })
}
pub async fn fetch_lightning_limits(
&self,
) -> Result<LightningPaymentLimitsResponse, PaymentError> {
self.ensure_is_started().await?;
let submarine_pair = self
.swapper
.get_submarine_pairs()?
.ok_or(PaymentError::PairsNotFound)?;
let send_limits = submarine_pair.limits;
let reverse_pair = self
.swapper
.get_reverse_swap_pairs()?
.ok_or(PaymentError::PairsNotFound)?;
let receive_limits = reverse_pair.limits;
Ok(LightningPaymentLimitsResponse {
send: Limits {
min_sat: send_limits.minimal,
max_sat: send_limits.maximal,
max_zero_conf_sat: send_limits.maximal_zero_conf,
},
receive: Limits {
min_sat: receive_limits.minimal,
max_sat: receive_limits.maximal,
max_zero_conf_sat: self.config.zero_conf_max_amount_sat(),
},
})
}
pub async fn fetch_onchain_limits(&self) -> Result<OnchainPaymentLimitsResponse, PaymentError> {
self.ensure_is_started().await?;
let (pair_outgoing, pair_incoming) = self.swapper.get_chain_pairs()?;
let send_limits = pair_outgoing
.ok_or(PaymentError::PairsNotFound)
.map(|pair| pair.limits)?;
let receive_limits = pair_incoming
.ok_or(PaymentError::PairsNotFound)
.map(|pair| pair.limits)?;
Ok(OnchainPaymentLimitsResponse {
send: Limits {
min_sat: send_limits.minimal,
max_sat: send_limits.maximal,
max_zero_conf_sat: send_limits.maximal_zero_conf,
},
receive: Limits {
min_sat: receive_limits.minimal,
max_sat: receive_limits.maximal,
max_zero_conf_sat: receive_limits.maximal_zero_conf,
},
})
}
pub async fn prepare_pay_onchain(
&self,
req: &PreparePayOnchainRequest,
) -> Result<PreparePayOnchainResponse, PaymentError> {
self.ensure_is_started().await?;
let get_info_res = self.get_info().await?;
let pair = self.get_chain_pair(Direction::Outgoing)?;
let claim_fees_sat = match req.fee_rate_sat_per_vbyte {
Some(sat_per_vbyte) => ESTIMATED_BTC_CLAIM_TX_VSIZE * sat_per_vbyte as u64,
None => pair.clone().fees.claim_estimate(),
};
let server_fees_sat = pair.fees.server();
info!("Preparing for onchain payment of kind: {:?}", req.amount);
let (payer_amount_sat, receiver_amount_sat, total_fees_sat) = match req.amount {
PayAmount::Receiver { amount_sat } => {
let receiver_amount_sat = amount_sat;
let user_lockup_amount_sat_without_service_fee =
receiver_amount_sat + claim_fees_sat + server_fees_sat;
let user_lockup_amount_sat = (user_lockup_amount_sat_without_service_fee as f64
* 100.0
/ (100.0 - pair.fees.percentage))
.ceil() as u64;
self.validate_user_lockup_amount_for_chain_pair(&pair, user_lockup_amount_sat)?;
let lockup_fees_sat = self.estimate_lockup_tx_fee(user_lockup_amount_sat).await?;
let boltz_fees_sat =
user_lockup_amount_sat - user_lockup_amount_sat_without_service_fee;
let total_fees_sat =
boltz_fees_sat + lockup_fees_sat + claim_fees_sat + server_fees_sat;
let payer_amount_sat = receiver_amount_sat + total_fees_sat;
(payer_amount_sat, receiver_amount_sat, total_fees_sat)
}
PayAmount::Drain => {
ensure_sdk!(
get_info_res.wallet_info.pending_receive_sat == 0
&& get_info_res.wallet_info.pending_send_sat == 0,
PaymentError::Generic {
err: "Cannot drain while there are pending payments".to_string(),
}
);
let payer_amount_sat = get_info_res.wallet_info.balance_sat;
let lockup_fees_sat = self.estimate_drain_tx_fee(None, None).await?;
let user_lockup_amount_sat = payer_amount_sat - lockup_fees_sat;
self.validate_user_lockup_amount_for_chain_pair(&pair, user_lockup_amount_sat)?;
let boltz_fees_sat = pair.fees.boltz(user_lockup_amount_sat);
let total_fees_sat =
boltz_fees_sat + lockup_fees_sat + claim_fees_sat + server_fees_sat;
let receiver_amount_sat = payer_amount_sat - total_fees_sat;
(payer_amount_sat, receiver_amount_sat, total_fees_sat)
}
};
let res = PreparePayOnchainResponse {
receiver_amount_sat,
claim_fees_sat,
total_fees_sat,
};
ensure_sdk!(
payer_amount_sat <= get_info_res.wallet_info.balance_sat,
PaymentError::InsufficientFunds
);
info!("Prepared onchain payment: {res:?}");
Ok(res)
}
pub async fn pay_onchain(
&self,
req: &PayOnchainRequest,
) -> Result<SendPaymentResponse, PaymentError> {
self.ensure_is_started().await?;
info!("Paying onchain, request = {req:?}");
let claim_address = self.validate_bitcoin_address(&req.address).await?;
let balance_sat = self.get_info().await?.wallet_info.balance_sat;
let receiver_amount_sat = req.prepare_response.receiver_amount_sat;
let pair = self.get_chain_pair(Direction::Outgoing)?;
let claim_fees_sat = req.prepare_response.claim_fees_sat;
let server_fees_sat = pair.fees.server();
let server_lockup_amount_sat = receiver_amount_sat + claim_fees_sat;
let user_lockup_amount_sat_without_service_fee =
receiver_amount_sat + claim_fees_sat + server_fees_sat;
let user_lockup_amount_sat = (user_lockup_amount_sat_without_service_fee as f64 * 100.0
/ (100.0 - pair.fees.percentage))
.ceil() as u64;
let boltz_fee_sat = user_lockup_amount_sat - user_lockup_amount_sat_without_service_fee;
self.validate_user_lockup_amount_for_chain_pair(&pair, user_lockup_amount_sat)?;
let payer_amount_sat = req.prepare_response.total_fees_sat + receiver_amount_sat;
let lockup_fees_sat = match payer_amount_sat == balance_sat {
true => self.estimate_drain_tx_fee(None, None).await?,
false => self.estimate_lockup_tx_fee(user_lockup_amount_sat).await?,
};
ensure_sdk!(
req.prepare_response.total_fees_sat
== boltz_fee_sat + lockup_fees_sat + claim_fees_sat + server_fees_sat,
PaymentError::InvalidOrExpiredFees
);
ensure_sdk!(
payer_amount_sat <= balance_sat,
PaymentError::InsufficientFunds
);
let preimage = Preimage::new();
let preimage_str = preimage.to_string().ok_or(PaymentError::InvalidPreimage)?;
let claim_keypair = utils::generate_keypair();
let claim_public_key = boltz_client::PublicKey {
compressed: true,
inner: claim_keypair.public_key(),
};
let refund_keypair = utils::generate_keypair();
let refund_public_key = boltz_client::PublicKey {
compressed: true,
inner: refund_keypair.public_key(),
};
let webhook = self.persister.get_webhook_url()?.map(|url| Webhook {
url,
hash_swap_id: Some(true),
status: Some(vec![
ChainSwapStates::TransactionFailed,
ChainSwapStates::TransactionLockupFailed,
ChainSwapStates::TransactionServerConfirmed,
]),
});
let create_response = self.swapper.create_chain_swap(CreateChainRequest {
from: "L-BTC".to_string(),
to: "BTC".to_string(),
preimage_hash: preimage.sha256,
claim_public_key: Some(claim_public_key),
refund_public_key: Some(refund_public_key),
user_lock_amount: None,
server_lock_amount: Some(server_lockup_amount_sat),
pair_hash: Some(pair.hash.clone()),
referral_id: None,
webhook,
})?;
let create_response_json =
ChainSwap::from_boltz_struct_to_json(&create_response, &create_response.id)?;
let swap_id = create_response.id;
let accept_zero_conf = server_lockup_amount_sat <= pair.limits.maximal_zero_conf;
let payer_amount_sat = req.prepare_response.total_fees_sat + receiver_amount_sat;
let swap = ChainSwap {
id: swap_id.clone(),
direction: Direction::Outgoing,
claim_address: Some(claim_address),
lockup_address: create_response.lockup_details.lockup_address,
timeout_block_height: create_response.lockup_details.timeout_block_height,
preimage: preimage_str,
description: Some("Bitcoin transfer".to_string()),
payer_amount_sat,
actual_payer_amount_sat: None,
receiver_amount_sat,
accepted_receiver_amount_sat: None,
claim_fees_sat,
pair_fees_json: serde_json::to_string(&pair).map_err(|e| {
PaymentError::generic(&format!("Failed to serialize outgoing ChainPair: {e:?}"))
})?,
accept_zero_conf,
create_response_json,
claim_private_key: claim_keypair.display_secret().to_string(),
refund_private_key: refund_keypair.display_secret().to_string(),
server_lockup_tx_id: None,
user_lockup_tx_id: None,
claim_tx_id: None,
refund_tx_id: None,
created_at: utils::now(),
state: PaymentState::Created,
version: 0,
};
self.persister.insert_or_update_chain_swap(&swap)?;
self.status_stream.track_swap_id(&swap_id)?;
self.wait_for_payment_with_timeout(Swap::Chain(swap), accept_zero_conf)
.await
.map(|payment| SendPaymentResponse { payment })
}
async fn wait_for_payment_with_timeout(
&self,
swap: Swap,
accept_zero_conf: bool,
) -> Result<Payment, PaymentError> {
let timeout_fut = tokio::time::sleep(Duration::from_secs(self.config.payment_timeout_sec));
tokio::pin!(timeout_fut);
let expected_swap_id = swap.id();
let mut events_stream = self.event_manager.subscribe();
let mut maybe_payment: Option<Payment> = None;
loop {
tokio::select! {
_ = &mut timeout_fut => match maybe_payment {
Some(payment) => return Ok(payment),
None => {
debug!("Timeout occurred without payment, set swap to timed out");
match swap {
Swap::Send(_) => self.send_swap_handler.update_swap_info(&expected_swap_id, TimedOut, None, None, None)?,
Swap::Chain(_) => self.chain_swap_handler.update_swap_info(&ChainSwapUpdate {
swap_id: expected_swap_id,
to_state: TimedOut,
..Default::default()
})?,
_ => ()
}
return Err(PaymentError::PaymentTimeout)
},
},
event = events_stream.recv() => match event {
Ok(SdkEvent::PaymentPending { details: payment }) => {
let maybe_payment_swap_id = payment.details.get_swap_id();
if matches!(maybe_payment_swap_id, Some(swap_id) if swap_id == expected_swap_id) {
match accept_zero_conf {
true => {
debug!("Received Send Payment pending event with zero-conf accepted");
return Ok(payment)
}
false => {
debug!("Received Send Payment pending event, waiting for confirmation");
maybe_payment = Some(payment);
}
}
};
},
Ok(SdkEvent::PaymentSucceeded { details: payment }) => {
let maybe_payment_swap_id = payment.details.get_swap_id();
if matches!(maybe_payment_swap_id, Some(swap_id) if swap_id == expected_swap_id) {
debug!("Received Send Payment succeed event");
return Ok(payment);
}
},
Ok(event) => debug!("Unhandled event waiting for payment: {event:?}"),
Err(e) => debug!("Received error waiting for payment: {e:?}"),
}
}
}
}
pub async fn prepare_receive_payment(
&self,
req: &PrepareReceiveRequest,
) -> Result<PrepareReceiveResponse, PaymentError> {
self.ensure_is_started().await?;
let mut min_payer_amount_sat = None;
let mut max_payer_amount_sat = None;
let mut swapper_feerate = None;
let fees_sat;
match req.payment_method {
PaymentMethod::Lightning => {
let Some(payer_amount_sat) = req.payer_amount_sat else {
return Err(PaymentError::AmountMissing { err: "`payer_amount_sat` must be specified when `PaymentMethod::Lightning` is used.".to_string() });
};
let reverse_pair = self
.swapper
.get_reverse_swap_pairs()?
.ok_or(PaymentError::PairsNotFound)?;
fees_sat = reverse_pair.fees.total(payer_amount_sat);
ensure_sdk!(payer_amount_sat > fees_sat, PaymentError::AmountOutOfRange);
reverse_pair
.limits
.within(payer_amount_sat)
.map_err(|_| PaymentError::AmountOutOfRange)?;
min_payer_amount_sat = Some(reverse_pair.limits.minimal);
max_payer_amount_sat = Some(reverse_pair.limits.maximal);
swapper_feerate = Some(reverse_pair.fees.percentage);
debug!(
"Preparing Lightning Receive Swap with: payer_amount_sat {payer_amount_sat} sat, fees_sat {fees_sat} sat"
);
}
PaymentMethod::BitcoinAddress => {
let payer_amount_sat = req.payer_amount_sat;
let pair =
self.get_and_validate_chain_pair(Direction::Incoming, payer_amount_sat)?;
let claim_fees_sat = pair.fees.claim_estimate();
let server_fees_sat = pair.fees.server();
let service_fees_sat = payer_amount_sat
.map(|user_lockup_amount_sat| pair.fees.boltz(user_lockup_amount_sat))
.unwrap_or_default();
min_payer_amount_sat = Some(pair.limits.minimal);
max_payer_amount_sat = Some(pair.limits.maximal);
swapper_feerate = Some(pair.fees.percentage);
fees_sat = service_fees_sat + claim_fees_sat + server_fees_sat;
debug!("Preparing Chain Receive Swap with: payer_amount_sat {payer_amount_sat:?}, fees_sat {fees_sat}");
}
PaymentMethod::LiquidAddress => {
fees_sat = 0;
let payer_amount_sat = req.payer_amount_sat;
debug!("Preparing Liquid Receive Swap with: amount_sat {payer_amount_sat:?}, fees_sat {fees_sat}");
}
};
Ok(PrepareReceiveResponse {
payer_amount_sat: req.payer_amount_sat,
fees_sat,
payment_method: req.payment_method.clone(),
min_payer_amount_sat,
max_payer_amount_sat,
swapper_feerate,
})
}
pub async fn receive_payment(
&self,
req: &ReceivePaymentRequest,
) -> Result<ReceivePaymentResponse, PaymentError> {
self.ensure_is_started().await?;
let PrepareReceiveResponse {
payment_method,
payer_amount_sat: amount_sat,
fees_sat,
..
} = &req.prepare_response;
match payment_method {
PaymentMethod::Lightning => {
let Some(amount_sat) = amount_sat else {
return Err(PaymentError::AmountMissing { err: "`amount_sat` must be specified when `PaymentMethod::Lightning` is used.".to_string() });
};
let (description, description_hash) = match (
req.description.clone(),
req.use_description_hash.unwrap_or_default(),
) {
(Some(description), true) => (
None,
Some(sha256::Hash::hash(description.as_bytes()).to_hex()),
),
(_, false) => (req.description.clone(), None),
_ => {
return Err(PaymentError::InvalidDescription {
err: "Missing payment description to hash".to_string(),
})
}
};
self.create_receive_swap(*amount_sat, *fees_sat, description, description_hash)
.await
}
PaymentMethod::BitcoinAddress => self.receive_onchain(*amount_sat, *fees_sat).await,
PaymentMethod::LiquidAddress => {
let address = self.onchain_wallet.next_unused_address().await?.to_string();
let receive_destination = match amount_sat {
Some(amount_sat) => LiquidAddressData {
address: address.to_string(),
network: self.config.network.into(),
amount_sat: Some(*amount_sat),
asset_id: Some(utils::lbtc_asset_id(self.config.network).to_string()),
label: None,
message: req.description.clone(),
}
.to_uri()
.map_err(|e| PaymentError::Generic {
err: format!("Could not build BIP21 URI: {e:?}"),
})?,
None => address,
};
Ok(ReceivePaymentResponse {
destination: receive_destination,
})
}
}
}
async fn create_receive_swap(
&self,
payer_amount_sat: u64,
fees_sat: u64,
description: Option<String>,
description_hash: Option<String>,
) -> Result<ReceivePaymentResponse, PaymentError> {
let reverse_pair = self
.swapper
.get_reverse_swap_pairs()?
.ok_or(PaymentError::PairsNotFound)?;
let new_fees_sat = reverse_pair.fees.total(payer_amount_sat);
ensure_sdk!(fees_sat == new_fees_sat, PaymentError::InvalidOrExpiredFees);
debug!("Creating Receive Swap with: payer_amount_sat {payer_amount_sat} sat, fees_sat {fees_sat} sat");
let keypair = utils::generate_keypair();
let preimage = Preimage::new();
let preimage_str = preimage.to_string().ok_or(PaymentError::InvalidPreimage)?;
let preimage_hash = preimage.sha256.to_string();
let mrh_addr = self.onchain_wallet.next_unused_address().await?;
let mrh_addr_str = mrh_addr.to_string();
let mrh_addr_hash = sha256::Hash::hash(mrh_addr_str.as_bytes());
let mrh_addr_hash_sig =
keypair.sign_schnorr(Message::from_digest_slice(mrh_addr_hash.as_byte_array())?);
let receiver_amount_sat = payer_amount_sat - fees_sat;
let webhook_claim_status =
match receiver_amount_sat > self.config.zero_conf_max_amount_sat() {
true => RevSwapStates::TransactionConfirmed,
false => RevSwapStates::TransactionMempool,
};
let webhook = self.persister.get_webhook_url()?.map(|url| Webhook {
url,
hash_swap_id: Some(true),
status: Some(vec![webhook_claim_status]),
});
let v2_req = CreateReverseRequest {
invoice_amount: payer_amount_sat,
from: "BTC".to_string(),
to: "L-BTC".to_string(),
preimage_hash: preimage.sha256,
claim_public_key: keypair.public_key().into(),
description,
description_hash,
address: Some(mrh_addr_str.clone()),
address_signature: Some(mrh_addr_hash_sig.to_hex()),
referral_id: None,
webhook,
};
let create_response = self.swapper.create_receive_swap(v2_req)?;
self.persister.insert_or_update_reserved_address(
&mrh_addr_str,
create_response.timeout_block_height,
)?;
let (bip21_lbtc_address, _bip21_amount_btc) = self
.swapper
.check_for_mrh(&create_response.invoice)?
.ok_or(PaymentError::receive_error("Invoice has no MRH"))?;
ensure_sdk!(
bip21_lbtc_address == mrh_addr_str,
PaymentError::receive_error("Invoice has incorrect address in MRH")
);
let swap_id = create_response.id.clone();
let invoice = Bolt11Invoice::from_str(&create_response.invoice)
.map_err(|err| PaymentError::invalid_invoice(&err.to_string()))?;
let payer_amount_sat =
invoice
.amount_milli_satoshis()
.ok_or(PaymentError::invalid_invoice(
"Invoice does not contain an amount",
))?
/ 1000;
let destination_pubkey = invoice_pubkey(&invoice);
ensure_sdk!(
invoice.payment_hash().to_string() == preimage_hash,
PaymentError::invalid_invoice("Invalid preimage returned by swapper")
);
let create_response_json = ReceiveSwap::from_boltz_struct_to_json(
&create_response,
&swap_id,
&invoice.to_string(),
)?;
let invoice_description = match invoice.description() {
Bolt11InvoiceDescription::Direct(msg) => Some(msg.to_string()),
Bolt11InvoiceDescription::Hash(_) => None,
};
self.persister
.insert_or_update_receive_swap(&ReceiveSwap {
id: swap_id.clone(),
preimage: preimage_str,
create_response_json,
claim_private_key: keypair.display_secret().to_string(),
invoice: invoice.to_string(),
payment_hash: Some(preimage_hash),
destination_pubkey: Some(destination_pubkey),
timeout_block_height: create_response.timeout_block_height,
description: invoice_description,
payer_amount_sat,
receiver_amount_sat,
pair_fees_json: serde_json::to_string(&reverse_pair).map_err(|e| {
PaymentError::generic(&format!("Failed to serialize ReversePair: {e:?}"))
})?,
claim_fees_sat: reverse_pair.fees.claim_estimate(),
lockup_tx_id: None,
claim_tx_id: None,
mrh_address: mrh_addr_str,
mrh_tx_id: None,
created_at: utils::now(),
state: PaymentState::Created,
version: 0,
})
.map_err(|_| PaymentError::PersistError)?;
self.status_stream.track_swap_id(&swap_id)?;
Ok(ReceivePaymentResponse {
destination: invoice.to_string(),
})
}
async fn create_receive_chain_swap(
&self,
user_lockup_amount_sat: Option<u64>,
fees_sat: u64,
) -> Result<ChainSwap, PaymentError> {
let pair = self.get_and_validate_chain_pair(Direction::Incoming, user_lockup_amount_sat)?;
let claim_fees_sat = pair.fees.claim_estimate();
let server_fees_sat = pair.fees.server();
let service_fees_sat = user_lockup_amount_sat
.map(|user_lockup_amount_sat| pair.fees.boltz(user_lockup_amount_sat))
.unwrap_or_default();
ensure_sdk!(
fees_sat == service_fees_sat + claim_fees_sat + server_fees_sat,
PaymentError::InvalidOrExpiredFees
);
let preimage = Preimage::new();
let preimage_str = preimage.to_string().ok_or(PaymentError::InvalidPreimage)?;
let claim_keypair = utils::generate_keypair();
let claim_public_key = boltz_client::PublicKey {
compressed: true,
inner: claim_keypair.public_key(),
};
let refund_keypair = utils::generate_keypair();
let refund_public_key = boltz_client::PublicKey {
compressed: true,
inner: refund_keypair.public_key(),
};
let webhook = self.persister.get_webhook_url()?.map(|url| Webhook {
url,
hash_swap_id: Some(true),
status: Some(vec![
ChainSwapStates::TransactionFailed,
ChainSwapStates::TransactionLockupFailed,
ChainSwapStates::TransactionServerConfirmed,
]),
});
let create_response = self.swapper.create_chain_swap(CreateChainRequest {
from: "BTC".to_string(),
to: "L-BTC".to_string(),
preimage_hash: preimage.sha256,
claim_public_key: Some(claim_public_key),
refund_public_key: Some(refund_public_key),
user_lock_amount: user_lockup_amount_sat,
server_lock_amount: None,
pair_hash: Some(pair.hash.clone()),
referral_id: None,
webhook,
})?;
let swap_id = create_response.id.clone();
let create_response_json =
ChainSwap::from_boltz_struct_to_json(&create_response, &swap_id)?;
let accept_zero_conf = user_lockup_amount_sat
.map(|user_lockup_amount_sat| user_lockup_amount_sat <= pair.limits.maximal_zero_conf)
.unwrap_or(false);
let receiver_amount_sat = user_lockup_amount_sat
.map(|user_lockup_amount_sat| user_lockup_amount_sat - fees_sat)
.unwrap_or(0);
let swap = ChainSwap {
id: swap_id.clone(),
direction: Direction::Incoming,
claim_address: None,
lockup_address: create_response.lockup_details.lockup_address,
timeout_block_height: create_response.lockup_details.timeout_block_height,
preimage: preimage_str,
description: Some("Bitcoin transfer".to_string()),
payer_amount_sat: user_lockup_amount_sat.unwrap_or(0),
actual_payer_amount_sat: None,
receiver_amount_sat,
accepted_receiver_amount_sat: None,
claim_fees_sat,
pair_fees_json: serde_json::to_string(&pair).map_err(|e| {
PaymentError::generic(&format!("Failed to serialize incoming ChainPair: {e:?}"))
})?,
accept_zero_conf,
create_response_json,
claim_private_key: claim_keypair.display_secret().to_string(),
refund_private_key: refund_keypair.display_secret().to_string(),
server_lockup_tx_id: None,
user_lockup_tx_id: None,
claim_tx_id: None,
refund_tx_id: None,
created_at: utils::now(),
state: PaymentState::Created,
version: 0,
};
self.persister.insert_or_update_chain_swap(&swap)?;
self.status_stream.track_swap_id(&swap.id)?;
Ok(swap)
}
async fn receive_onchain(
&self,
user_lockup_amount_sat: Option<u64>,
fees_sat: u64,
) -> Result<ReceivePaymentResponse, PaymentError> {
self.ensure_is_started().await?;
let swap = self
.create_receive_chain_swap(user_lockup_amount_sat, fees_sat)
.await?;
let create_response = swap.get_boltz_create_response()?;
let address = create_response.lockup_details.lockup_address;
let amount = create_response.lockup_details.amount as f64 / 100_000_000.0;
let bip21 = create_response.lockup_details.bip21.unwrap_or(format!(
"bitcoin:{address}?amount={amount}&label=Send%20to%20L-BTC%20address"
));
Ok(ReceivePaymentResponse { destination: bip21 })
}
pub async fn list_refundables(&self) -> SdkResult<Vec<RefundableSwap>> {
let chain_swaps = self.persister.list_refundable_chain_swaps()?;
let mut lockup_script_pubkeys = vec![];
for swap in &chain_swaps {
let script_pubkey = swap.get_receive_lockup_swap_script_pubkey(self.config.network)?;
lockup_script_pubkeys.push(script_pubkey);
}
let lockup_scripts: Vec<&boltz_client::bitcoin::Script> = lockup_script_pubkeys
.iter()
.map(|s| s.as_script())
.collect();
let scripts_balance = self
.bitcoin_chain_service
.lock()
.await
.scripts_get_balance(&lockup_scripts)?;
let mut refundables = vec![];
for (chain_swap, script_balance) in chain_swaps.into_iter().zip(scripts_balance) {
let swap_id = &chain_swap.id;
let refundable_confirmed_sat = script_balance.confirmed;
info!("Incoming Chain Swap {swap_id} is refundable with {refundable_confirmed_sat} confirmed sats");
let refundable: RefundableSwap = chain_swap.to_refundable(refundable_confirmed_sat);
refundables.push(refundable);
}
Ok(refundables)
}
pub async fn prepare_refund(
&self,
req: &PrepareRefundRequest,
) -> SdkResult<PrepareRefundResponse> {
let (tx_vsize, tx_fee_sat, refund_tx_id) = self
.chain_swap_handler
.prepare_refund(
&req.swap_address,
&req.refund_address,
req.fee_rate_sat_per_vbyte,
)
.await?;
Ok(PrepareRefundResponse {
tx_vsize,
tx_fee_sat,
refund_tx_id,
})
}
pub async fn refund(&self, req: &RefundRequest) -> Result<RefundResponse, PaymentError> {
let refund_tx_id = self
.chain_swap_handler
.refund_incoming_swap(
&req.swap_address,
&req.refund_address,
req.fee_rate_sat_per_vbyte,
true,
)
.or_else(|e| {
warn!("Failed to initiate cooperative refund, switching to non-cooperative: {e:?}");
self.chain_swap_handler.refund_incoming_swap(
&req.swap_address,
&req.refund_address,
req.fee_rate_sat_per_vbyte,
false,
)
})
.await?;
Ok(RefundResponse { refund_tx_id })
}
pub async fn rescan_onchain_swaps(&self) -> SdkResult<()> {
let mut rescannable_swaps: Vec<Swap> = self
.persister
.list_chain_swaps()?
.into_iter()
.map(Into::into)
.collect();
self.recoverer
.recover_from_onchain(&mut rescannable_swaps)
.await?;
for swap in rescannable_swaps {
let swap_id = &swap.id();
if let Swap::Chain(chain_swap) = swap {
if let Err(e) = self.chain_swap_handler.update_swap(chain_swap) {
error!("Error persisting rescanned Chain Swap {swap_id}: {e}");
}
}
}
Ok(())
}
fn validate_buy_bitcoin(&self, amount_sat: u64) -> Result<(), PaymentError> {
ensure_sdk!(
self.config.network == LiquidNetwork::Mainnet,
PaymentError::invalid_network("Can only buy bitcoin on Mainnet")
);
ensure_sdk!(
amount_sat % 1_000 == 0,
PaymentError::generic("Can only buy sat amounts that are multiples of 1000")
);
Ok(())
}
pub async fn prepare_buy_bitcoin(
&self,
req: &PrepareBuyBitcoinRequest,
) -> Result<PrepareBuyBitcoinResponse, PaymentError> {
self.validate_buy_bitcoin(req.amount_sat)?;
let res = self
.prepare_receive_payment(&PrepareReceiveRequest {
payment_method: PaymentMethod::BitcoinAddress,
payer_amount_sat: Some(req.amount_sat),
})
.await?;
let Some(amount_sat) = res.payer_amount_sat else {
return Err(PaymentError::Generic {
err: format!(
"Expected field `amount_sat` from response, got {:?}",
res.payer_amount_sat
),
});
};
Ok(PrepareBuyBitcoinResponse {
provider: req.provider,
amount_sat,
fees_sat: res.fees_sat,
})
}
pub async fn buy_bitcoin(&self, req: &BuyBitcoinRequest) -> Result<String, PaymentError> {
self.validate_buy_bitcoin(req.prepare_response.amount_sat)?;
let swap = self
.create_receive_chain_swap(
Some(req.prepare_response.amount_sat),
req.prepare_response.fees_sat,
)
.await?;
Ok(self
.buy_bitcoin_service
.buy_bitcoin(
req.prepare_response.provider,
&swap,
req.redirect_url.clone(),
)
.await?)
}
pub(crate) async fn get_monitored_swaps_list(&self, partial_sync: bool) -> Result<Vec<Swap>> {
let receive_swaps = self
.persister
.list_recoverable_receive_swaps()?
.into_iter()
.map(Into::into)
.collect();
match partial_sync {
false => {
let bitcoin_height = self.bitcoin_chain_service.lock().await.tip()?.height as u32;
let liquid_height = self.liquid_chain_service.lock().await.tip().await?;
let final_swap_states = [PaymentState::Complete, PaymentState::Failed];
let send_swaps = self
.persister
.list_recoverable_send_swaps()?
.into_iter()
.map(Into::into)
.collect();
let chain_swaps: Vec<Swap> = self
.persister
.list_chain_swaps()?
.into_iter()
.filter(|swap| match swap.direction {
Direction::Incoming => {
bitcoin_height
<= swap.timeout_block_height
+ CHAIN_SWAP_MONITORING_PERIOD_BITCOIN_BLOCKS
}
Direction::Outgoing => {
!final_swap_states.contains(&swap.state)
&& liquid_height <= swap.timeout_block_height
}
})
.map(Into::into)
.collect();
Ok([receive_swaps, send_swaps, chain_swaps].concat())
}
true => Ok(receive_swaps),
}
}
async fn sync_payments_with_chain_data(&self, partial_sync: bool) -> Result<()> {
let mut recoverable_swaps = self.get_monitored_swaps_list(partial_sync).await?;
self.recoverer
.recover_from_onchain(&mut recoverable_swaps)
.await?;
let mut tx_map = self.onchain_wallet.transactions_by_tx_id().await?;
for swap in recoverable_swaps {
let swap_id = &swap.id();
match swap {
Swap::Receive(receive_swap) => {
let history_updates = vec![&receive_swap.claim_tx_id, &receive_swap.mrh_tx_id];
for tx_id in history_updates
.into_iter()
.flatten()
.collect::<Vec<&String>>()
{
if let Some(tx) =
tx_map.remove(&lwk_wollet::elements::Txid::from_str(tx_id)?)
{
self.persister
.insert_or_update_payment_with_wallet_tx(&tx)?;
}
}
if let Err(e) = self.receive_swap_handler.update_swap(receive_swap) {
error!("Error persisting recovered receive swap {swap_id}: {e}");
}
}
Swap::Send(send_swap) => {
let history_updates = vec![&send_swap.lockup_tx_id, &send_swap.refund_tx_id];
for tx_id in history_updates
.into_iter()
.flatten()
.collect::<Vec<&String>>()
{
if let Some(tx) =
tx_map.remove(&lwk_wollet::elements::Txid::from_str(tx_id)?)
{
self.persister
.insert_or_update_payment_with_wallet_tx(&tx)?;
}
}
if let Err(e) = self.send_swap_handler.update_swap(send_swap) {
error!("Error persisting recovered send swap {swap_id}: {e}");
}
}
Swap::Chain(chain_swap) => {
let history_updates = match chain_swap.direction {
Direction::Incoming => vec![&chain_swap.claim_tx_id],
Direction::Outgoing => {
vec![&chain_swap.user_lockup_tx_id, &chain_swap.refund_tx_id]
}
};
for tx_id in history_updates
.into_iter()
.flatten()
.collect::<Vec<&String>>()
{
if let Some(tx) =
tx_map.remove(&lwk_wollet::elements::Txid::from_str(tx_id)?)
{
self.persister
.insert_or_update_payment_with_wallet_tx(&tx)?;
}
}
if let Err(e) = self.chain_swap_handler.update_swap(chain_swap) {
error!("Error persisting recovered Chain Swap {swap_id}: {e}");
}
}
};
}
let payments = self
.persister
.get_payments_by_tx_id(&ListPaymentsRequest::default())?;
let unconfirmed_payment_txs_data = self.persister.list_unconfirmed_payment_txs_data()?;
let unconfirmed_txs_by_id: HashMap<String, PaymentTxData> = unconfirmed_payment_txs_data
.into_iter()
.map(|tx| (tx.tx_id.clone(), tx))
.collect::<HashMap<String, PaymentTxData>>();
for tx in tx_map.values() {
let tx_id = tx.txid.to_string();
let maybe_payment = payments.get(&tx_id);
let mut updated = false;
match maybe_payment {
None
| Some(Payment {
details: PaymentDetails::Liquid { .. },
..
}) => {
let updated_needed = maybe_payment.map_or(true, |payment| {
payment.status == Pending && tx.height.is_some()
});
if updated_needed {
self.persister.insert_or_update_payment_with_wallet_tx(tx)?;
self.emit_payment_updated(Some(tx_id.clone())).await?;
updated = true
}
}
_ => {}
}
if !updated && unconfirmed_txs_by_id.contains_key(&tx_id) && tx.height.is_some() {
self.persister.insert_or_update_payment_with_wallet_tx(tx)?;
}
}
self.update_wallet_info().await?;
Ok(())
}
async fn update_wallet_info(&self) -> Result<()> {
let transactions = self.onchain_wallet.transactions().await?;
let mut wallet_amount_sat: i64 = 0;
transactions.into_iter().for_each(|tx| {
tx.balance.into_iter().for_each(|(asset_id, balance)| {
if asset_id == utils::lbtc_asset_id(self.config.network) {
if tx.height.is_some() || balance < 0 {
wallet_amount_sat += balance;
}
}
})
});
debug!("Onchain wallet balance: {wallet_amount_sat} sats");
let mut pending_send_sat = 0;
let mut pending_receive_sat = 0;
let payments = self.persister.get_payments(&ListPaymentsRequest {
states: Some(vec![
PaymentState::Pending,
PaymentState::RefundPending,
PaymentState::WaitingFeeAcceptance,
]),
..Default::default()
})?;
for payment in payments {
match payment.payment_type {
PaymentType::Send => match payment.details.get_refund_tx_amount_sat() {
Some(refund_tx_amount_sat) => pending_receive_sat += refund_tx_amount_sat,
None => pending_send_sat += payment.amount_sat,
},
PaymentType::Receive => pending_receive_sat += payment.amount_sat,
}
}
let info_response = WalletInfo {
balance_sat: wallet_amount_sat as u64,
pending_send_sat,
pending_receive_sat,
fingerprint: self.onchain_wallet.fingerprint()?,
pubkey: self.onchain_wallet.pubkey()?,
};
self.persister.set_wallet_info(&info_response)
}
pub async fn list_payments(
&self,
req: &ListPaymentsRequest,
) -> Result<Vec<Payment>, PaymentError> {
self.ensure_is_started().await?;
Ok(self.persister.get_payments(req)?)
}
pub async fn get_payment(
&self,
req: &GetPaymentRequest,
) -> Result<Option<Payment>, PaymentError> {
self.ensure_is_started().await?;
Ok(self.persister.get_payment_by_request(req)?)
}
pub async fn fetch_payment_proposed_fees(
&self,
req: &FetchPaymentProposedFeesRequest,
) -> SdkResult<FetchPaymentProposedFeesResponse> {
let chain_swap =
self.persister
.fetch_chain_swap_by_id(&req.swap_id)?
.ok_or(SdkError::Generic {
err: format!("Could not find Swap {}", req.swap_id),
})?;
ensure_sdk!(
chain_swap.state == WaitingFeeAcceptance,
SdkError::Generic {
err: "Payment is not WaitingFeeAcceptance".to_string()
}
);
let server_lockup_quote = self
.swapper
.get_zero_amount_chain_swap_quote(&req.swap_id)?;
let actual_payer_amount_sat =
chain_swap
.actual_payer_amount_sat
.ok_or(SdkError::Generic {
err: "No actual payer amount found when state is WaitingFeeAcceptance"
.to_string(),
})?;
let fees_sat =
actual_payer_amount_sat - server_lockup_quote.to_sat() + chain_swap.claim_fees_sat;
Ok(FetchPaymentProposedFeesResponse {
swap_id: req.swap_id.clone(),
fees_sat,
payer_amount_sat: actual_payer_amount_sat,
receiver_amount_sat: actual_payer_amount_sat - fees_sat,
})
}
pub async fn accept_payment_proposed_fees(
&self,
req: &AcceptPaymentProposedFeesRequest,
) -> Result<(), PaymentError> {
let FetchPaymentProposedFeesResponse {
swap_id,
fees_sat,
payer_amount_sat,
..
} = req.clone().response;
let chain_swap =
self.persister
.fetch_chain_swap_by_id(&swap_id)?
.ok_or(SdkError::Generic {
err: format!("Could not find Swap {}", swap_id),
})?;
ensure_sdk!(
chain_swap.state == WaitingFeeAcceptance,
PaymentError::Generic {
err: "Payment is not WaitingFeeAcceptance".to_string()
}
);
let server_lockup_quote = self.swapper.get_zero_amount_chain_swap_quote(&swap_id)?;
ensure_sdk!(
fees_sat == payer_amount_sat - server_lockup_quote.to_sat() + chain_swap.claim_fees_sat,
PaymentError::InvalidOrExpiredFees
);
self.persister
.update_accepted_receiver_amount(&swap_id, Some(payer_amount_sat - fees_sat))?;
self.swapper
.accept_zero_amount_chain_swap_quote(&swap_id, server_lockup_quote.to_sat())
.inspect_err(|e| {
error!("Failed to accept zero-amount swap {swap_id} quote: {e} - trying to erase the accepted receiver amount...");
let _ = self
.persister
.update_accepted_receiver_amount(&swap_id, None);
})?;
self.chain_swap_handler.update_swap_info(&ChainSwapUpdate {
swap_id,
to_state: Pending,
..Default::default()
})
}
pub fn empty_wallet_cache(&self) -> Result<()> {
let mut path = PathBuf::from(self.config.working_dir.clone());
path.push(Into::<ElementsNetwork>::into(self.config.network).as_str());
path.push("enc_cache");
fs::remove_dir_all(&path)?;
fs::create_dir_all(path)?;
Ok(())
}
pub async fn sync(&self, partial_sync: bool) -> SdkResult<()> {
self.ensure_is_started().await?;
let t0 = Instant::now();
let is_first_sync = !self
.persister
.get_is_first_sync_complete()?
.unwrap_or(false);
match is_first_sync {
true => {
self.event_manager.pause_notifications();
self.sync_payments_with_chain_data(partial_sync).await?;
self.event_manager.resume_notifications();
self.persister.set_is_first_sync_complete(true)?;
}
false => {
self.sync_payments_with_chain_data(partial_sync).await?;
}
}
let duration_ms = Instant::now().duration_since(t0).as_millis();
info!("Synchronized (partial: {partial_sync}) with mempool and onchain data ({duration_ms} ms)");
self.notify_event_listeners(SdkEvent::Synced).await?;
Ok(())
}
pub fn backup(&self, req: BackupRequest) -> Result<()> {
let backup_path = req
.backup_path
.map(PathBuf::from)
.unwrap_or(self.persister.get_default_backup_path());
self.persister.backup(backup_path)
}
pub fn restore(&self, req: RestoreRequest) -> Result<()> {
let backup_path = req
.backup_path
.map(PathBuf::from)
.unwrap_or(self.persister.get_default_backup_path());
ensure_sdk!(
backup_path.exists(),
SdkError::generic("Backup file does not exist").into()
);
self.persister.restore_from_backup(backup_path)
}
pub async fn prepare_lnurl_pay(
&self,
req: PrepareLnUrlPayRequest,
) -> Result<PrepareLnUrlPayResponse, LnUrlPayError> {
match validate_lnurl_pay(
req.amount_msat,
&req.comment,
&req.data,
self.config.network.into(),
req.validate_success_action_url,
)
.await?
{
ValidatedCallbackResponse::EndpointError { data } => {
Err(LnUrlPayError::Generic { err: data.reason })
}
ValidatedCallbackResponse::EndpointSuccess { data } => {
let prepare_response = self
.prepare_send_payment(&PrepareSendRequest {
destination: data.pr.clone(),
amount: None,
})
.await
.map_err(|e| LnUrlPayError::Generic { err: e.to_string() })?;
Ok(PrepareLnUrlPayResponse {
destination: prepare_response.destination,
fees_sat: prepare_response.fees_sat,
data: req.data,
comment: req.comment,
success_action: data.success_action,
})
}
}
}
pub async fn lnurl_pay(
&self,
req: model::LnUrlPayRequest,
) -> Result<LnUrlPayResult, LnUrlPayError> {
let prepare_response = req.prepare_response;
let payment = self
.send_payment(&SendPaymentRequest {
prepare_response: PrepareSendResponse {
destination: prepare_response.destination,
fees_sat: prepare_response.fees_sat,
},
})
.await
.map_err(|e| LnUrlPayError::Generic { err: e.to_string() })?
.payment;
let maybe_sa_processed: Option<SuccessActionProcessed> = match prepare_response
.success_action
.clone()
{
Some(sa) => {
match sa {
SuccessAction::Aes { data } => {
let PaymentDetails::Lightning {
swap_id, preimage, ..
} = &payment.details
else {
return Err(LnUrlPayError::Generic {
err: format!("Invalid payment type: expected type `PaymentDetails::Lightning`, got payment details {:?}.", payment.details),
});
};
match preimage {
Some(preimage_str) => {
debug!(
"Decrypting AES success action with preimage for Send Swap {}",
swap_id
);
let preimage =
sha256::Hash::from_str(preimage_str).map_err(|_| {
LnUrlPayError::Generic {
err: "Invalid preimage".to_string(),
}
})?;
let preimage_arr = preimage.to_byte_array();
let result = match (data, &preimage_arr).try_into() {
Ok(data) => AesSuccessActionDataResult::Decrypted { data },
Err(e) => AesSuccessActionDataResult::ErrorStatus {
reason: e.to_string(),
},
};
Some(SuccessActionProcessed::Aes { result })
}
None => {
debug!("Preimage not yet available to decrypt AES success action for Send Swap {}", swap_id);
None
}
}
}
SuccessAction::Message { data } => {
Some(SuccessActionProcessed::Message { data })
}
SuccessAction::Url { data } => Some(SuccessActionProcessed::Url { data }),
}
}
None => None,
};
let lnurl_pay_domain = match prepare_response.data.ln_address {
Some(_) => None,
None => Some(prepare_response.data.domain),
};
if let (Some(tx_id), Some(destination)) =
(payment.tx_id.clone(), payment.destination.clone())
{
self.persister
.insert_or_update_payment_details(PaymentTxDetails {
tx_id,
destination,
description: prepare_response.comment.clone(),
lnurl_info: Some(LnUrlInfo {
ln_address: prepare_response.data.ln_address,
lnurl_pay_comment: prepare_response.comment,
lnurl_pay_domain,
lnurl_pay_metadata: Some(prepare_response.data.metadata_str),
lnurl_pay_success_action: maybe_sa_processed.clone(),
lnurl_pay_unprocessed_success_action: prepare_response.success_action,
lnurl_withdraw_endpoint: None,
}),
})?;
}
Ok(LnUrlPayResult::EndpointSuccess {
data: model::LnUrlPaySuccessData {
payment,
success_action: maybe_sa_processed,
},
})
}
pub async fn lnurl_withdraw(
&self,
req: LnUrlWithdrawRequest,
) -> Result<LnUrlWithdrawResult, LnUrlWithdrawError> {
let prepare_response = self
.prepare_receive_payment(&{
PrepareReceiveRequest {
payment_method: PaymentMethod::Lightning,
payer_amount_sat: Some(req.amount_msat / 1_000),
}
})
.await?;
let receive_res = self
.receive_payment(&ReceivePaymentRequest {
prepare_response,
description: req.description.clone(),
use_description_hash: Some(false),
})
.await?;
let Ok(invoice) = parse_invoice(&receive_res.destination) else {
return Err(LnUrlWithdrawError::Generic {
err: "Received unexpected output from receive request".to_string(),
});
};
let res = validate_lnurl_withdraw(req.data.clone(), invoice.clone()).await?;
if let LnUrlWithdrawResult::Ok { data: _ } = res {
if let Some(ReceiveSwap {
claim_tx_id: Some(tx_id),
..
}) = self
.persister
.fetch_receive_swap_by_invoice(&invoice.bolt11)?
{
self.persister
.insert_or_update_payment_details(PaymentTxDetails {
tx_id,
destination: receive_res.destination,
description: req.description,
lnurl_info: Some(LnUrlInfo {
lnurl_withdraw_endpoint: Some(req.data.callback),
..Default::default()
}),
})?;
}
}
Ok(res)
}
pub async fn lnurl_auth(
&self,
req_data: LnUrlAuthRequestData,
) -> Result<LnUrlCallbackStatus, LnUrlAuthError> {
Ok(perform_lnurl_auth(&req_data, &SdkLnurlAuthSigner::new(self.signer.clone())).await?)
}
pub async fn register_webhook(&self, webhook_url: String) -> SdkResult<()> {
info!("Registering for webhook notifications");
self.persister.set_webhook_url(webhook_url)?;
Ok(())
}
pub async fn unregister_webhook(&self) -> SdkResult<()> {
info!("Unregistering for webhook notifications");
self.persister.remove_webhook_url()?;
Ok(())
}
pub async fn fetch_fiat_rates(&self) -> Result<Vec<Rate>, SdkError> {
self.fiat_api.fetch_fiat_rates().await.map_err(Into::into)
}
pub async fn list_fiat_currencies(&self) -> Result<Vec<FiatCurrency>, SdkError> {
self.fiat_api
.list_fiat_currencies()
.await
.map_err(Into::into)
}
pub async fn recommended_fees(&self) -> Result<RecommendedFees, SdkError> {
Ok(self
.bitcoin_chain_service
.lock()
.await
.recommended_fees()
.await?)
}
pub fn default_config(
network: LiquidNetwork,
breez_api_key: Option<String>,
) -> Result<Config, SdkError> {
let config = match network {
LiquidNetwork::Mainnet => Config::mainnet(breez_api_key),
LiquidNetwork::Testnet => Config::testnet(breez_api_key),
};
Ok(config)
}
pub async fn parse(&self, input: &str) -> Result<InputType, PaymentError> {
let external_parsers = &self.external_input_parsers;
parse(input, Some(external_parsers))
.await
.map_err(|e| PaymentError::generic(&e.to_string()))
}
pub fn parse_invoice(input: &str) -> Result<LNInvoice, PaymentError> {
parse_invoice(input).map_err(|e| PaymentError::invalid_invoice(&e.to_string()))
}
pub fn init_logging(log_dir: &str, app_logger: Option<Box<dyn log::Log>>) -> Result<()> {
crate::logger::init_logging(log_dir, app_logger)
}
}
#[cfg(test)]
mod tests {
use std::{str::FromStr, sync::Arc};
use anyhow::{anyhow, Result};
use boltz_client::{
boltz::{self, SwapUpdateTxDetails},
swaps::boltz::{ChainSwapStates, RevSwapStates, SubSwapStates},
};
use lwk_wollet::{elements::Txid, hashes::hex::DisplayHex};
use tokio::sync::Mutex;
use crate::chain_swap::ESTIMATED_BTC_LOCKUP_TX_VSIZE;
use crate::test_utils::chain_swap::{
TEST_BITCOIN_OUTGOING_SERVER_LOCKUP_TX, TEST_LIQUID_INCOMING_SERVER_LOCKUP_TX,
TEST_LIQUID_OUTGOING_USER_LOCKUP_TX,
};
use crate::test_utils::swapper::ZeroAmountSwapMockConfig;
use crate::{
model::{Direction, PaymentState, Swap},
sdk::LiquidSdk,
test_utils::{
chain::{MockBitcoinChainService, MockHistory, MockLiquidChainService},
chain_swap::{new_chain_swap, TEST_BITCOIN_INCOMING_USER_LOCKUP_TX},
persist::{create_persister, new_receive_swap, new_send_swap},
sdk::{new_liquid_sdk, new_liquid_sdk_with_chain_services},
status_stream::MockStatusStream,
swapper::MockSwapper,
wallet::TEST_LIQUID_TX,
},
};
use paste::paste;
struct NewSwapArgs {
direction: Direction,
accepts_zero_conf: bool,
initial_payment_state: Option<PaymentState>,
user_lockup_tx_id: Option<String>,
zero_amount: bool,
set_actual_payer_amount: bool,
}
impl Default for NewSwapArgs {
fn default() -> Self {
Self {
accepts_zero_conf: false,
initial_payment_state: None,
direction: Direction::Outgoing,
user_lockup_tx_id: None,
zero_amount: false,
set_actual_payer_amount: false,
}
}
}
impl NewSwapArgs {
pub fn set_direction(mut self, direction: Direction) -> Self {
self.direction = direction;
self
}
pub fn set_accepts_zero_conf(mut self, accepts_zero_conf: bool) -> Self {
self.accepts_zero_conf = accepts_zero_conf;
self
}
pub fn set_user_lockup_tx_id(mut self, user_lockup_tx_id: Option<String>) -> Self {
self.user_lockup_tx_id = user_lockup_tx_id;
self
}
pub fn set_initial_payment_state(mut self, payment_state: PaymentState) -> Self {
self.initial_payment_state = Some(payment_state);
self
}
pub fn set_zero_amount(mut self, zero_amount: bool) -> Self {
self.zero_amount = zero_amount;
self
}
pub fn set_set_actual_payer_amount(mut self, set_actual_payer_amount: bool) -> Self {
self.set_actual_payer_amount = set_actual_payer_amount;
self
}
}
macro_rules! trigger_swap_update {
(
$type:literal,
$args:expr,
$persister:expr,
$status_stream:expr,
$status:expr,
$transaction:expr,
$zero_conf_rejected:expr
) => {{
let swap = match $type {
"chain" => {
let swap = new_chain_swap(
$args.direction,
$args.initial_payment_state,
$args.accepts_zero_conf,
$args.user_lockup_tx_id,
$args.zero_amount,
$args.set_actual_payer_amount,
);
$persister.insert_or_update_chain_swap(&swap).unwrap();
Swap::Chain(swap)
}
"send" => {
let swap = new_send_swap($args.initial_payment_state);
$persister.insert_or_update_send_swap(&swap).unwrap();
Swap::Send(swap)
}
"receive" => {
let swap = new_receive_swap($args.initial_payment_state);
$persister.insert_or_update_receive_swap(&swap).unwrap();
Swap::Receive(swap)
}
_ => panic!(),
};
$status_stream
.clone()
.send_mock_update(boltz::Update {
id: swap.id(),
status: $status.to_string(),
transaction: $transaction,
zero_conf_rejected: $zero_conf_rejected,
})
.await
.unwrap();
paste! {
$persister.[<fetch _ $type _swap_by_id>](&swap.id())
.unwrap()
.ok_or(anyhow!("Could not retrieve {} swap", $type))
.unwrap()
}
}};
}
#[tokio::test]
async fn test_receive_swap_update_tracking() -> Result<()> {
create_persister!(persister);
let swapper = Arc::new(MockSwapper::default());
let status_stream = Arc::new(MockStatusStream::new());
let sdk = Arc::new(new_liquid_sdk(
persister.clone(),
swapper.clone(),
status_stream.clone(),
)?);
LiquidSdk::track_swap_updates(&sdk).await;
tokio::spawn(async move {
let unrecoverable_states: [RevSwapStates; 4] = [
RevSwapStates::SwapExpired,
RevSwapStates::InvoiceExpired,
RevSwapStates::TransactionFailed,
RevSwapStates::TransactionRefunded,
];
for status in unrecoverable_states {
let persisted_swap = trigger_swap_update!(
"receive",
NewSwapArgs::default(),
persister,
status_stream,
status,
None,
None
);
assert_eq!(persisted_swap.state, PaymentState::Failed);
}
for status in [
RevSwapStates::TransactionMempool,
RevSwapStates::TransactionConfirmed,
] {
let mock_tx = TEST_LIQUID_TX.clone();
let persisted_swap = trigger_swap_update!(
"receive",
NewSwapArgs::default(),
persister,
status_stream,
status,
Some(SwapUpdateTxDetails {
id: mock_tx.txid().to_string(),
hex: lwk_wollet::elements::encode::serialize(&mock_tx)
.to_lower_hex_string(),
}),
None
);
assert!(persisted_swap.claim_tx_id.is_some());
}
})
.await
.unwrap();
Ok(())
}
#[tokio::test]
async fn test_send_swap_update_tracking() -> Result<()> {
create_persister!(persister);
let swapper = Arc::new(MockSwapper::default());
let status_stream = Arc::new(MockStatusStream::new());
let sdk = Arc::new(new_liquid_sdk(
persister.clone(),
swapper.clone(),
status_stream.clone(),
)?);
LiquidSdk::track_swap_updates(&sdk).await;
tokio::spawn(async move {
let unrecoverable_states: [SubSwapStates; 3] = [
SubSwapStates::TransactionLockupFailed,
SubSwapStates::InvoiceFailedToPay,
SubSwapStates::SwapExpired,
];
for status in unrecoverable_states {
let persisted_swap = trigger_swap_update!(
"send",
NewSwapArgs::default(),
persister,
status_stream,
status,
None,
None
);
assert_eq!(persisted_swap.state, PaymentState::Failed);
}
let persisted_swap = trigger_swap_update!(
"send",
NewSwapArgs::default(),
persister,
status_stream,
SubSwapStates::TransactionClaimPending,
None,
None
);
assert_eq!(persisted_swap.state, PaymentState::Complete);
assert!(persisted_swap.preimage.is_some());
})
.await
.unwrap();
Ok(())
}
#[tokio::test]
async fn test_chain_swap_update_tracking() -> Result<()> {
create_persister!(persister);
let swapper = Arc::new(MockSwapper::default());
let status_stream = Arc::new(MockStatusStream::new());
let liquid_chain_service = Arc::new(Mutex::new(MockLiquidChainService::new()));
let bitcoin_chain_service = Arc::new(Mutex::new(MockBitcoinChainService::new()));
let sdk = Arc::new(new_liquid_sdk_with_chain_services(
persister.clone(),
swapper.clone(),
status_stream.clone(),
liquid_chain_service.clone(),
bitcoin_chain_service.clone(),
None,
)?);
LiquidSdk::track_swap_updates(&sdk).await;
tokio::spawn(async move {
let trigger_failed: [ChainSwapStates; 3] = [
ChainSwapStates::TransactionFailed,
ChainSwapStates::SwapExpired,
ChainSwapStates::TransactionRefunded,
];
for direction in [Direction::Incoming, Direction::Outgoing] {
for status in &trigger_failed {
let persisted_swap = trigger_swap_update!(
"chain",
NewSwapArgs::default().set_direction(direction),
persister,
status_stream,
status,
None,
None
);
assert_eq!(persisted_swap.state, PaymentState::Failed);
}
let (mock_user_lockup_tx_hex, mock_user_lockup_tx_id) = match direction {
Direction::Outgoing => {
let tx = TEST_LIQUID_OUTGOING_USER_LOCKUP_TX.clone();
(
lwk_wollet::elements::encode::serialize(&tx).to_lower_hex_string(),
tx.txid().to_string(),
)
}
Direction::Incoming => {
let tx = TEST_BITCOIN_INCOMING_USER_LOCKUP_TX.clone();
(
sdk_common::bitcoin::consensus::serialize(&tx).to_lower_hex_string(),
tx.txid().to_string(),
)
}
};
let (mock_server_lockup_tx_hex, mock_server_lockup_tx_id) = match direction {
Direction::Incoming => {
let tx = TEST_LIQUID_INCOMING_SERVER_LOCKUP_TX.clone();
(
lwk_wollet::elements::encode::serialize(&tx).to_lower_hex_string(),
tx.txid().to_string(),
)
}
Direction::Outgoing => {
let tx = TEST_BITCOIN_OUTGOING_SERVER_LOCKUP_TX.clone();
(
sdk_common::bitcoin::consensus::serialize(&tx).to_lower_hex_string(),
tx.txid().to_string(),
)
}
};
for user_lockup_tx_id in &[None, Some(mock_user_lockup_tx_id.clone())] {
if let Some(user_lockup_tx_id) = user_lockup_tx_id {
match direction {
Direction::Incoming => {
bitcoin_chain_service
.lock()
.await
.set_history(vec![MockHistory {
txid: Txid::from_str(user_lockup_tx_id).unwrap(),
height: 0,
block_hash: None,
block_timestamp: None,
}]);
}
Direction::Outgoing => {
liquid_chain_service
.lock()
.await
.set_history(vec![MockHistory {
txid: Txid::from_str(user_lockup_tx_id).unwrap(),
height: 0,
block_hash: None,
block_timestamp: None,
}]);
}
}
}
let persisted_swap = trigger_swap_update!(
"chain",
NewSwapArgs::default()
.set_direction(direction)
.set_initial_payment_state(PaymentState::Pending)
.set_user_lockup_tx_id(user_lockup_tx_id.clone()),
persister,
status_stream,
ChainSwapStates::TransactionLockupFailed,
None,
None
);
let expected_state = if user_lockup_tx_id.is_some() {
match direction {
Direction::Incoming => PaymentState::Refundable,
Direction::Outgoing => PaymentState::RefundPending,
}
} else {
PaymentState::Failed
};
assert_eq!(persisted_swap.state, expected_state);
}
for status in [
ChainSwapStates::TransactionMempool,
ChainSwapStates::TransactionConfirmed,
] {
if direction == Direction::Incoming {
bitcoin_chain_service
.lock()
.await
.set_history(vec![MockHistory {
txid: Txid::from_str(&mock_user_lockup_tx_id).unwrap(),
height: 0,
block_hash: None,
block_timestamp: None,
}]);
bitcoin_chain_service
.lock()
.await
.set_transactions(&[&mock_user_lockup_tx_hex]);
}
let persisted_swap = trigger_swap_update!(
"chain",
NewSwapArgs::default().set_direction(direction),
persister,
status_stream,
status,
Some(SwapUpdateTxDetails {
id: mock_user_lockup_tx_id.clone(),
hex: mock_user_lockup_tx_hex.clone(),
}), Some(true) );
assert_eq!(
persisted_swap.user_lockup_tx_id,
Some(mock_user_lockup_tx_id.clone())
);
assert!(!persisted_swap.accept_zero_conf);
}
for accepts_zero_conf in [false, true] {
let persisted_swap = trigger_swap_update!(
"chain",
NewSwapArgs::default()
.set_direction(direction)
.set_accepts_zero_conf(accepts_zero_conf)
.set_set_actual_payer_amount(true),
persister,
status_stream,
ChainSwapStates::TransactionServerMempool,
Some(SwapUpdateTxDetails {
id: mock_server_lockup_tx_id.clone(),
hex: mock_server_lockup_tx_hex.clone(),
}),
None
);
match accepts_zero_conf {
false => {
assert_eq!(persisted_swap.state, PaymentState::Pending);
assert!(persisted_swap.server_lockup_tx_id.is_some());
}
true => {
assert_eq!(persisted_swap.state, PaymentState::Pending);
assert!(persisted_swap.claim_tx_id.is_some());
}
};
}
let persisted_swap = trigger_swap_update!(
"chain",
NewSwapArgs::default()
.set_direction(direction)
.set_set_actual_payer_amount(true),
persister,
status_stream,
ChainSwapStates::TransactionServerConfirmed,
Some(SwapUpdateTxDetails {
id: mock_server_lockup_tx_id,
hex: mock_server_lockup_tx_hex,
}),
None
);
assert_eq!(persisted_swap.state, PaymentState::Pending);
assert!(persisted_swap.claim_tx_id.is_some());
}
let persisted_swap = trigger_swap_update!(
"chain",
NewSwapArgs::default().set_direction(Direction::Outgoing),
persister,
status_stream,
ChainSwapStates::Created,
None,
None
);
assert_eq!(persisted_swap.state, PaymentState::Pending);
assert!(persisted_swap.user_lockup_tx_id.is_some());
})
.await
.unwrap();
Ok(())
}
#[tokio::test]
async fn test_zero_amount_chain_swap_zero_leeway() -> Result<()> {
let user_lockup_sat = 50_000;
create_persister!(persister);
let swapper = Arc::new(MockSwapper::new());
let status_stream = Arc::new(MockStatusStream::new());
let liquid_chain_service = Arc::new(Mutex::new(MockLiquidChainService::new()));
let bitcoin_chain_service = Arc::new(Mutex::new(MockBitcoinChainService::new()));
let sdk = Arc::new(new_liquid_sdk_with_chain_services(
persister.clone(),
swapper.clone(),
status_stream.clone(),
liquid_chain_service.clone(),
bitcoin_chain_service.clone(),
None,
)?);
LiquidSdk::track_swap_updates(&sdk).await;
tokio::spawn(async move {
for fee_increase in [0, 1] {
swapper.set_zero_amount_swap_mock_config(ZeroAmountSwapMockConfig {
user_lockup_sat,
onchain_fee_increase_sat: fee_increase,
});
bitcoin_chain_service
.lock()
.await
.set_script_balance_sat(user_lockup_sat);
let persisted_swap = trigger_swap_update!(
"chain",
NewSwapArgs::default()
.set_direction(Direction::Incoming)
.set_accepts_zero_conf(false)
.set_zero_amount(true),
persister,
status_stream,
ChainSwapStates::TransactionLockupFailed,
None,
None
);
match fee_increase {
0 => {
assert_eq!(persisted_swap.state, PaymentState::Created);
}
1 => {
assert_eq!(persisted_swap.state, PaymentState::WaitingFeeAcceptance);
}
_ => panic!("Unexpected fee_increase"),
}
}
})
.await?;
Ok(())
}
#[tokio::test]
async fn test_zero_amount_chain_swap_with_leeway() -> Result<()> {
let user_lockup_sat = 50_000;
let onchain_fee_rate_leeway_sat_per_vbyte = 5;
create_persister!(persister);
let swapper = Arc::new(MockSwapper::new());
let status_stream = Arc::new(MockStatusStream::new());
let liquid_chain_service = Arc::new(Mutex::new(MockLiquidChainService::new()));
let bitcoin_chain_service = Arc::new(Mutex::new(MockBitcoinChainService::new()));
let sdk = Arc::new(new_liquid_sdk_with_chain_services(
persister.clone(),
swapper.clone(),
status_stream.clone(),
liquid_chain_service.clone(),
bitcoin_chain_service.clone(),
Some(onchain_fee_rate_leeway_sat_per_vbyte),
)?);
LiquidSdk::track_swap_updates(&sdk).await;
let max_fee_increase_for_auto_accept_sat =
onchain_fee_rate_leeway_sat_per_vbyte as u64 * ESTIMATED_BTC_LOCKUP_TX_VSIZE;
tokio::spawn(async move {
for fee_increase in [
max_fee_increase_for_auto_accept_sat,
max_fee_increase_for_auto_accept_sat + 1,
] {
swapper.set_zero_amount_swap_mock_config(ZeroAmountSwapMockConfig {
user_lockup_sat,
onchain_fee_increase_sat: fee_increase,
});
bitcoin_chain_service
.lock()
.await
.set_script_balance_sat(user_lockup_sat);
let persisted_swap = trigger_swap_update!(
"chain",
NewSwapArgs::default()
.set_direction(Direction::Incoming)
.set_accepts_zero_conf(false)
.set_zero_amount(true),
persister,
status_stream,
ChainSwapStates::TransactionLockupFailed,
None,
None
);
match fee_increase {
val if val == max_fee_increase_for_auto_accept_sat => {
assert_eq!(persisted_swap.state, PaymentState::Created);
}
val if val == (max_fee_increase_for_auto_accept_sat + 1) => {
assert_eq!(persisted_swap.state, PaymentState::WaitingFeeAcceptance);
}
_ => panic!("Unexpected fee_increase"),
}
}
})
.await?;
Ok(())
}
}