Skip to main content

ark_core/
vhtlc.rs

1//! Virtual Hash Time Lock Contract (VHTLC) implementation for Ark Lightning Swaps.
2//!
3//! This module implements VHTLC scripts that enable atomic swaps and conditional
4//! payments in the Ark protocol. The VHTLC provides multiple spending paths with
5//! different conditions and participants.
6
7use crate::ArkAddress;
8use crate::UNSPENDABLE_KEY;
9use bitcoin::hashes::ripemd160;
10use bitcoin::hashes::Hash;
11use bitcoin::opcodes::all::*;
12use bitcoin::taproot::TaprootBuilder;
13use bitcoin::taproot::TaprootSpendInfo;
14use bitcoin::Network;
15use bitcoin::PublicKey;
16use bitcoin::ScriptBuf;
17use bitcoin::Sequence;
18use bitcoin::XOnlyPublicKey;
19use serde::Deserialize;
20use serde::Serialize;
21use std::collections::BTreeMap;
22use std::str::FromStr;
23use thiserror::Error;
24
25#[derive(Debug, Error)]
26pub enum VhtlcError {
27    #[error("Invalid preimage hash length: expected 20 bytes, got {0}")]
28    InvalidPreimageHashLength(usize),
29    #[error("Invalid public key length: expected 32 bytes, got {0}")]
30    InvalidPublicKeyLength(usize),
31    #[error("Invalid locktime: {0}")]
32    InvalidLocktime(String),
33    #[error("Invalid delay: {0}")]
34    InvalidDelay(String),
35    #[error("Taproot construction failed: {0}")]
36    TaprootError(String),
37}
38
39/// Represents a script with its weight for taproot tree construction
40#[derive(Debug, Clone)]
41struct TaprootScriptItem {
42    script: ScriptBuf,
43    weight: u32,
44}
45
46/// Internal tree node for building the taproot tree structure
47#[derive(Debug, Clone)]
48enum TaprootTreeNode {
49    Leaf {
50        script: ScriptBuf,
51        weight: u32,
52    },
53    Branch {
54        left: Box<TaprootTreeNode>,
55        right: Box<TaprootTreeNode>,
56        weight: u32,
57    },
58}
59
60/// Options for creating a VHTLC (Virtual Hash Time Lock Contract)
61///
62/// This structure contains all the necessary parameters to construct a VHTLC,
63/// including the public keys of participants and various timeout values.
64#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
65pub struct VhtlcOptions {
66    pub sender: XOnlyPublicKey,
67    pub receiver: XOnlyPublicKey,
68    pub server: XOnlyPublicKey,
69    pub preimage_hash: ripemd160::Hash,
70    pub refund_locktime: u32,
71    pub unilateral_claim_delay: Sequence,
72    pub unilateral_refund_delay: Sequence,
73    pub unilateral_refund_without_receiver_delay: Sequence,
74}
75
76impl VhtlcOptions {
77    pub fn validate(&self) -> Result<(), VhtlcError> {
78        if self.refund_locktime == 0 {
79            return Err(VhtlcError::InvalidLocktime(
80                "Refund locktime must be greater than 0".to_string(),
81            ));
82        }
83
84        if !self.unilateral_claim_delay.is_relative_lock_time()
85            || self.unilateral_claim_delay.to_consensus_u32() == 0
86        {
87            return Err(VhtlcError::InvalidDelay(
88                "Unilateral claim delay must be a valid non-zero CSV relative lock time"
89                    .to_string(),
90            ));
91        }
92
93        if !self.unilateral_refund_delay.is_relative_lock_time()
94            || self.unilateral_refund_delay.to_consensus_u32() == 0
95        {
96            return Err(VhtlcError::InvalidDelay(
97                "Unilateral refund delay must be a valid non-zero CSV relative lock time"
98                    .to_string(),
99            ));
100        }
101
102        if !self
103            .unilateral_refund_without_receiver_delay
104            .is_relative_lock_time()
105            || self
106                .unilateral_refund_without_receiver_delay
107                .to_consensus_u32()
108                == 0
109        {
110            return Err(VhtlcError::InvalidDelay(
111                "Unilateral refund without receiver delay must be a valid non-zero CSV relative lock time"
112                    .to_string(),
113            ));
114        }
115
116        Ok(())
117    }
118
119    fn build_taproot(&self) -> Result<TaprootSpendInfo, VhtlcError> {
120        let internal_pubkey = PublicKey::from_str(UNSPENDABLE_KEY)
121            .map_err(|e| VhtlcError::TaprootError(format!("Failed to parse internal key: {e}")))?;
122        let internal_key = XOnlyPublicKey::from(internal_pubkey);
123
124        // Create script list with weights
125        // Lower weight = more likely to be used = shallower in tree
126        let scripts = vec![
127            TaprootScriptItem {
128                script: self.claim_script(),
129                weight: 1, // Most likely - collaborative claim
130            },
131            TaprootScriptItem {
132                script: self.refund_script(),
133                weight: 1, // Most likely - collaborative refund
134            },
135            TaprootScriptItem {
136                script: self.refund_without_receiver_script(),
137                weight: 1, // Less common
138            },
139            TaprootScriptItem {
140                script: self.unilateral_claim_script(),
141                weight: 1, // Less common
142            },
143            TaprootScriptItem {
144                script: self.unilateral_refund_script(),
145                weight: 1, // Least common
146            },
147            TaprootScriptItem {
148                script: self.unilateral_refund_without_receiver_script(),
149                weight: 1, // Least common
150            },
151        ];
152
153        // Build the tree using the weight-based algorithm
154        let tree = Self::taproot_list_to_tree(scripts)?;
155
156        // Create TaprootBuilder and add the tree
157        let builder = TaprootBuilder::new();
158        let builder = Self::add_tree_to_builder(builder, &tree, 0)?;
159
160        let secp = bitcoin::secp256k1::Secp256k1::new();
161        let taproot_spend_info = builder
162            .finalize(&secp, internal_key)
163            .map_err(|e| VhtlcError::TaprootError(format!("Failed to finalize taproot: {e:?}")))?;
164
165        Ok(taproot_spend_info)
166    }
167
168    /// Creates the claim script where receiver reveals the preimage
169    ///
170    /// Requires: preimage hash verification + receiver signature + server signature
171    pub fn claim_script(&self) -> ScriptBuf {
172        let preimage_hash = self.preimage_hash;
173
174        ScriptBuf::builder()
175            .push_opcode(OP_HASH160)
176            .push_slice(preimage_hash.as_byte_array())
177            .push_opcode(OP_EQUAL)
178            .push_opcode(OP_VERIFY)
179            .push_x_only_key(&self.receiver)
180            .push_opcode(OP_CHECKSIGVERIFY)
181            .push_x_only_key(&self.server)
182            .push_opcode(OP_CHECKSIG)
183            .into_script()
184    }
185
186    /// Creates the collaborative refund script
187    ///
188    /// Requires: sender + receiver + server signatures
189    pub fn refund_script(&self) -> ScriptBuf {
190        ScriptBuf::builder()
191            .push_x_only_key(&self.sender)
192            .push_opcode(OP_CHECKSIGVERIFY)
193            .push_x_only_key(&self.receiver)
194            .push_opcode(OP_CHECKSIGVERIFY)
195            .push_x_only_key(&self.server)
196            .push_opcode(OP_CHECKSIG)
197            .into_script()
198    }
199
200    /// Creates the refund script when receiver is unavailable
201    ///
202    /// Requires: CLTV timeout + sender + server signatures
203    pub fn refund_without_receiver_script(&self) -> ScriptBuf {
204        ScriptBuf::builder()
205            .push_int(self.refund_locktime as i64)
206            .push_opcode(OP_CLTV)
207            .push_opcode(OP_DROP)
208            .push_x_only_key(&self.sender)
209            .push_opcode(OP_CHECKSIGVERIFY)
210            .push_x_only_key(&self.server)
211            .push_opcode(OP_CHECKSIG)
212            .into_script()
213    }
214
215    /// Creates the unilateral claim script (no server cooperation needed)
216    ///
217    /// Requires: preimage hash verification + CSV delay + receiver signature
218    pub fn unilateral_claim_script(&self) -> ScriptBuf {
219        let preimage_hash = self.preimage_hash;
220        let sequence = self.unilateral_claim_delay;
221
222        ScriptBuf::builder()
223            .push_opcode(OP_HASH160)
224            .push_slice(preimage_hash.as_byte_array())
225            .push_opcode(OP_EQUAL)
226            .push_opcode(OP_VERIFY)
227            .push_int(sequence.to_consensus_u32() as i64)
228            .push_opcode(OP_CSV)
229            .push_opcode(OP_DROP)
230            .push_x_only_key(&self.receiver)
231            .push_opcode(OP_CHECKSIG)
232            .into_script()
233    }
234
235    /// Creates the unilateral refund script
236    ///
237    /// Requires: CSV delay + sender + receiver signatures
238    pub fn unilateral_refund_script(&self) -> ScriptBuf {
239        let sequence = self.unilateral_refund_delay;
240        ScriptBuf::builder()
241            .push_int(sequence.to_consensus_u32() as i64)
242            .push_opcode(OP_CSV)
243            .push_opcode(OP_DROP)
244            .push_x_only_key(&self.sender)
245            .push_opcode(OP_CHECKSIGVERIFY)
246            .push_x_only_key(&self.receiver)
247            .push_opcode(OP_CHECKSIG)
248            .into_script()
249    }
250
251    /// Creates the unilateral refund script when receiver is unavailable
252    ///
253    /// Requires: CSV delay + sender signature
254    pub fn unilateral_refund_without_receiver_script(&self) -> ScriptBuf {
255        let sequence = self.unilateral_refund_without_receiver_delay;
256        ScriptBuf::builder()
257            .push_int(sequence.to_consensus_u32() as i64)
258            .push_opcode(OP_CSV)
259            .push_opcode(OP_DROP)
260            .push_x_only_key(&self.sender)
261            .push_opcode(OP_CHECKSIG)
262            .into_script()
263    }
264
265    /// Build a balanced taproot tree from a list of scripts with weights
266    /// Following the TypeScript algorithm from scure-btc-signer
267    fn taproot_list_to_tree(
268        scripts: Vec<TaprootScriptItem>,
269    ) -> Result<TaprootTreeNode, VhtlcError> {
270        if scripts.is_empty() {
271            return Err(VhtlcError::TaprootError("Empty script list".to_string()));
272        }
273
274        // Clone input and convert to nodes
275        let mut lst: Vec<TaprootTreeNode> = scripts
276            .into_iter()
277            .map(|item| TaprootTreeNode::Leaf {
278                script: item.script,
279                weight: item.weight,
280            })
281            .collect();
282
283        // Build tree by combining nodes with smallest weights
284        while lst.len() >= 2 {
285            // Sort: elements with smallest weight are at the end of queue
286            lst.sort_by(|a, b| {
287                let weight_a = match a {
288                    TaprootTreeNode::Leaf { weight, .. } => *weight,
289                    TaprootTreeNode::Branch { weight, .. } => *weight,
290                };
291                let weight_b = match b {
292                    TaprootTreeNode::Leaf { weight, .. } => *weight,
293                    TaprootTreeNode::Branch { weight, .. } => *weight,
294                };
295                // Reverse comparison to put smallest at end
296                weight_b.cmp(&weight_a)
297            });
298
299            // Pop the two smallest weight nodes
300            let b = lst.pop().expect("an element");
301            let a = lst.pop().expect("an element");
302
303            // Calculate combined weight
304            let weight_a = match &a {
305                TaprootTreeNode::Leaf { weight, .. } => *weight,
306                TaprootTreeNode::Branch { weight, .. } => *weight,
307            };
308            let weight_b = match &b {
309                TaprootTreeNode::Leaf { weight, .. } => *weight,
310                TaprootTreeNode::Branch { weight, .. } => *weight,
311            };
312
313            // Create branch with combined weight
314            lst.push(TaprootTreeNode::Branch {
315                weight: weight_a + weight_b,
316                left: Box::new(a),
317                right: Box::new(b),
318            });
319        }
320
321        // Return the root node
322        Ok(lst.into_iter().next().expect("root node"))
323    }
324
325    /// Recursively add tree nodes to TaprootBuilder
326    fn add_tree_to_builder(
327        builder: TaprootBuilder,
328        node: &TaprootTreeNode,
329        depth: u8,
330    ) -> Result<TaprootBuilder, VhtlcError> {
331        match node {
332            TaprootTreeNode::Leaf { script, .. } => builder
333                .add_leaf(depth, script.clone())
334                .map_err(|e| VhtlcError::TaprootError(format!("Failed to add leaf: {e}"))),
335            TaprootTreeNode::Branch { left, right, .. } => {
336                let builder = Self::add_tree_to_builder(builder, left, depth + 1)?;
337                Self::add_tree_to_builder(builder, right, depth + 1)
338            }
339        }
340    }
341}
342
343/// VHTLC Script builder and manager
344///
345/// This struct creates and manages VHTLC scripts with six different spending paths:
346/// 1. **Claim**: Receiver reveals preimage (collaborative with server)
347/// 2. **Refund**: Collaborative refund (all three parties)
348/// 3. **Refund without Receiver**: Sender refunds after locktime (with server)
349/// 4. **Unilateral Claim**: Receiver claims after delay (no server needed)
350/// 5. **Unilateral Refund**: Collaborative unilateral refund after delay
351/// 6. **Unilateral Refund without Receiver**: Sender unilateral refund after both timeouts
352pub struct VhtlcScript {
353    options: VhtlcOptions,
354    taproot_spend_info: TaprootSpendInfo,
355    network: Network,
356}
357
358impl VhtlcScript {
359    /// Creates a new VHTLC script with the given options
360    ///
361    /// This will validate the options and build the complete taproot tree
362    /// with all spending paths.
363    pub fn new(options: VhtlcOptions, network: Network) -> Result<Self, VhtlcError> {
364        options.validate()?;
365
366        let taproot_spend_info = options.build_taproot()?;
367
368        Ok(Self {
369            options,
370            taproot_spend_info,
371            network,
372        })
373    }
374
375    pub fn taproot_spend_info(&self) -> &TaprootSpendInfo {
376        &self.taproot_spend_info
377    }
378
379    pub fn script_pubkey(&self) -> ScriptBuf {
380        ScriptBuf::builder()
381            .push_opcode(OP_PUSHNUM_1)
382            .push_slice(self.taproot_spend_info.output_key().serialize())
383            .into_script()
384    }
385
386    pub fn address(&self) -> ArkAddress {
387        ArkAddress::new(
388            self.network,
389            self.options.server,
390            self.taproot_spend_info().output_key(),
391        )
392    }
393
394    /// Creates the claim script where receiver reveals the preimage
395    ///
396    /// Requires: preimage hash verification + receiver signature + server signature
397    pub fn claim_script(&self) -> ScriptBuf {
398        self.options.claim_script()
399    }
400
401    /// Creates the collaborative refund script
402    ///
403    /// Requires: sender + receiver + server signatures
404    pub fn refund_script(&self) -> ScriptBuf {
405        self.options.refund_script()
406    }
407
408    /// Creates the refund script when receiver is unavailable
409    ///
410    /// Requires: CLTV timeout + sender + server signatures
411    pub fn refund_without_receiver_script(&self) -> ScriptBuf {
412        self.options.refund_without_receiver_script()
413    }
414
415    /// Creates the unilateral claim script (no server cooperation needed)
416    ///
417    /// Requires: preimage hash verification + CSV delay + receiver signature
418    pub fn unilateral_claim_script(&self) -> ScriptBuf {
419        self.options.unilateral_claim_script()
420    }
421
422    /// Creates the unilateral refund script
423    ///
424    /// Requires: CSV delay + sender + receiver signatures
425    pub fn unilateral_refund_script(&self) -> ScriptBuf {
426        self.options.unilateral_refund_script()
427    }
428
429    /// Creates the unilateral refund script when receiver is unavailable
430    ///
431    /// Requires: CSV delay + sender signature
432    pub fn unilateral_refund_without_receiver_script(&self) -> ScriptBuf {
433        self.options.unilateral_refund_without_receiver_script()
434    }
435
436    pub fn get_script_map(&self) -> BTreeMap<String, ScriptBuf> {
437        let mut map = BTreeMap::new();
438        map.insert("claim".to_string(), self.claim_script());
439        map.insert("refund".to_string(), self.refund_script());
440        map.insert(
441            "refund_without_receiver".to_string(),
442            self.refund_without_receiver_script(),
443        );
444        map.insert(
445            "unilateral_claim".to_string(),
446            self.unilateral_claim_script(),
447        );
448        map.insert(
449            "unilateral_refund".to_string(),
450            self.unilateral_refund_script(),
451        );
452        map.insert(
453            "unilateral_refund_without_receiver".to_string(),
454            self.unilateral_refund_without_receiver_script(),
455        );
456        map
457    }
458
459    pub fn tapscripts(self) -> Vec<ScriptBuf> {
460        vec![
461            self.claim_script(),
462            self.refund_script(),
463            self.refund_without_receiver_script(),
464            self.unilateral_claim_script(),
465            self.unilateral_refund_script(),
466            self.unilateral_refund_without_receiver_script(),
467        ]
468    }
469}
470
471#[cfg(test)]
472mod tests {
473    use super::*;
474    use bitcoin::hex::DisplayHex;
475    use bitcoin::hex::FromHex;
476    use bitcoin::Network;
477    use bitcoin::PublicKey;
478    use bitcoin::Sequence;
479    use bitcoin::XOnlyPublicKey;
480    use serde::Deserialize;
481    use serde::Serialize;
482    use std::collections::HashMap;
483    use std::fs;
484    use std::str::FromStr;
485
486    #[derive(Debug, Deserialize, Serialize)]
487    struct Fixtures {
488        valid: Vec<ValidTestCase>,
489        invalid: Vec<InvalidTestCase>,
490    }
491
492    #[derive(Debug, Deserialize, Serialize)]
493    struct ValidTestCase {
494        description: String,
495        #[serde(rename = "preimageHash")]
496        preimage_hash: String,
497        receiver: String,
498        sender: String,
499        server: String,
500        #[serde(rename = "refundLocktime")]
501        refund_locktime: u32,
502        #[serde(rename = "unilateralClaimDelay")]
503        unilateral_claim_delay: Delay,
504        #[serde(rename = "unilateralRefundDelay")]
505        unilateral_refund_delay: Delay,
506        #[serde(rename = "unilateralRefundWithoutReceiverDelay")]
507        unilateral_refund_without_receiver_delay: Delay,
508        expected: String,
509        scripts: ScriptHexes,
510        taproot: TaprootInfo,
511        #[serde(rename = "decodedScripts")]
512        decoded_scripts: HashMap<String, String>,
513    }
514
515    #[derive(Debug, Deserialize, Serialize)]
516    struct InvalidTestCase {
517        description: String,
518        #[serde(rename = "preimageHash")]
519        preimage_hash: String,
520        receiver: String,
521        sender: String,
522        server: String,
523        #[serde(rename = "refundLocktime")]
524        refund_locktime: u32,
525        #[serde(rename = "unilateralClaimDelay")]
526        unilateral_claim_delay: Delay,
527        #[serde(rename = "unilateralRefundDelay")]
528        unilateral_refund_delay: Delay,
529        #[serde(rename = "unilateralRefundWithoutReceiverDelay")]
530        unilateral_refund_without_receiver_delay: Delay,
531        error: String,
532    }
533
534    #[derive(Debug, Deserialize, Serialize)]
535    struct ScriptHexes {
536        #[serde(rename = "claimScript")]
537        claim_script: String,
538        #[serde(rename = "refundScript")]
539        refund_script: String,
540        #[serde(rename = "refundWithoutReceiverScript")]
541        refund_without_receiver_script: String,
542        #[serde(rename = "unilateralClaimScript")]
543        unilateral_claim_script: String,
544        #[serde(rename = "unilateralRefundScript")]
545        unilateral_refund_script: String,
546        #[serde(rename = "unilateralRefundWithoutReceiverScript")]
547        unilateral_refund_without_receiver_script: String,
548    }
549
550    #[derive(Debug, Deserialize, Serialize)]
551    struct TaprootInfo {
552        #[serde(rename = "tweakedPublicKey")]
553        tweaked_public_key: String,
554        #[serde(rename = "tapTree")]
555        tap_tree: String,
556        #[serde(rename = "internalKey")]
557        internal_key: String,
558    }
559
560    #[derive(Debug, Deserialize, Serialize)]
561    struct Delay {
562        #[serde(rename = "type")]
563        delay_type: String,
564        value: u32,
565    }
566
567    impl Delay {
568        fn to_sequence(&self) -> Result<Sequence, String> {
569            match self.delay_type.as_str() {
570                "blocks" => {
571                    if self.value == 0 {
572                        return Err("unilateral claim delay must greater than 0".to_string());
573                    }
574                    Ok(Sequence::from_height(self.value as u16))
575                }
576                "seconds" => {
577                    if self.value < 512 {
578                        return Err("seconds timelock must be greater or equal to 512".to_string());
579                    }
580                    if self.value % 512 != 0 {
581                        return Err("seconds timelock must be multiple of 512".to_string());
582                    }
583                    Sequence::from_seconds_ceil(self.value)
584                        .map_err(|e| format!("Invalid seconds value: {e}"))
585                }
586                _ => Err(format!("Unknown delay type: {}", self.delay_type)),
587            }
588        }
589    }
590
591    fn hex_to_bytes20(hex: &str) -> Result<[u8; 20], String> {
592        let bytes = Vec::from_hex(hex).map_err(|e| format!("Invalid hex: {e}"))?;
593        if bytes.len() != 20 {
594            return Err("preimage hash must be 20 bytes".to_string());
595        }
596        let mut arr = [0u8; 20];
597        arr.copy_from_slice(&bytes);
598        Ok(arr)
599    }
600
601    fn pubkey_to_xonly(pubkey_hex: &str) -> XOnlyPublicKey {
602        let pubkey = PublicKey::from_str(pubkey_hex).expect("valid public key");
603        XOnlyPublicKey::from(pubkey.inner)
604    }
605
606    #[test]
607    fn test_vhtlc_with_valid_fixtures() {
608        let fixtures_path = concat!(env!("CARGO_MANIFEST_DIR"), "/src/vhtlc_fixtures/vhtlc.json");
609        let fixtures_json = fs::read_to_string(fixtures_path).expect("to read fixtures file");
610        let fixtures: Fixtures = serde_json::from_str(&fixtures_json).expect("to parse fixtures");
611
612        for test_case in fixtures.valid {
613            let preimage_hash =
614                ripemd160::Hash::from_str(&test_case.preimage_hash).expect("valid hash");
615
616            let sender = pubkey_to_xonly(&test_case.sender);
617            let receiver = pubkey_to_xonly(&test_case.receiver);
618            let server = pubkey_to_xonly(&test_case.server);
619
620            let options = VhtlcOptions {
621                sender,
622                receiver,
623                server,
624                preimage_hash,
625                refund_locktime: test_case.refund_locktime,
626                unilateral_claim_delay: test_case
627                    .unilateral_claim_delay
628                    .to_sequence()
629                    .expect("valid delay"),
630                unilateral_refund_delay: test_case
631                    .unilateral_refund_delay
632                    .to_sequence()
633                    .expect("valid delay"),
634                unilateral_refund_without_receiver_delay: test_case
635                    .unilateral_refund_without_receiver_delay
636                    .to_sequence()
637                    .expect("valid delay"),
638            };
639
640            let vhtlc = VhtlcScript::new(options, Network::Testnet).expect("to create VHTLC");
641
642            // Test 1: Verify all script hex encodings
643            let claim_hex = vhtlc.claim_script().as_bytes().to_lower_hex_string();
644            assert_eq!(
645                claim_hex, test_case.scripts.claim_script,
646                "Claim script hex mismatch for test case: {}",
647                test_case.description
648            );
649
650            let refund_hex = vhtlc.refund_script().as_bytes().to_lower_hex_string();
651            assert_eq!(
652                refund_hex, test_case.scripts.refund_script,
653                "Refund script hex mismatch for test case: {}",
654                test_case.description
655            );
656
657            let refund_without_receiver_hex = vhtlc
658                .refund_without_receiver_script()
659                .as_bytes()
660                .to_lower_hex_string();
661            assert_eq!(
662                refund_without_receiver_hex, test_case.scripts.refund_without_receiver_script,
663                "Refund without receiver script hex mismatch for test case: {}",
664                test_case.description
665            );
666
667            let unilateral_claim_hex = vhtlc
668                .unilateral_claim_script()
669                .as_bytes()
670                .to_lower_hex_string();
671            assert_eq!(
672                unilateral_claim_hex, test_case.scripts.unilateral_claim_script,
673                "Unilateral claim script hex mismatch for test case: {}",
674                test_case.description
675            );
676
677            let unilateral_refund_hex = vhtlc
678                .unilateral_refund_script()
679                .as_bytes()
680                .to_lower_hex_string();
681            assert_eq!(
682                unilateral_refund_hex, test_case.scripts.unilateral_refund_script,
683                "Unilateral refund script hex mismatch for test case: {}",
684                test_case.description
685            );
686
687            let unilateral_refund_without_receiver_hex = vhtlc
688                .unilateral_refund_without_receiver_script()
689                .as_bytes()
690                .to_lower_hex_string();
691
692            assert_eq!(
693                unilateral_refund_without_receiver_hex,
694                test_case.scripts.unilateral_refund_without_receiver_script,
695                "Unilateral refund without receiver script hex mismatch for test case: {}. Our impl includes CLTV locktime, fixture expects only CSV",
696                test_case.description
697            );
698
699            // Test 2: Verify taproot information
700            let taproot_info = vhtlc.taproot_spend_info();
701
702            let internal_key = taproot_info.internal_key();
703            let internal_key_hex = internal_key.serialize().to_lower_hex_string();
704
705            // The internal key in fixtures is prefixed with version byte
706            let pubkey = PublicKey::from_str(&test_case.taproot.internal_key)
707                .expect("valid internal key in fixture");
708            let expected_internal = XOnlyPublicKey::from(pubkey.inner)
709                .serialize()
710                .to_lower_hex_string();
711
712            assert_eq!(
713                internal_key_hex, expected_internal,
714                "Internal key mismatch for test case: {}",
715                test_case.description
716            );
717
718            let output_key = taproot_info.output_key();
719            let output_key_hex = output_key.serialize().to_lower_hex_string();
720
721            assert_eq!(
722                output_key_hex, test_case.taproot.tweaked_public_key,
723                "Tweaked public key mismatch for test case: {}",
724                test_case.description
725            );
726
727            // Test 3: Verify address generation
728            let addr = vhtlc.address();
729            let address_str = addr.encode();
730
731            assert_eq!(
732                address_str, test_case.expected,
733                "Address mismatch for test case: {}",
734                test_case.description
735            );
736        }
737    }
738
739    #[test]
740    fn test_vhtlc_with_invalid_fixtures() {
741        let fixtures_path = concat!(env!("CARGO_MANIFEST_DIR"), "/src/vhtlc_fixtures/vhtlc.json");
742        let fixtures_json = fs::read_to_string(fixtures_path).expect("to read fixtures file");
743        let fixtures: Fixtures = serde_json::from_str(&fixtures_json).expect("to parse fixtures");
744
745        for test_case in fixtures.invalid {
746            // Try to parse preimage hash
747            let preimage_hash_result = hex_to_bytes20(&test_case.preimage_hash);
748
749            if let Err(e) = preimage_hash_result {
750                assert!(
751                    e.contains(&test_case.error),
752                    "Expected error containing '{}', got '{}' for test case: {}",
753                    test_case.error,
754                    e,
755                    test_case.description
756                );
757                continue;
758            }
759
760            // Check refund locktime
761            if test_case.refund_locktime == 0 {
762                assert!(
763                    test_case
764                        .error
765                        .contains("refund locktime must be greater than 0"),
766                    "Expected refund locktime error for test case: {}",
767                    test_case.description
768                );
769                continue;
770            }
771
772            // Try to convert delays
773            let claim_delay_result = test_case.unilateral_claim_delay.to_sequence();
774            if let Err(e) = claim_delay_result {
775                assert!(
776                    e.contains(&test_case.error),
777                    "Expected error containing '{}', got '{}' for claim delay in test case: {}",
778                    test_case.error,
779                    e,
780                    test_case.description
781                );
782                continue;
783            }
784
785            let refund_delay_result = test_case.unilateral_refund_delay.to_sequence();
786            if let Err(e) = refund_delay_result {
787                assert!(
788                    e.contains(&test_case.error),
789                    "Expected error containing '{}', got '{}' for refund delay in test case: {}",
790                    test_case.error,
791                    e,
792                    test_case.description
793                );
794                continue;
795            }
796
797            let refund_without_receiver_delay_result = test_case
798                .unilateral_refund_without_receiver_delay
799                .to_sequence();
800            if let Err(e) = refund_without_receiver_delay_result {
801                assert!(
802                    e.contains(&test_case.error),
803                    "Expected error containing '{}', got '{}' for refund without receiver delay in test case: {}",
804                    test_case.error,
805                    e,
806                    test_case.description
807                );
808                continue;
809            }
810
811            // If we got here, all validations passed but they shouldn't have
812            panic!(
813                "Invalid test case '{}' didn't fail as expected",
814                test_case.description
815            );
816        }
817    }
818
819    #[test]
820    fn test_specific_script_encodings() {
821        // Test specific script encoding for the first valid case
822        let sender =
823            pubkey_to_xonly("030192e796452d6df9697c280542e1560557bcf79a347d925895043136225c7cb4");
824        let receiver =
825            pubkey_to_xonly("021e1bb85455fe3f5aed60d101aa4dbdb9e7714f6226769a97a17a5331dadcd53b");
826        let server =
827            pubkey_to_xonly("03aad52d58162e9eefeafc7ad8a1cdca8060b5f01df1e7583362d052e266208f88");
828        let preimage_hash =
829            ripemd160::Hash::from_str("4d487dd3753a89bc9fe98401d1196523058251fc").unwrap();
830
831        let options = VhtlcOptions {
832            sender,
833            receiver,
834            server,
835            preimage_hash,
836            refund_locktime: 265,
837            unilateral_claim_delay: Sequence::from_height(17),
838            unilateral_refund_delay: Sequence::from_height(144),
839            unilateral_refund_without_receiver_delay: Sequence::from_height(144),
840        };
841
842        let vhtlc = VhtlcScript::new(options, Network::Testnet).expect("to create VHTLC");
843
844        // Verify claim script
845        let claim_script = vhtlc.claim_script();
846        let claim_hex = claim_script.as_bytes().to_lower_hex_string();
847        let expected_claim = "a9144d487dd3753a89bc9fe98401d1196523058251fc8769201e1bb85455fe3f5aed60d101aa4dbdb9e7714f6226769a97a17a5331dadcd53bad20aad52d58162e9eefeafc7ad8a1cdca8060b5f01df1e7583362d052e266208f88ac";
848        assert_eq!(
849            claim_hex, expected_claim,
850            "Claim script should match fixture"
851        );
852
853        // Verify unilateral claim script (with CSV=17)
854        let unilateral_claim = vhtlc.unilateral_claim_script();
855        let unilateral_claim_hex = unilateral_claim.as_bytes().to_lower_hex_string();
856
857        // Check the CSV encoding for value 17
858        assert!(
859            unilateral_claim_hex.contains("0111"),
860            "Should contain CSV value 17 as 0x0111"
861        );
862
863        let expected_unilateral_claim = "a9144d487dd3753a89bc9fe98401d1196523058251fc87690111b275201e1bb85455fe3f5aed60d101aa4dbdb9e7714f6226769a97a17a5331dadcd53bac";
864        assert_eq!(
865            unilateral_claim_hex, expected_unilateral_claim,
866            "Unilateral claim script should match fixture"
867        );
868    }
869}