diff --git a/Cargo.toml b/Cargo.toml index fd3794eb..5126715d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,6 +10,7 @@ members = [ "ark-rust-secp256k1", "ark-dlc-sample", "ark-rs", + "ark-lightning", ] resolver = "2" diff --git a/ark-core/src/ark_address.rs b/ark-core/src/ark_address.rs index 0a19411d..5026e353 100644 --- a/ark-core/src/ark_address.rs +++ b/ark-core/src/ark_address.rs @@ -1,3 +1,5 @@ +use std::str::FromStr; + use crate::Error; use bech32::Bech32m; use bech32::Hrp; @@ -80,6 +82,14 @@ impl std::fmt::Display for ArkAddress { } } +impl FromStr for ArkAddress { + type Err = Error; + + fn from_str(s: &str) -> Result { + Self::decode(s) + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/ark-lightning/Cargo.toml b/ark-lightning/Cargo.toml new file mode 100644 index 00000000..38a9100d --- /dev/null +++ b/ark-lightning/Cargo.toml @@ -0,0 +1,39 @@ +[package] +name = "ark-lightning" +version = "0.1.0" +edition = "2024" + +[dependencies] +anyhow = "1.0" +ark-core = { path = "../ark-core" } +bitcoin = { version = "0.32.4", features = ["base64", "rand", "serde"] } +futures-util = "0.3" +hex = "0.4" +lightning = { version = "0.1" } +musig = { package = "ark-secp256k1", path = "../ark-rust-secp256k1", features = ["serde", "rand"] } +reqwest = { version = "0.12", features = ["json"] } +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" +thiserror = "1.0" +tokio = { version = "1", features = ["full"] } +tokio-tungstenite = { version = "0.24", features = ["native-tls"] } + +lampo-common = { git = "https://github.com/vincenzopalazzo/lampo.rs.git" } +nosql_db = { git = "https://github.com/vincenzopalazzo/nosql-db.git" } +nosql_sled = { git = "https://github.com/vincenzopalazzo/nosql-db.git" } + +[dev-dependencies] +hex = "0.4" +serde_json = "1.0" + +[[example]] +name = "vhtlc_example" +path = "examples/vhtlc_example.rs" + +[[example]] +name = "boltz_ws_example" +path = "examples/boltz_ws_example.rs" + +[[example]] +name = "boltz_ws_reconnect" +path = "examples/boltz_ws_reconnect.rs" diff --git a/ark-lightning/scripts/generate_ts_vectors.js b/ark-lightning/scripts/generate_ts_vectors.js new file mode 100644 index 00000000..74c53055 --- /dev/null +++ b/ark-lightning/scripts/generate_ts_vectors.js @@ -0,0 +1,58 @@ +// Script to generate test vectors from TypeScript SDK for comparison +// Run this in the arkade-os/ts-sdk repository to get the expected hex values + +const { VhtlcScript } = require('../path/to/ts-sdk'); // Adjust path as needed + +// Test data from fixtures (same as used in Rust tests) +const testData = { + preimageHash: Buffer.from('4d487dd3753a89bc9fe98401d1196523058251fc', 'hex'), + receiver: Buffer.from('021e1bb85455fe3f5aed60d101aa4dbdb9e7714f6226769a97a17a5331dadcd53b', 'hex'), + sender: Buffer.from('030192e796452d6df9697c280542e1560557bcf79a347d925895043136225c7cb4', 'hex'), + server: Buffer.from('03aad52d58162e9eefeafc7ad8a1cdca8060b5f01df1e7583362d052e266208f88', 'hex'), + refundLocktime: 265, + unilateralClaimDelay: { type: 'blocks', value: 17 }, + unilateralRefundDelay: { type: 'blocks', value: 144 }, + unilateralRefundWithoutReceiverDelay: { type: 'blocks', value: 144 } +}; + +try { + // Create VHTLC instance + const vhtlc = new VhtlcScript(testData); + + console.log('=== TypeScript SDK VHTLC Script Vectors ===\n'); + + // Export all script hex values + const vectors = { + claim: vhtlc.claim().script.toString('hex'), + refund: vhtlc.refund().script.toString('hex'), + refundWithoutReceiver: vhtlc.refundWithoutReceiver().script.toString('hex'), + unilateralClaim: vhtlc.unilateralClaim().script.toString('hex'), + unilateralRefund: vhtlc.unilateralRefund().script.toString('hex'), + unilateralRefundWithoutReceiver: vhtlc.unilateralRefundWithoutReceiver().script.toString('hex') + }; + + // Output for comparison + console.log('TypeScript SDK Script Vectors:'); + console.log('1. Claim Script: ', vectors.claim); + console.log('2. Refund Script: ', vectors.refund); + console.log('3. Refund Without Receiver Script: ', vectors.refundWithoutReceiver); + console.log('4. Unilateral Claim Script: ', vectors.unilateralClaim); + console.log('5. Unilateral Refund Script: ', vectors.unilateralRefund); + console.log('6. Unilateral Refund Without Receiver: ', vectors.unilateralRefundWithoutReceiver); + + // Also output as JSON for easy parsing + console.log('\n=== JSON Format ==='); + console.log(JSON.stringify(vectors, null, 2)); + + // Export taproot address for comparison + const address = vhtlc.address('testnet'); // or 'mainnet' + console.log('\n=== Address ==='); + console.log('TypeScript SDK Address:', address); + +} catch (error) { + console.error('Error generating TypeScript vectors:', error); + console.log('\nPlease ensure:'); + console.log('1. You are running this in the arkade-os/ts-sdk directory'); + console.log('2. The path to VhtlcScript is correct'); + console.log('3. All dependencies are installed (npm install)'); +} \ No newline at end of file diff --git a/ark-lightning/src/arkln.rs b/ark-lightning/src/arkln.rs new file mode 100644 index 00000000..7df02889 --- /dev/null +++ b/ark-lightning/src/arkln.rs @@ -0,0 +1,168 @@ +//! Lightning Network Module for the Ark Lightning Swap +//! +//! Vincenzo Palazzo +use crate::vhtlc::VhtlcScript; +use ark_core::send::VtxoInput; +use ark_core::ArkAddress; +use bitcoin::Amount; +use bitcoin::Psbt; +use bitcoin::Transaction; +use bitcoin::XOnlyPublicKey; +use lightning::bolt11_invoice::Bolt11Invoice; +use lightning::offers::invoice::Bolt12Invoice; +use lightning::offers::offer::Offer; +use std::future::Future; +use std::pin::Pin; + +#[derive(Debug, Clone)] +pub struct RcvOptions { + pub invoice_amount: Amount, + pub description: Option, + pub claim_public_key: String, +} + +#[derive(Debug, Clone)] +pub struct SentOptions { + pub invoice: Bolt11Invoice, + pub refund_public_key: String, +} + +pub trait EventHandle: Send + Sync { + fn on_invoice_paid(&self, invoice: Bolt11Invoice, amount: Amount, preimage: Vec); + fn on_offer_paid( + &self, + offer: Offer, + invoice: Bolt12Invoice, + amount: Amount, + preimage: Vec, + ); + fn on_payment_pending(&self, amount: Amount); + fn on_payment_failed(&self, amount: Amount); + fn on_payment_received(&self, amount: Amount); +} + +pub struct DummyEventHandler; + +impl EventHandle for DummyEventHandler { + fn on_invoice_paid(&self, _invoice: Bolt11Invoice, _amount: Amount, _preimage: Vec) {} + fn on_offer_paid( + &self, + _offer: Offer, + _invoice: Bolt12Invoice, + _amount: Amount, + _preimage: Vec, + ) { + } + fn on_payment_pending(&self, _amount: Amount) {} + fn on_payment_failed(&self, _amount: Amount) {} + fn on_payment_received(&self, _amount: Amount) {} +} + +/// A struct representing the Lightning Network functionality. +pub trait Lightning { + /// Get a Bolt11 invoice! + fn get_invoice( + &self, + opts: RcvOptions, + ) -> impl Future> + Send; + + /// Get an bolt12 offer! + fn get_offer(&self, offer: RcvOptions) -> impl Future> + Send; + + /// Pay a bolt11 invoice! + fn pay_invoice(&self, opts: SentOptions) -> impl Future> + Send; + + /// Pay a bolt12 offer! + fn pay_offer(&self, opts: SentOptions) -> impl Future> + Send; + + /// Pay a BIP321 payment request! + fn pay_bip321(&self, bip321: &str) -> impl Future> + Send; + // TODO: add the bip 353 support! +} + +pub trait ArkWallet { + /// Send funds on a specific address + fn send_bitcoin( + &self, + address: ArkAddress, + amount: Amount, + ) -> Pin> + Send>>; + + // Extract the xpub from the wallet + fn get_xpub(&self) -> XOnlyPublicKey; + + // Sign a transaction with the wallet or something that can sign! + fn sign_tx(&self, psbt: Psbt) -> Pin> + Send>>; + + fn new_address(&self) -> Pin> + Send>>; + + fn get_server_xpub(&self) -> XOnlyPublicKey; + + // FIXME define a vtxo type + fn spendable_utxos( + &self, + vhtlc: &VhtlcScript, + ) -> Pin>> + Send>>; + + fn broadcast_tx( + &self, + psbt: Psbt, + ) -> Pin> + Send>>; +} + +/// Dummy wallet implementation for testing +pub struct DummyWallet; + +impl DummyWallet { + pub fn new() -> Self { + Self + } +} + +impl ArkWallet for DummyWallet { + fn send_bitcoin( + &self, + _address: ArkAddress, + _amount: Amount, + ) -> Pin> + Send>> { + Box::pin(async { Ok(()) }) + } + + fn get_xpub(&self) -> XOnlyPublicKey { + // Return a dummy public key + XOnlyPublicKey::from_slice(&[0u8; 32]).unwrap() + } + + fn sign_tx(&self, psbt: Psbt) -> Pin> + Send>> { + let psbt = psbt.clone(); + Box::pin(async move { Ok(psbt) }) + } + + fn new_address(&self) -> Pin> + Send>> { + unimplemented!() + } + + fn get_server_xpub(&self) -> XOnlyPublicKey { + // Return a dummy public key + XOnlyPublicKey::from_slice(&[0u8; 32]).unwrap() + } + + fn spendable_utxos( + &self, + _vhtlc: &VhtlcScript, + ) -> Pin>> + Send>> { + Box::pin(async { Ok(vec![]) }) + } + + fn broadcast_tx( + &self, + psbt: Psbt, + ) -> Pin> + Send>> { + let tx = psbt + .clone() + .extract_tx() + .map_err(|err| anyhow::anyhow!("Failed to extract tx: {}", err)) + .unwrap(); + Box::pin(async move { Ok(tx) }) + } +} diff --git a/ark-lightning/src/boltz.rs b/ark-lightning/src/boltz.rs new file mode 100644 index 00000000..7788db9f --- /dev/null +++ b/ark-lightning/src/boltz.rs @@ -0,0 +1,14 @@ +//! Boltz Module +//! +//! Author: Vincenzo Palazzo +mod boltz; +mod boltz_ws; +mod model; +mod storage; + +pub use boltz::*; +pub use boltz_ws::PersistedSwap; +pub use boltz_ws::SwapMetadata; +pub use boltz_ws::SwapStatus; +pub use boltz_ws::SwapType; +pub use model::*; diff --git a/ark-lightning/src/boltz/boltz.rs b/ark-lightning/src/boltz/boltz.rs new file mode 100644 index 00000000..f899e6bf --- /dev/null +++ b/ark-lightning/src/boltz/boltz.rs @@ -0,0 +1,540 @@ +//! Boltz API https://api.docs.boltz.exchange/ +//! +//! Author: Vincenzo Palazzo + +use super::boltz_ws::BoltzWebSocketClient; +use super::boltz_ws::ConnectionState; +use super::boltz_ws::PersistedSwap; +use super::boltz_ws::SwapMetadata; +use super::boltz_ws::SwapStatus; +use super::boltz_ws::SwapType as WsSwapType; +use super::model::CreateReverseSwapRequest; +use super::model::CreateReverseSwapResponse; +use super::model::CreateSubmarineSwapRequest; +use super::model::CreateSubmarineSwapResponse; +use super::model::GetSwapPreimageResponse; +use super::model::GetSwapStatusResponse; +use super::model::PairLimits; +use crate::arkln::ArkWallet; +use crate::arkln::DummyEventHandler; +use crate::arkln::DummyWallet; +use crate::arkln::EventHandle; +use crate::arkln::Lightning; +use crate::arkln::RcvOptions; +use crate::arkln::SentOptions; +use crate::boltz::boltz_ws::SwapUpdate; +use crate::ldk::bolt11_invoice as invoice; +use crate::ldk::offers; +use crate::vhtlc::VhtlcOptions; +use crate::vhtlc::VhtlcScript; +use anyhow::Result; +use ark_core::send::build_offchain_transactions; +use ark_core::send::OffchainTransactions; +use ark_core::send::VtxoInput; +use bitcoin::hashes::sha256; +use bitcoin::Amount; +use bitcoin::Sequence; +use futures_util::lock::Mutex; +use std::collections::HashMap; +use std::future::Future; +use std::sync::Arc; +use tokio::sync::RwLock; + +#[derive(Debug, Clone, Copy)] +pub enum Network { + Bitcoin, + Testnet, + Mutinynet, + Regtest, +} + +impl Network { + fn api_url(&self) -> &str { + match self { + Network::Bitcoin => "https://api.boltz.exchange", + Network::Testnet => "https://api.testnet.boltz.exchange", + Network::Mutinynet => "https://api.testnet.boltz.exchange", + Network::Regtest => "http://localhost:9001", + } + } +} + +impl From for bitcoin::Network { + fn from(network: Network) -> Self { + match network { + Network::Bitcoin => bitcoin::Network::Bitcoin, + Network::Testnet => bitcoin::Network::Testnet, + Network::Mutinynet => bitcoin::Network::Signet, + Network::Regtest => bitcoin::Network::Regtest, + } + } +} + +pub struct BoltzLightning { + client: reqwest::Client, + network: Network, + api_url: String, + ws_client: Arc>, + + wallet: Arc>, + + receiver: lampo_common::event::Subscriber, + + handler: Mutex>, + + // Indexer client for getting VTXOs + _indexer: Option>, +} + +impl BoltzLightning { + pub async fn new(network: Network) -> Result { + let client = reqwest::Client::new(); + let api_url = network.api_url().to_string(); + + let mut ws_client = BoltzWebSocketClient::new(network.clone()); + ws_client.connect().await?; + + let receiver = ws_client.subscribe(); + Ok(Self { + client, + network, + api_url, + ws_client: Arc::new(RwLock::new(ws_client)), + wallet: Arc::new(Mutex::new(DummyWallet::new())), + receiver, + + handler: Mutex::new(Arc::new(DummyEventHandler)), + _indexer: None, + }) + } + + pub async fn set_event_handler(&self, handler: Arc) { + let mut guard = self.handler.lock().await; + *guard = handler; + } + + /// Build the Boltz API from the env variables! + pub async fn build_from_env() -> Result { + let network_str = std::env::var("BOLTZ_NETWORK").unwrap_or_else(|_| "testnet".to_string()); + let network = match network_str.as_str() { + "bitcoin" | "mainnet" => Network::Bitcoin, + "testnet" => Network::Testnet, + "mutinynet" => Network::Mutinynet, + "regtest" => Network::Regtest, + _ => Network::Testnet, + }; + + Self::new(network).await + } + + /// See: https://github.com/arkade-os/boltz-swap/blob/d7b321840e8f90d70ab8d74990c61bb25aa92dc1/src/arkade-lightning.ts#L254 + pub(crate) async fn claim_htlc(&self, swap: &PersistedSwap) -> Result<()> { + let wallet = self.wallet.lock().await; + + let preimage = swap.metadata.preimage().ok_or_else(|| { + anyhow::anyhow!("No preimage found for swap id `{}`", swap.id.clone()) + })?; + let _preimage_bytes = hex::decode(&preimage) + .map_err(|err| anyhow::anyhow!("Failed to decode preimage hex: {}", err))?; + + let sender_xpub = swap.metadata.refund_xpub().ok_or_else(|| { + anyhow::anyhow!( + "No claim public key found for swap id `{}`", + swap.id.clone() + ) + })?; + let sender_xpub_bytes = hex::decode(&sender_xpub) + .map_err(|err| anyhow::anyhow!("Failed to decode sender xpub hex: {}", err))?; + let sender_xpub = bitcoin::XOnlyPublicKey::from_slice(&sender_xpub_bytes) + .map_err(|err| anyhow::anyhow!("Parsing sender xpub: {}", err))?; + let receiver_xpub = wallet.get_xpub(); + let server_xpub = wallet.get_server_xpub(); + + let preimage_hash_str = swap.metadata.preimage_hash().unwrap(); + let preimage_hash_bytes = hex::decode(&preimage_hash_str) + .map_err(|err| anyhow::anyhow!("Failed to decode preimage hash hex: {}", err))?; + let mut preimage_hash = [0u8; 20]; + preimage_hash.copy_from_slice(&preimage_hash_bytes); + + let htlc = VhtlcScript::new(VhtlcOptions { + preimage_hash, + sender: sender_xpub, + receiver: receiver_xpub, + server: server_xpub, + refund_locktime: swap.metadata.timeout_block_height() as u32, + unilateral_claim_delay: Sequence::ZERO, + unilateral_refund_delay: Sequence::ZERO, + unilateral_refund_without_receiver_delay: Sequence::ZERO, + })?; + + if htlc + .address(self.network.into(), sender_xpub) + .unwrap() + .to_string() + != swap.metadata.address() + { + return Err(anyhow::anyhow!( + "HTLC address does not match the expected address" + )); + } + + let vtxo_inputs: Vec = wallet.spendable_utxos(&htlc).await?; + if vtxo_inputs.is_empty() { + return Err(anyhow::anyhow!("No spendable VTXOs found for HTLC claim")); + } + + // Create output for the claim (send to wallet address) + let claim_address = wallet.new_address().await?; + let claim_amount = swap.metadata.amount(); + + // Build offchain transactions using ark_core + let outputs = vec![(&claim_address, claim_amount)]; + let dust_limit = Amount::from_sat(1000); // TODO: Get from server config + + let OffchainTransactions { + ark_tx, + checkpoint_txs, + } = build_offchain_transactions( + &outputs, + None, // No change address for now + &vtxo_inputs, + dust_limit, + ) + .map_err(|err| anyhow::anyhow!("Failed to build offchain transactions: {}", err))?; + + let ark_tx = wallet.sign_tx(ark_tx).await?; + wallet.broadcast_tx(ark_tx).await?; + Ok(()) + } + + pub async fn spawn(self: Arc) { + let this = self.clone(); + tokio::spawn(async move { + let mut receiver = this.receiver.subscribe(); + while let Some(SwapUpdate { id, status }) = receiver.recv().await { + let ws = this.ws_client.read().await; + let result = ws.update_swap_status(id.clone(), status.clone()).await; + if let Err(err) = result { + eprintln!("Failed to update swap status: {}", err); + continue; + } + + let handler = this.handler.lock().await; + let handler = handler.clone(); + + let swap = ws.get_swap(&id).await; + assert!(swap.is_some()); + // SAFETY: it should be never None here + let swap = swap.unwrap(); + match status { + SwapStatus::Created => { + let status = ws.get_swap(&id).await; + assert!(status.is_some(), "Swap with id `{}` must exist", id); + } + SwapStatus::TransactionMempool | SwapStatus::TransactionConfirmed => { + // make a double check with what we see on chain or in the virtual mempool + println!("Swap {} failed!", id); + let Err(err) = self.claim_htlc(&swap).await else { + continue; + }; + eprintln!("Failed to claim HTLC for swap {}: {}", id, err); + } + // Ark -> lightning + SwapStatus::InvoiceSet => { + println!("Swap {} invoice settled!", id); + let status_response = self.get_swap_status(&swap.id).await; + match status_response { + Ok(fresh_swap_status) => { + println!("Fresh swap status for {}: {:?}", id, fresh_swap_status); + if let Some(tx_info) = &fresh_swap_status.transaction { + let txid = tx_info.id.clone(); + // TODO: emit and event for the called! + println!("Transaction ID: {}", txid); + } + } + Err(_) => { + println!("Failed to get fresh swap status for {}", id); + } + } + // FIXME: we should pass more information in here! + handler.on_payment_received(swap.metadata.amount()); + } + SwapStatus::InvoicePending => { + println!("Swap {} invoice pending!", id); + handler.on_payment_pending(swap.metadata.amount()); + } + SwapStatus::InvoicePaid => { + println!("Swap {} invoice paid!", id); + handler.on_invoice_paid( + swap.metadata.invoice().unwrap(), + swap.metadata.amount(), + hex::decode(swap.metadata.preimage().unwrap()).unwrap(), + ); + } + SwapStatus::InvoiceFailedToPay => { + println!("Swap {} invoice failed to pay!", id); + // We should drop the swap from the storage, and probably keep track + // somehow in the failure + handler.on_payment_failed(Amount::from_sat(0)); + } + SwapStatus::TransactionRefunded => { + println!("Swap {} transaction refunded!", id); + + // We should drop the swap from the storage, and probably keep track + // somehow in the failure + handler.on_payment_failed(Amount::from_sat(0)); + } + SwapStatus::TransactionFailed => { + println!("Swap {} transaction failed!", id); + + // We should drop the swap from the storage, and probably keep track + // somehow in the failure + handler.on_payment_failed(Amount::from_sat(0)); + } + SwapStatus::TransactionClaimed => { + println!("Swap {} transaction claimed!", id); + handler.on_payment_received(Amount::from_sat(0)); + } + SwapStatus::InvoiceExpired => { + println!("Swap {} invoice expired!", id); + handler.on_payment_failed(Amount::from_sat(0)); + } + SwapStatus::SwapExpired => { + println!("Swap {} expired!", id); + handler.on_payment_failed(Amount::from_sat(0)); + } + SwapStatus::Error { error } => { + println!("Swap {} error: {}", id, error.clone()); + // We should drop the swap from the storage, and probably keep track + // somehow in the failure + } + } + } + }); + } + + pub async fn get_limits(&self) -> Result> { + let url = format!("{}/v2/swap/submarine", self.api_url); + let response = self.client.get(&url).send().await?; + + if !response.status().is_success() { + anyhow::bail!("Failed to get limits: {}", response.status()); + } + + let limits: HashMap = response.json().await?; + Ok(limits) + } + + pub async fn create_submarine_swap( + &self, + request: CreateSubmarineSwapRequest, + ) -> Result { + let url = format!("{}/v2/swap/submarine", self.api_url); + let response = self.client.post(&url).json(&request).send().await?; + + if !response.status().is_success() { + let error_text = response.text().await?; + anyhow::bail!("Failed to create submarine swap: {}", error_text); + } + + let swap_response: CreateSubmarineSwapResponse = response.json().await?; + Ok(swap_response) + } + + pub async fn create_reverse_swap( + &self, + request: CreateReverseSwapRequest, + ) -> Result { + let url = format!("{}/v2/swap/reverse", self.api_url); + let response = self.client.post(&url).json(&request).send().await?; + + if !response.status().is_success() { + let error_text = response.text().await?; + anyhow::bail!("Failed to create reverse swap: {}", error_text); + } + + let swap_response: CreateReverseSwapResponse = response.json().await?; + Ok(swap_response) + } + + pub async fn get_swap_status(&self, swap_id: &str) -> Result { + let url = format!("{}/v2/swap/{}", self.api_url, swap_id); + let response = self.client.get(&url).send().await?; + + if !response.status().is_success() { + anyhow::bail!("Failed to get swap status: {}", response.status()); + } + + let status: GetSwapStatusResponse = response.json().await?; + Ok(status) + } + + pub async fn get_swap_preimage(&self, swap_id: &str) -> Result { + let url = format!("{}/v2/swap/submarine/{}/preimage", self.api_url, swap_id); + let response = self.client.get(&url).send().await?; + + if !response.status().is_success() { + anyhow::bail!("Failed to get swap preimage: {}", response.status()); + } + + let preimage: GetSwapPreimageResponse = response.json().await?; + Ok(preimage) + } + + pub fn format_public_key(public_key: &str) -> String { + let key = public_key.trim_start_matches("0x"); + if key.len() == 64 { + format!("02{}", key) + } else { + key.to_string() + } + } + + /// Get the current status of a persisted swap + pub async fn get_swap_status_from_cache(&self, swap_id: &str) -> Option { + let ws_client = self.ws_client.read().await; + ws_client.get_swap(swap_id).await + } + + /// Remove a swap from persistence and stop monitoring it + pub async fn cleanup_swap(&self, swap_id: &str) -> Result<()> { + let ws_client = self.ws_client.read().await; + ws_client.remove_swap(swap_id).await + } + + /// Manually trigger a WebSocket ping to keep the connection alive + pub async fn ping_ws(&self) -> Result<()> { + let ws_client = self.ws_client.read().await; + ws_client.ping().await + } + + /// Check if WebSocket is connected + pub async fn is_ws_connected(&self) -> bool { + let ws_client = self.ws_client.read().await; + ws_client.is_connected().await + } + + /// Get current WebSocket connection state + pub async fn get_ws_connection_state(&self) -> ConnectionState { + let ws_client = self.ws_client.read().await; + ws_client.get_connection_state().await + } + + /// Manually disconnect WebSocket (useful for cleanup) + pub async fn disconnect_ws(&self) { + let ws_client = self.ws_client.read().await; + ws_client.disconnect().await; + } +} + +impl Lightning for BoltzLightning { + fn get_invoice( + &self, + opts: RcvOptions, + ) -> impl Future> + Send { + async move { + // create the random number called preimage! + // hash this preimage with sha256 and call it preimage_hash + let preimage: [u8; 32] = musig::rand::random(); + let preimage_hash = sha256::Hash::const_hash(&preimage).to_string(); + + let claim_public_key = self.wallet.lock().await.get_xpub(); + let request = CreateReverseSwapRequest { + invoice_amount: opts.invoice_amount.to_sat() as u64, + // FIXME: this need to came from the wallet! + claim_public_key: claim_public_key.to_string(), + preimage_hash: preimage_hash.clone(), + description: opts.description, + }; + let response = self.create_reverse_swap(request).await?; + let invoice: invoice::Bolt11Invoice = response + .invoice + .parse() + .map_err(|err| anyhow::anyhow!("Parsing invoice `{err}`"))?; + + // Persist the swap and subscribe to WebSocket updates + let swap = PersistedSwap { + id: response.id.clone(), + swap_type: WsSwapType::Reverse, + status: SwapStatus::Created, + created_at: std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs(), + metadata: SwapMetadata::Reverse { + preimage: hex::encode(preimage), + preimage_hash, + swap_tree: serde_json::to_value(&response.swap_tree).unwrap(), + refund_public_key: response.refund_public_key.clone(), + lockup_address: response.lockup_address.clone(), + timeout_block_height: response.timeout_block_height, + onchain_amount: response.onchain_amount, + blinding_key: response.blinding_key.clone(), + invoice: response.invoice.clone(), + }, + }; + + let ws_client = self.ws_client.read().await; + ws_client.persist_swap(swap).await?; + Ok(invoice) + } + } + + fn get_offer( + &self, + _opts: RcvOptions, + ) -> impl Future> + Send { + async { unimplemented!() } + } + + fn pay_invoice(&self, opts: SentOptions) -> impl Future> + Send { + async move { + let request = CreateSubmarineSwapRequest { + invoice: opts.invoice.to_string(), + refund_public_key: opts.refund_public_key.to_string(), + }; + + let response = self.create_submarine_swap(request).await?; + + // Persist the swap and subscribe to WebSocket updates + let swap = PersistedSwap { + id: response.id.clone(), + swap_type: WsSwapType::Submarine, + status: SwapStatus::Created, + created_at: std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs(), + metadata: SwapMetadata::Submarine { + address: response.address.clone(), + redeem_script: response.redeem_script.clone(), + accept_zero_conf: response.accept_zero_conf, + expected_amount: response.expected_amount, + claim_public_key: response.claim_public_key.clone(), + timeout_block_height: response.timeout_block_height, + blinding_key: response.blinding_key.clone(), + }, + }; + + let wallet = self.wallet.lock().await; + wallet + .send_bitcoin( + response.address.parse().unwrap(), + Amount::from_sat(response.expected_amount), + ) + .await?; + let ws_client = self.ws_client.read().await; + ws_client.persist_swap(swap).await?; + + Ok(()) + } + } + + fn pay_offer(&self, _opts: SentOptions) -> impl Future> + Send { + async { unimplemented!() } + } + + fn pay_bip321(&self, _bip321: &str) -> impl Future> + Send { + async { unimplemented!() } + } +} diff --git a/ark-lightning/src/boltz/boltz_ws.rs b/ark-lightning/src/boltz/boltz_ws.rs new file mode 100644 index 00000000..c10668f2 --- /dev/null +++ b/ark-lightning/src/boltz/boltz_ws.rs @@ -0,0 +1,645 @@ +//! Boltz WebSocket client for real-time swap monitoring +//! +//! This module provides a WebSocket client for monitoring Boltz submarine and reverse +//! swaps in real-time. +//! +//! # Features +//! +//! - **Real-time Updates**: Receive instant notifications when swap status changes +//! - **Callback System**: Register custom handlers for swap status updates +//! - **Network Support**: Works with Bitcoin mainnet, testnet, mutinynet, and regtest +//! +//! # Example +//! +//! ```rust +//! use ark_lightning::boltz_ws::{BoltzWebSocketClient, SwapUpdate}; +//! use ark_lightning::boltz::Network; +//! +//! #[tokio::main] +//! async fn main() -> Result<(), Box> { +//! // Create and connect client +//! let mut client = BoltzWebSocketClient::new(Network::Testnet); +//! client.connect().await?; +//! +//! // Subscribe to swap updates +//! client.subscribe_to_swap("swap_id_123".to_string()).await?; +//! +//! // Register a callback for status updates +//! let callback = Arc::new(|update: SwapUpdate| { +//! println!("Swap {} status: {:?}", update.id, update.status); +//! }); +//! client.register_callback("swap_id_123".to_string(), callback).await; +//! +//! Ok(()) +//! } +//! ``` +//! +//! Author: Vincenzo Palazzo + +use crate::boltz::storage::NoSqlStorage; +use crate::boltz::storage::SwapStorage; +use crate::boltz::storage::SwapStorageOptions; +use crate::boltz::Network; +use anyhow::Result; +use bitcoin::Amount; +use core::fmt; +use futures_util::SinkExt; +use futures_util::StreamExt; +use lampo_common::event::Subscriber; +use lightning::bolt11_invoice::Bolt11Invoice; +use serde::Deserialize; +use serde::Serialize; +use std::sync::Arc; +use tokio::sync::mpsc; +use tokio::sync::Mutex; +use tokio_tungstenite::connect_async; +use tokio_tungstenite::tungstenite::protocol::Message; + +/// WebSocket request messages sent to the Boltz server +/// +/// These messages control subscriptions and maintain the connection. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "op", rename_all = "lowercase")] +pub enum WsRequest { + /// Subscribe to updates for specific swaps + /// + /// # Fields + /// - `channel`: Usually "swap.update" for swap status updates + /// - `args`: List of swap IDs to monitor + Subscribe { channel: String, args: Vec }, + /// Unsubscribe from swap updates + /// + /// # Fields + /// - `channel`: The channel to unsubscribe from + /// - `args`: List of swap IDs to stop monitoring + Unsubscribe { channel: String, args: Vec }, + /// Heartbeat message to keep the connection alive + Ping, +} + +/// WebSocket response messages received from the Boltz server +/// +/// These messages provide swap updates and connection status information. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "event", rename_all = "lowercase")] +pub enum WsResponse { + /// Confirmation that subscription was successful + Subscribe { channel: String, args: Vec }, + /// Confirmation that unsubscription was successful + Unsubscribe { channel: String, args: Vec }, + /// Swap status update notification + /// + /// Contains one or more swap updates with new status information + Update { + channel: String, + args: Vec, + }, + /// Error response from the server + Error { channel: String, reason: String }, + /// Response to a ping request + Pong, +} + +/// Represents a swap status update received via WebSocket +/// +/// This is the primary data structure for tracking swap progress. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SwapUpdate { + /// Unique identifier of the swap + pub id: String, + /// Current status of the swap + pub status: SwapStatus, +} + +/// All possible states of a Boltz swap +/// +/// Swaps progress through these states during their lifecycle. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum SwapStatus { + /// Initial state when swap is created + Created, + /// Lockup transaction detected in mempool + TransactionMempool, + /// Lockup transaction confirmed on-chain + TransactionConfirmed, + /// Transaction Refunded + TransactionRefunded, + /// Transaction Failed + TransactionFailed, + /// Transaction Claimed + TransactionClaimed, + /// Lightning invoice has been set + InvoiceSet, + /// Waiting for Lightning invoice payment + InvoicePending, + /// Lightning invoice successfully paid + InvoicePaid, + /// Lightning invoice payment failed + InvoiceFailedToPay, + /// Invoice Expired + InvoiceExpired, + /// Swap expired - can be refunded + SwapExpired, + /// Swap failed with error + Error { error: String }, +} + +unsafe impl Send for SwapStatus {} +unsafe impl Sync for SwapStatus {} + +impl fmt::Display for SwapStatus { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + SwapStatus::Created => write!(f, "created"), + SwapStatus::TransactionMempool => write!(f, "transaction_mempool"), + SwapStatus::TransactionConfirmed => write!(f, "transaction_confirmed"), + SwapStatus::TransactionRefunded => write!(f, "transaction_refunded"), + SwapStatus::TransactionFailed => write!(f, "transaction_failed"), + SwapStatus::TransactionClaimed => write!(f, "transaction_claimed"), + SwapStatus::InvoiceSet => write!(f, "invoice_set"), + SwapStatus::InvoicePending => write!(f, "invoice_pending"), + SwapStatus::InvoicePaid => write!(f, "invoice_paid"), + SwapStatus::InvoiceFailedToPay => write!(f, "invoice_failed_to_pay"), + SwapStatus::InvoiceExpired => write!(f, "invoice_expired"), + SwapStatus::SwapExpired => write!(f, "swap_expired"), + SwapStatus::Error { error } => write!(f, "error: {}", error), + } + } +} + +/// Swap metadata fields based on swap type +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(untagged)] +pub enum SwapMetadata { + /// Metadata for reverse submarine swaps (Lightning to on-chain) + Reverse { + /// Preimage for the swap + preimage: String, + /// Hash of the preimage + preimage_hash: String, + /// Swap tree structure + swap_tree: serde_json::Value, + /// Public key for refund + refund_public_key: String, + /// Address where funds are locked + lockup_address: String, + /// Block height when swap times out + timeout_block_height: u64, + /// Amount to be sent on-chain + onchain_amount: u64, + /// Optional blinding key for confidential transactions + blinding_key: Option, + /// Invoice associated with the swap + invoice: String, + }, + /// Metadata for submarine swaps (ark to Lightning) + Submarine { + /// Address to send funds to + address: String, + /// Redeem script for the swap + redeem_script: String, + /// Whether zero-confirmation transactions are accepted + accept_zero_conf: bool, + /// Expected amount to be received + expected_amount: u64, + /// Public key for claiming funds + claim_public_key: String, + /// Block height when swap times out + timeout_block_height: u64, + /// Optional blinding key for confidential transactions + blinding_key: Option, + }, +} + +impl SwapMetadata { + /// Retrieves the preimage if available + /// + /// # Returns + /// - `Some(String)` containing the preimage for reverse swaps + /// - `None` for submarine swaps + pub fn preimage(&self) -> Option { + match self { + SwapMetadata::Reverse { preimage, .. } => Some(preimage.clone()), + SwapMetadata::Submarine { .. } => None, + } + } + + pub fn preimage_hash(&self) -> Option { + match self { + SwapMetadata::Reverse { preimage_hash, .. } => Some(preimage_hash.clone()), + SwapMetadata::Submarine { .. } => None, + } + } + + pub fn address(&self) -> String { + match self { + SwapMetadata::Reverse { lockup_address, .. } => lockup_address.clone(), + SwapMetadata::Submarine { address, .. } => address.clone(), + } + } + + pub fn amount(&self) -> Amount { + let amount = match self { + SwapMetadata::Reverse { onchain_amount, .. } => *onchain_amount, + SwapMetadata::Submarine { + expected_amount, .. + } => *expected_amount, + }; + Amount::from_sat(amount) + } + + pub fn invoice(&self) -> Option { + match self { + SwapMetadata::Reverse { invoice, .. } => { + let invoice = invoice.parse::().unwrap(); + Some(invoice) + } + SwapMetadata::Submarine { .. } => None, + } + } + + pub fn timeout_block_height(&self) -> u64 { + match self { + SwapMetadata::Reverse { + timeout_block_height, + .. + } => *timeout_block_height, + SwapMetadata::Submarine { + timeout_block_height, + .. + } => *timeout_block_height, + } + } + + pub fn refund_xpub(&self) -> Option { + match self { + SwapMetadata::Reverse { + refund_public_key, .. + } => Some(refund_public_key.clone()), + SwapMetadata::Submarine { .. } => None, + } + } +} + +/// Persistent swap data +/// +/// This structure maintains swap state. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PersistedSwap { + /// Unique swap identifier + pub id: String, + /// Type of swap (submarine or reverse) + pub swap_type: SwapType, + /// Current swap status + pub status: SwapStatus, + /// Unix timestamp when swap was created + pub created_at: u64, + /// Swap-specific metadata + pub metadata: SwapMetadata, +} + +/// Type of Boltz swap +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum SwapType { + /// On-chain to Lightning swap + Submarine, + /// Lightning to on-chain swap + Reverse, +} + +impl fmt::Display for SwapType { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + SwapType::Submarine => write!(f, "submarine"), + SwapType::Reverse => write!(f, "reverse_submarine"), + } + } +} + +/// WebSocket connection states +/// +/// Tracks the current state of the WebSocket connection. +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum ConnectionState { + /// Not connected to the server + Disconnected, + /// Successfully connected and operational + Connected, +} + +/// Main WebSocket client for Boltz swap monitoring +/// +/// This client provides a WebSocket connection to the Boltz server +/// for real-time swap status updates. +/// +/// # Features +/// - Persistent swap tracking +/// - Concurrent callback system +/// - Network-specific endpoint selection +pub struct BoltzWebSocketClient { + #[allow(dead_code)] + network: Network, + ws_url: String, + swaps: NoSqlStorage, + connection_state: Arc>, + + sender: Arc>>>, + receiver: Arc>>>, + + // Event base system, the ws when receive a new update from the + // `receiver` channel will emit a new event with the emitter that + // is the location when all the subscription are listening! + emitter: lampo_common::event::Emitter, +} + +impl BoltzWebSocketClient { + /// Creates a new WebSocket client for the specified network + /// + /// # Arguments + /// - `network`: The Bitcoin network to connect to (mainnet, testnet, mutinynet, or regtest) + /// + /// # Returns + /// A new unconnected WebSocket client instance + /// + /// # Example + /// ``` + /// let client = BoltzWebSocketClient::new(Network::Testnet); + /// ``` + pub fn new(network: Network) -> Self { + let ws_url = match network { + Network::Bitcoin => "wss://api.boltz.exchange/v2/ws", + Network::Testnet | Network::Mutinynet => "wss://api.testnet.boltz.exchange/v2/ws", + Network::Regtest => "ws://localhost:9001/v2/ws", + }; + + Self { + network, + ws_url: ws_url.to_string(), + swaps: NoSqlStorage::new(SwapStorageOptions { + path: "boltz_swaps_db".to_string(), + }) + .unwrap(), + connection_state: Arc::new(Mutex::new(ConnectionState::Disconnected)), + + sender: Arc::new(Mutex::new(None)), + receiver: Arc::new(Mutex::new(None)), + + emitter: lampo_common::event::Emitter::default(), + } + } + + pub fn subscribe(&self) -> Subscriber { + self.emitter.subscriber() + } + + /// Establishes WebSocket connection to the Boltz server + /// + /// # Returns + /// - `Ok(())` if connection is successful + /// - `Err` if connection fails + /// + /// # Example + /// ``` + /// let mut client = BoltzWebSocketClient::new(Network::Testnet); + /// client.connect().await?; + /// ``` + pub async fn connect(&mut self) -> Result<()> { + // Connect to WebSocket + let (ws_stream, _) = connect_async(&self.ws_url).await?; + let (mut write, mut read) = ws_stream.split(); + let (tx, mut rx) = mpsc::unbounded_channel::(); + + // Update sender + { + let mut sender_guard = self.sender.lock().await; + *sender_guard = Some(tx.clone()); + } + + // Update connection state + { + let mut state = self.connection_state.lock().await; + *state = ConnectionState::Connected; + } + + let connection_state_clone = self.connection_state.clone(); + let sender_clone = self.sender.clone(); + let receiver_clone = self.receiver.clone(); + + // Spawn task to handle outgoing messages + tokio::spawn(async move { + while let Some(request) = rx.recv().await { + let msg = serde_json::to_string(&request).unwrap(); + if let Err(e) = write.send(Message::Text(msg)).await { + eprintln!("Failed to send WebSocket message: {}", e); + break; + } + } + }); + + // Spawn task to handle incoming messages + let emitter_clone = self.emitter.clone(); + tokio::spawn(async move { + while let Some(msg) = read.next().await { + match msg { + Ok(Message::Text(text)) => { + if let Ok(response) = serde_json::from_str::(&text) { + // FIXME: emit a new event here for the end user! + match response { + WsResponse::Update { args, .. } => { + for update in args { + // Emit event for each swap update + emitter_clone.emit(update.clone()); + } + } + _ => { + println!("Received message: {:?}", response); + } + } + } + } + Ok(Message::Close(_)) => { + println!("WebSocket connection closed by server"); + break; + } + Err(e) => { + eprintln!("WebSocket error: {}", e); + break; + } + _ => {} + } + } + + // Update state to disconnected + let mut state = connection_state_clone.lock().await; + *state = ConnectionState::Disconnected; + + // Clear the sender + let mut sender_guard = sender_clone.lock().await; + *sender_guard = None; + + let mut receiver_guard = receiver_clone.lock().await; + *receiver_guard = None; + }); + + Ok(()) + } + + /// Subscribes to status updates for a specific swap + /// + /// # Arguments + /// - `swap_id`: The unique identifier of the swap to monitor + /// + /// # Returns + /// - `Ok(())` if subscription request is sent successfully + /// - `Err` if the client is not connected + /// + /// # Example + /// ``` + /// client.subscribe_to_swap("swap_123".to_string()).await?; + /// ``` + pub async fn subscribe_to_swap(&self, swap_id: String) -> Result<()> { + let request = WsRequest::Subscribe { + channel: "swap.update".to_string(), + args: vec![swap_id], + }; + + self.send_request(request).await + } + + async fn send_request(&self, request: WsRequest) -> Result<()> { + let sender_guard = self.sender.lock().await; + if let Some(sender) = &*sender_guard { + sender.send(request)?; + Ok(()) + } else { + anyhow::bail!("WebSocket not connected") + } + } + + /// Unsubscribes from status updates for a specific swap + /// + /// # Arguments + /// - `swap_id`: The swap ID to stop monitoring + /// + /// # Returns + /// - `Ok(())` if unsubscription request is sent successfully + /// - `Err` if the client is not connected + pub async fn unsubscribe_from_swap(&self, swap_id: String) -> Result<()> { + let request = WsRequest::Unsubscribe { + channel: "swap.update".to_string(), + args: vec![swap_id], + }; + + self.send_request(request).await + } + + /// Persists swap data and automatically subscribes to its updates + /// + /// # Arguments + /// - `swap`: The swap data to persist + /// + /// # Returns + /// - `Ok(())` if swap is persisted and subscription is successful + /// - `Err` if subscription fails + /// + /// # Example + /// ``` + /// let swap = PersistedSwap { + /// id: "swap_123".to_string(), + /// swap_type: SwapType::Submarine, + /// status: SwapStatus::Created, + /// created_at: 1234567890, + /// metadata: HashMap::new(), + /// }; + /// client.persist_swap(swap).await?; + /// ``` + pub async fn persist_swap(&self, swap: PersistedSwap) -> Result<()> { + let swap = self.swaps.save_swap(swap.clone()).await?.value; + // Subscribe to updates for this swap + self.subscribe_to_swap(swap.id).await?; + Ok(()) + } + + /// Retrieves persisted swap data by ID + /// + /// # Arguments + /// - `swap_id`: The ID of the swap to retrieve + /// + /// # Returns + /// - `Some(PersistedSwap)` if the swap exists + /// - `None` if the swap is not found + pub async fn get_swap(&self, swap_id: &str) -> Option { + self.swaps + .get_swap(swap_id.to_string()) + .await + .ok() + .flatten() + } + + pub async fn update_swap_status(&self, swap_id: String, new_status: SwapStatus) -> Result<()> { + self.swaps + .update_swap_with_status(&swap_id, new_status) + .await?; + Ok(()) + } + + /// Removes a swap from tracking and unsubscribes from its updates + /// + /// # Arguments + /// - `swap_id`: The ID of the swap to remove + /// + /// # Returns + /// - `Ok(())` if removal is successful + /// - `Err` if unsubscription fails + pub async fn remove_swap(&self, swap_id: &str) -> Result<()> { + let swap = self.swaps.delete_swap(swap_id).await?; + let Some(swap) = swap else { + anyhow::bail!("Swap with ID `{swap_id}` not found"); + }; + + // Unsubscribe from updates + self.unsubscribe_from_swap(swap.id.to_string()).await?; + + Ok(()) + } + + /// Sends a ping message to keep the connection alive + /// + /// # Returns + /// - `Ok(())` if ping is sent successfully + /// - `Err` if the client is not connected + pub async fn ping(&self) -> Result<()> { + let request = WsRequest::Ping; + self.send_request(request).await + } + + /// Gets the current connection state + /// + /// # Returns + /// The current `ConnectionState` enum value + pub async fn get_connection_state(&self) -> ConnectionState { + *self.connection_state.lock().await + } + + /// Checks if the client is currently connected + /// + /// # Returns + /// - `true` if the WebSocket is connected and operational + /// - `false` otherwise + pub async fn is_connected(&self) -> bool { + *self.connection_state.lock().await == ConnectionState::Connected + } + + /// Gracefully disconnects from the WebSocket server + pub async fn disconnect(&self) { + // Clear sender to trigger disconnection + let mut sender_guard = self.sender.lock().await; + *sender_guard = None; + + // Update state + let mut state = self.connection_state.lock().await; + *state = ConnectionState::Disconnected; + } +} diff --git a/ark-lightning/src/boltz/model.rs b/ark-lightning/src/boltz/model.rs new file mode 100644 index 00000000..7025b659 --- /dev/null +++ b/ark-lightning/src/boltz/model.rs @@ -0,0 +1,132 @@ +//! Boltz API models +//! +//! Author: Vincenzo Palazzo , +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CreateReverseSwapRequest { + #[serde(rename = "invoiceAmount")] + pub invoice_amount: u64, + #[serde(rename = "claimPublicKey")] + pub claim_public_key: String, + #[serde(rename = "preimageHash")] + pub preimage_hash: String, + pub description: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CreateReverseSwapResponse { + pub id: String, + pub invoice: String, + #[serde(rename = "swapTree")] + pub swap_tree: SwapTree, + #[serde(rename = "refundPublicKey")] + pub refund_public_key: String, + #[serde(rename = "lockupAddress")] + pub lockup_address: String, + #[serde(rename = "timeoutBlockHeight")] + pub timeout_block_height: u64, + #[serde(rename = "onchainAmount")] + pub onchain_amount: u64, + #[serde(rename = "blindingKey", skip_serializing_if = "Option::is_none")] + pub blinding_key: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SwapTree { + #[serde(rename = "claimLeaf")] + pub claim_leaf: TreeLeaf, + #[serde(rename = "refundLeaf")] + pub refund_leaf: TreeLeaf, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TreeLeaf { + pub version: u8, + pub output: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct GetSwapStatusResponse { + pub status: String, + #[serde(rename = "zeroConfRejected", skip_serializing_if = "Option::is_none")] + pub zero_conf_rejected: Option, + pub transaction: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub error: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TransactionInfo { + pub id: String, + pub hex: Option, + #[serde(rename = "blockHeight", skip_serializing_if = "Option::is_none")] + pub block_height: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct GetSwapPreimageResponse { + pub preimage: String, +} diff --git a/ark-lightning/src/boltz/storage.rs b/ark-lightning/src/boltz/storage.rs new file mode 100644 index 00000000..e883c60c --- /dev/null +++ b/ark-lightning/src/boltz/storage.rs @@ -0,0 +1,128 @@ +//! Boltz Storage module Module +//! +//! This module allow to make persistance of the Boltz swap data +//! and allow async execution of the swap +//! +//! Author: Vincenzo Palazzo + +use super::boltz_ws::PersistedSwap; +use crate::boltz::SwapStatus; +use nosql_db::NoSQL; +use std::future::Future; +use std::sync::Arc; + +pub struct SwapStorageOptions { + pub path: String, +} + +pub struct SwapStorageData { + pub value: PersistedSwap, +} + +/// SwapStorage trait that define the API +/// to store and swap data. +pub trait SwapStorage: Sync { + /// Save the swap data + fn save_swap( + &self, + swap: PersistedSwap, + ) -> impl Future> + Send; + + /// Delete the swap data + fn delete_swap( + &self, + swap_id: &str, + ) -> impl Future>> + Send; + + fn get_swap( + &self, + swap_id: String, + ) -> impl Future>> + Send; + + fn update_swap_with_status( + &self, + swap_id: &str, + status: SwapStatus, + ) -> impl Future> + Send { + let swap_id = swap_id.to_string(); + let status = status; + async move { + if let Some(mut swap) = self.get_swap(swap_id.clone()).await? { + swap.status = status.clone(); + self.save_swap(swap).await?; + } else { + return Err(anyhow::anyhow!("Swap with id `{}` not found", swap_id)); + } + Ok(()) + } + } +} + +const SWAP_PREFIX: &str = "swap"; +/// NoSql storage implementation +pub struct NoSqlStorage { + inner: Arc, +} + +impl NoSqlStorage { + /// Create a new instance of the NoSqlStorage + pub fn new(opts: SwapStorageOptions) -> Result { + let db = nosql_sled::SledDB::new(&opts.path)?; + Ok(Self { + inner: Arc::new(db), + }) + } + + fn create_internal_key(swap: PersistedSwap) -> String { + format!("{SWAP_PREFIX}/{}", swap.id) + } +} + +impl SwapStorage for NoSqlStorage { + fn save_swap( + &self, + swap: PersistedSwap, + ) -> impl Future> + Send { + let db = self.inner.clone(); + let key = Self::create_internal_key(swap.clone()); + async move { + let data = serde_json::to_string(&swap) + .map_err(|e| anyhow::anyhow!("Failed to serialize swap: {}", e))?; + db.put(&key, &data)?; + Ok(SwapStorageData { value: swap }) + } + } + + fn delete_swap( + &self, + swap_id: &str, + ) -> impl Future>> + Send { + let db = self.inner.clone(); + let swap_id = swap_id.to_string(); + async move { + let data = nosql_sled::SledDB::drop(&db, &swap_id)?; + let data = if let Some(data) = data { + serde_json::from_str::(&data).ok() + } else { + None + }; + Ok(data) + } + } + + fn get_swap( + &self, + swap_id: String, + ) -> impl Future>> + Send { + let db = self.inner.clone(); + async move { + let data = db.get(&swap_id).ok(); + let data = if let Some(data) = data { + serde_json::from_str::(&data).ok() + } else { + None + }; + Ok(data) + } + } +} diff --git a/ark-lightning/src/lib.rs b/ark-lightning/src/lib.rs new file mode 100644 index 00000000..2c6ef6cd --- /dev/null +++ b/ark-lightning/src/lib.rs @@ -0,0 +1,9 @@ +//! Lightning Network Module for the Ark Lightning Swap +//! +//! Vincenzo Palazzo + +pub use lightning as ldk; + +pub mod arkln; +pub mod boltz; +pub mod vhtlc; diff --git a/ark-lightning/src/vhtlc.rs b/ark-lightning/src/vhtlc.rs new file mode 100644 index 00000000..632c2f06 --- /dev/null +++ b/ark-lightning/src/vhtlc.rs @@ -0,0 +1,407 @@ +//! Virtual Hash Time Lock Contract (VHTLC) implementation for Ark Lightning Swaps +//! +//! This module implements VHTLC scripts that enable atomic swaps and conditional +//! payments in the Ark protocol. The VHTLC provides multiple spending paths with +//! different conditions and participants. + +use ark_core::ArkAddress; +use ark_core::UNSPENDABLE_KEY; +use bitcoin::opcodes::all::*; +use bitcoin::taproot::TaprootBuilder; +use bitcoin::taproot::TaprootSpendInfo; +use bitcoin::Network; +use bitcoin::PublicKey; +use bitcoin::ScriptBuf; +use bitcoin::Sequence; +use bitcoin::XOnlyPublicKey; +use serde::Deserialize; +use serde::Serialize; +use std::collections::BTreeMap; +use std::str::FromStr; +use thiserror::Error; + +#[derive(Debug, Error)] +pub enum VhtlcError { + #[error("Invalid preimage hash length: expected 20 bytes, got {0}")] + InvalidPreimageHashLength(usize), + #[error("Invalid public key length: expected 32 bytes, got {0}")] + InvalidPublicKeyLength(usize), + #[error("Invalid locktime: {0}")] + InvalidLocktime(String), + #[error("Invalid delay: {0}")] + InvalidDelay(String), + #[error("Taproot construction failed: {0}")] + TaprootError(String), +} + +/// Represents a script with its weight for taproot tree construction +#[derive(Debug, Clone)] +struct TaprootScriptItem { + script: ScriptBuf, + weight: u32, +} + +/// Internal tree node for building the taproot tree structure +#[derive(Debug, Clone)] +enum TaprootTreeNode { + Leaf { + script: ScriptBuf, + weight: u32, + }, + Branch { + left: Box, + right: Box, + weight: u32, + }, +} + +/// Options for creating a VHTLC (Virtual Hash Time Lock Contract) +/// +/// This structure contains all the necessary parameters to construct a VHTLC, +/// including the public keys of participants and various timeout values. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct VhtlcOptions { + pub sender: XOnlyPublicKey, + pub receiver: XOnlyPublicKey, + pub server: XOnlyPublicKey, + pub preimage_hash: [u8; 20], + pub refund_locktime: u32, + pub unilateral_claim_delay: Sequence, + pub unilateral_refund_delay: Sequence, + pub unilateral_refund_without_receiver_delay: Sequence, +} + +impl VhtlcOptions { + pub fn validate(&self) -> Result<(), VhtlcError> { + if self.refund_locktime == 0 { + return Err(VhtlcError::InvalidLocktime( + "Refund locktime must be greater than 0".to_string(), + )); + } + + if !self.unilateral_claim_delay.is_relative_lock_time() + || self.unilateral_claim_delay.to_consensus_u32() == 0 + { + return Err(VhtlcError::InvalidDelay( + "Unilateral claim delay must be a valid non-zero CSV relative lock time" + .to_string(), + )); + } + + if !self.unilateral_refund_delay.is_relative_lock_time() + || self.unilateral_refund_delay.to_consensus_u32() == 0 + { + return Err(VhtlcError::InvalidDelay( + "Unilateral refund delay must be a valid non-zero CSV relative lock time" + .to_string(), + )); + } + + if !self + .unilateral_refund_without_receiver_delay + .is_relative_lock_time() + || self + .unilateral_refund_without_receiver_delay + .to_consensus_u32() + == 0 + { + return Err(VhtlcError::InvalidDelay( + "Unilateral refund without receiver delay must be a valid non-zero CSV relative lock time" + .to_string(), + )); + } + + Ok(()) + } +} + +/// VHTLC Script builder and manager +/// +/// This struct creates and manages VHTLC scripts with six different spending paths: +/// 1. **Claim**: Receiver reveals preimage (collaborative with server) +/// 2. **Refund**: Collaborative refund (all three parties) +/// 3. **Refund without Receiver**: Sender refunds after locktime (with server) +/// 4. **Unilateral Claim**: Receiver claims after delay (no server needed) +/// 5. **Unilateral Refund**: Collaborative unilateral refund after delay +/// 6. **Unilateral Refund without Receiver**: Sender unilateral refund after both timeouts +pub struct VhtlcScript { + options: VhtlcOptions, + taproot_info: Option, +} + +impl VhtlcScript { + /// Creates a new VHTLC script with the given options + /// + /// This will validate the options and build the complete taproot tree + /// with all spending paths. + pub fn new(options: VhtlcOptions) -> Result { + options.validate()?; + let mut script = Self { + options, + taproot_info: None, + }; + script.build_taproot()?; + Ok(script) + } + + /// Creates the claim script where receiver reveals the preimage + /// + /// Requires: preimage hash verification + receiver signature + server signature + pub fn claim_script(&self) -> ScriptBuf { + ScriptBuf::builder() + .push_opcode(OP_HASH160) + .push_slice(&self.options.preimage_hash) + .push_opcode(OP_EQUAL) + .push_opcode(OP_VERIFY) + .push_x_only_key(&self.options.receiver) + .push_opcode(OP_CHECKSIGVERIFY) + .push_x_only_key(&self.options.server) + .push_opcode(OP_CHECKSIG) + .into_script() + } + + /// Creates the collaborative refund script + /// + /// Requires: sender + receiver + server signatures + pub fn refund_script(&self) -> ScriptBuf { + ScriptBuf::builder() + .push_x_only_key(&self.options.sender) + .push_opcode(OP_CHECKSIGVERIFY) + .push_x_only_key(&self.options.receiver) + .push_opcode(OP_CHECKSIGVERIFY) + .push_x_only_key(&self.options.server) + .push_opcode(OP_CHECKSIG) + .into_script() + } + + /// Creates the refund script when receiver is unavailable + /// + /// Requires: CLTV timeout + sender + server signatures + pub fn refund_without_receiver_script(&self) -> ScriptBuf { + ScriptBuf::builder() + .push_int(self.options.refund_locktime as i64) + .push_opcode(OP_CLTV) + .push_opcode(OP_DROP) + .push_x_only_key(&self.options.sender) + .push_opcode(OP_CHECKSIGVERIFY) + .push_x_only_key(&self.options.server) + .push_opcode(OP_CHECKSIG) + .into_script() + } + + /// Creates the unilateral claim script (no server cooperation needed) + /// + /// Requires: preimage hash verification + CSV delay + receiver signature + pub fn unilateral_claim_script(&self) -> ScriptBuf { + let sequence = self.options.unilateral_claim_delay; + ScriptBuf::builder() + .push_opcode(OP_HASH160) + .push_slice(&self.options.preimage_hash) + .push_opcode(OP_EQUAL) + .push_opcode(OP_VERIFY) + .push_int(sequence.to_consensus_u32() as i64) + .push_opcode(OP_CSV) + .push_opcode(OP_DROP) + .push_x_only_key(&self.options.receiver) + .push_opcode(OP_CHECKSIG) + .into_script() + } + + /// Creates the unilateral refund script + /// + /// Requires: CSV delay + sender + receiver signatures + pub fn unilateral_refund_script(&self) -> ScriptBuf { + let sequence = self.options.unilateral_refund_delay; + ScriptBuf::builder() + .push_int(sequence.to_consensus_u32() as i64) + .push_opcode(OP_CSV) + .push_opcode(OP_DROP) + .push_x_only_key(&self.options.sender) + .push_opcode(OP_CHECKSIGVERIFY) + .push_x_only_key(&self.options.receiver) + .push_opcode(OP_CHECKSIG) + .into_script() + } + + /// Creates the unilateral refund script when receiver is unavailable + /// + /// Requires: CSV delay + sender signature + pub fn unilateral_refund_without_receiver_script(&self) -> ScriptBuf { + let sequence = self.options.unilateral_refund_without_receiver_delay; + ScriptBuf::builder() + .push_int(sequence.to_consensus_u32() as i64) + .push_opcode(OP_CSV) + .push_opcode(OP_DROP) + .push_x_only_key(&self.options.sender) + .push_opcode(OP_CHECKSIG) + .into_script() + } + + /// Build a balanced taproot tree from a list of scripts with weights + /// Following the TypeScript algorithm from scure-btc-signer + fn taproot_list_to_tree( + scripts: Vec, + ) -> Result { + if scripts.is_empty() { + return Err(VhtlcError::TaprootError("Empty script list".to_string())); + } + + // Clone input and convert to nodes + let mut lst: Vec = scripts + .into_iter() + .map(|item| TaprootTreeNode::Leaf { + script: item.script, + weight: item.weight, + }) + .collect(); + + // Build tree by combining nodes with smallest weights + while lst.len() >= 2 { + // Sort: elements with smallest weight are at the end of queue + lst.sort_by(|a, b| { + let weight_a = match a { + TaprootTreeNode::Leaf { weight, .. } => *weight, + TaprootTreeNode::Branch { weight, .. } => *weight, + }; + let weight_b = match b { + TaprootTreeNode::Leaf { weight, .. } => *weight, + TaprootTreeNode::Branch { weight, .. } => *weight, + }; + // Reverse comparison to put smallest at end + weight_b.cmp(&weight_a) + }); + + // Pop the two smallest weight nodes + let b = lst.pop().unwrap(); + let a = lst.pop().unwrap(); + + // Calculate combined weight + let weight_a = match &a { + TaprootTreeNode::Leaf { weight, .. } => *weight, + TaprootTreeNode::Branch { weight, .. } => *weight, + }; + let weight_b = match &b { + TaprootTreeNode::Leaf { weight, .. } => *weight, + TaprootTreeNode::Branch { weight, .. } => *weight, + }; + + // Create branch with combined weight + lst.push(TaprootTreeNode::Branch { + weight: weight_a + weight_b, + left: Box::new(a), + right: Box::new(b), + }); + } + + // Return the root node + Ok(lst.into_iter().next().unwrap()) + } + + /// Recursively add tree nodes to TaprootBuilder + fn add_tree_to_builder( + builder: TaprootBuilder, + node: &TaprootTreeNode, + depth: u8, + ) -> Result { + match node { + TaprootTreeNode::Leaf { script, .. } => builder + .add_leaf(depth, script.clone()) + .map_err(|e| VhtlcError::TaprootError(format!("Failed to add leaf: {}", e))), + TaprootTreeNode::Branch { left, right, .. } => { + let builder = Self::add_tree_to_builder(builder, left, depth + 1)?; + Self::add_tree_to_builder(builder, right, depth + 1) + } + } + } + + fn build_taproot(&mut self) -> Result<(), VhtlcError> { + let internal_pubkey = PublicKey::from_str(UNSPENDABLE_KEY).map_err(|e| { + VhtlcError::TaprootError(format!("Failed to parse internal key: {}", e)) + })?; + let internal_key = XOnlyPublicKey::from(internal_pubkey); + + // Create script list with weights + // Lower weight = more likely to be used = shallower in tree + let scripts = vec![ + TaprootScriptItem { + script: self.claim_script(), + weight: 1, // Most likely - collaborative claim + }, + TaprootScriptItem { + script: self.refund_script(), + weight: 1, // Most likely - collaborative refund + }, + TaprootScriptItem { + script: self.refund_without_receiver_script(), + weight: 1, // Less common + }, + TaprootScriptItem { + script: self.unilateral_claim_script(), + weight: 1, // Less common + }, + TaprootScriptItem { + script: self.unilateral_refund_script(), + weight: 1, // Least common + }, + TaprootScriptItem { + script: self.unilateral_refund_without_receiver_script(), + weight: 1, // Least common + }, + ]; + + // Build the tree using the weight-based algorithm + let tree = Self::taproot_list_to_tree(scripts)?; + + // Create TaprootBuilder and add the tree + let builder = TaprootBuilder::new(); + let builder = Self::add_tree_to_builder(builder, &tree, 0)?; + + let secp = bitcoin::secp256k1::Secp256k1::new(); + let taproot_info = builder.finalize(&secp, internal_key).map_err(|e| { + VhtlcError::TaprootError(format!("Failed to finalize taproot: {:?}", e)) + })?; + + self.taproot_info = Some(taproot_info); + Ok(()) + } + + pub fn taproot_info(&self) -> Option<&TaprootSpendInfo> { + self.taproot_info.as_ref() + } + + pub fn script_pubkey(&self) -> Option { + self.taproot_info.as_ref().map(|info| { + ScriptBuf::builder() + .push_opcode(OP_PUSHNUM_1) + .push_slice(info.output_key().serialize()) + .into_script() + }) + } + + pub fn address(&self, network: Network, server: XOnlyPublicKey) -> Option { + ArkAddress::new(network, server, self.taproot_info()?.output_key()).into() + } + + pub fn get_script_map(&self) -> BTreeMap { + let mut map = BTreeMap::new(); + map.insert("claim".to_string(), self.claim_script()); + map.insert("refund".to_string(), self.refund_script()); + map.insert( + "refund_without_receiver".to_string(), + self.refund_without_receiver_script(), + ); + map.insert( + "unilateral_claim".to_string(), + self.unilateral_claim_script(), + ); + map.insert( + "unilateral_refund".to_string(), + self.unilateral_refund_script(), + ); + map.insert( + "unilateral_refund_without_receiver".to_string(), + self.unilateral_refund_without_receiver_script(), + ); + map + } +} diff --git a/ark-lightning/tests/fixtures/vhtlc.json b/ark-lightning/tests/fixtures/vhtlc.json new file mode 100644 index 00000000..fbe4aa02 --- /dev/null +++ b/ark-lightning/tests/fixtures/vhtlc.json @@ -0,0 +1,258 @@ +{ + "valid": [ + { + "description": "CSV locktime > 16", + "preimageHash": "4d487dd3753a89bc9fe98401d1196523058251fc", + "receiver": "021e1bb85455fe3f5aed60d101aa4dbdb9e7714f6226769a97a17a5331dadcd53b", + "sender": "030192e796452d6df9697c280542e1560557bcf79a347d925895043136225c7cb4", + "server": "03aad52d58162e9eefeafc7ad8a1cdca8060b5f01df1e7583362d052e266208f88", + "refundLocktime": 265, + "unilateralClaimDelay": { + "type": "blocks", + "value": 17 + }, + "unilateralRefundDelay": { + "type": "blocks", + "value": 144 + }, + "unilateralRefundWithoutReceiverDelay": { + "type": "blocks", + "value": 144 + }, + "expected": "tark1qz4d2t2czchfaml2l3ad3gwde2qxpd0srhc7wkpnvtg99cnxyz8c3pnvvhnhumhwhqthmlxmdryakwx99s6508y8dunj9sty2p5mr7unh5re63", + "scripts": { + "claimScript": "a9144d487dd3753a89bc9fe98401d1196523058251fc8769201e1bb85455fe3f5aed60d101aa4dbdb9e7714f6226769a97a17a5331dadcd53bad20aad52d58162e9eefeafc7ad8a1cdca8060b5f01df1e7583362d052e266208f88ac", + "refundScript": "200192e796452d6df9697c280542e1560557bcf79a347d925895043136225c7cb4ad201e1bb85455fe3f5aed60d101aa4dbdb9e7714f6226769a97a17a5331dadcd53bad20aad52d58162e9eefeafc7ad8a1cdca8060b5f01df1e7583362d052e266208f88ac", + "refundWithoutReceiverScript": "020901b175200192e796452d6df9697c280542e1560557bcf79a347d925895043136225c7cb4ad20aad52d58162e9eefeafc7ad8a1cdca8060b5f01df1e7583362d052e266208f88ac", + "unilateralClaimScript": "a9144d487dd3753a89bc9fe98401d1196523058251fc87690111b275201e1bb85455fe3f5aed60d101aa4dbdb9e7714f6226769a97a17a5331dadcd53bac", + "unilateralRefundScript": "029000b275200192e796452d6df9697c280542e1560557bcf79a347d925895043136225c7cb4ad201e1bb85455fe3f5aed60d101aa4dbdb9e7714f6226769a97a17a5331dadcd53bac", + "unilateralRefundWithoutReceiverScript": "029000b275200192e796452d6df9697c280542e1560557bcf79a347d925895043136225c7cb4ac" + }, + "taproot": { + "tweakedPublicKey": "866c65e77e6eeeb8177dfcdb68c9db38c52c35479c876f2722c1645069b1fb93", + "tapTree": "0601c05ca9144d487dd3753a89bc9fe98401d1196523058251fc8769201e1bb85455fe3f5aed60d101aa4dbdb9e7714f6226769a97a17a5331dadcd53bad20aad52d58162e9eefeafc7ad8a1cdca8060b5f01df1e7583362d052e266208f88ac01c066200192e796452d6df9697c280542e1560557bcf79a347d925895043136225c7cb4ad201e1bb85455fe3f5aed60d101aa4dbdb9e7714f6226769a97a17a5331dadcd53bad20aad52d58162e9eefeafc7ad8a1cdca8060b5f01df1e7583362d052e266208f88ac01c049020901b175200192e796452d6df9697c280542e1560557bcf79a347d925895043136225c7cb4ad20aad52d58162e9eefeafc7ad8a1cdca8060b5f01df1e7583362d052e266208f88ac01c03ea9144d487dd3753a89bc9fe98401d1196523058251fc87690111b275201e1bb85455fe3f5aed60d101aa4dbdb9e7714f6226769a97a17a5331dadcd53bac01c049029000b275200192e796452d6df9697c280542e1560557bcf79a347d925895043136225c7cb4ad201e1bb85455fe3f5aed60d101aa4dbdb9e7714f6226769a97a17a5331dadcd53bac01c027029000b275200192e796452d6df9697c280542e1560557bcf79a347d925895043136225c7cb4ac", + "internalKey": "0250929b74c1a04954b78b4b6035e97a5e078a5a0f28ec96d547bfee9ace803ac0" + }, + "decodedScripts": { + "claimScript": "HASH160 0x4d487dd3753a89bc9fe98401d1196523058251fc EQUAL VERIFY 0x1e1bb85455fe3f5aed60d101aa4dbdb9e7714f6226769a97a17a5331dadcd53b CHECKSIGVERIFY 0xaad52d58162e9eefeafc7ad8a1cdca8060b5f01df1e7583362d052e266208f88 CHECKSIG", + "refundScript": "0x0192e796452d6df9697c280542e1560557bcf79a347d925895043136225c7cb4 CHECKSIGVERIFY 0x1e1bb85455fe3f5aed60d101aa4dbdb9e7714f6226769a97a17a5331dadcd53b CHECKSIGVERIFY 0xaad52d58162e9eefeafc7ad8a1cdca8060b5f01df1e7583362d052e266208f88 CHECKSIG", + "refundWithoutReceiverScript": "0x0901 CHECKLOCKTIMEVERIFY DROP 0x0192e796452d6df9697c280542e1560557bcf79a347d925895043136225c7cb4 CHECKSIGVERIFY 0xaad52d58162e9eefeafc7ad8a1cdca8060b5f01df1e7583362d052e266208f88 CHECKSIG", + "unilateralClaimScript": "HASH160 0x4d487dd3753a89bc9fe98401d1196523058251fc EQUAL VERIFY 0x11 CHECKSEQUENCEVERIFY DROP 0x1e1bb85455fe3f5aed60d101aa4dbdb9e7714f6226769a97a17a5331dadcd53b CHECKSIG", + "unilateralRefundScript": "0x9000 CHECKSEQUENCEVERIFY DROP 0x0192e796452d6df9697c280542e1560557bcf79a347d925895043136225c7cb4 CHECKSIGVERIFY 0x1e1bb85455fe3f5aed60d101aa4dbdb9e7714f6226769a97a17a5331dadcd53b CHECKSIG", + "unilateralRefundWithoutReceiverScript": "0x9000 CHECKSEQUENCEVERIFY DROP 0x0192e796452d6df9697c280542e1560557bcf79a347d925895043136225c7cb4 CHECKSIG" + } + }, + { + "description": "CSV locktime <= 16", + "preimageHash": "4d487dd3753a89bc9fe98401d1196523058251fc", + "receiver": "021e1bb85455fe3f5aed60d101aa4dbdb9e7714f6226769a97a17a5331dadcd53b", + "sender": "030192e796452d6df9697c280542e1560557bcf79a347d925895043136225c7cb4", + "server": "03aad52d58162e9eefeafc7ad8a1cdca8060b5f01df1e7583362d052e266208f88", + "refundLocktime": 265, + "unilateralClaimDelay": { + "type": "blocks", + "value": 16 + }, + "unilateralRefundDelay": { + "type": "blocks", + "value": 144 + }, + "unilateralRefundWithoutReceiverDelay": { + "type": "blocks", + "value": 144 + }, + "expected": "tark1qz4d2t2czchfaml2l3ad3gwde2qxpd0srhc7wkpnvtg99cnxyz8c3vyn9exe9gjwcjp5ez0wfhhawvvg0xfenzztjmgp3ddrvkwhw04eztqjn6", + "scripts": { + "claimScript": "a9144d487dd3753a89bc9fe98401d1196523058251fc8769201e1bb85455fe3f5aed60d101aa4dbdb9e7714f6226769a97a17a5331dadcd53bad20aad52d58162e9eefeafc7ad8a1cdca8060b5f01df1e7583362d052e266208f88ac", + "refundScript": "200192e796452d6df9697c280542e1560557bcf79a347d925895043136225c7cb4ad201e1bb85455fe3f5aed60d101aa4dbdb9e7714f6226769a97a17a5331dadcd53bad20aad52d58162e9eefeafc7ad8a1cdca8060b5f01df1e7583362d052e266208f88ac", + "refundWithoutReceiverScript": "020901b175200192e796452d6df9697c280542e1560557bcf79a347d925895043136225c7cb4ad20aad52d58162e9eefeafc7ad8a1cdca8060b5f01df1e7583362d052e266208f88ac", + "unilateralClaimScript": "a9144d487dd3753a89bc9fe98401d1196523058251fc876960b275201e1bb85455fe3f5aed60d101aa4dbdb9e7714f6226769a97a17a5331dadcd53bac", + "unilateralRefundScript": "029000b275200192e796452d6df9697c280542e1560557bcf79a347d925895043136225c7cb4ad201e1bb85455fe3f5aed60d101aa4dbdb9e7714f6226769a97a17a5331dadcd53bac", + "unilateralRefundWithoutReceiverScript": "029000b275200192e796452d6df9697c280542e1560557bcf79a347d925895043136225c7cb4ac" + }, + "taproot": { + "tweakedPublicKey": "b0932e4d92a24ec4834c89ee4defd73188799399884b96d018b5a3659d773eb9", + "tapTree": "0601c05ca9144d487dd3753a89bc9fe98401d1196523058251fc8769201e1bb85455fe3f5aed60d101aa4dbdb9e7714f6226769a97a17a5331dadcd53bad20aad52d58162e9eefeafc7ad8a1cdca8060b5f01df1e7583362d052e266208f88ac01c066200192e796452d6df9697c280542e1560557bcf79a347d925895043136225c7cb4ad201e1bb85455fe3f5aed60d101aa4dbdb9e7714f6226769a97a17a5331dadcd53bad20aad52d58162e9eefeafc7ad8a1cdca8060b5f01df1e7583362d052e266208f88ac01c049020901b175200192e796452d6df9697c280542e1560557bcf79a347d925895043136225c7cb4ad20aad52d58162e9eefeafc7ad8a1cdca8060b5f01df1e7583362d052e266208f88ac01c03da9144d487dd3753a89bc9fe98401d1196523058251fc876960b275201e1bb85455fe3f5aed60d101aa4dbdb9e7714f6226769a97a17a5331dadcd53bac01c049029000b275200192e796452d6df9697c280542e1560557bcf79a347d925895043136225c7cb4ad201e1bb85455fe3f5aed60d101aa4dbdb9e7714f6226769a97a17a5331dadcd53bac01c027029000b275200192e796452d6df9697c280542e1560557bcf79a347d925895043136225c7cb4ac", + "internalKey": "0250929b74c1a04954b78b4b6035e97a5e078a5a0f28ec96d547bfee9ace803ac0" + }, + "decodedScripts": { + "claimScript": "HASH160 0x4d487dd3753a89bc9fe98401d1196523058251fc EQUAL VERIFY 0x1e1bb85455fe3f5aed60d101aa4dbdb9e7714f6226769a97a17a5331dadcd53b CHECKSIGVERIFY 0xaad52d58162e9eefeafc7ad8a1cdca8060b5f01df1e7583362d052e266208f88 CHECKSIG", + "refundScript": "0x0192e796452d6df9697c280542e1560557bcf79a347d925895043136225c7cb4 CHECKSIGVERIFY 0x1e1bb85455fe3f5aed60d101aa4dbdb9e7714f6226769a97a17a5331dadcd53b CHECKSIGVERIFY 0xaad52d58162e9eefeafc7ad8a1cdca8060b5f01df1e7583362d052e266208f88 CHECKSIG", + "refundWithoutReceiverScript": "0x0901 CHECKLOCKTIMEVERIFY DROP 0x0192e796452d6df9697c280542e1560557bcf79a347d925895043136225c7cb4 CHECKSIGVERIFY 0xaad52d58162e9eefeafc7ad8a1cdca8060b5f01df1e7583362d052e266208f88 CHECKSIG", + "unilateralClaimScript": "HASH160 0x4d487dd3753a89bc9fe98401d1196523058251fc EQUAL VERIFY 16 CHECKSEQUENCEVERIFY DROP 0x1e1bb85455fe3f5aed60d101aa4dbdb9e7714f6226769a97a17a5331dadcd53b CHECKSIG", + "unilateralRefundScript": "0x9000 CHECKSEQUENCEVERIFY DROP 0x0192e796452d6df9697c280542e1560557bcf79a347d925895043136225c7cb4 CHECKSIGVERIFY 0x1e1bb85455fe3f5aed60d101aa4dbdb9e7714f6226769a97a17a5331dadcd53b CHECKSIG", + "unilateralRefundWithoutReceiverScript": "0x9000 CHECKSEQUENCEVERIFY DROP 0x0192e796452d6df9697c280542e1560557bcf79a347d925895043136225c7cb4 CHECKSIG" + } + }, + { + "description": "with seconds CSV timelock", + "preimageHash": "4d487dd3753a89bc9fe98401d1196523058251fc", + "receiver": "021e1bb85455fe3f5aed60d101aa4dbdb9e7714f6226769a97a17a5331dadcd53b", + "sender": "030192e796452d6df9697c280542e1560557bcf79a347d925895043136225c7cb4", + "server": "03aad52d58162e9eefeafc7ad8a1cdca8060b5f01df1e7583362d052e266208f88", + "refundLocktime": 265, + "unilateralClaimDelay": { + "type": "seconds", + "value": 512 + }, + "unilateralRefundDelay": { + "type": "seconds", + "value": 1024 + }, + "unilateralRefundWithoutReceiverDelay": { + "type": "seconds", + "value": 1536 + }, + "expected": "tark1qz4d2t2czchfaml2l3ad3gwde2qxpd0srhc7wkpnvtg99cnxyz8c3f354ncawvx3enha2ydyrmactc6fyuvqppsqpl5k63hzupmrl7ndmz8pnu", + "scripts": { + "claimScript": "a9144d487dd3753a89bc9fe98401d1196523058251fc8769201e1bb85455fe3f5aed60d101aa4dbdb9e7714f6226769a97a17a5331dadcd53bad20aad52d58162e9eefeafc7ad8a1cdca8060b5f01df1e7583362d052e266208f88ac", + "refundScript": "200192e796452d6df9697c280542e1560557bcf79a347d925895043136225c7cb4ad201e1bb85455fe3f5aed60d101aa4dbdb9e7714f6226769a97a17a5331dadcd53bad20aad52d58162e9eefeafc7ad8a1cdca8060b5f01df1e7583362d052e266208f88ac", + "refundWithoutReceiverScript": "020901b175200192e796452d6df9697c280542e1560557bcf79a347d925895043136225c7cb4ad20aad52d58162e9eefeafc7ad8a1cdca8060b5f01df1e7583362d052e266208f88ac", + "unilateralClaimScript": "a9144d487dd3753a89bc9fe98401d1196523058251fc876903010040b275201e1bb85455fe3f5aed60d101aa4dbdb9e7714f6226769a97a17a5331dadcd53bac", + "unilateralRefundScript": "03020040b275200192e796452d6df9697c280542e1560557bcf79a347d925895043136225c7cb4ad201e1bb85455fe3f5aed60d101aa4dbdb9e7714f6226769a97a17a5331dadcd53bac", + "unilateralRefundWithoutReceiverScript": "03030040b275200192e796452d6df9697c280542e1560557bcf79a347d925895043136225c7cb4ac" + }, + "taproot": { + "tweakedPublicKey": "a634acf1d730d1ccefd511a41efb85e34927180086000fe96d46e2e0763ffa6d", + "tapTree": "0601c05ca9144d487dd3753a89bc9fe98401d1196523058251fc8769201e1bb85455fe3f5aed60d101aa4dbdb9e7714f6226769a97a17a5331dadcd53bad20aad52d58162e9eefeafc7ad8a1cdca8060b5f01df1e7583362d052e266208f88ac01c066200192e796452d6df9697c280542e1560557bcf79a347d925895043136225c7cb4ad201e1bb85455fe3f5aed60d101aa4dbdb9e7714f6226769a97a17a5331dadcd53bad20aad52d58162e9eefeafc7ad8a1cdca8060b5f01df1e7583362d052e266208f88ac01c049020901b175200192e796452d6df9697c280542e1560557bcf79a347d925895043136225c7cb4ad20aad52d58162e9eefeafc7ad8a1cdca8060b5f01df1e7583362d052e266208f88ac01c040a9144d487dd3753a89bc9fe98401d1196523058251fc876903010040b275201e1bb85455fe3f5aed60d101aa4dbdb9e7714f6226769a97a17a5331dadcd53bac01c04a03020040b275200192e796452d6df9697c280542e1560557bcf79a347d925895043136225c7cb4ad201e1bb85455fe3f5aed60d101aa4dbdb9e7714f6226769a97a17a5331dadcd53bac01c02803030040b275200192e796452d6df9697c280542e1560557bcf79a347d925895043136225c7cb4ac", + "internalKey": "0250929b74c1a04954b78b4b6035e97a5e078a5a0f28ec96d547bfee9ace803ac0" + }, + "decodedScripts": { + "claimScript": "HASH160 0x4d487dd3753a89bc9fe98401d1196523058251fc EQUAL VERIFY 0x1e1bb85455fe3f5aed60d101aa4dbdb9e7714f6226769a97a17a5331dadcd53b CHECKSIGVERIFY 0xaad52d58162e9eefeafc7ad8a1cdca8060b5f01df1e7583362d052e266208f88 CHECKSIG", + "refundScript": "0x0192e796452d6df9697c280542e1560557bcf79a347d925895043136225c7cb4 CHECKSIGVERIFY 0x1e1bb85455fe3f5aed60d101aa4dbdb9e7714f6226769a97a17a5331dadcd53b CHECKSIGVERIFY 0xaad52d58162e9eefeafc7ad8a1cdca8060b5f01df1e7583362d052e266208f88 CHECKSIG", + "refundWithoutReceiverScript": "0x0901 CHECKLOCKTIMEVERIFY DROP 0x0192e796452d6df9697c280542e1560557bcf79a347d925895043136225c7cb4 CHECKSIGVERIFY 0xaad52d58162e9eefeafc7ad8a1cdca8060b5f01df1e7583362d052e266208f88 CHECKSIG", + "unilateralClaimScript": "HASH160 0x4d487dd3753a89bc9fe98401d1196523058251fc EQUAL VERIFY 0x010040 CHECKSEQUENCEVERIFY DROP 0x1e1bb85455fe3f5aed60d101aa4dbdb9e7714f6226769a97a17a5331dadcd53b CHECKSIG", + "unilateralRefundScript": "0x020040 CHECKSEQUENCEVERIFY DROP 0x0192e796452d6df9697c280542e1560557bcf79a347d925895043136225c7cb4 CHECKSIGVERIFY 0x1e1bb85455fe3f5aed60d101aa4dbdb9e7714f6226769a97a17a5331dadcd53b CHECKSIG", + "unilateralRefundWithoutReceiverScript": "0x030040 CHECKSEQUENCEVERIFY DROP 0x0192e796452d6df9697c280542e1560557bcf79a347d925895043136225c7cb4 CHECKSIG" + } + } + ], + "invalid": [ + { + "description": "Invalid preimageHash length (too short)", + "preimageHash": "4d487dd3753a89bc9fe98401d1196523058251", + "receiver": "021e1bb85455fe3f5aed60d101aa4dbdb9e7714f6226769a97a17a5331dadcd53b", + "sender": "030192e796452d6df9697c280542e1560557bcf79a347d925895043136225c7cb4", + "server": "03aad52d58162e9eefeafc7ad8a1cdca8060b5f01df1e7583362d052e266208f88", + "refundLocktime": 265, + "unilateralClaimDelay": { + "type": "blocks", + "value": 17 + }, + "unilateralRefundDelay": { + "type": "blocks", + "value": 144 + }, + "unilateralRefundWithoutReceiverDelay": { + "type": "blocks", + "value": 144 + }, + "error": "preimage hash must be 20 bytes" + }, + { + "description": "Invalid preimageHash length (too long)", + "preimageHash": "4d487dd3753a89bc9fe98401d1196523058251fc1234567890abcdef", + "receiver": "021e1bb85455fe3f5aed60d101aa4dbdb9e7714f6226769a97a17a5331dadcd53b", + "sender": "030192e796452d6df9697c280542e1560557bcf79a347d925895043136225c7cb4", + "server": "03aad52d58162e9eefeafc7ad8a1cdca8060b5f01df1e7583362d052e266208f88", + "refundLocktime": 265, + "unilateralClaimDelay": { + "type": "blocks", + "value": 17 + }, + "unilateralRefundDelay": { + "type": "blocks", + "value": 144 + }, + "unilateralRefundWithoutReceiverDelay": { + "type": "blocks", + "value": 144 + }, + "error": "preimage hash must be 20 bytes" + }, + { + "description": "Zero timelock value", + "preimageHash": "4d487dd3753a89bc9fe98401d1196523058251fc", + "receiver": "021e1bb85455fe3f5aed60d101aa4dbdb9e7714f6226769a97a17a5331dadcd53b", + "sender": "030192e796452d6df9697c280542e1560557bcf79a347d925895043136225c7cb4", + "server": "03aad52d58162e9eefeafc7ad8a1cdca8060b5f01df1e7583362d052e266208f88", + "refundLocktime": 265, + "unilateralClaimDelay": { + "type": "blocks", + "value": 0 + }, + "unilateralRefundDelay": { + "type": "blocks", + "value": 144 + }, + "unilateralRefundWithoutReceiverDelay": { + "type": "blocks", + "value": 144 + }, + "error": "unilateral claim delay must greater than 0" + }, + { + "description": "Invalid refund locktime (zero)", + "preimageHash": "4d487dd3753a89bc9fe98401d1196523058251fc", + "receiver": "021e1bb85455fe3f5aed60d101aa4dbdb9e7714f6226769a97a17a5331dadcd53b", + "sender": "030192e796452d6df9697c280542e1560557bcf79a347d925895043136225c7cb4", + "server": "03aad52d58162e9eefeafc7ad8a1cdca8060b5f01df1e7583362d052e266208f88", + "refundLocktime": 0, + "unilateralClaimDelay": { + "type": "blocks", + "value": 17 + }, + "unilateralRefundDelay": { + "type": "blocks", + "value": 144 + }, + "unilateralRefundWithoutReceiverDelay": { + "type": "blocks", + "value": 144 + }, + "error": "refund locktime must be greater than 0" + }, + { + "description": "Invalid seconds timelock (not multiple of 512)", + "preimageHash": "4d487dd3753a89bc9fe98401d1196523058251fc", + "receiver": "021e1bb85455fe3f5aed60d101aa4dbdb9e7714f6226769a97a17a5331dadcd53b", + "sender": "030192e796452d6df9697c280542e1560557bcf79a347d925895043136225c7cb4", + "server": "03aad52d58162e9eefeafc7ad8a1cdca8060b5f01df1e7583362d052e266208f88", + "refundLocktime": 265, + "unilateralClaimDelay": { + "type": "seconds", + "value": 1000 + }, + "unilateralRefundDelay": { + "type": "seconds", + "value": 1024 + }, + "unilateralRefundWithoutReceiverDelay": { + "type": "seconds", + "value": 1536 + }, + "error": "seconds timelock must be multiple of 512" + }, + { + "description": "Invalid seconds timelock (less than 512)", + "preimageHash": "4d487dd3753a89bc9fe98401d1196523058251fc", + "receiver": "021e1bb85455fe3f5aed60d101aa4dbdb9e7714f6226769a97a17a5331dadcd53b", + "sender": "030192e796452d6df9697c280542e1560557bcf79a347d925895043136225c7cb4", + "server": "03aad52d58162e9eefeafc7ad8a1cdca8060b5f01df1e7583362d052e266208f88", + "refundLocktime": 265, + "unilateralClaimDelay": { + "type": "seconds", + "value": 512 + }, + "unilateralRefundDelay": { + "type": "seconds", + "value": 511 + }, + "unilateralRefundWithoutReceiverDelay": { + "type": "seconds", + "value": 1536 + }, + "error": "seconds timelock must be greater or equal to 512" + } + ] +} diff --git a/ark-lightning/tests/vhtlc_fixtures.rs b/ark-lightning/tests/vhtlc_fixtures.rs new file mode 100644 index 00000000..831a8224 --- /dev/null +++ b/ark-lightning/tests/vhtlc_fixtures.rs @@ -0,0 +1,387 @@ +use ark_lightning::vhtlc::VhtlcOptions; +use ark_lightning::vhtlc::VhtlcScript; +use bitcoin::Network; +use bitcoin::PublicKey; +use bitcoin::Sequence; +use bitcoin::XOnlyPublicKey; +use serde::Deserialize; +use serde::Serialize; +use std::collections::HashMap; +use std::fs; +use std::str::FromStr; + +#[derive(Debug, Deserialize, Serialize)] +struct Fixtures { + valid: Vec, + invalid: Vec, +} + +#[derive(Debug, Deserialize, Serialize)] +struct ValidTestCase { + description: String, + #[serde(rename = "preimageHash")] + preimage_hash: String, + receiver: String, + sender: String, + server: String, + #[serde(rename = "refundLocktime")] + refund_locktime: u32, + #[serde(rename = "unilateralClaimDelay")] + unilateral_claim_delay: Delay, + #[serde(rename = "unilateralRefundDelay")] + unilateral_refund_delay: Delay, + #[serde(rename = "unilateralRefundWithoutReceiverDelay")] + unilateral_refund_without_receiver_delay: Delay, + expected: String, + scripts: ScriptHexes, + taproot: TaprootInfo, + #[serde(rename = "decodedScripts")] + decoded_scripts: HashMap, +} + +#[derive(Debug, Deserialize, Serialize)] +struct InvalidTestCase { + description: String, + #[serde(rename = "preimageHash")] + preimage_hash: String, + receiver: String, + sender: String, + server: String, + #[serde(rename = "refundLocktime")] + refund_locktime: u32, + #[serde(rename = "unilateralClaimDelay")] + unilateral_claim_delay: Delay, + #[serde(rename = "unilateralRefundDelay")] + unilateral_refund_delay: Delay, + #[serde(rename = "unilateralRefundWithoutReceiverDelay")] + unilateral_refund_without_receiver_delay: Delay, + error: String, +} + +#[derive(Debug, Deserialize, Serialize)] +struct ScriptHexes { + #[serde(rename = "claimScript")] + claim_script: String, + #[serde(rename = "refundScript")] + refund_script: String, + #[serde(rename = "refundWithoutReceiverScript")] + refund_without_receiver_script: String, + #[serde(rename = "unilateralClaimScript")] + unilateral_claim_script: String, + #[serde(rename = "unilateralRefundScript")] + unilateral_refund_script: String, + #[serde(rename = "unilateralRefundWithoutReceiverScript")] + unilateral_refund_without_receiver_script: String, +} + +#[derive(Debug, Deserialize, Serialize)] +struct TaprootInfo { + #[serde(rename = "tweakedPublicKey")] + tweaked_public_key: String, + #[serde(rename = "tapTree")] + tap_tree: String, + #[serde(rename = "internalKey")] + internal_key: String, +} + +#[derive(Debug, Deserialize, Serialize)] +struct Delay { + #[serde(rename = "type")] + delay_type: String, + value: u32, +} + +impl Delay { + fn to_sequence(&self) -> Result { + match self.delay_type.as_str() { + "blocks" => { + if self.value == 0 { + return Err("unilateral claim delay must greater than 0".to_string()); + } + Ok(Sequence::from_height(self.value as u16)) + } + "seconds" => { + if self.value < 512 { + return Err("seconds timelock must be greater or equal to 512".to_string()); + } + if self.value % 512 != 0 { + return Err("seconds timelock must be multiple of 512".to_string()); + } + Sequence::from_seconds_ceil(self.value) + .map_err(|e| format!("Invalid seconds value: {}", e)) + } + _ => Err(format!("Unknown delay type: {}", self.delay_type)), + } + } +} + +fn hex_to_bytes20(hex: &str) -> Result<[u8; 20], String> { + let bytes = hex::decode(hex).map_err(|e| format!("Invalid hex: {}", e))?; + if bytes.len() != 20 { + return Err(format!("preimage hash must be 20 bytes")); + } + let mut arr = [0u8; 20]; + arr.copy_from_slice(&bytes); + Ok(arr) +} + +fn pubkey_to_xonly(pubkey_hex: &str) -> XOnlyPublicKey { + let pubkey = PublicKey::from_str(pubkey_hex).expect("Invalid public key"); + XOnlyPublicKey::from(pubkey.inner) +} + +#[test] +fn test_vhtlc_with_valid_fixtures() { + let fixtures_path = concat!(env!("CARGO_MANIFEST_DIR"), "/tests/fixtures/vhtlc.json"); + let fixtures_json = fs::read_to_string(fixtures_path).expect("Failed to read fixtures file"); + let fixtures: Fixtures = + serde_json::from_str(&fixtures_json).expect("Failed to parse fixtures"); + + for test_case in fixtures.valid { + let preimage_hash = hex_to_bytes20(&test_case.preimage_hash) + .expect("Valid fixtures should have valid preimage hash"); + + let sender = pubkey_to_xonly(&test_case.sender); + let receiver = pubkey_to_xonly(&test_case.receiver); + let server = pubkey_to_xonly(&test_case.server); + + let options = VhtlcOptions { + sender, + receiver, + server, + preimage_hash, + refund_locktime: test_case.refund_locktime, + unilateral_claim_delay: test_case + .unilateral_claim_delay + .to_sequence() + .expect("Valid delay"), + unilateral_refund_delay: test_case + .unilateral_refund_delay + .to_sequence() + .expect("Valid delay"), + unilateral_refund_without_receiver_delay: test_case + .unilateral_refund_without_receiver_delay + .to_sequence() + .expect("Valid delay"), + }; + + let vhtlc = VhtlcScript::new(options).expect("Failed to create VHTLC"); + + // Test 1: Verify all script hex encodings + let claim_hex = hex::encode(vhtlc.claim_script().as_bytes()); + assert_eq!( + claim_hex, test_case.scripts.claim_script, + "Claim script hex mismatch for test case: {}", + test_case.description + ); + + let refund_hex = hex::encode(vhtlc.refund_script().as_bytes()); + assert_eq!( + refund_hex, test_case.scripts.refund_script, + "Refund script hex mismatch for test case: {}", + test_case.description + ); + + let refund_without_receiver_hex = + hex::encode(vhtlc.refund_without_receiver_script().as_bytes()); + assert_eq!( + refund_without_receiver_hex, test_case.scripts.refund_without_receiver_script, + "Refund without receiver script hex mismatch for test case: {}", + test_case.description + ); + + let unilateral_claim_hex = hex::encode(vhtlc.unilateral_claim_script().as_bytes()); + assert_eq!( + unilateral_claim_hex, test_case.scripts.unilateral_claim_script, + "Unilateral claim script hex mismatch for test case: {}", + test_case.description + ); + + let unilateral_refund_hex = hex::encode(vhtlc.unilateral_refund_script().as_bytes()); + assert_eq!( + unilateral_refund_hex, test_case.scripts.unilateral_refund_script, + "Unilateral refund script hex mismatch for test case: {}", + test_case.description + ); + + let unilateral_refund_without_receiver_hex = + hex::encode(vhtlc.unilateral_refund_without_receiver_script().as_bytes()); + + assert_eq!( + unilateral_refund_without_receiver_hex, test_case.scripts.unilateral_refund_without_receiver_script, + "Unilateral refund without receiver script hex mismatch for test case: {}. Our impl includes CLTV locktime, fixture expects only CSV", + test_case.description + ); + + // Test 2: Verify taproot information + let taproot_info = vhtlc.taproot_info().expect(&format!( + "Taproot info should be available for test case: {}", + test_case.description + )); + + let internal_key = taproot_info.internal_key(); + let internal_key_hex = hex::encode(internal_key.serialize()); + + // The internal key in fixtures is prefixed with version byte + let pubkey = PublicKey::from_str(&test_case.taproot.internal_key) + .expect("Invalid internal key in fixture"); + let expected_internal = hex::encode(XOnlyPublicKey::from(pubkey.inner).serialize()); + + assert_eq!( + internal_key_hex, expected_internal, + "Internal key mismatch for test case: {}", + test_case.description + ); + + let output_key = taproot_info.output_key(); + let output_key_hex = hex::encode(output_key.serialize()); + + assert_eq!( + output_key_hex, test_case.taproot.tweaked_public_key, + "Tweaked public key mismatch for test case: {}", + test_case.description + ); + + // Test 3: Verify address generation + let addr = vhtlc.address(Network::Testnet, server).expect(&format!( + "Failed to generate address for test case: {}", + test_case.description + )); + let address_str = addr.encode(); + + assert_eq!( + address_str, test_case.expected, + "Address mismatch for test case: {}", + test_case.description + ); + } +} + +#[test] +fn test_vhtlc_with_invalid_fixtures() { + let fixtures_path = concat!(env!("CARGO_MANIFEST_DIR"), "/tests/fixtures/vhtlc.json"); + let fixtures_json = fs::read_to_string(fixtures_path).expect("Failed to read fixtures file"); + let fixtures: Fixtures = + serde_json::from_str(&fixtures_json).expect("Failed to parse fixtures"); + + for test_case in fixtures.invalid { + // Try to parse preimage hash + let preimage_hash_result = hex_to_bytes20(&test_case.preimage_hash); + + if let Err(e) = preimage_hash_result { + assert!( + e.contains(&test_case.error), + "Expected error containing '{}', got '{}' for test case: {}", + test_case.error, + e, + test_case.description + ); + continue; + } + + // Check refund locktime + if test_case.refund_locktime == 0 { + assert!( + test_case + .error + .contains("refund locktime must be greater than 0"), + "Expected refund locktime error for test case: {}", + test_case.description + ); + continue; + } + + // Try to convert delays + let claim_delay_result = test_case.unilateral_claim_delay.to_sequence(); + if let Err(e) = claim_delay_result { + assert!( + e.contains(&test_case.error), + "Expected error containing '{}', got '{}' for claim delay in test case: {}", + test_case.error, + e, + test_case.description + ); + continue; + } + + let refund_delay_result = test_case.unilateral_refund_delay.to_sequence(); + if let Err(e) = refund_delay_result { + assert!( + e.contains(&test_case.error), + "Expected error containing '{}', got '{}' for refund delay in test case: {}", + test_case.error, + e, + test_case.description + ); + continue; + } + + let refund_without_receiver_delay_result = test_case + .unilateral_refund_without_receiver_delay + .to_sequence(); + if let Err(e) = refund_without_receiver_delay_result { + assert!( + e.contains(&test_case.error), + "Expected error containing '{}', got '{}' for refund without receiver delay in test case: {}", + test_case.error, e, test_case.description + ); + continue; + } + + // If we got here, all validations passed but they shouldn't have + panic!( + "Invalid test case '{}' didn't fail as expected", + test_case.description + ); + } +} + +#[test] +fn test_specific_script_encodings() { + // Test specific script encoding for the first valid case + let sender = + pubkey_to_xonly("030192e796452d6df9697c280542e1560557bcf79a347d925895043136225c7cb4"); + let receiver = + pubkey_to_xonly("021e1bb85455fe3f5aed60d101aa4dbdb9e7714f6226769a97a17a5331dadcd53b"); + let server = + pubkey_to_xonly("03aad52d58162e9eefeafc7ad8a1cdca8060b5f01df1e7583362d052e266208f88"); + let preimage_hash = hex_to_bytes20("4d487dd3753a89bc9fe98401d1196523058251fc").unwrap(); + + let options = VhtlcOptions { + sender, + receiver, + server, + preimage_hash, + refund_locktime: 265, + unilateral_claim_delay: Sequence::from_height(17), + unilateral_refund_delay: Sequence::from_height(144), + unilateral_refund_without_receiver_delay: Sequence::from_height(144), + }; + + let vhtlc = VhtlcScript::new(options).expect("Failed to create VHTLC"); + + // Verify claim script + let claim_script = vhtlc.claim_script(); + let claim_hex = hex::encode(claim_script.as_bytes()); + let expected_claim = "a9144d487dd3753a89bc9fe98401d1196523058251fc8769201e1bb85455fe3f5aed60d101aa4dbdb9e7714f6226769a97a17a5331dadcd53bad20aad52d58162e9eefeafc7ad8a1cdca8060b5f01df1e7583362d052e266208f88ac"; + assert_eq!( + claim_hex, expected_claim, + "Claim script should match fixture" + ); + + // Verify unilateral claim script (with CSV=17) + let unilateral_claim = vhtlc.unilateral_claim_script(); + let unilateral_claim_hex = hex::encode(unilateral_claim.as_bytes()); + + // Check the CSV encoding for value 17 + assert!( + unilateral_claim_hex.contains("0111"), + "Should contain CSV value 17 as 0x0111" + ); + + let expected_unilateral_claim = "a9144d487dd3753a89bc9fe98401d1196523058251fc87690111b275201e1bb85455fe3f5aed60d101aa4dbdb9e7714f6226769a97a17a5331dadcd53bac"; + assert_eq!( + unilateral_claim_hex, expected_unilateral_claim, + "Unilateral claim script should match fixture" + ); +}