Skip to main content

abtc_adapters/wallet/
mod.rs

1//! Wallet Implementations
2//!
3//! Provides wallet adapters:
4//! - `InMemoryWallet`: In-memory wallet for testing and development
5//! - `FileBasedWalletStore`: JSON file persistence for wallet state
6//! - `PersistentWallet`: Wraps InMemoryWallet with automatic file persistence
7//!
8//! Production wallets should use secure key storage (hardware wallets,
9//! encrypted DBs, etc.).
10
11pub mod file_store;
12pub mod persistent;
13
14pub use file_store::FileBasedWalletStore;
15pub use persistent::PersistentWallet;
16
17use abtc_domain::primitives::{Amount, OutPoint, Transaction, TxOut};
18use abtc_domain::wallet::address::{Address, AddressType};
19use abtc_domain::wallet::coin_selection::{Coin, CoinSelector, SelectionStrategy};
20use abtc_domain::wallet::keys::PrivateKey;
21use abtc_domain::wallet::tx_builder::{
22    InputInfo, TransactionBuilder, P2PKH_INPUT_VSIZE, P2WPKH_INPUT_VSIZE, P2WPKH_OUTPUT_VSIZE,
23    TX_OVERHEAD_VSIZE,
24};
25use abtc_ports::wallet::store::{WalletKeyEntry, WalletSnapshot, WalletUtxoEntry};
26use abtc_ports::wallet::UnspentOutput;
27use abtc_ports::{Balance, WalletPort};
28use async_trait::async_trait;
29use std::collections::HashMap;
30use std::sync::Arc;
31use tokio::sync::RwLock;
32
33/// Default fee rate in satoshis per virtual byte
34const DEFAULT_FEE_RATE: f64 = 10.0;
35
36/// A tracked key with its address
37#[derive(Clone)]
38struct WalletKey {
39    /// The private key
40    private_key: PrivateKey,
41    /// The derived address
42    address: Address,
43    /// Optional label for wallet export/display.
44    label: Option<String>,
45}
46
47/// In-memory wallet implementation
48///
49/// Tracks private keys and unspent outputs. Keys are stored in memory
50/// (NOT suitable for production use — keys should be encrypted/in HSM).
51pub struct InMemoryWallet {
52    /// Keys indexed by address string
53    keys: Arc<RwLock<HashMap<String, WalletKey>>>,
54    /// Unspent outputs tracked by the wallet
55    utxos: Arc<RwLock<Vec<UnspentOutput>>>,
56    /// Whether to use mainnet addresses
57    mainnet: bool,
58    /// Preferred address type for new addresses
59    address_type: AddressType,
60    /// Key index counter (for deterministic-like key gen)
61    key_counter: Arc<RwLock<u64>>,
62}
63
64impl InMemoryWallet {
65    /// Create a new in-memory wallet.
66    pub fn new(mainnet: bool, address_type: AddressType) -> Self {
67        InMemoryWallet {
68            keys: Arc::new(RwLock::new(HashMap::new())),
69            utxos: Arc::new(RwLock::new(Vec::new())),
70            mainnet,
71            address_type,
72            key_counter: Arc::new(RwLock::new(0)),
73        }
74    }
75
76    /// Create a default mainnet P2WPKH wallet.
77    pub fn default_mainnet() -> Self {
78        Self::new(true, AddressType::P2WPKH)
79    }
80
81    /// Create a default testnet P2WPKH wallet.
82    pub fn default_testnet() -> Self {
83        Self::new(false, AddressType::P2WPKH)
84    }
85
86    /// Add a UTXO to the wallet.
87    pub async fn add_utxo(&self, utxo: UnspentOutput) {
88        let mut utxos = self.utxos.write().await;
89        utxos.push(utxo);
90    }
91
92    /// Remove spent UTXOs (by outpoints).
93    pub async fn remove_utxos(&self, spent_outpoints: &[OutPoint]) {
94        let mut utxos = self.utxos.write().await;
95        utxos.retain(|u| !spent_outpoints.contains(&u.outpoint));
96    }
97
98    /// Get the number of keys in the wallet.
99    pub async fn key_count(&self) -> usize {
100        let keys = self.keys.read().await;
101        keys.len()
102    }
103
104    /// Find the private key for a given script pubkey.
105    async fn find_key_for_script(&self, script_pubkey: &abtc_domain::Script) -> Option<WalletKey> {
106        let keys = self.keys.read().await;
107        keys.values()
108            .find(|wk| wk.address.script_pubkey.as_bytes() == script_pubkey.as_bytes())
109            .cloned()
110    }
111
112    /// Estimate the input virtual size based on the UTXO's script type.
113    fn estimate_input_vsize(script_pubkey: &abtc_domain::Script) -> u32 {
114        if script_pubkey.is_p2wpkh() {
115            P2WPKH_INPUT_VSIZE
116        } else if script_pubkey.is_p2pkh() {
117            P2PKH_INPUT_VSIZE
118        } else {
119            // Default to P2WPKH size
120            P2WPKH_INPUT_VSIZE
121        }
122    }
123
124    /// Whether this wallet uses mainnet addresses.
125    pub fn is_mainnet(&self) -> bool {
126        self.mainnet
127    }
128
129    /// Get the preferred address type as a string.
130    pub fn address_type_str(&self) -> &str {
131        match self.address_type {
132            AddressType::P2PKH => "p2pkh",
133            AddressType::P2WPKH => "p2wpkh",
134            AddressType::P2shP2wpkh => "p2sh-p2wpkh",
135            AddressType::P2TR => "p2tr",
136        }
137    }
138
139    /// Parse an address type string back into an AddressType variant.
140    pub fn parse_address_type(s: &str) -> Option<AddressType> {
141        match s {
142            "p2pkh" => Some(AddressType::P2PKH),
143            "p2wpkh" => Some(AddressType::P2WPKH),
144            "p2sh-p2wpkh" => Some(AddressType::P2shP2wpkh),
145            "p2tr" => Some(AddressType::P2TR),
146            _ => None,
147        }
148    }
149
150    /// Create a snapshot of the current wallet state for persistence.
151    ///
152    /// Serializes keys (as WIF), UTXOs (as hex), and metadata into a
153    /// plain data container that can be saved to disk.
154    pub async fn snapshot(&self) -> WalletSnapshot {
155        let keys = self.keys.read().await;
156        let utxos = self.utxos.read().await;
157        let counter = self.key_counter.read().await;
158
159        let key_entries: Vec<WalletKeyEntry> = keys
160            .iter()
161            .map(|(addr, wk)| WalletKeyEntry {
162                address: addr.clone(),
163                wif: wk.private_key.to_wif(),
164                label: wk.label.clone(),
165            })
166            .collect();
167
168        let utxo_entries: Vec<WalletUtxoEntry> = utxos
169            .iter()
170            .map(|u| {
171                let script_bytes = u.output.script_pubkey.as_bytes();
172                let script_hex: String =
173                    script_bytes.iter().map(|b| format!("{:02x}", b)).collect();
174
175                WalletUtxoEntry {
176                    txid_hex: u.outpoint.txid.to_hex_reversed(),
177                    vout: u.outpoint.vout,
178                    amount_sat: u.output.value.as_sat(),
179                    script_pubkey_hex: script_hex,
180                    confirmations: u.confirmations,
181                    is_coinbase: u.is_coinbase,
182                }
183            })
184            .collect();
185
186        WalletSnapshot {
187            version: 1,
188            mainnet: self.mainnet,
189            address_type: self.address_type_str().to_string(),
190            key_counter: *counter,
191            keys: key_entries,
192            utxos: utxo_entries,
193        }
194    }
195
196    /// Restore wallet state from a previously saved snapshot.
197    ///
198    /// Parses WIF keys, reconstructs addresses, rebuilds UTXOs, and
199    /// restores the key counter. Existing state is replaced.
200    pub async fn restore_from_snapshot(
201        &self,
202        snapshot: &WalletSnapshot,
203    ) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
204        // Restore keys
205        let mut keys = self.keys.write().await;
206        keys.clear();
207
208        for entry in &snapshot.keys {
209            let private_key = PrivateKey::from_wif(&entry.wif)
210                .map_err(|e| format!("failed to parse WIF for {}: {}", entry.address, e))?;
211
212            let address = Address::decode(&entry.address)
213                .map_err(|e| format!("failed to decode address {}: {}", entry.address, e))?;
214
215            keys.insert(
216                entry.address.clone(),
217                WalletKey {
218                    private_key,
219                    address,
220                    label: entry.label.clone(),
221                },
222            );
223        }
224
225        // Restore UTXOs
226        let mut utxos = self.utxos.write().await;
227        utxos.clear();
228
229        for entry in &snapshot.utxos {
230            // Parse txid from reversed hex (display format) back to internal bytes
231            let txid_hex_raw = Self::reverse_hex(&entry.txid_hex)
232                .ok_or_else(|| format!("invalid txid hex: {}", entry.txid_hex))?;
233            let txid = abtc_domain::Txid::from_hex(&txid_hex_raw)
234                .ok_or_else(|| format!("failed to parse txid: {}", entry.txid_hex))?;
235
236            // Parse script pubkey from hex
237            let script_bytes = Self::hex_to_bytes(&entry.script_pubkey_hex)
238                .ok_or_else(|| format!("invalid script hex: {}", entry.script_pubkey_hex))?;
239
240            utxos.push(UnspentOutput {
241                outpoint: OutPoint::new(txid, entry.vout),
242                output: TxOut::new(
243                    Amount::from_sat(entry.amount_sat),
244                    abtc_domain::Script::from_bytes(script_bytes),
245                ),
246                confirmations: entry.confirmations,
247                is_coinbase: entry.is_coinbase,
248            });
249        }
250
251        // Restore key counter
252        let mut counter = self.key_counter.write().await;
253        *counter = snapshot.key_counter;
254
255        tracing::info!(
256            "Restored wallet: {} keys, {} UTXOs, counter={}",
257            keys.len(),
258            utxos.len(),
259            snapshot.key_counter
260        );
261
262        Ok(())
263    }
264
265    /// Reverse a hex string byte-by-byte (e.g., "aabb" → "bbaa").
266    fn reverse_hex(hex: &str) -> Option<String> {
267        if hex.len() % 2 != 0 {
268            return None;
269        }
270        let mut result = String::with_capacity(hex.len());
271        for i in (0..hex.len()).rev().step_by(2) {
272            if i == 0 {
273                result.push_str(&hex[0..2]);
274            } else {
275                result.push_str(&hex[i - 1..i + 1]);
276            }
277        }
278        Some(result)
279    }
280
281    /// Parse a hex string into bytes.
282    fn hex_to_bytes(hex: &str) -> Option<Vec<u8>> {
283        if hex.len() % 2 != 0 {
284            return None;
285        }
286        let mut bytes = Vec::with_capacity(hex.len() / 2);
287        for i in (0..hex.len()).step_by(2) {
288            let byte = u8::from_str_radix(&hex[i..i + 2], 16).ok()?;
289            bytes.push(byte);
290        }
291        Some(bytes)
292    }
293}
294
295impl Default for InMemoryWallet {
296    fn default() -> Self {
297        Self::default_mainnet()
298    }
299}
300
301#[async_trait]
302impl WalletPort for InMemoryWallet {
303    async fn get_balance(&self) -> Result<Balance, Box<dyn std::error::Error + Send + Sync>> {
304        let utxos = self.utxos.read().await;
305
306        let confirmed: i64 = utxos
307            .iter()
308            .filter(|u| u.confirmations >= 1)
309            .map(|u| u.output.value.as_sat())
310            .sum();
311
312        let unconfirmed: i64 = utxos
313            .iter()
314            .filter(|u| u.confirmations == 0)
315            .map(|u| u.output.value.as_sat())
316            .sum();
317
318        let immature: i64 = utxos
319            .iter()
320            .filter(|u| u.is_coinbase && u.confirmations < 100)
321            .map(|u| u.output.value.as_sat())
322            .sum();
323
324        Ok(Balance {
325            confirmed: Amount::from_sat(confirmed),
326            unconfirmed: Amount::from_sat(unconfirmed),
327            immature: Amount::from_sat(immature),
328        })
329    }
330
331    async fn list_unspent(
332        &self,
333        min_confirmations: u32,
334        max_amount: Option<Amount>,
335    ) -> Result<Vec<UnspentOutput>, Box<dyn std::error::Error + Send + Sync>> {
336        let utxos = self.utxos.read().await;
337        let filtered: Vec<UnspentOutput> = utxos
338            .iter()
339            .filter(|u| u.confirmations >= min_confirmations)
340            .filter(|u| match max_amount {
341                Some(max) => u.output.value.as_sat() <= max.as_sat(),
342                None => true,
343            })
344            .cloned()
345            .collect();
346        Ok(filtered)
347    }
348
349    async fn create_transaction(
350        &self,
351        to: Vec<(String, Amount)>,
352        fee_rate: Option<f64>,
353    ) -> Result<Transaction, Box<dyn std::error::Error + Send + Sync>> {
354        let fee_rate = fee_rate.unwrap_or(DEFAULT_FEE_RATE);
355
356        // Decode destination addresses and build outputs
357        let mut outputs = Vec::new();
358        let mut total_send: i64 = 0;
359
360        for (addr_str, amount) in &to {
361            let addr = Address::decode(addr_str)
362                .map_err(|e| format!("invalid address '{}': {}", addr_str, e))?;
363            outputs.push(TxOut::new(*amount, addr.script_pubkey));
364            total_send += amount.as_sat();
365        }
366
367        let target = Amount::from_sat(total_send);
368
369        // Get wallet UTXOs and build coin candidates
370        let utxos = self.utxos.read().await;
371        let coins: Vec<Coin> = utxos
372            .iter()
373            .enumerate()
374            .filter(|(_, u)| u.confirmations >= 1) // Only confirmed UTXOs
375            .map(|(i, u)| Coin {
376                index: i,
377                amount: u.output.value,
378                input_size: Self::estimate_input_vsize(&u.output.script_pubkey),
379            })
380            .collect();
381
382        // Calculate total output size
383        let output_vsize: u32 = outputs.len() as u32 * P2WPKH_OUTPUT_VSIZE + P2WPKH_OUTPUT_VSIZE; // +1 for change output
384
385        // Select coins
386        let selection = CoinSelector::select(
387            &coins,
388            target,
389            fee_rate,
390            output_vsize,
391            TX_OVERHEAD_VSIZE,
392            SelectionStrategy::LargestFirst,
393        )
394        .map_err(|e| format!("coin selection failed: {}", e))?;
395
396        // Add change output if significant (> dust threshold of 546 sat)
397        if selection.change.as_sat() > 546 {
398            // Use our first key's address as change address
399            let keys = self.keys.read().await;
400            if let Some(change_key) = keys.values().next() {
401                outputs.push(TxOut::new(
402                    selection.change,
403                    change_key.address.script_pubkey.clone(),
404                ));
405            }
406        }
407
408        // Build the transaction
409        let mut builder = TransactionBuilder::new().version(2);
410
411        for &coin_idx in &selection.selected_indices {
412            let utxo = &utxos[coin_idx];
413            let wallet_key = self.find_key_for_script(&utxo.output.script_pubkey).await;
414
415            builder = builder.add_input(InputInfo {
416                outpoint: utxo.outpoint,
417                script_pubkey: utxo.output.script_pubkey.clone(),
418                amount: utxo.output.value,
419                signing_key: wallet_key.map(|wk| wk.private_key),
420                sequence: 0xFFFFFFFE, // RBF-compatible
421                tap_script_path: None,
422            });
423        }
424
425        for output in outputs {
426            builder = builder.add_output(output);
427        }
428
429        // Build unsigned (signing is separate step via sign_transaction)
430        let tx = builder
431            .build_unsigned()
432            .map_err(|e| format!("build failed: {}", e))?;
433
434        Ok(tx)
435    }
436
437    async fn sign_transaction(
438        &self,
439        tx: &Transaction,
440    ) -> Result<Transaction, Box<dyn std::error::Error + Send + Sync>> {
441        // Reconstruct builder with signing keys
442        let utxos = self.utxos.read().await;
443        let mut builder = TransactionBuilder::new().version(tx.version);
444
445        for input in &tx.inputs {
446            // Find the UTXO for this input
447            let utxo = utxos
448                .iter()
449                .find(|u| u.outpoint == input.previous_output)
450                .ok_or_else(|| format!("unknown UTXO for input {}", input.previous_output))?;
451
452            let wallet_key = self.find_key_for_script(&utxo.output.script_pubkey).await;
453
454            builder = builder.add_input(InputInfo {
455                outpoint: input.previous_output,
456                script_pubkey: utxo.output.script_pubkey.clone(),
457                amount: utxo.output.value,
458                signing_key: wallet_key.map(|wk| wk.private_key),
459                sequence: input.sequence,
460                tap_script_path: None,
461            });
462        }
463
464        for output in &tx.outputs {
465            builder = builder.add_output(output.clone());
466        }
467
468        builder = builder.lock_time(tx.lock_time);
469
470        let signed = builder
471            .sign()
472            .map_err(|e| format!("signing failed: {}", e))?;
473
474        Ok(signed)
475    }
476
477    async fn send_transaction(
478        &self,
479        tx: &Transaction,
480    ) -> Result<String, Box<dyn std::error::Error + Send + Sync>> {
481        let txid = tx.txid();
482        tracing::info!("Wallet: broadcasting transaction {}", txid);
483
484        // Mark spent UTXOs
485        let spent: Vec<OutPoint> = tx
486            .inputs
487            .iter()
488            .map(|input| input.previous_output)
489            .collect();
490        self.remove_utxos(&spent).await;
491
492        Ok(txid.to_hex_reversed())
493    }
494
495    async fn get_new_address(
496        &self,
497        label: Option<&str>,
498    ) -> Result<String, Box<dyn std::error::Error + Send + Sync>> {
499        // Generate a new key
500        let key = PrivateKey::generate(true, self.mainnet);
501        let pubkey = key.public_key();
502
503        // Derive address based on preferred type
504        let address = match self.address_type {
505            AddressType::P2PKH => Address::p2pkh(&pubkey, self.mainnet),
506            AddressType::P2WPKH => Address::p2wpkh(&pubkey, self.mainnet)
507                .map_err(|e| format!("address derivation failed: {}", e))?,
508            AddressType::P2shP2wpkh => Address::p2sh_p2wpkh(&pubkey, self.mainnet)
509                .map_err(|e| format!("address derivation failed: {}", e))?,
510            AddressType::P2TR => {
511                // Derive x-only internal key from the compressed pubkey
512                let serialized = pubkey.serialize();
513                let mut x_only = [0u8; 32];
514                x_only.copy_from_slice(&serialized[1..33]);
515                Address::p2tr_from_internal_key(&x_only, self.mainnet)
516                    .map_err(|e| format!("address derivation failed: {}", e))?
517            }
518        };
519
520        let addr_string = address.encoded.clone();
521
522        // Store the key
523        let wallet_key = WalletKey {
524            private_key: key,
525            address,
526            label: label.map(|s| s.to_string()),
527        };
528
529        let mut keys = self.keys.write().await;
530        keys.insert(addr_string.clone(), wallet_key);
531
532        // Increment counter
533        let mut counter = self.key_counter.write().await;
534        *counter += 1;
535
536        tracing::debug!(
537            "Generated new {} address: {}",
538            match self.address_type {
539                AddressType::P2PKH => "P2PKH",
540                AddressType::P2WPKH => "P2WPKH",
541                AddressType::P2shP2wpkh => "P2SH-P2WPKH",
542                AddressType::P2TR => "P2TR",
543            },
544            addr_string
545        );
546
547        Ok(addr_string)
548    }
549
550    async fn import_key(
551        &self,
552        privkey_wif: &str,
553        label: Option<&str>,
554        _rescan: bool,
555    ) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
556        let key =
557            PrivateKey::from_wif(privkey_wif).map_err(|e| format!("invalid WIF key: {}", e))?;
558
559        let pubkey = key.public_key();
560
561        // Derive address based on preferred type
562        let address = match self.address_type {
563            AddressType::P2PKH => Address::p2pkh(&pubkey, self.mainnet),
564            AddressType::P2WPKH => Address::p2wpkh(&pubkey, self.mainnet)
565                .map_err(|e| format!("address derivation failed: {}", e))?,
566            AddressType::P2shP2wpkh => Address::p2sh_p2wpkh(&pubkey, self.mainnet)
567                .map_err(|e| format!("address derivation failed: {}", e))?,
568            AddressType::P2TR => {
569                let serialized = pubkey.serialize();
570                let mut x_only = [0u8; 32];
571                x_only.copy_from_slice(&serialized[1..33]);
572                Address::p2tr_from_internal_key(&x_only, self.mainnet)
573                    .map_err(|e| format!("address derivation failed: {}", e))?
574            }
575        };
576
577        let addr_string = address.encoded.clone();
578
579        let wallet_key = WalletKey {
580            private_key: key,
581            address,
582            label: label.map(|s| s.to_string()),
583        };
584
585        let mut keys = self.keys.write().await;
586        keys.insert(addr_string.clone(), wallet_key);
587
588        tracing::info!("Imported key for address: {}", addr_string);
589        Ok(())
590    }
591
592    async fn get_transaction_history(
593        &self,
594        _count: u32,
595        _skip: u32,
596    ) -> Result<Vec<Transaction>, Box<dyn std::error::Error + Send + Sync>> {
597        // TODO: Implement transaction history tracking
598        Ok(Vec::new())
599    }
600}
601
602#[cfg(test)]
603mod tests {
604    use super::*;
605
606    #[tokio::test]
607    async fn test_wallet_creation() {
608        let wallet = InMemoryWallet::default_mainnet();
609        let balance = wallet.get_balance().await.unwrap();
610        assert_eq!(balance.confirmed.as_sat(), 0);
611        assert_eq!(balance.unconfirmed.as_sat(), 0);
612    }
613
614    #[tokio::test]
615    async fn test_generate_p2pkh_address() {
616        let wallet = InMemoryWallet::new(true, AddressType::P2PKH);
617        let addr = wallet.get_new_address(Some("test")).await.unwrap();
618        assert!(addr.starts_with('1'));
619        assert_eq!(wallet.key_count().await, 1);
620    }
621
622    #[tokio::test]
623    async fn test_generate_p2wpkh_address() {
624        let wallet = InMemoryWallet::default_mainnet();
625        let addr = wallet.get_new_address(None).await.unwrap();
626        assert!(addr.starts_with("bc1q"));
627        assert_eq!(wallet.key_count().await, 1);
628    }
629
630    #[tokio::test]
631    async fn test_generate_p2sh_p2wpkh_address() {
632        let wallet = InMemoryWallet::new(true, AddressType::P2shP2wpkh);
633        let addr = wallet.get_new_address(None).await.unwrap();
634        assert!(addr.starts_with('3'));
635    }
636
637    #[tokio::test]
638    async fn test_generate_testnet_address() {
639        let wallet = InMemoryWallet::default_testnet();
640        let addr = wallet.get_new_address(None).await.unwrap();
641        assert!(addr.starts_with("tb1q"));
642    }
643
644    #[tokio::test]
645    async fn test_import_and_export_key() {
646        let wallet = InMemoryWallet::default_mainnet();
647
648        // Generate a key, export WIF
649        let key = PrivateKey::generate(true, true);
650        let wif = key.to_wif();
651
652        // Import it
653        wallet
654            .import_key(&wif, Some("imported"), false)
655            .await
656            .unwrap();
657        assert_eq!(wallet.key_count().await, 1);
658    }
659
660    #[tokio::test]
661    async fn test_add_and_list_utxos() {
662        let wallet = InMemoryWallet::default_mainnet();
663
664        let utxo = UnspentOutput {
665            outpoint: OutPoint::new(abtc_domain::Txid::zero(), 0),
666            output: TxOut::new(Amount::from_sat(100_000), abtc_domain::Script::new()),
667            confirmations: 6,
668            is_coinbase: false,
669        };
670
671        wallet.add_utxo(utxo).await;
672
673        let unspent = wallet.list_unspent(1, None).await.unwrap();
674        assert_eq!(unspent.len(), 1);
675        assert_eq!(unspent[0].output.value.as_sat(), 100_000);
676
677        let balance = wallet.get_balance().await.unwrap();
678        assert_eq!(balance.confirmed.as_sat(), 100_000);
679    }
680
681    #[tokio::test]
682    async fn test_multiple_addresses() {
683        let wallet = InMemoryWallet::default_mainnet();
684
685        let addr1 = wallet.get_new_address(Some("first")).await.unwrap();
686        let addr2 = wallet.get_new_address(Some("second")).await.unwrap();
687
688        assert_ne!(addr1, addr2);
689        assert_eq!(wallet.key_count().await, 2);
690    }
691
692    #[tokio::test]
693    async fn test_balance_confirmed_vs_unconfirmed() {
694        let wallet = InMemoryWallet::default_mainnet();
695
696        // Add a confirmed UTXO
697        wallet
698            .add_utxo(UnspentOutput {
699                outpoint: OutPoint::new(abtc_domain::Txid::zero(), 0),
700                output: TxOut::new(Amount::from_sat(50_000), abtc_domain::Script::new()),
701                confirmations: 3,
702                is_coinbase: false,
703            })
704            .await;
705
706        // Add an unconfirmed UTXO
707        wallet
708            .add_utxo(UnspentOutput {
709                outpoint: OutPoint::new(abtc_domain::Txid::zero(), 1),
710                output: TxOut::new(Amount::from_sat(25_000), abtc_domain::Script::new()),
711                confirmations: 0,
712                is_coinbase: false,
713            })
714            .await;
715
716        let balance = wallet.get_balance().await.unwrap();
717        assert_eq!(balance.confirmed.as_sat(), 50_000);
718        assert_eq!(balance.unconfirmed.as_sat(), 25_000);
719    }
720
721    #[tokio::test]
722    async fn test_balance_immature_coinbase() {
723        let wallet = InMemoryWallet::default_mainnet();
724
725        // Coinbase with < 100 confirmations → immature
726        wallet
727            .add_utxo(UnspentOutput {
728                outpoint: OutPoint::new(abtc_domain::Txid::zero(), 0),
729                output: TxOut::new(Amount::from_sat(5_000_000_000), abtc_domain::Script::new()),
730                confirmations: 50,
731                is_coinbase: true,
732            })
733            .await;
734
735        // Coinbase with >= 100 confirmations → not immature
736        wallet
737            .add_utxo(UnspentOutput {
738                outpoint: OutPoint::new(abtc_domain::Txid::zero(), 1),
739                output: TxOut::new(Amount::from_sat(5_000_000_000), abtc_domain::Script::new()),
740                confirmations: 100,
741                is_coinbase: true,
742            })
743            .await;
744
745        let balance = wallet.get_balance().await.unwrap();
746        assert_eq!(balance.immature.as_sat(), 5_000_000_000); // only the 50-conf one
747    }
748
749    #[tokio::test]
750    async fn test_list_unspent_min_confirmations() {
751        let wallet = InMemoryWallet::default_mainnet();
752
753        wallet
754            .add_utxo(UnspentOutput {
755                outpoint: OutPoint::new(abtc_domain::Txid::zero(), 0),
756                output: TxOut::new(Amount::from_sat(10_000), abtc_domain::Script::new()),
757                confirmations: 0,
758                is_coinbase: false,
759            })
760            .await;
761        wallet
762            .add_utxo(UnspentOutput {
763                outpoint: OutPoint::new(abtc_domain::Txid::zero(), 1),
764                output: TxOut::new(Amount::from_sat(20_000), abtc_domain::Script::new()),
765                confirmations: 3,
766                is_coinbase: false,
767            })
768            .await;
769        wallet
770            .add_utxo(UnspentOutput {
771                outpoint: OutPoint::new(abtc_domain::Txid::zero(), 2),
772                output: TxOut::new(Amount::from_sat(30_000), abtc_domain::Script::new()),
773                confirmations: 10,
774                is_coinbase: false,
775            })
776            .await;
777
778        // min_confirmations = 0 → all 3
779        let all = wallet.list_unspent(0, None).await.unwrap();
780        assert_eq!(all.len(), 3);
781
782        // min_confirmations = 1 → 2 (skip unconfirmed)
783        let confirmed = wallet.list_unspent(1, None).await.unwrap();
784        assert_eq!(confirmed.len(), 2);
785
786        // min_confirmations = 6 → 1
787        let deep = wallet.list_unspent(6, None).await.unwrap();
788        assert_eq!(deep.len(), 1);
789        assert_eq!(deep[0].output.value.as_sat(), 30_000);
790    }
791
792    #[tokio::test]
793    async fn test_list_unspent_max_amount() {
794        let wallet = InMemoryWallet::default_mainnet();
795
796        wallet
797            .add_utxo(UnspentOutput {
798                outpoint: OutPoint::new(abtc_domain::Txid::zero(), 0),
799                output: TxOut::new(Amount::from_sat(1_000), abtc_domain::Script::new()),
800                confirmations: 1,
801                is_coinbase: false,
802            })
803            .await;
804        wallet
805            .add_utxo(UnspentOutput {
806                outpoint: OutPoint::new(abtc_domain::Txid::zero(), 1),
807                output: TxOut::new(Amount::from_sat(100_000), abtc_domain::Script::new()),
808                confirmations: 1,
809                is_coinbase: false,
810            })
811            .await;
812
813        let filtered = wallet
814            .list_unspent(0, Some(Amount::from_sat(50_000)))
815            .await
816            .unwrap();
817        assert_eq!(filtered.len(), 1);
818        assert_eq!(filtered[0].output.value.as_sat(), 1_000);
819    }
820
821    #[tokio::test]
822    async fn test_remove_utxos() {
823        let wallet = InMemoryWallet::default_mainnet();
824
825        let op1 = OutPoint::new(
826            abtc_domain::Txid::from_hash(abtc_domain::primitives::Hash256::from_bytes([0x01; 32])),
827            0,
828        );
829        let op2 = OutPoint::new(
830            abtc_domain::Txid::from_hash(abtc_domain::primitives::Hash256::from_bytes([0x02; 32])),
831            0,
832        );
833
834        wallet
835            .add_utxo(UnspentOutput {
836                outpoint: op1,
837                output: TxOut::new(Amount::from_sat(10_000), abtc_domain::Script::new()),
838                confirmations: 1,
839                is_coinbase: false,
840            })
841            .await;
842        wallet
843            .add_utxo(UnspentOutput {
844                outpoint: op2,
845                output: TxOut::new(Amount::from_sat(20_000), abtc_domain::Script::new()),
846                confirmations: 1,
847                is_coinbase: false,
848            })
849            .await;
850
851        assert_eq!(wallet.list_unspent(0, None).await.unwrap().len(), 2);
852
853        wallet.remove_utxos(&[op1]).await;
854
855        let remaining = wallet.list_unspent(0, None).await.unwrap();
856        assert_eq!(remaining.len(), 1);
857        assert_eq!(remaining[0].outpoint, op2);
858    }
859
860    #[tokio::test]
861    async fn test_send_transaction_removes_spent_utxos() {
862        let wallet = InMemoryWallet::default_mainnet();
863
864        let op = OutPoint::new(
865            abtc_domain::Txid::from_hash(abtc_domain::primitives::Hash256::from_bytes([0x01; 32])),
866            0,
867        );
868
869        wallet
870            .add_utxo(UnspentOutput {
871                outpoint: op,
872                output: TxOut::new(Amount::from_sat(100_000), abtc_domain::Script::new()),
873                confirmations: 6,
874                is_coinbase: false,
875            })
876            .await;
877
878        // Create a tx that spends the UTXO
879        use abtc_domain::primitives::TxIn;
880        let tx = Transaction::v1(
881            vec![TxIn::final_input(op, abtc_domain::Script::new())],
882            vec![TxOut::new(
883                Amount::from_sat(90_000),
884                abtc_domain::Script::new(),
885            )],
886            0,
887        );
888
889        let txid_hex = wallet.send_transaction(&tx).await.unwrap();
890        assert!(!txid_hex.is_empty());
891
892        // UTXO should be removed
893        let remaining = wallet.list_unspent(0, None).await.unwrap();
894        assert_eq!(remaining.len(), 0);
895    }
896
897    #[tokio::test]
898    async fn test_import_invalid_wif_fails() {
899        let wallet = InMemoryWallet::default_mainnet();
900        let result = wallet.import_key("not-a-valid-wif", None, false).await;
901        assert!(result.is_err());
902    }
903
904    #[tokio::test]
905    async fn test_default_wallet() {
906        let wallet = InMemoryWallet::default();
907        let addr = wallet.get_new_address(None).await.unwrap();
908        // Default is mainnet P2WPKH
909        assert!(addr.starts_with("bc1q"));
910    }
911
912    #[tokio::test]
913    async fn test_empty_balance() {
914        let wallet = InMemoryWallet::default_mainnet();
915        let balance = wallet.get_balance().await.unwrap();
916        assert_eq!(balance.confirmed.as_sat(), 0);
917        assert_eq!(balance.unconfirmed.as_sat(), 0);
918        assert_eq!(balance.immature.as_sat(), 0);
919    }
920}