Skip to main content

chains_sdk/
atomic_swap.rs

1//! Cross-chain Atomic Swap primitives using Hash Time-Locked Contracts (HTLC).
2//!
3//! Provides the building blocks for trustless cross-chain swaps:
4//! - Swap secret generation and verification
5//! - Bitcoin HTLC redeem scripts (P2WSH-compatible)
6//! - Ethereum HTLC ABI-encoded function calls
7//! - Timelock management
8//!
9//! # Example
10//! ```no_run
11//! use chains_sdk::atomic_swap::*;
12//!
13//! let secret = SwapSecret::generate()?;
14//! let params = HtlcParams {
15//!     hash_lock: secret.hash,
16//!     time_lock: 1_700_000_000,
17//!     sender: [0xAA; 20],
18//!     receiver: [0xBB; 20],
19//! };
20//!
21//! // Bitcoin side
22//! let btc_script = build_bitcoin_htlc_script(&secret.hash, 500_000, &[0x02; 33], &[0x03; 33]);
23//!
24//! // Ethereum side
25//! let lock_data = encode_eth_htlc_lock(&params);
26//! let claim_data = encode_eth_htlc_claim(&secret.preimage);
27//! # Ok::<(), chains_sdk::error::SignerError>(())
28//! ```
29
30use crate::crypto;
31use crate::error::SignerError;
32use crate::ethereum::abi::{self, AbiValue};
33use crate::security;
34use zeroize::{Zeroize, ZeroizeOnDrop};
35
36// ═══════════════════════════════════════════════════════════════════
37// Bitcoin Script Opcodes (HTLC subset)
38// ═══════════════════════════════════════════════════════════════════
39
40const OP_IF: u8 = 0x63;
41const OP_ELSE: u8 = 0x67;
42const OP_ENDIF: u8 = 0x68;
43const OP_DROP: u8 = 0x75;
44const OP_EQUALVERIFY: u8 = 0x88;
45const OP_SHA256: u8 = 0xa8;
46const OP_CHECKSIG: u8 = 0xac;
47const OP_CLTV: u8 = 0xb1; // OP_CHECKLOCKTIMEVERIFY
48const OP_CSV: u8 = 0xb2; // OP_CHECKSEQUENCEVERIFY
49
50// ═══════════════════════════════════════════════════════════════════
51// Swap Secret
52// ═══════════════════════════════════════════════════════════════════
53
54/// A swap secret: 32-byte preimage and its SHA-256 hash.
55///
56/// The preimage is zeroized from memory when this struct is dropped.
57#[derive(Clone, Zeroize, ZeroizeOnDrop)]
58pub struct SwapSecret {
59    /// The 32-byte secret preimage (keep private until claiming).
60    pub preimage: [u8; 32],
61    /// SHA-256 hash of the preimage (publicly shared as the hash lock).
62    #[zeroize(skip)]
63    pub hash: [u8; 32],
64}
65
66impl SwapSecret {
67    /// Generate a new cryptographically secure swap secret.
68    ///
69    /// # Errors
70    /// Returns `SignerError` if the system RNG fails (e.g., entropy exhaustion).
71    pub fn generate() -> Result<Self, SignerError> {
72        let mut preimage = [0u8; 32];
73        security::secure_random(&mut preimage)?;
74        let hash = crypto::sha256(&preimage);
75        Ok(Self { preimage, hash })
76    }
77
78    /// Create a swap secret from a known preimage.
79    #[must_use]
80    pub fn from_preimage(preimage: [u8; 32]) -> Self {
81        let hash = crypto::sha256(&preimage);
82        Self { preimage, hash }
83    }
84
85    /// Verify that a preimage matches the expected hash.
86    #[must_use]
87    pub fn verify(preimage: &[u8; 32], expected_hash: &[u8; 32]) -> bool {
88        let computed = crypto::sha256(preimage);
89        // Use constant-time comparison to avoid timing attacks
90        ct_eq(&computed, expected_hash)
91    }
92}
93
94/// Constant-time byte comparison.
95fn ct_eq(a: &[u8; 32], b: &[u8; 32]) -> bool {
96    let mut diff = 0u8;
97    for i in 0..32 {
98        diff |= a[i] ^ b[i];
99    }
100    diff == 0
101}
102
103// ═══════════════════════════════════════════════════════════════════
104// HTLC Parameters
105// ═══════════════════════════════════════════════════════════════════
106
107/// Parameters for an HTLC.
108#[derive(Debug, Clone, PartialEq, Eq)]
109pub struct HtlcParams {
110    /// SHA-256 hash lock (32 bytes).
111    pub hash_lock: [u8; 32],
112    /// Absolute timelock (Unix timestamp or block height).
113    pub time_lock: u64,
114    /// Sender address (refund path).
115    pub sender: [u8; 20],
116    /// Receiver address (claim path).
117    pub receiver: [u8; 20],
118}
119
120/// Check if a timelock has expired.
121///
122/// Returns `true` if `current_time >= time_lock`.
123#[must_use]
124pub fn is_expired(time_lock: u64, current_time: u64) -> bool {
125    current_time >= time_lock
126}
127
128// ═══════════════════════════════════════════════════════════════════
129// Bitcoin HTLC Script
130// ═══════════════════════════════════════════════════════════════════
131
132/// Build a Bitcoin HTLC redeem script (P2WSH-compatible).
133///
134/// ```text
135/// OP_IF
136///   OP_SHA256 <hash_lock> OP_EQUALVERIFY
137///   <receiver_pubkey> OP_CHECKSIG
138/// OP_ELSE
139///   <locktime> OP_CHECKLOCKTIMEVERIFY OP_DROP
140///   <sender_pubkey> OP_CHECKSIG
141/// OP_ENDIF
142/// ```
143///
144/// The claim path (OP_IF) requires the preimage + receiver signature.
145/// The refund path (OP_ELSE) requires the timelock to expire + sender signature.
146#[must_use]
147pub fn build_bitcoin_htlc_script(
148    hash_lock: &[u8; 32],
149    locktime: u32,
150    receiver_pubkey: &[u8; 33],
151    sender_pubkey: &[u8; 33],
152) -> Vec<u8> {
153    let mut script = Vec::with_capacity(128);
154
155    // Claim path
156    script.push(OP_IF);
157    script.push(OP_SHA256);
158    script.push(32); // push 32 bytes
159    script.extend_from_slice(hash_lock);
160    script.push(OP_EQUALVERIFY);
161    script.push(33); // push 33 bytes
162    script.extend_from_slice(receiver_pubkey);
163    script.push(OP_CHECKSIG);
164
165    // Refund path
166    script.push(OP_ELSE);
167    push_script_number(&mut script, locktime as i64);
168    script.push(OP_CLTV);
169    script.push(OP_DROP);
170    script.push(33);
171    script.extend_from_slice(sender_pubkey);
172    script.push(OP_CHECKSIG);
173
174    script.push(OP_ENDIF);
175
176    script
177}
178
179/// Build a Bitcoin HTLC script using relative timelock (CSV).
180///
181/// Similar to the CLTV version but uses `OP_CHECKSEQUENCEVERIFY`
182/// for relative timelocks (number of blocks since confirmation).
183#[must_use]
184pub fn build_bitcoin_htlc_csv_script(
185    hash_lock: &[u8; 32],
186    sequence: u32,
187    receiver_pubkey: &[u8; 33],
188    sender_pubkey: &[u8; 33],
189) -> Vec<u8> {
190    let mut script = Vec::with_capacity(128);
191
192    script.push(OP_IF);
193    script.push(OP_SHA256);
194    script.push(32);
195    script.extend_from_slice(hash_lock);
196    script.push(OP_EQUALVERIFY);
197    script.push(33);
198    script.extend_from_slice(receiver_pubkey);
199    script.push(OP_CHECKSIG);
200
201    script.push(OP_ELSE);
202    push_script_number(&mut script, sequence as i64);
203    script.push(OP_CSV);
204    script.push(OP_DROP);
205    script.push(33);
206    script.extend_from_slice(sender_pubkey);
207    script.push(OP_CHECKSIG);
208
209    script.push(OP_ENDIF);
210
211    script
212}
213
214/// Build the claim witness for a Bitcoin HTLC.
215///
216/// Returns `[signature, preimage, OP_TRUE, redeem_script]`.
217#[must_use]
218pub fn build_btc_claim_witness(
219    signature: &[u8],
220    preimage: &[u8; 32],
221    redeem_script: &[u8],
222) -> Vec<Vec<u8>> {
223    vec![
224        signature.to_vec(),
225        preimage.to_vec(),
226        vec![0x01], // OP_TRUE — select the IF branch
227        redeem_script.to_vec(),
228    ]
229}
230
231/// Build the refund witness for a Bitcoin HTLC.
232///
233/// Returns `[signature, OP_FALSE, redeem_script]`.
234#[must_use]
235pub fn build_btc_refund_witness(signature: &[u8], redeem_script: &[u8]) -> Vec<Vec<u8>> {
236    vec![
237        signature.to_vec(),
238        vec![], // empty = OP_FALSE — select the ELSE branch
239        redeem_script.to_vec(),
240    ]
241}
242
243/// Compute the P2WSH script hash for an HTLC redeem script.
244///
245/// `OP_0 <SHA256(redeem_script)>` — the scriptPubKey for P2WSH.
246#[must_use]
247pub fn htlc_script_pubkey(redeem_script: &[u8]) -> Vec<u8> {
248    let hash = crypto::sha256(redeem_script);
249    let mut spk = Vec::with_capacity(34);
250    spk.push(0x00); // witness version 0
251    spk.push(32); // push 32 bytes
252    spk.extend_from_slice(&hash);
253    spk
254}
255
256// ═══════════════════════════════════════════════════════════════════
257// Ethereum HTLC ABI Encoding
258// ═══════════════════════════════════════════════════════════════════
259
260/// ABI-encode an Ethereum HTLC `lock(bytes32 hashLock, uint256 timelock, address receiver)`.
261#[must_use]
262pub fn encode_eth_htlc_lock(params: &HtlcParams) -> Vec<u8> {
263    let lock_fn = abi::Function::new("lock(bytes32,uint256,address)");
264    lock_fn.encode(&[
265        AbiValue::Uint256(params.hash_lock),
266        AbiValue::from_u64(params.time_lock),
267        AbiValue::Address(params.receiver),
268    ])
269}
270
271/// ABI-encode an Ethereum HTLC `claim(bytes32 preimage)`.
272#[must_use]
273pub fn encode_eth_htlc_claim(preimage: &[u8; 32]) -> Vec<u8> {
274    let claim_fn = abi::Function::new("claim(bytes32)");
275    claim_fn.encode(&[AbiValue::Uint256(*preimage)])
276}
277
278/// ABI-encode an Ethereum HTLC `refund(bytes32 hashLock)`.
279#[must_use]
280pub fn encode_eth_htlc_refund(hash_lock: &[u8; 32]) -> Vec<u8> {
281    let refund_fn = abi::Function::new("refund(bytes32)");
282    refund_fn.encode(&[AbiValue::Uint256(*hash_lock)])
283}
284
285/// ABI-encode an ERC-20 HTLC `lockTokens(address token, bytes32 hashLock, uint256 timelock, address receiver, uint256 amount)`.
286#[must_use]
287pub fn encode_eth_htlc_lock_tokens(token: &[u8; 20], params: &HtlcParams, amount: u64) -> Vec<u8> {
288    let lock_fn = abi::Function::new("lockTokens(address,bytes32,uint256,address,uint256)");
289    lock_fn.encode(&[
290        AbiValue::Address(*token),
291        AbiValue::Uint256(params.hash_lock),
292        AbiValue::from_u64(params.time_lock),
293        AbiValue::Address(params.receiver),
294        AbiValue::from_u64(amount),
295    ])
296}
297
298// ═══════════════════════════════════════════════════════════════════
299// Helpers
300// ═══════════════════════════════════════════════════════════════════
301
302/// Push a number in Bitcoin's minimal script number encoding.
303fn push_script_number(script: &mut Vec<u8>, n: i64) {
304    if n == 0 {
305        script.push(0x00);
306        return;
307    }
308    if (1..=16).contains(&n) {
309        script.push(0x50 + n as u8);
310        return;
311    }
312
313    let negative = n < 0;
314    let mut abs_n = if negative { (-n) as u64 } else { n as u64 };
315    let mut bytes = Vec::new();
316
317    while abs_n > 0 {
318        bytes.push((abs_n & 0xFF) as u8);
319        abs_n >>= 8;
320    }
321
322    if bytes.last().is_some_and(|b| b & 0x80 != 0) {
323        bytes.push(if negative { 0x80 } else { 0x00 });
324    } else if negative {
325        let last = bytes.len() - 1;
326        bytes[last] |= 0x80;
327    }
328
329    script.push(bytes.len() as u8);
330    script.extend_from_slice(&bytes);
331}
332
333// ═══════════════════════════════════════════════════════════════════
334// Tests
335// ═══════════════════════════════════════════════════════════════════
336
337#[cfg(test)]
338#[allow(clippy::unwrap_used, clippy::expect_used)]
339mod tests {
340    use super::*;
341
342    const RECEIVER_PK: [u8; 33] = [0x02; 33];
343    const SENDER_PK: [u8; 33] = [0x03; 33];
344    const RECEIVER_ADDR: [u8; 20] = [0xBB; 20];
345    const SENDER_ADDR: [u8; 20] = [0xAA; 20];
346
347    fn sample_params() -> HtlcParams {
348        let secret = SwapSecret::from_preimage([0xDD; 32]);
349        HtlcParams {
350            hash_lock: secret.hash,
351            time_lock: 1_700_000_000,
352            sender: SENDER_ADDR,
353            receiver: RECEIVER_ADDR,
354        }
355    }
356
357    // ─── SwapSecret ─────────────────────────────────────────────
358
359    #[test]
360    fn test_generate_secret_unique() {
361        let s1 = SwapSecret::generate().unwrap();
362        let s2 = SwapSecret::generate().unwrap();
363        assert_ne!(s1.preimage, s2.preimage);
364        assert_ne!(s1.hash, s2.hash);
365    }
366
367    #[test]
368    fn test_secret_hash_matches() {
369        let secret = SwapSecret::generate().unwrap();
370        assert_eq!(crypto::sha256(&secret.preimage), secret.hash);
371    }
372
373    #[test]
374    fn test_from_preimage() {
375        let preimage = [0xAB; 32];
376        let secret = SwapSecret::from_preimage(preimage);
377        assert_eq!(secret.preimage, preimage);
378        assert_eq!(secret.hash, crypto::sha256(&preimage));
379    }
380
381    #[test]
382    fn test_verify_preimage_correct() {
383        let secret = SwapSecret::generate().unwrap();
384        assert!(SwapSecret::verify(&secret.preimage, &secret.hash));
385    }
386
387    #[test]
388    fn test_verify_preimage_incorrect() {
389        let secret = SwapSecret::generate().unwrap();
390        let wrong = [0xFF; 32];
391        assert!(!SwapSecret::verify(&wrong, &secret.hash));
392    }
393
394    #[test]
395    fn test_verify_constant_time() {
396        // This is a functional test — we verify ct_eq works correctly
397        let a = [0xAA; 32];
398        let b = [0xAA; 32];
399        let c = [0xBB; 32];
400        assert!(ct_eq(&a, &b));
401        assert!(!ct_eq(&a, &c));
402    }
403
404    // ─── Timelock ───────────────────────────────────────────────
405
406    #[test]
407    fn test_not_expired() {
408        assert!(!is_expired(1_700_000_000, 1_699_999_999));
409    }
410
411    #[test]
412    fn test_exactly_expired() {
413        assert!(is_expired(1_700_000_000, 1_700_000_000));
414    }
415
416    #[test]
417    fn test_past_expired() {
418        assert!(is_expired(1_700_000_000, 1_800_000_000));
419    }
420
421    // ─── Bitcoin HTLC Script ────────────────────────────────────
422
423    #[test]
424    fn test_btc_htlc_script_contains_sha256() {
425        let hash = [0xAA; 32];
426        let script = build_bitcoin_htlc_script(&hash, 500_000, &RECEIVER_PK, &SENDER_PK);
427        assert!(script.contains(&OP_SHA256));
428    }
429
430    #[test]
431    fn test_btc_htlc_script_contains_cltv() {
432        let hash = [0xAA; 32];
433        let script = build_bitcoin_htlc_script(&hash, 500_000, &RECEIVER_PK, &SENDER_PK);
434        assert!(script.contains(&OP_CLTV));
435    }
436
437    #[test]
438    fn test_btc_htlc_script_structure() {
439        let hash = [0xAA; 32];
440        let script = build_bitcoin_htlc_script(&hash, 500_000, &RECEIVER_PK, &SENDER_PK);
441        assert_eq!(script[0], OP_IF);
442        assert_eq!(*script.last().unwrap(), OP_ENDIF);
443        assert!(script.contains(&OP_ELSE));
444    }
445
446    #[test]
447    fn test_btc_htlc_script_contains_hash_lock() {
448        let hash = [0xAA; 32];
449        let script = build_bitcoin_htlc_script(&hash, 500_000, &RECEIVER_PK, &SENDER_PK);
450        let has_hash = script.windows(32).any(|w| w == hash);
451        assert!(has_hash, "script must contain hash lock");
452    }
453
454    #[test]
455    fn test_btc_htlc_script_contains_pubkeys() {
456        let hash = [0xAA; 32];
457        let script = build_bitcoin_htlc_script(&hash, 500_000, &RECEIVER_PK, &SENDER_PK);
458        let has_receiver = script.windows(33).any(|w| w == RECEIVER_PK);
459        let has_sender = script.windows(33).any(|w| w == SENDER_PK);
460        assert!(has_receiver);
461        assert!(has_sender);
462    }
463
464    #[test]
465    fn test_btc_htlc_csv_contains_csv() {
466        let hash = [0xAA; 32];
467        let script = build_bitcoin_htlc_csv_script(&hash, 144, &RECEIVER_PK, &SENDER_PK);
468        assert!(script.contains(&OP_CSV));
469        assert!(!script.contains(&OP_CLTV)); // should use CSV, not CLTV
470    }
471
472    #[test]
473    fn test_btc_htlc_deterministic() {
474        let hash = [0xAA; 32];
475        let s1 = build_bitcoin_htlc_script(&hash, 500_000, &RECEIVER_PK, &SENDER_PK);
476        let s2 = build_bitcoin_htlc_script(&hash, 500_000, &RECEIVER_PK, &SENDER_PK);
477        assert_eq!(s1, s2);
478    }
479
480    // ─── Bitcoin HTLC Witnesses ─────────────────────────────────
481
482    #[test]
483    fn test_btc_claim_witness_structure() {
484        let preimage = [0xBB; 32];
485        let sig = vec![0xCC; 64];
486        let script = vec![0xDD; 100];
487        let witness = build_btc_claim_witness(&sig, &preimage, &script);
488        assert_eq!(witness.len(), 4);
489        assert_eq!(witness[0], sig);
490        assert_eq!(witness[1].as_slice(), &preimage);
491        assert_eq!(witness[2], vec![0x01]); // TRUE
492        assert_eq!(witness[3], script);
493    }
494
495    #[test]
496    fn test_btc_refund_witness_structure() {
497        let sig = vec![0xCC; 64];
498        let script = vec![0xDD; 100];
499        let witness = build_btc_refund_witness(&sig, &script);
500        assert_eq!(witness.len(), 3);
501        assert_eq!(witness[0], sig);
502        assert!(witness[1].is_empty()); // FALSE
503        assert_eq!(witness[2], script);
504    }
505
506    // ─── P2WSH Script Pubkey ────────────────────────────────────
507
508    #[test]
509    fn test_htlc_script_pubkey_length() {
510        let script = vec![0xAA; 50];
511        let spk = htlc_script_pubkey(&script);
512        assert_eq!(spk.len(), 34); // OP_0 + push32 + hash
513    }
514
515    #[test]
516    fn test_htlc_script_pubkey_witness_v0() {
517        let script = vec![0xAA; 50];
518        let spk = htlc_script_pubkey(&script);
519        assert_eq!(spk[0], 0x00); // witness v0
520        assert_eq!(spk[1], 32); // push 32 bytes
521    }
522
523    // ─── Ethereum HTLC ABI ──────────────────────────────────────
524
525    #[test]
526    fn test_eth_htlc_lock_selector() {
527        let params = sample_params();
528        let data = encode_eth_htlc_lock(&params);
529        let expected = abi::function_selector("lock(bytes32,uint256,address)");
530        assert_eq!(&data[..4], &expected);
531    }
532
533    #[test]
534    fn test_eth_htlc_lock_length() {
535        let params = sample_params();
536        let data = encode_eth_htlc_lock(&params);
537        // 4 (selector) + 32*3 (bytes32, uint256, address) = 100
538        assert_eq!(data.len(), 100);
539    }
540
541    #[test]
542    fn test_eth_htlc_claim_selector() {
543        let data = encode_eth_htlc_claim(&[0xAA; 32]);
544        let expected = abi::function_selector("claim(bytes32)");
545        assert_eq!(&data[..4], &expected);
546    }
547
548    #[test]
549    fn test_eth_htlc_claim_length() {
550        let data = encode_eth_htlc_claim(&[0xAA; 32]);
551        assert_eq!(data.len(), 36); // selector + bytes32
552    }
553
554    #[test]
555    fn test_eth_htlc_refund_selector() {
556        let data = encode_eth_htlc_refund(&[0xAA; 32]);
557        let expected = abi::function_selector("refund(bytes32)");
558        assert_eq!(&data[..4], &expected);
559    }
560
561    #[test]
562    fn test_eth_htlc_lock_tokens_selector() {
563        let params = sample_params();
564        let data = encode_eth_htlc_lock_tokens(&[0xFF; 20], &params, 1000);
565        let expected =
566            abi::function_selector("lockTokens(address,bytes32,uint256,address,uint256)");
567        assert_eq!(&data[..4], &expected);
568    }
569
570    // ─── End-to-End ─────────────────────────────────────────────
571
572    #[test]
573    fn test_e2e_swap_flow() {
574        // 1. Alice generates secret
575        let secret = SwapSecret::generate().unwrap();
576
577        // 2. Bob verifies the hash on-chain
578        assert!(SwapSecret::verify(&secret.preimage, &secret.hash));
579
580        // 3. Build Bitcoin HTLC
581        let btc_script = build_bitcoin_htlc_script(&secret.hash, 500_000, &RECEIVER_PK, &SENDER_PK);
582        assert!(!btc_script.is_empty());
583
584        // 4. Build Ethereum HTLC
585        let params = HtlcParams {
586            hash_lock: secret.hash,
587            time_lock: 1_700_000_000,
588            sender: SENDER_ADDR,
589            receiver: RECEIVER_ADDR,
590        };
591        let eth_lock = encode_eth_htlc_lock(&params);
592        assert!(!eth_lock.is_empty());
593
594        // 5. Alice claims with preimage
595        let eth_claim = encode_eth_htlc_claim(&secret.preimage);
596        assert!(!eth_claim.is_empty());
597
598        // 6. Verify P2WSH output
599        let spk = htlc_script_pubkey(&btc_script);
600        assert_eq!(spk.len(), 34);
601    }
602
603    #[test]
604    fn test_e2e_expired_refund() {
605        let secret = SwapSecret::generate().unwrap();
606        let time_lock = 1_700_000_000u64;
607
608        // Not expired yet
609        assert!(!is_expired(time_lock, 1_699_000_000));
610
611        // Expired — can refund
612        assert!(is_expired(time_lock, 1_700_000_001));
613
614        // Build refund
615        let eth_refund = encode_eth_htlc_refund(&secret.hash);
616        assert!(!eth_refund.is_empty());
617    }
618}