mirror of
https://github.com/aljazceru/breez-sdk-liquid.git
synced 2026-01-19 22:14:28 +01:00
* Support regtest * Fix bindings, print and switch boltz client crate * Remove stop-grace-period * Fix docker compose issues * feat: add rt-sync to regtest setup * Configure local rtsync instance on default regtest config * Bump sdk-common rev * Fix compose restart missing quotation * Specify platform only in base configurations * Fix after rebase --------- Co-authored-by: yse <hydra_yse@proton.me>
510 lines
18 KiB
Rust
510 lines
18 KiB
Rust
use std::collections::HashMap;
|
|
use std::fs::{self, create_dir_all};
|
|
use std::io::Write;
|
|
use std::path::PathBuf;
|
|
use std::time::Instant;
|
|
use std::{path::Path, str::FromStr, sync::Arc};
|
|
|
|
use anyhow::{anyhow, Result};
|
|
use async_trait::async_trait;
|
|
use boltz_client::ElementsAddress;
|
|
use log::{debug, info, warn};
|
|
use lwk_common::Signer as LwkSigner;
|
|
use lwk_common::{singlesig_desc, Singlesig};
|
|
use lwk_wollet::elements::{AssetId, Txid};
|
|
use lwk_wollet::ElectrumOptions;
|
|
use lwk_wollet::{
|
|
elements::{hex::ToHex, Address, Transaction},
|
|
ElectrumClient, ElectrumUrl, ElementsNetwork, FsPersister, Tip, WalletTx, Wollet,
|
|
WolletDescriptor,
|
|
};
|
|
use sdk_common::bitcoin::hashes::{sha256, Hash};
|
|
use sdk_common::bitcoin::secp256k1::PublicKey;
|
|
use sdk_common::lightning::util::message_signing::verify;
|
|
use tokio::sync::Mutex;
|
|
|
|
use crate::model::Signer;
|
|
use crate::persist::Persister;
|
|
use crate::signer::SdkLwkSigner;
|
|
use crate::{
|
|
ensure_sdk,
|
|
error::PaymentError,
|
|
model::{Config, LiquidNetwork},
|
|
};
|
|
use lwk_wollet::secp256k1::Message;
|
|
|
|
static LN_MESSAGE_PREFIX: &[u8] = b"Lightning Signed Message:";
|
|
|
|
#[async_trait]
|
|
pub trait OnchainWallet: Send + Sync {
|
|
/// List all transactions in the wallet
|
|
async fn transactions(&self) -> Result<Vec<WalletTx>, PaymentError>;
|
|
|
|
/// List all transactions in the wallet mapped by tx id
|
|
async fn transactions_by_tx_id(&self) -> Result<HashMap<Txid, WalletTx>, PaymentError>;
|
|
|
|
/// Build a transaction to send funds to a recipient
|
|
async fn build_tx(
|
|
&self,
|
|
fee_rate_sats_per_kvb: Option<f32>,
|
|
recipient_address: &str,
|
|
asset_id: &str,
|
|
amount_sat: u64,
|
|
) -> Result<Transaction, PaymentError>;
|
|
|
|
/// Builds a drain tx.
|
|
///
|
|
/// ### Arguments
|
|
/// - `fee_rate_sats_per_kvb`: custom drain tx feerate
|
|
/// - `recipient_address`: drain tx recipient
|
|
/// - `enforce_amount_sat`: if set, the drain tx will only be built if the amount transferred is
|
|
/// this amount, otherwise it will fail with a validation error
|
|
async fn build_drain_tx(
|
|
&self,
|
|
fee_rate_sats_per_kvb: Option<f32>,
|
|
recipient_address: &str,
|
|
enforce_amount_sat: Option<u64>,
|
|
) -> Result<Transaction, PaymentError>;
|
|
|
|
/// Build a transaction to send funds to a recipient. If building a transaction
|
|
/// results in an InsufficientFunds error, attempt to build a drain transaction
|
|
/// validating that the `amount_sat` matches the drain output.
|
|
async fn build_tx_or_drain_tx(
|
|
&self,
|
|
fee_rate_sats_per_kvb: Option<f32>,
|
|
recipient_address: &str,
|
|
asset_id: &str,
|
|
amount_sat: u64,
|
|
) -> Result<Transaction, PaymentError>;
|
|
|
|
/// Get the next unused address in the wallet
|
|
async fn next_unused_address(&self) -> Result<Address, PaymentError>;
|
|
|
|
/// Get the current tip of the blockchain the wallet is aware of
|
|
async fn tip(&self) -> Tip;
|
|
|
|
/// Get the public key of the wallet
|
|
fn pubkey(&self) -> Result<String>;
|
|
|
|
/// Get the fingerprint of the wallet
|
|
fn fingerprint(&self) -> Result<String>;
|
|
|
|
/// Sign given message with the wallet private key. Returns a zbase
|
|
/// encoded signature.
|
|
fn sign_message(&self, msg: &str) -> Result<String>;
|
|
|
|
/// Check whether given message was signed by the given
|
|
/// pubkey and the signature (zbase encoded) is valid.
|
|
fn check_message(&self, message: &str, pubkey: &str, signature: &str) -> Result<bool>;
|
|
|
|
/// Perform a full scan of the wallet
|
|
async fn full_scan(&self) -> Result<(), PaymentError>;
|
|
}
|
|
|
|
pub(crate) struct LiquidOnchainWallet {
|
|
config: Config,
|
|
persister: Arc<Persister>,
|
|
wallet: Arc<Mutex<Wollet>>,
|
|
electrum_client: Mutex<Option<ElectrumClient>>,
|
|
working_dir: String,
|
|
pub(crate) signer: SdkLwkSigner,
|
|
}
|
|
|
|
impl LiquidOnchainWallet {
|
|
pub(crate) fn new(
|
|
config: Config,
|
|
working_dir: &String,
|
|
persister: Arc<Persister>,
|
|
user_signer: Arc<Box<dyn Signer>>,
|
|
) -> Result<Self> {
|
|
let signer = crate::signer::SdkLwkSigner::new(user_signer.clone())?;
|
|
let wollet = Self::create_wallet(&config, working_dir, &signer)?;
|
|
|
|
let working_dir_buf = PathBuf::from_str(working_dir)?;
|
|
if !working_dir_buf.exists() {
|
|
create_dir_all(&working_dir_buf)?;
|
|
}
|
|
|
|
Ok(Self {
|
|
config,
|
|
persister,
|
|
wallet: Arc::new(Mutex::new(wollet)),
|
|
electrum_client: Mutex::new(None),
|
|
working_dir: working_dir.clone(),
|
|
signer,
|
|
})
|
|
}
|
|
|
|
fn create_wallet<P: AsRef<Path>>(
|
|
config: &Config,
|
|
working_dir: P,
|
|
signer: &SdkLwkSigner,
|
|
) -> Result<Wollet> {
|
|
let elements_network: ElementsNetwork = config.network.into();
|
|
let descriptor = LiquidOnchainWallet::get_descriptor(signer, config.network)?;
|
|
let mut lwk_persister =
|
|
FsPersister::new(working_dir.as_ref(), elements_network, &descriptor)?;
|
|
let wollet_res = Wollet::new(elements_network, lwk_persister, descriptor.clone());
|
|
match wollet_res {
|
|
Ok(wollet) => Ok(wollet),
|
|
Err(
|
|
lwk_wollet::Error::PersistError(_)
|
|
| lwk_wollet::Error::UpdateHeightTooOld { .. }
|
|
| lwk_wollet::Error::UpdateOnDifferentStatus { .. },
|
|
) => {
|
|
warn!("Update error initialising wollet, wipping storage and retrying: {wollet_res:?}");
|
|
let mut path = working_dir.as_ref().to_path_buf();
|
|
path.push(elements_network.as_str());
|
|
fs::remove_dir_all(&path)?;
|
|
warn!("Wiping wallet in path: {:?}", path);
|
|
lwk_persister = FsPersister::new(working_dir, elements_network, &descriptor)?;
|
|
Ok(Wollet::new(
|
|
elements_network,
|
|
lwk_persister,
|
|
descriptor.clone(),
|
|
)?)
|
|
}
|
|
Err(e) => Err(e.into()),
|
|
}
|
|
}
|
|
|
|
fn get_descriptor(
|
|
signer: &SdkLwkSigner,
|
|
network: LiquidNetwork,
|
|
) -> Result<WolletDescriptor, PaymentError> {
|
|
let is_mainnet = network == LiquidNetwork::Mainnet;
|
|
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()?)
|
|
}
|
|
}
|
|
|
|
#[async_trait]
|
|
impl OnchainWallet for LiquidOnchainWallet {
|
|
/// List all transactions in the wallet
|
|
async fn transactions(&self) -> Result<Vec<WalletTx>, PaymentError> {
|
|
let wallet = self.wallet.lock().await;
|
|
wallet.transactions().map_err(|e| PaymentError::Generic {
|
|
err: format!("Failed to fetch wallet transactions: {e:?}"),
|
|
})
|
|
}
|
|
|
|
/// List all transactions in the wallet mapped by tx id
|
|
async fn transactions_by_tx_id(&self) -> Result<HashMap<Txid, WalletTx>, PaymentError> {
|
|
let tx_map: HashMap<Txid, WalletTx> = self
|
|
.transactions()
|
|
.await?
|
|
.iter()
|
|
.map(|tx| (tx.txid, tx.clone()))
|
|
.collect();
|
|
Ok(tx_map)
|
|
}
|
|
|
|
/// Build a transaction to send funds to a recipient
|
|
async fn build_tx(
|
|
&self,
|
|
fee_rate_sats_per_kvb: Option<f32>,
|
|
recipient_address: &str,
|
|
asset_id: &str,
|
|
amount_sat: u64,
|
|
) -> Result<Transaction, PaymentError> {
|
|
let lwk_wollet = self.wallet.lock().await;
|
|
let address =
|
|
ElementsAddress::from_str(recipient_address).map_err(|e| PaymentError::Generic {
|
|
err: format!(
|
|
"Recipient address {recipient_address} is not a valid ElementsAddress: {e:?}"
|
|
),
|
|
})?;
|
|
let mut tx_builder = lwk_wollet::TxBuilder::new(self.config.network.into())
|
|
.fee_rate(fee_rate_sats_per_kvb)
|
|
.enable_ct_discount();
|
|
if asset_id.eq(&self.config.lbtc_asset_id()) {
|
|
tx_builder = tx_builder.add_lbtc_recipient(&address, amount_sat)?;
|
|
} else {
|
|
let asset = AssetId::from_str(asset_id)?;
|
|
tx_builder = tx_builder.add_recipient(&address, amount_sat, asset)?;
|
|
}
|
|
let mut pset = tx_builder.finish(&lwk_wollet)?;
|
|
self.signer
|
|
.sign(&mut pset)
|
|
.map_err(|e| PaymentError::Generic {
|
|
err: format!("Failed to sign transaction: {e:?}"),
|
|
})?;
|
|
Ok(lwk_wollet.finalize(&mut pset)?)
|
|
}
|
|
|
|
async fn build_drain_tx(
|
|
&self,
|
|
fee_rate_sats_per_kvb: Option<f32>,
|
|
recipient_address: &str,
|
|
enforce_amount_sat: Option<u64>,
|
|
) -> Result<Transaction, PaymentError> {
|
|
let lwk_wollet = self.wallet.lock().await;
|
|
|
|
let address =
|
|
ElementsAddress::from_str(recipient_address).map_err(|e| PaymentError::Generic {
|
|
err: format!(
|
|
"Recipient address {recipient_address} is not a valid ElementsAddress: {e:?}"
|
|
),
|
|
})?;
|
|
let mut pset = lwk_wollet
|
|
.tx_builder()
|
|
.drain_lbtc_wallet()
|
|
.drain_lbtc_to(address)
|
|
.fee_rate(fee_rate_sats_per_kvb)
|
|
.enable_ct_discount()
|
|
.finish()?;
|
|
|
|
if let Some(enforce_amount_sat) = enforce_amount_sat {
|
|
let pset_details = lwk_wollet.get_details(&pset)?;
|
|
let pset_balance_sat = pset_details
|
|
.balance
|
|
.balances
|
|
.get(&lwk_wollet.policy_asset())
|
|
.unwrap_or(&0);
|
|
let pset_fees = pset_details.balance.fee;
|
|
|
|
ensure_sdk!(
|
|
(*pset_balance_sat * -1) as u64 - pset_fees == enforce_amount_sat,
|
|
PaymentError::Generic {
|
|
err: format!("Drain tx amount {pset_balance_sat} sat doesn't match enforce_amount_sat {enforce_amount_sat} sat")
|
|
}
|
|
);
|
|
}
|
|
|
|
self.signer
|
|
.sign(&mut pset)
|
|
.map_err(|e| PaymentError::Generic {
|
|
err: format!("Failed to sign transaction: {e:?}"),
|
|
})?;
|
|
Ok(lwk_wollet.finalize(&mut pset)?)
|
|
}
|
|
|
|
async fn build_tx_or_drain_tx(
|
|
&self,
|
|
fee_rate_sats_per_kvb: Option<f32>,
|
|
recipient_address: &str,
|
|
asset_id: &str,
|
|
amount_sat: u64,
|
|
) -> Result<Transaction, PaymentError> {
|
|
match self
|
|
.build_tx(
|
|
fee_rate_sats_per_kvb,
|
|
recipient_address,
|
|
asset_id,
|
|
amount_sat,
|
|
)
|
|
.await
|
|
{
|
|
Ok(tx) => Ok(tx),
|
|
Err(PaymentError::InsufficientFunds) if asset_id.eq(&self.config.lbtc_asset_id()) => {
|
|
warn!("Cannot build tx due to insufficient funds, attempting to build drain tx");
|
|
self.build_drain_tx(fee_rate_sats_per_kvb, recipient_address, Some(amount_sat))
|
|
.await
|
|
}
|
|
Err(e) => Err(e),
|
|
}
|
|
}
|
|
|
|
/// Get the next unused address in the wallet
|
|
async fn next_unused_address(&self) -> Result<Address, PaymentError> {
|
|
let tip = self.tip().await.height();
|
|
let address = match self.persister.next_expired_reserved_address(tip)? {
|
|
Some(reserved_address) => {
|
|
debug!(
|
|
"Got reserved address {} that expired on block height {}",
|
|
reserved_address.address, reserved_address.expiry_block_height
|
|
);
|
|
ElementsAddress::from_str(&reserved_address.address)
|
|
.map_err(|e| PaymentError::Generic { err: e.to_string() })?
|
|
}
|
|
None => {
|
|
let next_index = self.persister.next_derivation_index()?;
|
|
let address_result = self.wallet.lock().await.address(next_index)?;
|
|
let address = address_result.address().clone();
|
|
let index = address_result.index();
|
|
debug!(
|
|
"Got unused address {} with derivation index {}",
|
|
address, index
|
|
);
|
|
if next_index.is_none() {
|
|
self.persister.set_last_derivation_index(index)?;
|
|
}
|
|
address
|
|
}
|
|
};
|
|
|
|
Ok(address)
|
|
}
|
|
|
|
/// Get the current tip of the blockchain the wallet is aware of
|
|
async fn tip(&self) -> Tip {
|
|
self.wallet.lock().await.tip()
|
|
}
|
|
|
|
/// Get the public key of the wallet
|
|
fn pubkey(&self) -> Result<String> {
|
|
Ok(self.signer.xpub()?.public_key.to_string())
|
|
}
|
|
|
|
/// Get the fingerprint of the wallet
|
|
fn fingerprint(&self) -> Result<String> {
|
|
Ok(self.signer.fingerprint()?.to_hex())
|
|
}
|
|
|
|
/// Perform a full scan of the wallet
|
|
async fn full_scan(&self) -> Result<(), PaymentError> {
|
|
let full_scan_started = Instant::now();
|
|
|
|
// create electrum client if doesn't already exist
|
|
let mut electrum_client = self.electrum_client.lock().await;
|
|
if electrum_client.is_none() {
|
|
let (tls, validate_domain) = match self.config.network {
|
|
LiquidNetwork::Mainnet | LiquidNetwork::Testnet => (true, true),
|
|
LiquidNetwork::Regtest => (false, false),
|
|
};
|
|
|
|
let electrum_url =
|
|
ElectrumUrl::new(&self.config.liquid_electrum_url, tls, validate_domain)?;
|
|
*electrum_client = Some(ElectrumClient::with_options(
|
|
&electrum_url,
|
|
ElectrumOptions { timeout: Some(3) },
|
|
)?);
|
|
}
|
|
let client = electrum_client
|
|
.as_mut()
|
|
.ok_or_else(|| PaymentError::Generic {
|
|
err: "Electrum client not initialized".to_string(),
|
|
})?;
|
|
|
|
// Use the cached derivation index with a buffer of 5 to perform the scan
|
|
let index = self
|
|
.persister
|
|
.get_last_derivation_index()?
|
|
.map(|i| i + 5)
|
|
.unwrap_or_default();
|
|
let mut wallet = self.wallet.lock().await;
|
|
|
|
let res =
|
|
match lwk_wollet::full_scan_to_index_with_electrum_client(&mut wallet, index, client) {
|
|
Ok(()) => Ok(()),
|
|
Err(lwk_wollet::Error::UpdateHeightTooOld { .. }) => {
|
|
warn!(
|
|
"Full scan failed with update height too old, wiping storage and retrying"
|
|
);
|
|
let mut new_wallet =
|
|
Self::create_wallet(&self.config, &self.working_dir, &self.signer)?;
|
|
lwk_wollet::full_scan_to_index_with_electrum_client(
|
|
&mut new_wallet,
|
|
index,
|
|
client,
|
|
)?;
|
|
*wallet = new_wallet;
|
|
Ok(())
|
|
}
|
|
Err(e) => Err(e.into()),
|
|
};
|
|
let duration_ms = Instant::now().duration_since(full_scan_started).as_millis();
|
|
info!("lwk wallet full_scan duration: ({duration_ms} ms)");
|
|
res
|
|
}
|
|
|
|
fn sign_message(&self, message: &str) -> Result<String> {
|
|
// Prefix and double hash message
|
|
let mut engine = sha256::HashEngine::default();
|
|
engine.write_all(LN_MESSAGE_PREFIX)?;
|
|
engine.write_all(message.as_bytes())?;
|
|
let hashed_msg = sha256::Hash::from_engine(engine);
|
|
let double_hashed_msg = Message::from_digest(sha256::Hash::hash(&hashed_msg).into_inner());
|
|
// Get message signature and encode to zbase32
|
|
let recoverable_sig = self.signer.sign_ecdsa_recoverable(&double_hashed_msg)?;
|
|
Ok(zbase32::encode_full_bytes(recoverable_sig.as_slice()))
|
|
}
|
|
|
|
fn check_message(&self, message: &str, pubkey: &str, signature: &str) -> Result<bool> {
|
|
let pk = PublicKey::from_str(pubkey)?;
|
|
Ok(verify(message.as_bytes(), signature, &pk))
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::model::Config;
|
|
use crate::signer::SdkSigner;
|
|
use crate::test_utils::persist::create_persister;
|
|
use crate::wallet::LiquidOnchainWallet;
|
|
use anyhow::Result;
|
|
use tempdir::TempDir;
|
|
|
|
#[tokio::test]
|
|
async fn test_sign_and_check_message() -> Result<()> {
|
|
let mnemonic = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about";
|
|
let sdk_signer: Box<dyn Signer> = Box::new(SdkSigner::new(mnemonic, "", false).unwrap());
|
|
let sdk_signer = Arc::new(sdk_signer);
|
|
|
|
let config = Config::testnet(None);
|
|
|
|
// Create a temporary directory for working_dir
|
|
let temp_dir = TempDir::new("").unwrap();
|
|
let working_dir = temp_dir.path().to_str().unwrap().to_string();
|
|
|
|
create_persister!(storage);
|
|
|
|
let wallet: Arc<dyn OnchainWallet> = Arc::new(
|
|
LiquidOnchainWallet::new(config, &working_dir, storage, sdk_signer.clone()).unwrap(),
|
|
);
|
|
|
|
// Test message
|
|
let message = "Hello, Liquid!";
|
|
|
|
// Sign the message
|
|
let signature = wallet.sign_message(message).unwrap();
|
|
|
|
// Get the public key
|
|
let pubkey = wallet.pubkey().unwrap();
|
|
|
|
// Check the message
|
|
let is_valid = wallet.check_message(message, &pubkey, &signature).unwrap();
|
|
assert!(is_valid, "Message signature should be valid");
|
|
|
|
// Check with an incorrect message
|
|
let incorrect_message = "Wrong message";
|
|
let is_invalid = wallet
|
|
.check_message(incorrect_message, &pubkey, &signature)
|
|
.unwrap();
|
|
assert!(
|
|
!is_invalid,
|
|
"Message signature should be invalid for incorrect message"
|
|
);
|
|
|
|
// Check with an incorrect public key
|
|
let incorrect_pubkey = "02a1633cafcc01ebfb6d78e39f687a1f0995c62fc95f51ead10a02ee0be551b5dc";
|
|
let is_invalid = wallet
|
|
.check_message(message, incorrect_pubkey, &signature)
|
|
.unwrap();
|
|
assert!(
|
|
!is_invalid,
|
|
"Message signature should be invalid for incorrect public key"
|
|
);
|
|
|
|
// Check with an incorrect signature
|
|
let incorrect_signature = zbase32::encode_full_bytes(&[0; 65]);
|
|
let is_invalid = wallet
|
|
.check_message(message, &pubkey, &incorrect_signature)
|
|
.unwrap();
|
|
assert!(
|
|
!is_invalid,
|
|
"Message signature should be invalid for incorrect signature"
|
|
);
|
|
|
|
// The temporary directory will be automatically deleted when temp_dir goes out of scope
|
|
Ok(())
|
|
}
|
|
}
|