mirror of
https://github.com/aljazceru/breez-sdk-liquid.git
synced 2026-01-26 01:14:21 +01:00
1043 lines
38 KiB
Rust
1043 lines
38 KiB
Rust
use std::{
|
|
fs,
|
|
path::PathBuf,
|
|
str::FromStr,
|
|
sync::{Arc, Mutex},
|
|
thread::sleep,
|
|
time::Duration,
|
|
};
|
|
|
|
use anyhow::{anyhow, Result};
|
|
use boltz_client::{
|
|
network::electrum::ElectrumConfig,
|
|
swaps::{
|
|
boltz::{RevSwapStates, SubSwapStates},
|
|
boltzv2::*,
|
|
liquidv2::LBtcSwapTxV2,
|
|
},
|
|
util::secrets::{LiquidSwapKey, Preimage, SwapKey},
|
|
Amount, Bolt11Invoice, Keypair, LBtcSwapScriptV2, SwapType,
|
|
};
|
|
use elements::hashes::hex::DisplayHex;
|
|
use log::{debug, info, warn};
|
|
use lwk_common::{singlesig_desc, Signer, Singlesig};
|
|
use lwk_signer::{AnySigner, SwSigner};
|
|
use lwk_wollet::{
|
|
elements::{Address, Transaction},
|
|
full_scan_with_electrum_client, BlockchainBackend, ElectrumClient, ElectrumUrl,
|
|
ElementsNetwork, FsPersister, Wollet as LwkWollet, WolletDescriptor,
|
|
};
|
|
|
|
use crate::{
|
|
boltz_status_stream::BoltzStatusStream, ensure_sdk, error::PaymentError, get_invoice_amount,
|
|
model::*, persist::Persister, utils,
|
|
};
|
|
|
|
/// Claim tx feerate, in sats per vbyte.
|
|
/// Since the Liquid blocks are consistently empty for now, we hardcode the minimum feerate.
|
|
pub const LIQUID_CLAIM_TX_FEERATE_MSAT: f32 = 100.0;
|
|
|
|
pub const DEFAULT_DATA_DIR: &str = ".data";
|
|
|
|
pub struct LiquidSdk {
|
|
electrum_url: ElectrumUrl,
|
|
network: Network,
|
|
/// LWK Wollet, a watch-only Liquid wallet for this instance
|
|
lwk_wollet: Arc<Mutex<LwkWollet>>,
|
|
/// LWK Signer, for signing Liquid transactions
|
|
lwk_signer: SwSigner,
|
|
active_address: Option<u32>,
|
|
persister: Persister,
|
|
status_stream: BoltzStatusStream,
|
|
data_dir_path: String,
|
|
}
|
|
|
|
impl LiquidSdk {
|
|
pub fn connect(req: ConnectRequest) -> Result<Arc<LiquidSdk>> {
|
|
let is_mainnet = req.network == Network::Liquid;
|
|
let signer = SwSigner::new(&req.mnemonic, is_mainnet)?;
|
|
let descriptor = LiquidSdk::get_descriptor(&signer, req.network)?;
|
|
|
|
LiquidSdk::new(LiquidSdkOptions {
|
|
signer,
|
|
descriptor,
|
|
electrum_url: None,
|
|
data_dir_path: req.data_dir,
|
|
network: req.network,
|
|
})
|
|
}
|
|
|
|
fn new(opts: LiquidSdkOptions) -> Result<Arc<Self>> {
|
|
let network = opts.network;
|
|
let elements_network: ElementsNetwork = opts.network.into();
|
|
let electrum_url = opts.get_electrum_url();
|
|
let data_dir_path = opts.data_dir_path.unwrap_or(DEFAULT_DATA_DIR.to_string());
|
|
|
|
let lwk_persister = FsPersister::new(&data_dir_path, network.into(), &opts.descriptor)?;
|
|
let lwk_wollet = Arc::new(Mutex::new(LwkWollet::new(
|
|
elements_network,
|
|
lwk_persister,
|
|
opts.descriptor,
|
|
)?));
|
|
|
|
fs::create_dir_all(&data_dir_path)?;
|
|
|
|
let persister = Persister::new(&data_dir_path, network)?;
|
|
persister.init()?;
|
|
|
|
let status_stream = BoltzStatusStream::new();
|
|
|
|
let sdk = Arc::new(LiquidSdk {
|
|
lwk_wollet,
|
|
network,
|
|
electrum_url,
|
|
lwk_signer: opts.signer,
|
|
active_address: None,
|
|
persister,
|
|
data_dir_path,
|
|
status_stream,
|
|
});
|
|
|
|
sdk.status_stream.track_pending_swaps(sdk.clone())?;
|
|
|
|
Ok(sdk)
|
|
}
|
|
|
|
fn get_descriptor(signer: &SwSigner, network: Network) -> Result<WolletDescriptor> {
|
|
let is_mainnet = network == Network::Liquid;
|
|
let descriptor_str = singlesig_desc(
|
|
signer,
|
|
Singlesig::Wpkh,
|
|
lwk_common::DescriptorBlindingKey::Slip77,
|
|
is_mainnet,
|
|
)
|
|
.map_err(|e| anyhow!("Invalid descriptor: {e}"))?;
|
|
Ok(descriptor_str.parse()?)
|
|
}
|
|
|
|
fn get_submarine_keys(&self, derivation_index: i32) -> Result<Keypair, PaymentError> {
|
|
let mnemonic = self
|
|
.lwk_signer
|
|
.mnemonic()
|
|
.ok_or(PaymentError::SignerError {
|
|
err: "Could not claim: Mnemonic not found".to_string(),
|
|
})?;
|
|
let swap_key = SwapKey::from_submarine_account(
|
|
&mnemonic.to_string(),
|
|
"",
|
|
self.network.into(),
|
|
derivation_index as u64,
|
|
)?;
|
|
let lsk = LiquidSwapKey::try_from(swap_key)?;
|
|
Ok(lsk.keypair)
|
|
}
|
|
|
|
pub(crate) fn try_handle_reverse_swap_status(
|
|
&self,
|
|
swap_state: RevSwapStates,
|
|
id: &str,
|
|
) -> Result<()> {
|
|
let con = self.persister.get_connection()?;
|
|
let ongoing_swap_out = Persister::fetch_ongoing_swap_out(&con, id)?
|
|
.ok_or(anyhow!("No ongoing swap out found for ID {id}"))?;
|
|
|
|
match swap_state {
|
|
RevSwapStates::SwapExpired
|
|
| RevSwapStates::InvoiceExpired
|
|
| RevSwapStates::TransactionFailed
|
|
| RevSwapStates::TransactionRefunded => {
|
|
warn!("Cannot claim swap {id}, unrecoverable state: {swap_state:?}");
|
|
self.persister
|
|
.resolve_ongoing_swap(id, None)
|
|
.map_err(|_| anyhow!("Could not resolve swap {id} in database"))?;
|
|
}
|
|
// We may be offline, or claiming failued due to other reasons until the swap reached these states
|
|
// If an ongoing reverse swap is in any of these states, we should be able to claim
|
|
RevSwapStates::TransactionMempool
|
|
| RevSwapStates::TransactionConfirmed
|
|
| RevSwapStates::InvoiceSettled => match self.try_claim_v2(&ongoing_swap_out) {
|
|
Ok(txid) => {
|
|
let payer_amount_sat = get_invoice_amount!(ongoing_swap_out.invoice);
|
|
self.persister
|
|
.resolve_ongoing_swap(
|
|
id,
|
|
Some((
|
|
txid,
|
|
PaymentData {
|
|
payer_amount_sat,
|
|
receiver_amount_sat: ongoing_swap_out.receiver_amount_sat,
|
|
},
|
|
)),
|
|
)
|
|
.map_err(|e| anyhow!("Could not resolve swap {id}: {e}"))?;
|
|
}
|
|
Err(err) => {
|
|
if let PaymentError::AlreadyClaimed = err {
|
|
warn!("Funds already claimed");
|
|
self.persister
|
|
.resolve_ongoing_swap(id, None)
|
|
.map_err(|_| anyhow!("Could not resolve swap {id} in database"))?;
|
|
}
|
|
warn!("Could not claim swap {id} yet. Err: {err}");
|
|
}
|
|
},
|
|
RevSwapStates::Created | RevSwapStates::MinerFeePaid => {
|
|
// Too soon to try to claim
|
|
}
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
pub(crate) fn try_handle_submarine_swap_status(
|
|
&self,
|
|
swap_state: SubSwapStates,
|
|
id: &str,
|
|
) -> Result<()> {
|
|
let con = self.persister.get_connection()?;
|
|
let ongoing_swap_in = Persister::fetch_ongoing_swap_in(&con, id)?
|
|
.ok_or(anyhow!("No ongoing swap in found for ID {id}"))?;
|
|
|
|
let Some(txid) = ongoing_swap_in.lockup_txid.clone() else {
|
|
return Err(anyhow!("Swap-in {id} is pending but no txid is present"));
|
|
};
|
|
let receiver_amount_sat = get_invoice_amount!(ongoing_swap_in.invoice);
|
|
let keypair = self.get_submarine_keys(0)?;
|
|
let create_response: CreateSubmarineResponse =
|
|
serde_json::from_str(&ongoing_swap_in.create_response_json)?;
|
|
|
|
match swap_state {
|
|
SubSwapStates::TransactionClaimPending => {
|
|
let Ok(swap_script) = LBtcSwapScriptV2::submarine_from_swap_resp(
|
|
&create_response,
|
|
keypair.public_key().into(),
|
|
) else {
|
|
self.persister
|
|
.resolve_ongoing_swap(id, None)
|
|
.map_err(|_| anyhow!("Could not resolve swap {id} in database"))?;
|
|
|
|
return Err(anyhow!("Could not rebuild refund details for swap-in {id}"));
|
|
};
|
|
|
|
self.post_submarine_claim_details(
|
|
id,
|
|
&swap_script,
|
|
&ongoing_swap_in.invoice,
|
|
&keypair,
|
|
)
|
|
.map_err(|e| anyhow!("Could not post claim details. Err: {e:?}"))?;
|
|
|
|
self.persister
|
|
.resolve_ongoing_swap(
|
|
id,
|
|
Some((
|
|
txid,
|
|
PaymentData {
|
|
payer_amount_sat: ongoing_swap_in.payer_amount_sat,
|
|
receiver_amount_sat,
|
|
},
|
|
)),
|
|
)
|
|
.map_err(|_| anyhow!("Could not resolve swap {id} in database"))?;
|
|
|
|
Ok(())
|
|
}
|
|
SubSwapStates::TransactionClaimed => {
|
|
warn!("Swap-in {id} has already been claimed. Resolving...");
|
|
|
|
self.persister
|
|
.resolve_ongoing_swap(
|
|
id,
|
|
Some((
|
|
txid,
|
|
PaymentData {
|
|
payer_amount_sat: ongoing_swap_in.payer_amount_sat,
|
|
receiver_amount_sat,
|
|
},
|
|
)),
|
|
)
|
|
.map_err(|_| anyhow!("Could not resolve swap {id} in database"))?;
|
|
|
|
warn!("Swap-in {id} resolved successfully");
|
|
Ok(())
|
|
}
|
|
SubSwapStates::TransactionLockupFailed
|
|
| SubSwapStates::InvoiceFailedToPay
|
|
| SubSwapStates::SwapExpired => {
|
|
warn!("Swap-in {id} is in an unrecoverable state: {swap_state:?}");
|
|
|
|
// If swap state is unrecoverable, try refunding
|
|
let Ok(swap_script) = LBtcSwapScriptV2::submarine_from_swap_resp(
|
|
&create_response,
|
|
keypair.public_key().into(),
|
|
) else {
|
|
self.persister
|
|
.resolve_ongoing_swap(id, None)
|
|
.map_err(|_| anyhow!("Could not resolve swap {id} in database"))?;
|
|
|
|
return Err(anyhow!("Could not rebuild refund details for swap-in {id}"));
|
|
};
|
|
|
|
let refund_txid =
|
|
self.try_refund(id, &swap_script, &keypair, receiver_amount_sat)?;
|
|
|
|
warn!("Swap-in {id} refunded successfully. Txid: {refund_txid}");
|
|
|
|
self.persister
|
|
.resolve_ongoing_swap(id, None)
|
|
.map_err(|_| anyhow!("Could not resolve swap {id} in database"))
|
|
}
|
|
_ => Err(anyhow!("New state for submarine swap {id}: {swap_state:?}")),
|
|
}
|
|
}
|
|
|
|
pub(crate) fn list_ongoing_swaps(&self) -> Result<Vec<OngoingSwap>> {
|
|
self.persister.list_ongoing_swaps()
|
|
}
|
|
|
|
fn scan(&self) -> Result<(), lwk_wollet::Error> {
|
|
let mut electrum_client = ElectrumClient::new(&self.electrum_url)?;
|
|
let mut lwk_wollet = self.lwk_wollet.lock().unwrap();
|
|
full_scan_with_electrum_client(&mut lwk_wollet, &mut electrum_client)
|
|
}
|
|
|
|
fn address(&self) -> Result<Address, lwk_wollet::Error> {
|
|
let lwk_wollet = self.lwk_wollet.lock().unwrap();
|
|
Ok(lwk_wollet.address(self.active_address)?.address().clone())
|
|
}
|
|
|
|
fn total_balance_sat(&self, with_scan: bool) -> Result<u64> {
|
|
if with_scan {
|
|
self.scan()?;
|
|
}
|
|
let balance = self.lwk_wollet.lock().unwrap().balance()?;
|
|
Ok(balance.values().sum())
|
|
}
|
|
|
|
pub fn get_info(&self, req: GetInfoRequest) -> Result<GetInfoResponse> {
|
|
debug!("active_address: {}", self.address()?);
|
|
|
|
Ok(GetInfoResponse {
|
|
balance_sat: self.total_balance_sat(req.with_scan)?,
|
|
pubkey: self.lwk_signer.xpub().public_key.to_string(),
|
|
})
|
|
}
|
|
|
|
pub(crate) fn boltz_client_v2(&self) -> BoltzApiClientV2 {
|
|
BoltzApiClientV2::new(self.boltz_url_v2())
|
|
}
|
|
|
|
pub(crate) fn boltz_url_v2(&self) -> &str {
|
|
match self.network {
|
|
Network::LiquidTestnet => BOLTZ_TESTNET_URL_V2,
|
|
Network::Liquid => BOLTZ_MAINNET_URL_V2,
|
|
}
|
|
}
|
|
|
|
fn network_config(&self) -> ElectrumConfig {
|
|
ElectrumConfig::new(
|
|
self.network.into(),
|
|
&self.electrum_url.to_string(),
|
|
true,
|
|
true,
|
|
100,
|
|
)
|
|
}
|
|
|
|
fn build_tx(
|
|
&self,
|
|
fee_rate: Option<f32>,
|
|
recipient_address: &str,
|
|
amount_sat: u64,
|
|
) -> Result<Transaction, PaymentError> {
|
|
self.scan()?;
|
|
let lwk_wollet = self.lwk_wollet.lock().unwrap();
|
|
let mut pset = lwk_wollet.send_lbtc(amount_sat, recipient_address, fee_rate)?;
|
|
let signer = AnySigner::Software(self.lwk_signer.clone());
|
|
signer.sign(&mut pset)?;
|
|
Ok(lwk_wollet.finalize(&mut pset)?)
|
|
}
|
|
|
|
fn validate_invoice(&self, invoice: &str) -> Result<Bolt11Invoice, PaymentError> {
|
|
let invoice = invoice
|
|
.trim()
|
|
.parse::<Bolt11Invoice>()
|
|
.map_err(|_| PaymentError::InvalidInvoice)?;
|
|
|
|
match (invoice.network().to_string().as_str(), self.network) {
|
|
("bitcoin", Network::Liquid) => {}
|
|
("testnet", Network::LiquidTestnet) => {}
|
|
_ => return Err(PaymentError::InvalidInvoice),
|
|
}
|
|
|
|
ensure_sdk!(!invoice.is_expired(), PaymentError::InvalidInvoice);
|
|
|
|
Ok(invoice)
|
|
}
|
|
|
|
fn validate_submarine_pairs(
|
|
client: &BoltzApiClientV2,
|
|
receiver_amount_sat: u64,
|
|
) -> Result<SubmarinePair, PaymentError> {
|
|
let lbtc_pair = client
|
|
.get_submarine_pairs()?
|
|
.get_lbtc_to_btc_pair()
|
|
.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_broadcast_fee_estimation(&self, amount_sat: u64) -> Result<u64> {
|
|
// TODO Replace this with own address when LWK supports taproot
|
|
// https://github.com/Blockstream/lwk/issues/31
|
|
let temp_p2tr_addr = match self.network {
|
|
Network::Liquid => "lq1pqvzxvqhrf54dd4sny4cag7497pe38252qefk46t92frs7us8r80ja9ha8r5me09nn22m4tmdqp5p4wafq3s59cql3v9n45t5trwtxrmxfsyxjnstkctj",
|
|
Network::LiquidTestnet => "tlq1pq0wqu32e2xacxeyps22x8gjre4qk3u6r70pj4r62hzczxeyz8x3yxucrpn79zy28plc4x37aaf33kwt6dz2nn6gtkya6h02mwpzy4eh69zzexq7cf5y5"
|
|
};
|
|
|
|
// Create a throw-away tx similar to the lockup tx, in order to estimate fees
|
|
Ok(self
|
|
.build_tx(None, temp_p2tr_addr, amount_sat)?
|
|
.all_fees()
|
|
.values()
|
|
.sum())
|
|
}
|
|
|
|
pub fn prepare_send_payment(
|
|
&self,
|
|
req: &PrepareSendRequest,
|
|
) -> Result<PrepareSendResponse, PaymentError> {
|
|
let invoice = self.validate_invoice(&req.invoice)?;
|
|
let receiver_amount_sat = invoice
|
|
.amount_milli_satoshis()
|
|
.ok_or(PaymentError::AmountOutOfRange)?
|
|
/ 1000;
|
|
|
|
let client = self.boltz_client_v2();
|
|
let lbtc_pair = Self::validate_submarine_pairs(&client, receiver_amount_sat)?;
|
|
|
|
let broadcast_fees_sat = self.get_broadcast_fee_estimation(receiver_amount_sat)?;
|
|
|
|
Ok(PrepareSendResponse {
|
|
invoice: req.invoice.clone(),
|
|
fees_sat: lbtc_pair.fees.total(receiver_amount_sat) + broadcast_fees_sat,
|
|
})
|
|
}
|
|
|
|
fn verify_payment_hash(preimage: &str, invoice: &str) -> Result<(), PaymentError> {
|
|
let preimage = Preimage::from_str(preimage)?;
|
|
let preimage_hash = preimage.sha256.to_string();
|
|
let invoice = Bolt11Invoice::from_str(invoice).map_err(|_| PaymentError::InvalidInvoice)?;
|
|
let invoice_payment_hash = invoice.payment_hash();
|
|
|
|
(invoice_payment_hash.to_string() == preimage_hash)
|
|
.then_some(())
|
|
.ok_or(PaymentError::InvalidPreimage)
|
|
}
|
|
|
|
fn new_refund_tx(&self, swap_script: &LBtcSwapScriptV2) -> Result<LBtcSwapTxV2, PaymentError> {
|
|
let wallet = self.lwk_wollet.lock().unwrap();
|
|
let output_address = wallet.address(Some(0))?.address().to_string();
|
|
let network_config = self.network_config();
|
|
Ok(LBtcSwapTxV2::new_refund(
|
|
swap_script.clone(),
|
|
&output_address,
|
|
&network_config,
|
|
)?)
|
|
}
|
|
|
|
fn try_refund(
|
|
&self,
|
|
swap_id: &str,
|
|
swap_script: &LBtcSwapScriptV2,
|
|
keypair: &Keypair,
|
|
amount_sat: u64,
|
|
) -> Result<String, PaymentError> {
|
|
let refund_tx = self.new_refund_tx(swap_script)?;
|
|
|
|
let broadcast_fees_sat = Amount::from_sat(self.get_broadcast_fee_estimation(amount_sat)?);
|
|
let client = self.boltz_client_v2();
|
|
let is_lowball = Some((&client, boltz_client::network::Chain::from(self.network)));
|
|
|
|
match refund_tx.sign_refund(
|
|
keypair,
|
|
broadcast_fees_sat,
|
|
Some((&client, &swap_id.to_string())),
|
|
) {
|
|
// Try with cooperative refund
|
|
Ok(tx) => {
|
|
let txid = refund_tx.broadcast(&tx, &self.network_config(), is_lowball)?;
|
|
debug!("Successfully broadcast cooperative refund for swap-in {swap_id}");
|
|
Ok(txid)
|
|
}
|
|
// Try with non-cooperative refund
|
|
Err(e) => {
|
|
debug!("Cooperative refund failed: {:?}", e);
|
|
let tx = refund_tx.sign_refund(keypair, broadcast_fees_sat, None)?;
|
|
let txid = refund_tx.broadcast(&tx, &self.network_config(), is_lowball)?;
|
|
debug!("Successfully broadcast non-cooperative refund for swap-in {swap_id}");
|
|
Ok(txid)
|
|
}
|
|
}
|
|
}
|
|
|
|
fn post_submarine_claim_details(
|
|
&self,
|
|
swap_id: &str,
|
|
swap_script: &LBtcSwapScriptV2,
|
|
invoice: &str,
|
|
keypair: &Keypair,
|
|
) -> Result<(), PaymentError> {
|
|
debug!("Claim is pending for swap-in {swap_id}. Initiating cooperative claim");
|
|
let client = self.boltz_client_v2();
|
|
let refund_tx = self.new_refund_tx(swap_script)?;
|
|
|
|
let claim_tx_response = client.get_claim_tx_details(&swap_id.to_string())?;
|
|
|
|
debug!("Received claim tx details: {:?}", &claim_tx_response);
|
|
|
|
Self::verify_payment_hash(&claim_tx_response.preimage, invoice)?;
|
|
|
|
let (partial_sig, pub_nonce) =
|
|
refund_tx.submarine_partial_sig(keypair, &claim_tx_response)?;
|
|
|
|
client.post_claim_tx_details(&swap_id.to_string(), pub_nonce, partial_sig)?;
|
|
debug!("Successfully sent claim details for swap-in {swap_id}");
|
|
Ok(())
|
|
}
|
|
|
|
fn lockup_funds(
|
|
&self,
|
|
swap_id: &str,
|
|
create_response: &CreateSubmarineResponse,
|
|
) -> Result<String, PaymentError> {
|
|
debug!(
|
|
"Initiated swap-in: send {} sats to liquid address {}",
|
|
create_response.expected_amount, create_response.address
|
|
);
|
|
|
|
let lockup_tx = self.build_tx(
|
|
None,
|
|
&create_response.address,
|
|
create_response.expected_amount,
|
|
)?;
|
|
|
|
let electrum_client = ElectrumClient::new(&self.electrum_url)?;
|
|
let lockup_txid = electrum_client.broadcast(&lockup_tx)?.to_string();
|
|
|
|
debug!(
|
|
"Successfully broadcast lockup transaction for swap-in {swap_id}. Txid: {lockup_txid}"
|
|
);
|
|
Ok(lockup_txid)
|
|
}
|
|
|
|
pub fn send_payment(
|
|
&self,
|
|
req: &PrepareSendResponse,
|
|
) -> Result<SendPaymentResponse, PaymentError> {
|
|
let invoice = self.validate_invoice(&req.invoice)?;
|
|
let receiver_amount_sat = invoice
|
|
.amount_milli_satoshis()
|
|
.ok_or(PaymentError::AmountOutOfRange)?
|
|
/ 1000;
|
|
|
|
let client = self.boltz_client_v2();
|
|
let lbtc_pair = Self::validate_submarine_pairs(&client, receiver_amount_sat)?;
|
|
|
|
let broadcast_fees_sat = self.get_broadcast_fee_estimation(receiver_amount_sat)?;
|
|
|
|
ensure_sdk!(
|
|
req.fees_sat == lbtc_pair.fees.total(receiver_amount_sat) + broadcast_fees_sat,
|
|
PaymentError::InvalidOrExpiredFees
|
|
);
|
|
|
|
let keypair = self.get_submarine_keys(0)?;
|
|
let refund_public_key = boltz_client::PublicKey {
|
|
compressed: true,
|
|
inner: keypair.public_key(),
|
|
};
|
|
|
|
let create_response = client.post_swap_req(&CreateSubmarineRequest {
|
|
from: "L-BTC".to_string(),
|
|
to: "BTC".to_string(),
|
|
invoice: req.invoice.to_string(),
|
|
refund_public_key,
|
|
pair_hash: Some(lbtc_pair.hash),
|
|
// TODO: Add referral id
|
|
referral_id: None,
|
|
})?;
|
|
let create_response_json =
|
|
serde_json::to_string(&create_response).map_err(|_| PaymentError::Generic {
|
|
err: "Could not store swap response locally".to_string(),
|
|
})?;
|
|
|
|
let swap_id = &create_response.id;
|
|
let swap_script = LBtcSwapScriptV2::submarine_from_swap_resp(
|
|
&create_response,
|
|
keypair.public_key().into(),
|
|
)?;
|
|
|
|
debug!("Opening WS connection for swap {}", &swap_id);
|
|
|
|
let mut socket = client.connect_ws()?;
|
|
let subscription = Subscription::new(swap_id);
|
|
let subscribe_json = serde_json::to_string(&subscription)
|
|
.map_err(|e| anyhow!("Failed to serialize subscription msg: {e:?}"))?;
|
|
socket
|
|
.send(tungstenite::Message::Text(subscribe_json))
|
|
.map_err(|e| anyhow!("Failed to subscribe to websocket updates: {e:?}"))?;
|
|
|
|
// We insert the pending send to avoid it being handled by the status stream
|
|
self.status_stream
|
|
.insert_tracked_swap(swap_id, SwapType::Submarine);
|
|
|
|
self.persister
|
|
.insert_or_update_ongoing_swap_in(OngoingSwapIn {
|
|
id: swap_id.clone(),
|
|
invoice: req.invoice.clone(),
|
|
payer_amount_sat: req.fees_sat + receiver_amount_sat,
|
|
create_response_json: create_response_json.clone(),
|
|
lockup_txid: None,
|
|
})?;
|
|
|
|
let result;
|
|
let mut lockup_txid = String::new();
|
|
loop {
|
|
let data = match utils::get_swap_status_v2(&mut socket, swap_id) {
|
|
Ok(data) => data,
|
|
Err(_) => {
|
|
// TODO: close socket if dead, skip EOF errors
|
|
continue;
|
|
}
|
|
};
|
|
|
|
let state = data
|
|
.parse::<SubSwapStates>()
|
|
.map_err(|_| PaymentError::Generic {
|
|
err: "Invalid state received from swapper".to_string(),
|
|
})?;
|
|
|
|
// See https://docs.boltz.exchange/v/api/lifecycle#normal-submarine-swaps
|
|
match state {
|
|
// Boltz has locked the HTLC, we proceed with locking up the funds
|
|
SubSwapStates::InvoiceSet => {
|
|
// Check that we have not persisted the swap already
|
|
let con = self.persister.get_connection()?;
|
|
|
|
if let Some(ongoing_swap) = Persister::fetch_ongoing_swap_in(&con, swap_id)
|
|
.map_err(|_| PaymentError::PersistError)?
|
|
{
|
|
if ongoing_swap.lockup_txid.is_some() {
|
|
continue;
|
|
}
|
|
};
|
|
|
|
lockup_txid = self.lockup_funds(swap_id, &create_response)?;
|
|
self.persister
|
|
.insert_or_update_ongoing_swap_in(OngoingSwapIn {
|
|
id: swap_id.clone(),
|
|
invoice: req.invoice.clone(),
|
|
payer_amount_sat: req.fees_sat + receiver_amount_sat,
|
|
create_response_json: create_response_json.clone(),
|
|
lockup_txid: Some(lockup_txid.clone()),
|
|
})?;
|
|
}
|
|
|
|
// Boltz has detected the lockup in the mempool, we can speed up
|
|
// the claim by doing so cooperatively
|
|
SubSwapStates::TransactionClaimPending => {
|
|
self.post_submarine_claim_details(
|
|
swap_id,
|
|
&swap_script,
|
|
&req.invoice,
|
|
&keypair,
|
|
)?;
|
|
|
|
debug!("Boltz successfully claimed the funds. Resolving swap-in {swap_id}");
|
|
self.persister.resolve_ongoing_swap(
|
|
swap_id,
|
|
Some((
|
|
lockup_txid.clone(),
|
|
PaymentData {
|
|
payer_amount_sat: receiver_amount_sat + req.fees_sat,
|
|
receiver_amount_sat,
|
|
},
|
|
)),
|
|
)?;
|
|
|
|
self.status_stream
|
|
.resolve_tracked_swap(swap_id, SwapType::ReverseSubmarine);
|
|
|
|
// TODO: Change lockup txid to claim txid
|
|
result = Ok(SendPaymentResponse { txid: lockup_txid });
|
|
|
|
debug!("Successfully resolved swap-in {swap_id}");
|
|
break;
|
|
}
|
|
|
|
// Either:
|
|
// 1. Boltz failed to pay
|
|
// 2. The swap has expired (>24h)
|
|
// 3. Lockup failed (we sent too little funds)
|
|
// We initiate a cooperative refund, and then fallback to a regular one
|
|
SubSwapStates::InvoiceFailedToPay
|
|
| SubSwapStates::SwapExpired
|
|
| SubSwapStates::TransactionLockupFailed => {
|
|
let refund_txid =
|
|
self.try_refund(swap_id, &swap_script, &keypair, receiver_amount_sat)?;
|
|
|
|
result = Err(PaymentError::Refunded {
|
|
err: format!(
|
|
"Unrecoverable state for swap-in {swap_id}: {}",
|
|
state.to_string()
|
|
),
|
|
txid: refund_txid.clone(),
|
|
});
|
|
break;
|
|
}
|
|
_ => {}
|
|
};
|
|
|
|
sleep(Duration::from_millis(500));
|
|
}
|
|
|
|
socket.close(None).unwrap();
|
|
result
|
|
}
|
|
|
|
fn try_claim_v2(&self, ongoing_swap_out: &OngoingSwapOut) -> Result<String, PaymentError> {
|
|
debug!("Trying to claim reverse swap {}", &ongoing_swap_out.id);
|
|
|
|
let lsk = self.get_liquid_swap_key()?;
|
|
let our_keys = lsk.keypair;
|
|
|
|
let create_response: CreateReverseResponse =
|
|
serde_json::from_str(&ongoing_swap_out.redeem_script).unwrap();
|
|
let swap_script = LBtcSwapScriptV2::reverse_from_swap_resp(
|
|
&create_response,
|
|
our_keys.public_key().into(),
|
|
)?;
|
|
|
|
let claim_address = self.address()?.to_string();
|
|
let claim_tx = LBtcSwapTxV2::new_claim(
|
|
swap_script,
|
|
claim_address,
|
|
&self.network_config(),
|
|
self.boltz_url_v2().into(),
|
|
ongoing_swap_out.id.clone(),
|
|
)?;
|
|
|
|
let tx = claim_tx.sign_claim(
|
|
&our_keys,
|
|
&Preimage::from_str(&ongoing_swap_out.preimage)?,
|
|
Amount::from_sat(ongoing_swap_out.claim_fees_sat),
|
|
// Enable cooperative claim (Some) or not (None)
|
|
Some((&self.boltz_client_v2(), ongoing_swap_out.id.clone())),
|
|
// None
|
|
)?;
|
|
|
|
// Electrum only broadcasts txs with lowball fees on testnet
|
|
// For mainnet, we use Boltz to broadcast
|
|
match self.network {
|
|
Network::Liquid => {
|
|
let tx_hex = elements::encode::serialize(&tx).to_lower_hex_string();
|
|
let response = self
|
|
.boltz_client_v2()
|
|
.broadcast_tx(self.network.into(), &tx_hex)?;
|
|
info!("Claim broadcast response: {response:?}");
|
|
}
|
|
Network::LiquidTestnet => {
|
|
let electrum_client = ElectrumClient::new(&self.electrum_url)?;
|
|
electrum_client.broadcast(&tx)?;
|
|
}
|
|
};
|
|
|
|
info!("Succesfully broadcasted claim tx {}", tx.txid());
|
|
debug!("Claim Tx {:?}", tx);
|
|
|
|
Ok(tx.txid().to_string())
|
|
}
|
|
|
|
#[allow(dead_code)]
|
|
fn validate_reverse_pairs(
|
|
client: &BoltzApiClientV2,
|
|
payer_amount_sat: u64,
|
|
) -> Result<ReversePair, PaymentError> {
|
|
let lbtc_pair = client
|
|
.get_reverse_pairs()?
|
|
.get_btc_to_lbtc_pair()
|
|
.ok_or(PaymentError::PairsNotFound)?;
|
|
|
|
lbtc_pair.limits.within(payer_amount_sat)?;
|
|
|
|
let fees_sat = lbtc_pair.fees.total(payer_amount_sat);
|
|
|
|
ensure_sdk!(payer_amount_sat > fees_sat, PaymentError::AmountOutOfRange);
|
|
|
|
Ok(lbtc_pair)
|
|
}
|
|
|
|
pub fn prepare_receive_payment(
|
|
&self,
|
|
req: &PrepareReceiveRequest,
|
|
) -> Result<PrepareReceiveResponse, PaymentError> {
|
|
let reverse_pair = self
|
|
.boltz_client_v2()
|
|
.get_reverse_pairs()?
|
|
.get_btc_to_lbtc_pair()
|
|
.ok_or(PaymentError::PairsNotFound)?;
|
|
|
|
let payer_amount_sat = req.payer_amount_sat;
|
|
let fees_sat = reverse_pair.fees.total(req.payer_amount_sat);
|
|
|
|
ensure_sdk!(payer_amount_sat > fees_sat, PaymentError::AmountOutOfRange);
|
|
|
|
reverse_pair
|
|
.limits
|
|
.within(payer_amount_sat)
|
|
.map_err(|_| PaymentError::AmountOutOfRange)?;
|
|
|
|
debug!("Preparing reverse swap with: payer_amount_sat {payer_amount_sat} sat, fees_sat {fees_sat} sat");
|
|
|
|
Ok(PrepareReceiveResponse {
|
|
payer_amount_sat,
|
|
fees_sat,
|
|
})
|
|
}
|
|
|
|
pub fn receive_payment(
|
|
&self,
|
|
req: &PrepareReceiveResponse,
|
|
) -> Result<ReceivePaymentResponse, PaymentError> {
|
|
let payer_amount_sat = req.payer_amount_sat;
|
|
let fees_sat = req.fees_sat;
|
|
|
|
let reverse_pair = self
|
|
.boltz_client_v2()
|
|
.get_reverse_pairs()?
|
|
.get_btc_to_lbtc_pair()
|
|
.ok_or(PaymentError::PairsNotFound)?;
|
|
let new_fees_sat = reverse_pair.fees.total(req.payer_amount_sat);
|
|
ensure_sdk!(fees_sat == new_fees_sat, PaymentError::InvalidOrExpiredFees);
|
|
|
|
debug!("Creating reverse swap with: payer_amount_sat {payer_amount_sat} sat, fees_sat {fees_sat} sat");
|
|
|
|
let lsk = self.get_liquid_swap_key()?;
|
|
|
|
let preimage = Preimage::new();
|
|
let preimage_str = preimage.to_string().ok_or(PaymentError::InvalidPreimage)?;
|
|
let preimage_hash = preimage.sha256.to_string();
|
|
|
|
let v2_req = CreateReverseRequest {
|
|
invoice_amount: req.payer_amount_sat as u32, // TODO update our model
|
|
from: "BTC".to_string(),
|
|
to: "L-BTC".to_string(),
|
|
preimage_hash: preimage.sha256,
|
|
claim_public_key: lsk.keypair.public_key().into(),
|
|
address: None,
|
|
address_signature: None,
|
|
referral_id: None,
|
|
};
|
|
let create_response = self.boltz_client_v2().post_reverse_req(v2_req)?;
|
|
|
|
// TODO Persisting this in the DB (reusing "redeem_script" field), as we need it later when claiming
|
|
let redeem_script = serde_json::to_string(&create_response).unwrap();
|
|
|
|
let swap_id = create_response.id;
|
|
let invoice = Bolt11Invoice::from_str(&create_response.invoice)
|
|
.map_err(|_| PaymentError::InvalidInvoice)?;
|
|
let blinding_str =
|
|
create_response
|
|
.blinding_key
|
|
.ok_or(boltz_client::error::Error::Protocol(
|
|
"Boltz response does not contain a blinding key.".to_string(),
|
|
))?;
|
|
let payer_amount_sat = invoice
|
|
.amount_milli_satoshis()
|
|
.ok_or(PaymentError::InvalidInvoice)?
|
|
/ 1000;
|
|
|
|
// Double check that the generated invoice includes our data
|
|
// https://docs.boltz.exchange/v/api/dont-trust-verify#lightning-invoice-verification
|
|
if invoice.payment_hash().to_string() != preimage_hash {
|
|
return Err(PaymentError::InvalidInvoice);
|
|
};
|
|
|
|
self.persister
|
|
.insert_or_update_ongoing_swap_out(OngoingSwapOut {
|
|
id: swap_id.clone(),
|
|
preimage: preimage_str,
|
|
blinding_key: blinding_str,
|
|
redeem_script,
|
|
invoice: invoice.to_string(),
|
|
receiver_amount_sat: payer_amount_sat - req.fees_sat,
|
|
claim_fees_sat: reverse_pair.fees.claim_estimate(),
|
|
})
|
|
.map_err(|_| PaymentError::PersistError)?;
|
|
|
|
Ok(ReceivePaymentResponse {
|
|
id: swap_id,
|
|
invoice: invoice.to_string(),
|
|
})
|
|
}
|
|
|
|
pub fn list_payments(&self, with_scan: bool, include_pending: bool) -> Result<Vec<Payment>> {
|
|
if with_scan {
|
|
self.scan()?;
|
|
}
|
|
|
|
let transactions = self.lwk_wollet.lock().unwrap().transactions()?;
|
|
|
|
let payment_data = self.persister.get_payment_data()?;
|
|
let mut payments: Vec<Payment> = transactions
|
|
.iter()
|
|
.map(|tx| {
|
|
let id = tx.txid.to_string();
|
|
let data = payment_data.get(&id);
|
|
let amount_sat = tx.balance.values().sum::<i64>();
|
|
let fees_sat = data.map(|d| d.payer_amount_sat - d.receiver_amount_sat);
|
|
|
|
Payment {
|
|
id: Some(id.clone()),
|
|
timestamp: tx.timestamp,
|
|
amount_sat: amount_sat.unsigned_abs(),
|
|
payment_type: match amount_sat >= 0 {
|
|
true => PaymentType::Received,
|
|
false => PaymentType::Sent,
|
|
},
|
|
invoice: None,
|
|
fees_sat,
|
|
}
|
|
})
|
|
.collect();
|
|
|
|
if include_pending {
|
|
for swap in self.persister.list_ongoing_swaps()? {
|
|
payments.insert(0, swap.into());
|
|
}
|
|
}
|
|
|
|
Ok(payments)
|
|
}
|
|
|
|
/// Empties all Liquid Wallet caches for this network type.
|
|
pub fn empty_wallet_cache(&self) -> Result<()> {
|
|
let mut path = PathBuf::from(self.data_dir_path.clone());
|
|
path.push(Into::<ElementsNetwork>::into(self.network).as_str());
|
|
path.push("enc_cache");
|
|
|
|
fs::remove_dir_all(&path)?;
|
|
fs::create_dir_all(path)?;
|
|
|
|
Ok(())
|
|
}
|
|
|
|
pub fn restore(&self, req: RestoreRequest) -> Result<()> {
|
|
let backup_path = match req.backup_path {
|
|
Some(p) => PathBuf::from_str(&p)?,
|
|
None => self.persister.get_backup_path(),
|
|
};
|
|
self.persister.restore_from_backup(backup_path)
|
|
}
|
|
|
|
pub fn backup(&self) -> Result<()> {
|
|
self.persister.backup()
|
|
}
|
|
|
|
fn get_liquid_swap_key(&self) -> Result<LiquidSwapKey, PaymentError> {
|
|
let mnemonic = self
|
|
.lwk_signer
|
|
.mnemonic()
|
|
.ok_or(PaymentError::SignerError {
|
|
err: "Mnemonic not found".to_string(),
|
|
})?;
|
|
let swap_key =
|
|
SwapKey::from_reverse_account(&mnemonic.to_string(), "", self.network.into(), 0)?;
|
|
LiquidSwapKey::try_from(swap_key).map_err(|e| PaymentError::SignerError {
|
|
err: format!("Could not create LiquidSwapKey: {e:?}"),
|
|
})
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use anyhow::Result;
|
|
use tempdir::TempDir;
|
|
|
|
use crate::model::*;
|
|
use crate::sdk::{LiquidSdk, Network};
|
|
|
|
const TEST_MNEMONIC: &str = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about";
|
|
|
|
fn create_temp_dir() -> Result<(TempDir, String)> {
|
|
let data_dir = TempDir::new(&uuid::Uuid::new_v4().to_string())?;
|
|
let data_dir_str = data_dir
|
|
.as_ref()
|
|
.to_path_buf()
|
|
.to_str()
|
|
.expect("Expecting valid temporary path")
|
|
.to_owned();
|
|
Ok((data_dir, data_dir_str))
|
|
}
|
|
|
|
fn list_pending(sdk: &LiquidSdk) -> Result<Vec<Payment>> {
|
|
let payments = sdk.list_payments(true, true)?;
|
|
|
|
Ok(payments
|
|
.iter()
|
|
.filter(|p| {
|
|
[PaymentType::PendingSend, PaymentType::PendingReceive].contains(&p.payment_type)
|
|
})
|
|
.cloned()
|
|
.collect())
|
|
}
|
|
|
|
#[test]
|
|
fn normal_submarine_swap() -> Result<()> {
|
|
let (_data_dir, data_dir_str) = create_temp_dir()?;
|
|
let sdk = LiquidSdk::connect(ConnectRequest {
|
|
mnemonic: TEST_MNEMONIC.to_string(),
|
|
data_dir: Some(data_dir_str),
|
|
network: Network::LiquidTestnet,
|
|
})?;
|
|
|
|
let invoice = "lntb10u1pnqwkjrpp5j8ucv9mgww0ajk95yfpvuq0gg5825s207clrzl5thvtuzfn68h0sdqqcqzzsxqr23srzjqv8clnrfs9keq3zlg589jvzpw87cqh6rjks0f9g2t9tvuvcqgcl45f6pqqqqqfcqqyqqqqlgqqqqqqgq2qsp5jnuprlxrargr6hgnnahl28nvutj3gkmxmmssu8ztfhmmey3gq2ss9qyyssq9ejvcp6frwklf73xvskzdcuhnnw8dmxag6v44pffwqrxznsly4nqedem3p3zhn6u4ln7k79vk6zv55jjljhnac4gnvr677fyhfgn07qp4x6wrq".to_string();
|
|
sdk.prepare_send_payment(&PrepareSendRequest { invoice })?;
|
|
assert!(!list_pending(&sdk)?.is_empty());
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
fn reverse_submarine_swap() -> Result<()> {
|
|
let (_data_dir, data_dir_str) = create_temp_dir()?;
|
|
let sdk = LiquidSdk::connect(ConnectRequest {
|
|
mnemonic: TEST_MNEMONIC.to_string(),
|
|
data_dir: Some(data_dir_str),
|
|
network: Network::LiquidTestnet,
|
|
})?;
|
|
|
|
let prepare_response = sdk.prepare_receive_payment(&PrepareReceiveRequest {
|
|
payer_amount_sat: 1_000,
|
|
})?;
|
|
sdk.receive_payment(&prepare_response)?;
|
|
assert!(!list_pending(&sdk)?.is_empty());
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
fn reverse_submarine_swap_recovery() -> Result<()> {
|
|
Ok(())
|
|
}
|
|
}
|