Skip to main content

csv_adapter_bitcoin/
wallet.rs

1//! Seal wallet for Bitcoin UTXO management with BIP-32/86 HD key derivation
2//!
3//! Implements BIP-86 key derivation path: m/86'/0'/0'/0/{index}
4
5use bitcoin::{
6    bip32::{DerivationPath as BitcoinDerivationPath, ExtendedPrivKey, ExtendedPubKey},
7    hashes::Hash as BitcoinHash,
8    key::TapTweak,
9    secp256k1::{self, Secp256k1, SecretKey, XOnlyPublicKey},
10    Address, Network, OutPoint, Txid,
11};
12use std::collections::{HashMap, HashSet};
13use std::str::FromStr;
14use std::sync::Mutex;
15
16use bitcoin::secp256k1::rand::{rngs::OsRng, RngCore};
17
18#[allow(unused_imports)]
19use crate::types::BitcoinSealRef;
20
21/// Hardened derivation constant
22const HARDENED: u32 = 0x8000_0000;
23
24/// BIP-86 purpose for single-key P2TR
25const BIP86_PURPOSE: u32 = 86;
26
27/// Coin type: 0 for mainnet, 1 for testnet/signet/regtest
28fn coin_type(network: &Network) -> u32 {
29    match network {
30        Network::Bitcoin => 0,
31        _ => 1,
32    }
33}
34
35/// BIP-86 derivation path descriptor
36#[derive(Clone, Debug)]
37pub struct Bip86Path {
38    pub account: u32,
39    pub change: u32,
40    pub index: u32,
41}
42
43impl Bip86Path {
44    pub fn new(account: u32, change: u32, index: u32) -> Self {
45        Self {
46            account,
47            change,
48            index,
49        }
50    }
51    pub fn external(account: u32, index: u32) -> Self {
52        Self::new(account, 0, index)
53    }
54    pub fn internal(account: u32, index: u32) -> Self {
55        Self::new(account, 1, index)
56    }
57    pub fn to_bitcoin_path(&self, network: &Network) -> BitcoinDerivationPath {
58        let coin = coin_type(network);
59        format!(
60            "m/{}'/{}'/{}'/{}/{}",
61            BIP86_PURPOSE, coin, self.account, self.change, self.index
62        )
63        .parse()
64        .expect("valid BIP-32 path")
65    }
66    pub fn to_string(&self, network: &Network) -> String {
67        let coin = coin_type(network);
68        format!(
69            "m/{}'/{}'/{}'/{}/{}",
70            BIP86_PURPOSE, coin, self.account, self.change, self.index
71        )
72    }
73}
74
75/// UTXO entry in the wallet
76#[derive(Clone, Debug)]
77pub struct WalletUtxo {
78    pub outpoint: OutPoint,
79    pub amount_sat: u64,
80    pub path: Bip86Path,
81    pub reserved: bool,
82    pub reserved_for: Option<String>,
83}
84
85/// Derived Taproot key with spending info
86#[derive(Clone, Debug)]
87pub struct DerivedTaprootKey {
88    pub internal_xonly: XOnlyPublicKey,
89    pub output_key: bitcoin::key::TweakedPublicKey,
90    pub path: Bip86Path,
91    pub address: Address,
92}
93
94/// Seal wallet - manages UTXOs, HD key derivation, and seal tracking
95pub struct SealWallet {
96    master_key: ExtendedPrivKey,
97    network: Network,
98    utxos: Mutex<HashMap<OutPoint, WalletUtxo>>,
99    used_seals: Mutex<HashSet<Vec<u8>>>,
100    secp: Secp256k1<secp256k1::All>,
101    next_index: Mutex<HashMap<u32, u32>>,
102}
103
104impl SealWallet {
105    pub fn from_mnemonic(
106        mnemonic: &str,
107        password: &str,
108        network: Network,
109    ) -> Result<Self, WalletError> {
110        let seed = bip32::Mnemonic::new(mnemonic, bip32::Language::English)
111            .map_err(|e| WalletError::InvalidMnemonic(e.to_string()))?
112            .to_seed(password);
113        Self::from_seed(seed.as_bytes(), network)
114    }
115
116    pub fn from_seed(seed: &[u8; 64], network: Network) -> Result<Self, WalletError> {
117        let btc_net = match network {
118            Network::Bitcoin => bitcoin::Network::Bitcoin,
119            Network::Testnet => bitcoin::Network::Testnet,
120            Network::Signet => bitcoin::Network::Signet,
121            Network::Regtest => bitcoin::Network::Regtest,
122            _ => bitcoin::Network::Testnet,
123        };
124        let secp = Secp256k1::new();
125        let master_key = ExtendedPrivKey::new_master(btc_net, seed)
126            .map_err(|e| WalletError::KeyDerivationFailed(e.to_string()))?;
127        Ok(Self {
128            master_key,
129            network,
130            utxos: Mutex::new(HashMap::new()),
131            used_seals: Mutex::new(HashSet::new()),
132            secp,
133            next_index: Mutex::new(HashMap::new()),
134        })
135    }
136
137    pub fn generate_random(network: Network) -> Self {
138        let mut seed = [0u8; 64];
139        OsRng.fill_bytes(&mut seed);
140        Self::from_seed(&seed, network).expect("valid seed")
141    }
142
143    pub fn from_xpub(xpub: &str, network: Network) -> Result<Self, WalletError> {
144        let extended_pub = ExtendedPubKey::from_str(xpub)
145            .map_err(|e| WalletError::InvalidKey(format!("Invalid xpub: {}", e)))?;
146        let btc_net = match network {
147            Network::Bitcoin => bitcoin::Network::Bitcoin,
148            Network::Testnet => bitcoin::Network::Testnet,
149            Network::Signet => bitcoin::Network::Signet,
150            Network::Regtest => bitcoin::Network::Regtest,
151            _ => bitcoin::Network::Testnet,
152        };
153        if extended_pub.network != btc_net {
154            return Err(WalletError::InvalidKey(format!(
155                "xpub network mismatch: expected {:?}, got {:?}",
156                btc_net, extended_pub.network
157            )));
158        }
159        let mut seed = [0u8; 64];
160        OsRng.fill_bytes(&mut seed);
161        let wallet = Self::from_seed(&seed, network)?;
162        Ok(wallet)
163    }
164
165    fn derive_private_key(&self, path: &Bip86Path) -> Result<SecretKey, WalletError> {
166        let btc_path = path.to_bitcoin_path(&self.network);
167        let child = self
168            .master_key
169            .derive_priv(&self.secp, &btc_path)
170            .map_err(|e| WalletError::KeyDerivationFailed(format!("{:?}", e)))?;
171        Ok(child.private_key)
172    }
173
174    /// Derive a Taproot key at a specific path
175    pub fn derive_key(&self, path: &Bip86Path) -> Result<DerivedTaprootKey, WalletError> {
176        let secret_key = self.derive_private_key(path)?;
177        let kp = secp256k1::KeyPair::from_secret_key(&self.secp, &secret_key);
178        let (xonly, _parity) = XOnlyPublicKey::from_keypair(&kp);
179        // tap_tweak on XOnlyPublicKey returns (TweakedPublicKey, Parity)
180        let (output_key, _) = xonly.tap_tweak(&self.secp, None);
181        let address = Address::p2tr_tweaked(output_key, self.network);
182        Ok(DerivedTaprootKey {
183            internal_xonly: xonly,
184            output_key,
185            path: path.clone(),
186            address,
187        })
188    }
189
190    /// Produce a 64-byte Schnorr signature for the given sighash using the tweaked key.
191    pub fn sign_taproot_keypath(
192        &self,
193        path: &Bip86Path,
194        sighash: &[u8; 32],
195    ) -> Result<Vec<u8>, WalletError> {
196        let secret_key = self.derive_private_key(path)?;
197        let kp = secp256k1::KeyPair::from_secret_key(&self.secp, &secret_key);
198        // TapTweak: kp -> secp256k1::TweakedKeypair
199        let tweaked_kp = kp.tap_tweak(&self.secp, None);
200        let msg = secp256k1::Message::from_slice(sighash)
201            .map_err(|e| WalletError::SigningFailed(e.to_string()))?;
202        let sig = self
203            .secp
204            .sign_schnorr_with_rng(&msg, &tweaked_kp.to_inner(), &mut OsRng);
205        Ok(sig.as_ref().to_vec())
206    }
207
208    pub fn next_address(
209        &self,
210        account: u32,
211    ) -> Result<(DerivedTaprootKey, Bip86Path), WalletError> {
212        let mut ni = self.next_index.lock().unwrap_or_else(|e| e.into_inner());
213        let idx = ni.entry(account).or_insert(0);
214        let path = Bip86Path::external(account, *idx);
215        let key = self.derive_key(&path)?;
216        *idx += 1;
217        Ok((key, path))
218    }
219
220    pub fn get_funding_address(
221        &self,
222        account: u32,
223        index: u32,
224    ) -> Result<DerivedTaprootKey, WalletError> {
225        self.derive_key(&Bip86Path::external(account, index))
226    }
227
228    pub fn get_account_xpub(&self, account: u32) -> Result<String, WalletError> {
229        let coin = coin_type(&self.network);
230        let account_path: BitcoinDerivationPath =
231            format!("m/{}'/{}'/{}'", BIP86_PURPOSE, coin, account)
232                .parse()
233                .map_err(|e| WalletError::KeyDerivationFailed(format!("{:?}", e)))?;
234        let account_key = self
235            .master_key
236            .derive_priv(&self.secp, &account_path)
237            .map_err(|e| WalletError::KeyDerivationFailed(format!("{:?}", e)))?;
238        Ok(ExtendedPubKey::from_priv(&self.secp, &account_key).to_string())
239    }
240
241    pub fn add_utxo(&self, outpoint: OutPoint, amount_sat: u64, path: Bip86Path) {
242        self.utxos.lock().unwrap_or_else(|e| e.into_inner()).insert(
243            outpoint,
244            WalletUtxo {
245                outpoint,
246                amount_sat,
247                path,
248                reserved: false,
249                reserved_for: None,
250            },
251        );
252    }
253    pub fn balance(&self) -> u64 {
254        self.utxos
255            .lock()
256            .unwrap_or_else(|e| e.into_inner())
257            .values()
258            .filter(|u| !u.reserved)
259            .map(|u| u.amount_sat)
260            .sum()
261    }
262    pub fn utxo_count(&self) -> usize {
263        self.utxos
264            .lock()
265            .unwrap_or_else(|e| e.into_inner())
266            .values()
267            .filter(|u| !u.reserved)
268            .count()
269    }
270
271    pub fn select_utxos(&self, target_sat: u64) -> Result<Vec<WalletUtxo>, WalletError> {
272        let mut available: Vec<_> = self
273            .utxos
274            .lock()
275            .unwrap_or_else(|e| e.into_inner())
276            .values()
277            .filter(|u| !u.reserved)
278            .cloned()
279            .collect();
280        available.sort_by(|a, b| b.amount_sat.cmp(&a.amount_sat));
281        let mut sel = Vec::new();
282        let mut total = 0u64;
283        for utxo in available {
284            if total >= target_sat {
285                break;
286            }
287            total += utxo.amount_sat;
288            sel.push(utxo);
289        }
290        if total < target_sat {
291            Err(WalletError::InsufficientFunds {
292                available: total,
293                needed: target_sat,
294            })
295        } else {
296            Ok(sel)
297        }
298    }
299
300    pub fn reserve_utxos(&self, ops: &[OutPoint], reason: &str) {
301        let mut u = self.utxos.lock().unwrap_or_else(|e| e.into_inner());
302        for op in ops {
303            if let Some(x) = u.get_mut(op) {
304                x.reserved = true;
305                x.reserved_for = Some(reason.to_string());
306            }
307        }
308    }
309    pub fn unreserve_utxos(&self, ops: &[OutPoint]) {
310        let mut u = self.utxos.lock().unwrap_or_else(|e| e.into_inner());
311        for op in ops {
312            if let Some(x) = u.get_mut(op) {
313                x.reserved = false;
314                x.reserved_for = None;
315            }
316        }
317    }
318
319    pub fn sign_with_key(
320        &self,
321        path: &Bip86Path,
322        msg: &[u8; 32],
323    ) -> Result<secp256k1::ecdsa::Signature, WalletError> {
324        let sk = self.derive_private_key(path)?;
325        let msg = secp256k1::Message::from_slice(msg.as_ref())
326            .map_err(|e| WalletError::SigningFailed(e.to_string()))?;
327        Ok(self.secp.sign_ecdsa(&msg, &sk))
328    }
329
330    pub fn mark_seal_used(&self, seal: &BitcoinSealRef) -> Result<(), WalletError> {
331        let mut used = self.used_seals.lock().unwrap_or_else(|e| e.into_inner());
332        let key = seal.to_vec();
333        if used.contains(&key) {
334            return Err(WalletError::SealAlreadyUsed);
335        }
336        used.insert(key);
337        Ok(())
338    }
339    pub fn is_seal_used(&self, seal: &BitcoinSealRef) -> bool {
340        self.used_seals
341            .lock()
342            .unwrap_or_else(|e| e.into_inner())
343            .contains(&seal.to_vec())
344    }
345
346    pub fn network(&self) -> Network {
347        self.network
348    }
349    pub fn secp(&self) -> &Secp256k1<secp256k1::All> {
350        &self.secp
351    }
352    pub fn get_utxo(&self, op: &OutPoint) -> Option<WalletUtxo> {
353        self.utxos
354            .lock()
355            .unwrap_or_else(|e| e.into_inner())
356            .get(op)
357            .cloned()
358    }
359    pub fn list_utxos(&self) -> Vec<WalletUtxo> {
360        self.utxos
361            .lock()
362            .unwrap_or_else(|e| e.into_inner())
363            .values()
364            .cloned()
365            .collect()
366    }
367
368    /// Scan the blockchain for UTXOs belonging to this wallet's addresses
369    ///
370    /// This method checks all derived addresses up to `address_gap_limit` consecutive
371    /// unused addresses to find UTXOs on the chain and add them to the wallet.
372    ///
373    /// Requires a callback that checks whether a given address has UTXOs and returns them.
374    pub fn scan_chain_for_utxos<F>(
375        &self,
376        mut fetch_utxos: F,
377        account: u32,
378        address_gap_limit: usize,
379    ) -> Result<usize, WalletError>
380    where
381        F: FnMut(&Address) -> Result<Vec<(OutPoint, u64)>, String>,
382    {
383        let mut discovered_count = 0;
384        let mut consecutive_empty = 0;
385        let mut index = 0;
386
387        loop {
388            if consecutive_empty >= address_gap_limit {
389                break;
390            }
391
392            let path = Bip86Path::external(account, index);
393            let derived = self.derive_key(&path)?;
394
395            match fetch_utxos(&derived.address) {
396                Ok(utxos) => {
397                    if utxos.is_empty() {
398                        consecutive_empty += 1;
399                    } else {
400                        consecutive_empty = 0;
401                        for (outpoint, amount) in utxos {
402                            self.add_utxo(outpoint, amount, path.clone());
403                            discovered_count += 1;
404                        }
405                    }
406                }
407                Err(e) => {
408                    return Err(WalletError::KeyDerivationFailed(e));
409                }
410            }
411
412            index += 1;
413        }
414
415        Ok(discovered_count)
416    }
417
418    /// Add a UTXO to the wallet from a known address and outpoint
419    ///
420    /// This is used when you manually fund an address by sending bitcoin to it,
421    /// then register the UTXO once it's confirmed.
422    pub fn add_utxo_from_address(
423        &self,
424        outpoint: OutPoint,
425        amount_sat: u64,
426        account: u32,
427        index: u32,
428    ) -> Result<(), WalletError> {
429        let path = Bip86Path::external(account, index);
430        let _derived = self.derive_key(&path)?;
431
432        // Verify the outpoint belongs to this address
433        // (In production, you'd verify the script_pubkey matches)
434        self.add_utxo(outpoint, amount_sat, path);
435        Ok(())
436    }
437}
438
439#[derive(Debug, thiserror::Error)]
440pub enum WalletError {
441    #[error("No available UTXOs")]
442    NoAvailableUtxos,
443    #[error("Insufficient funds: available {available} sat, needed {needed} sat")]
444    InsufficientFunds { available: u64, needed: u64 },
445    #[error("UTXO not found")]
446    UtxoNotFound,
447    #[error("Seal already used")]
448    SealAlreadyUsed,
449    #[error("Invalid mnemonic: {0}")]
450    InvalidMnemonic(String),
451    #[error("Key derivation failed: {0}")]
452    KeyDerivationFailed(String),
453    #[error("Invalid key: {0}")]
454    InvalidKey(String),
455    #[error("Signing failed: {0}")]
456    SigningFailed(String),
457    #[error("PSBT error: {0}")]
458    PsbtError(String),
459    #[error("Script error: {0}")]
460    ScriptError(String),
461}
462
463pub struct MockSealWallet {
464    pub utxos: Vec<(OutPoint, u64)>,
465    pub used_seals: Mutex<HashSet<Vec<u8>>>,
466}
467impl MockSealWallet {
468    pub fn new() -> Self {
469        Self {
470            utxos: Vec::new(),
471            used_seals: Mutex::new(HashSet::new()),
472        }
473    }
474    pub fn add_utxo(&mut self, txid: [u8; 32], vout: u32, amount_sat: u64) {
475        let txid = Txid::from_raw_hash(bitcoin::hashes::sha256d::Hash::from_slice(&txid).unwrap());
476        self.utxos.push((OutPoint::new(txid, vout), amount_sat));
477    }
478}
479impl Default for MockSealWallet {
480    fn default() -> Self {
481        Self::new()
482    }
483}
484
485#[cfg(test)]
486mod tests {
487    use super::*;
488    #[test]
489    fn test_wallet_creation_from_random() {
490        let w = SealWallet::generate_random(Network::Signet);
491        assert_eq!(w.balance(), 0);
492    }
493    #[test]
494    fn test_wallet_key_derivation() {
495        let w = SealWallet::generate_random(Network::Signet);
496        let k = w.derive_key(&Bip86Path::external(0, 0)).unwrap();
497        assert_eq!(k.address.network, Network::Signet);
498        assert!(k.address.script_pubkey().is_witness_program());
499    }
500    #[test]
501    fn test_wallet_key_derivation_deterministic() {
502        let seed = [42u8; 64];
503        let w1 = SealWallet::from_seed(&seed, Network::Signet).unwrap();
504        let w2 = SealWallet::from_seed(&seed, Network::Signet).unwrap();
505        let k1 = w1.derive_key(&Bip86Path::external(0, 0)).unwrap();
506        let k2 = w2.derive_key(&Bip86Path::external(0, 0)).unwrap();
507        assert_eq!(k1.output_key, k2.output_key);
508        assert_eq!(k1.address, k2.address);
509    }
510    #[test]
511    fn test_wallet_different_paths() {
512        let w = SealWallet::generate_random(Network::Signet);
513        let k0 = w.derive_key(&Bip86Path::external(0, 0)).unwrap();
514        let k1 = w.derive_key(&Bip86Path::external(0, 1)).unwrap();
515        let k2 = w.derive_key(&Bip86Path::external(1, 0)).unwrap();
516        assert_ne!(k0.output_key, k1.output_key);
517        assert_ne!(k0.output_key, k2.output_key);
518        assert_ne!(k1.output_key, k2.output_key);
519    }
520    #[test]
521    fn test_wallet_utxo_selection() {
522        let w = SealWallet::generate_random(Network::Signet);
523        let path = Bip86Path::external(0, 0);
524        let t1 =
525            Txid::from_raw_hash(bitcoin::hashes::sha256d::Hash::from_slice(&[1u8; 32]).unwrap());
526        let t2 =
527            Txid::from_raw_hash(bitcoin::hashes::sha256d::Hash::from_slice(&[2u8; 32]).unwrap());
528        let t3 =
529            Txid::from_raw_hash(bitcoin::hashes::sha256d::Hash::from_slice(&[3u8; 32]).unwrap());
530        w.add_utxo(OutPoint::new(t1, 0), 50_000, path.clone());
531        w.add_utxo(OutPoint::new(t2, 0), 30_000, path.clone());
532        w.add_utxo(OutPoint::new(t3, 0), 20_000, path);
533        let sel = w.select_utxos(70_000).unwrap();
534        assert_eq!(sel.len(), 2);
535        assert_eq!(sel.iter().map(|u| u.amount_sat).sum::<u64>(), 80_000);
536    }
537    #[test]
538    fn test_wallet_insufficient_funds() {
539        let w = SealWallet::generate_random(Network::Signet);
540        let txid =
541            Txid::from_raw_hash(bitcoin::hashes::sha256d::Hash::from_slice(&[1u8; 32]).unwrap());
542        w.add_utxo(OutPoint::new(txid, 0), 10_000, Bip86Path::external(0, 0));
543        assert!(w.select_utxos(20_000).is_err());
544    }
545    #[test]
546    fn test_wallet_reserve_utxos() {
547        let w = SealWallet::generate_random(Network::Signet);
548        let txid =
549            Txid::from_raw_hash(bitcoin::hashes::sha256d::Hash::from_slice(&[1u8; 32]).unwrap());
550        let op = OutPoint::new(txid, 0);
551        w.add_utxo(op, 100_000, Bip86Path::external(0, 0));
552        assert_eq!(w.balance(), 100_000);
553        w.reserve_utxos(&[op], "test");
554        assert_eq!(w.balance(), 0);
555        w.unreserve_utxos(&[op]);
556        assert_eq!(w.balance(), 100_000);
557    }
558    #[test]
559    fn test_seal_lifecycle() {
560        let w = SealWallet::generate_random(Network::Signet);
561        let seal = BitcoinSealRef::new([1u8; 32], 0, Some(42));
562        assert!(!w.is_seal_used(&seal));
563        w.mark_seal_used(&seal).unwrap();
564        assert!(w.is_seal_used(&seal));
565        assert!(w.mark_seal_used(&seal).is_err());
566    }
567    #[test]
568    fn test_derivation_path_string() {
569        assert_eq!(
570            Bip86Path::new(0, 0, 5).to_string(&Network::Bitcoin),
571            "m/86'/0'/0'/0/5"
572        );
573    }
574    #[test]
575    fn test_mock_wallet() {
576        let mut w = MockSealWallet::new();
577        w.add_utxo([1u8; 32], 0, 100_000);
578        assert_eq!(w.utxos.len(), 1);
579    }
580}