kaccy_bitcoin/
multisig.rs

1//! Multi-signature wallet support module
2//!
3//! This module provides multi-signature wallet capabilities for enhanced
4//! security, including 2-of-3, 3-of-5, and other M-of-N configurations.
5
6use bitcoin::{
7    Address, Network, ScriptBuf,
8    bip32::{DerivationPath, Xpub},
9    script::Builder as ScriptBuilder,
10    secp256k1::{PublicKey, Secp256k1},
11};
12use serde::{Deserialize, Serialize};
13use std::collections::HashMap;
14use std::str::FromStr;
15
16use crate::error::{BitcoinError, Result};
17
18/// Multi-signature configuration
19#[derive(Debug, Clone, Serialize, Deserialize)]
20pub struct MultisigConfig {
21    /// Number of required signatures (M)
22    pub required_signatures: u8,
23    /// Total number of keys (N)
24    pub total_keys: u8,
25    /// Extended public keys for all participants
26    pub xpubs: Vec<String>,
27    /// Key labels/identifiers
28    pub key_labels: Vec<String>,
29    /// Derivation path for addresses
30    pub derivation_path: String,
31    /// Network (mainnet, testnet, signet)
32    pub network: Network,
33}
34
35impl MultisigConfig {
36    /// Create a new 2-of-3 multisig configuration
37    pub fn new_2of3(xpubs: Vec<String>, key_labels: Vec<String>, network: Network) -> Result<Self> {
38        if xpubs.len() != 3 {
39            return Err(BitcoinError::Wallet(
40                "2-of-3 requires exactly 3 xpubs".to_string(),
41            ));
42        }
43        if key_labels.len() != 3 {
44            return Err(BitcoinError::Wallet(
45                "2-of-3 requires exactly 3 labels".to_string(),
46            ));
47        }
48
49        Ok(Self {
50            required_signatures: 2,
51            total_keys: 3,
52            xpubs,
53            key_labels,
54            derivation_path: "m/48'/0'/0'/2'".to_string(), // BIP48 for multisig
55            network,
56        })
57    }
58
59    /// Create a custom M-of-N configuration
60    pub fn new_custom(
61        required_signatures: u8,
62        xpubs: Vec<String>,
63        key_labels: Vec<String>,
64        network: Network,
65    ) -> Result<Self> {
66        let total_keys = xpubs.len() as u8;
67
68        if required_signatures > total_keys {
69            return Err(BitcoinError::Wallet(
70                "Required signatures cannot exceed total keys".to_string(),
71            ));
72        }
73
74        if required_signatures == 0 {
75            return Err(BitcoinError::Wallet(
76                "Required signatures must be at least 1".to_string(),
77            ));
78        }
79
80        if total_keys > 15 {
81            return Err(BitcoinError::Wallet(
82                "Maximum 15 keys supported for standard multisig".to_string(),
83            ));
84        }
85
86        if key_labels.len() != xpubs.len() {
87            return Err(BitcoinError::Wallet(
88                "Number of labels must match number of xpubs".to_string(),
89            ));
90        }
91
92        Ok(Self {
93            required_signatures,
94            total_keys,
95            xpubs,
96            key_labels,
97            derivation_path: "m/48'/0'/0'/2'".to_string(),
98            network,
99        })
100    }
101
102    /// Validate all xpubs
103    pub fn validate(&self) -> Result<()> {
104        for (i, xpub) in self.xpubs.iter().enumerate() {
105            Xpub::from_str(xpub).map_err(|e| {
106                BitcoinError::InvalidXpub(format!("Invalid xpub at index {}: {}", i, e))
107            })?;
108        }
109        Ok(())
110    }
111
112    /// Get the multisig type string (e.g., "2-of-3")
113    pub fn type_string(&self) -> String {
114        format!("{}-of-{}", self.required_signatures, self.total_keys)
115    }
116}
117
118/// Multi-signature wallet
119pub struct MultisigWallet {
120    config: MultisigConfig,
121    /// Parsed extended public keys
122    xpubs: Vec<Xpub>,
123    /// Address cache
124    address_cache: HashMap<u32, MultisigAddress>,
125    /// Next address index
126    next_index: u32,
127}
128
129impl MultisigWallet {
130    /// Create a new multisig wallet
131    pub fn new(config: MultisigConfig) -> Result<Self> {
132        config.validate()?;
133
134        let xpubs: Vec<Xpub> = config
135            .xpubs
136            .iter()
137            .map(|x| Xpub::from_str(x))
138            .collect::<std::result::Result<Vec<_>, _>>()
139            .map_err(|e| BitcoinError::InvalidXpub(e.to_string()))?;
140
141        Ok(Self {
142            config,
143            xpubs,
144            address_cache: HashMap::new(),
145            next_index: 0,
146        })
147    }
148
149    /// Get the multisig configuration
150    pub fn config(&self) -> &MultisigConfig {
151        &self.config
152    }
153
154    /// Generate a new receiving address
155    pub fn get_new_address(&mut self) -> Result<MultisigAddress> {
156        let index = self.next_index;
157        let address = self.derive_address(index, false)?;
158        self.address_cache.insert(index, address.clone());
159        self.next_index += 1;
160        Ok(address)
161    }
162
163    /// Get address at specific index
164    pub fn get_address(&mut self, index: u32) -> Result<MultisigAddress> {
165        if let Some(cached) = self.address_cache.get(&index) {
166            return Ok(cached.clone());
167        }
168
169        let address = self.derive_address(index, false)?;
170        self.address_cache.insert(index, address.clone());
171        Ok(address)
172    }
173
174    /// Derive a multisig address
175    fn derive_address(&self, index: u32, is_change: bool) -> Result<MultisigAddress> {
176        let secp = Secp256k1::new();
177
178        // Derive public keys for this index
179        let chain = if is_change { 1 } else { 0 };
180        let path = DerivationPath::from_str(&format!("m/{}/{}", chain, index))
181            .map_err(|e| BitcoinError::DerivationFailed(e.to_string()))?;
182
183        let mut pubkeys: Vec<PublicKey> = Vec::new();
184
185        for xpub in &self.xpubs {
186            let derived = xpub
187                .derive_pub(&secp, &path)
188                .map_err(|e| BitcoinError::DerivationFailed(e.to_string()))?;
189            pubkeys.push(derived.public_key);
190        }
191
192        // Sort pubkeys lexicographically for deterministic ordering
193        pubkeys.sort_by_key(|a| a.serialize());
194
195        // Create the redeem script
196        let redeem_script =
197            Self::create_multisig_script(self.config.required_signatures, &pubkeys)?;
198
199        // For P2WSH (native SegWit multisig)
200        let witness_script = redeem_script.clone();
201        let _script_hash = witness_script.wscript_hash();
202
203        let address = Address::p2wsh(&witness_script, self.config.network);
204
205        Ok(MultisigAddress {
206            address: address.to_string(),
207            index,
208            is_change,
209            redeem_script: hex::encode(redeem_script.as_bytes()),
210            witness_script: hex::encode(witness_script.as_bytes()),
211            pubkeys: pubkeys.iter().map(|p| hex::encode(p.serialize())).collect(),
212        })
213    }
214
215    /// Create a multisig script
216    fn create_multisig_script(required: u8, pubkeys: &[PublicKey]) -> Result<ScriptBuf> {
217        let mut builder = ScriptBuilder::new().push_int(required as i64);
218
219        for pubkey in pubkeys {
220            let serialized = pubkey.serialize();
221            builder = builder.push_slice(serialized);
222        }
223
224        let script = builder
225            .push_int(pubkeys.len() as i64)
226            .push_opcode(bitcoin::opcodes::all::OP_CHECKMULTISIG)
227            .into_script();
228
229        Ok(script)
230    }
231
232    /// Check if an address belongs to this wallet
233    pub fn is_our_address(&self, address: &str) -> bool {
234        // Check cache first
235        for cached in self.address_cache.values() {
236            if cached.address == address {
237                return true;
238            }
239        }
240
241        // Check first 1000 addresses (gap limit)
242        for i in 0..1000 {
243            if let Ok(addr) = self.derive_address_uncached(i, false) {
244                if addr.address == address {
245                    return true;
246                }
247            }
248            if let Ok(addr) = self.derive_address_uncached(i, true) {
249                if addr.address == address {
250                    return true;
251                }
252            }
253        }
254
255        false
256    }
257
258    /// Derive address without caching
259    fn derive_address_uncached(&self, index: u32, is_change: bool) -> Result<MultisigAddress> {
260        self.derive_address(index, is_change)
261    }
262
263    /// Get next address index
264    pub fn next_index(&self) -> u32 {
265        self.next_index
266    }
267
268    /// Set next address index (for recovery)
269    pub fn set_next_index(&mut self, index: u32) {
270        self.next_index = index;
271    }
272}
273
274/// Multisig address with metadata
275#[derive(Debug, Clone, Serialize, Deserialize)]
276pub struct MultisigAddress {
277    /// Bitcoin address string
278    pub address: String,
279    /// Derivation index
280    pub index: u32,
281    /// Whether this is a change address
282    pub is_change: bool,
283    /// Hex-encoded redeem script
284    pub redeem_script: String,
285    /// Hex-encoded witness script (for P2WSH)
286    pub witness_script: String,
287    /// Hex-encoded public keys (sorted)
288    pub pubkeys: Vec<String>,
289}
290
291/// Partially signed multisig transaction
292#[derive(Debug, Clone, Serialize, Deserialize)]
293pub struct MultisigTransaction {
294    /// Transaction ID (once finalized)
295    pub txid: Option<String>,
296    /// Unsigned transaction (hex)
297    pub unsigned_tx: String,
298    /// PSBT (base64)
299    pub psbt: String,
300    /// Signatures collected so far
301    pub signatures: Vec<MultisigSignature>,
302    /// Required number of signatures
303    pub required_signatures: u8,
304    /// Total signers
305    pub total_signers: u8,
306    /// Transaction status
307    pub status: MultisigTxStatus,
308    /// Inputs being spent
309    pub inputs: Vec<MultisigInput>,
310    /// Outputs
311    pub outputs: Vec<MultisigOutput>,
312}
313
314impl MultisigTransaction {
315    /// Check if transaction has enough signatures
316    pub fn has_enough_signatures(&self) -> bool {
317        self.signatures.len() as u8 >= self.required_signatures
318    }
319
320    /// Get remaining signatures needed
321    pub fn signatures_needed(&self) -> u8 {
322        self.required_signatures
323            .saturating_sub(self.signatures.len() as u8)
324    }
325
326    /// Get list of signers who have signed
327    pub fn signed_by(&self) -> Vec<&str> {
328        self.signatures
329            .iter()
330            .map(|s| s.signer_label.as_str())
331            .collect()
332    }
333
334    /// Get list of signers who haven't signed yet
335    pub fn pending_signers(&self, all_labels: &[String]) -> Vec<String> {
336        let signed: std::collections::HashSet<_> =
337            self.signatures.iter().map(|s| &s.signer_label).collect();
338        all_labels
339            .iter()
340            .filter(|l| !signed.contains(*l))
341            .cloned()
342            .collect()
343    }
344}
345
346/// A signature for a multisig transaction
347#[derive(Debug, Clone, Serialize, Deserialize)]
348pub struct MultisigSignature {
349    /// Signer's key label
350    pub signer_label: String,
351    /// Signer's public key (hex)
352    pub signer_pubkey: String,
353    /// Signature data (hex)
354    pub signature: String,
355    /// Input index this signature is for
356    pub input_index: u32,
357    /// Timestamp when signed
358    pub signed_at: String,
359}
360
361/// Status of a multisig transaction
362#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
363pub enum MultisigTxStatus {
364    /// Awaiting signatures
365    Pending,
366    /// Has enough signatures, ready to broadcast
367    ReadyToBroadcast,
368    /// Broadcasted, awaiting confirmation
369    Broadcasted,
370    /// Confirmed
371    Confirmed,
372    /// Rejected/cancelled
373    Rejected,
374}
375
376/// Input for multisig transaction
377#[derive(Debug, Clone, Serialize, Deserialize)]
378pub struct MultisigInput {
379    /// Previous transaction ID
380    pub txid: String,
381    /// Previous output index
382    pub vout: u32,
383    /// Amount in satoshis
384    pub amount_sats: u64,
385    /// Address being spent from
386    pub address: String,
387    /// Witness script (for signing)
388    pub witness_script: String,
389}
390
391/// Output for multisig transaction
392#[derive(Debug, Clone, Serialize, Deserialize)]
393pub struct MultisigOutput {
394    /// Destination address
395    pub address: String,
396    /// Amount in satoshis
397    pub amount_sats: u64,
398    /// Whether this is change
399    pub is_change: bool,
400}
401
402/// Multisig transaction builder
403pub struct MultisigTxBuilder {
404    wallet: MultisigWallet,
405    inputs: Vec<MultisigInput>,
406    outputs: Vec<MultisigOutput>,
407    fee_rate: u64,
408}
409
410impl MultisigTxBuilder {
411    /// Create a new transaction builder
412    pub fn new(wallet: MultisigWallet) -> Self {
413        Self {
414            wallet,
415            inputs: Vec::new(),
416            outputs: Vec::new(),
417            fee_rate: 1, // 1 sat/vbyte default
418        }
419    }
420
421    /// Add an input
422    pub fn add_input(mut self, input: MultisigInput) -> Self {
423        self.inputs.push(input);
424        self
425    }
426
427    /// Add multiple inputs
428    pub fn add_inputs(mut self, inputs: Vec<MultisigInput>) -> Self {
429        self.inputs.extend(inputs);
430        self
431    }
432
433    /// Add an output
434    pub fn add_output(mut self, address: impl Into<String>, amount_sats: u64) -> Self {
435        self.outputs.push(MultisigOutput {
436            address: address.into(),
437            amount_sats,
438            is_change: false,
439        });
440        self
441    }
442
443    /// Set fee rate (sat/vbyte)
444    pub fn fee_rate(mut self, rate: u64) -> Self {
445        self.fee_rate = rate;
446        self
447    }
448
449    /// Calculate estimated transaction size
450    fn estimate_vsize(&self) -> u64 {
451        // P2WSH multisig size estimation
452        // Input: ~(73*m + 34*n + 20) witness bytes + 41 non-witness bytes
453        // Output: ~43 bytes for P2WPKH, ~43 for P2WSH
454        let m = self.wallet.config.required_signatures as u64;
455        let n = self.wallet.config.total_keys as u64;
456
457        let input_weight = self.inputs.len() as u64 * (41 * 4 + 73 * m + 34 * n + 20);
458        let output_weight = self.outputs.len() as u64 * 43 * 4;
459        let overhead_weight = 44; // 10 bytes * 4 + 4 for marker/flag
460
461        (input_weight + output_weight + overhead_weight).div_ceil(4)
462    }
463
464    /// Calculate required fee
465    pub fn calculate_fee(&self) -> u64 {
466        self.estimate_vsize() * self.fee_rate
467    }
468
469    /// Build the unsigned transaction
470    pub fn build(mut self) -> Result<MultisigTransaction> {
471        if self.inputs.is_empty() {
472            return Err(BitcoinError::Wallet("No inputs provided".to_string()));
473        }
474
475        if self.outputs.is_empty() {
476            return Err(BitcoinError::Wallet("No outputs provided".to_string()));
477        }
478
479        let fee = self.calculate_fee();
480        let total_input: u64 = self.inputs.iter().map(|i| i.amount_sats).sum();
481        let total_output: u64 = self.outputs.iter().map(|o| o.amount_sats).sum();
482
483        if total_input < total_output + fee {
484            return Err(BitcoinError::Wallet(format!(
485                "Insufficient funds: {} < {} + {} (fee)",
486                total_input, total_output, fee
487            )));
488        }
489
490        // Add change output if needed
491        let change = total_input - total_output - fee;
492        if change > 546 {
493            // Dust threshold
494            let change_address = self.wallet.derive_address(self.wallet.next_index, true)?;
495            self.outputs.push(MultisigOutput {
496                address: change_address.address,
497                amount_sats: change,
498                is_change: true,
499            });
500        }
501
502        // In production, this would create actual PSBT
503        // For now, return a placeholder structure
504        let unsigned_tx = format!(
505            "unsigned_tx_{}inputs_{}outputs",
506            self.inputs.len(),
507            self.outputs.len()
508        );
509
510        let psbt = base64::Engine::encode(
511            &base64::engine::general_purpose::STANDARD,
512            format!("psbt_placeholder_{}", unsigned_tx).as_bytes(),
513        );
514
515        Ok(MultisigTransaction {
516            txid: None,
517            unsigned_tx,
518            psbt,
519            signatures: Vec::new(),
520            required_signatures: self.wallet.config.required_signatures,
521            total_signers: self.wallet.config.total_keys,
522            status: MultisigTxStatus::Pending,
523            inputs: self.inputs,
524            outputs: self.outputs,
525        })
526    }
527}
528
529/// Multisig custody manager for large balance handling
530pub struct CustodyManager {
531    /// Hot wallet (single-sig for quick operations)
532    #[allow(dead_code)]
533    hot_wallet_xpub: Option<String>,
534    /// Cold storage (multisig)
535    cold_wallet: Option<MultisigWallet>,
536    /// Threshold for auto-sweep to cold storage
537    auto_sweep_threshold_sats: u64,
538    /// Minimum hot wallet balance to maintain
539    min_hot_balance_sats: u64,
540}
541
542impl CustodyManager {
543    /// Create a new custody manager
544    pub fn new() -> Self {
545        Self {
546            hot_wallet_xpub: None,
547            cold_wallet: None,
548            auto_sweep_threshold_sats: 10_000_000, // 0.1 BTC
549            min_hot_balance_sats: 1_000_000,       // 0.01 BTC
550        }
551    }
552
553    /// Set up hot wallet
554    pub fn with_hot_wallet(mut self, xpub: impl Into<String>) -> Self {
555        self.hot_wallet_xpub = Some(xpub.into());
556        self
557    }
558
559    /// Set up cold storage
560    pub fn with_cold_wallet(mut self, config: MultisigConfig) -> Result<Self> {
561        self.cold_wallet = Some(MultisigWallet::new(config)?);
562        Ok(self)
563    }
564
565    /// Set auto-sweep threshold
566    pub fn auto_sweep_threshold(mut self, sats: u64) -> Self {
567        self.auto_sweep_threshold_sats = sats;
568        self
569    }
570
571    /// Set minimum hot balance
572    pub fn min_hot_balance(mut self, sats: u64) -> Self {
573        self.min_hot_balance_sats = sats;
574        self
575    }
576
577    /// Check if balance should be swept to cold storage
578    pub fn should_sweep(&self, hot_balance_sats: u64) -> bool {
579        hot_balance_sats > self.auto_sweep_threshold_sats
580    }
581
582    /// Calculate sweep amount
583    pub fn sweep_amount(&self, hot_balance_sats: u64) -> u64 {
584        if hot_balance_sats <= self.auto_sweep_threshold_sats {
585            return 0;
586        }
587
588        // Keep minimum in hot wallet, sweep the rest
589        hot_balance_sats.saturating_sub(self.min_hot_balance_sats)
590    }
591
592    /// Get cold storage address for sweeping
593    pub fn get_cold_address(&mut self) -> Result<String> {
594        let wallet = self
595            .cold_wallet
596            .as_mut()
597            .ok_or_else(|| BitcoinError::Wallet("Cold wallet not configured".to_string()))?;
598
599        let address = wallet.get_new_address()?;
600        Ok(address.address)
601    }
602
603    /// Check if cold wallet is configured
604    pub fn has_cold_storage(&self) -> bool {
605        self.cold_wallet.is_some()
606    }
607
608    /// Get cold wallet info
609    pub fn cold_wallet_info(&self) -> Option<ColdWalletInfo> {
610        self.cold_wallet.as_ref().map(|w| ColdWalletInfo {
611            type_string: w.config.type_string(),
612            key_labels: w.config.key_labels.clone(),
613            next_address_index: w.next_index,
614        })
615    }
616}
617
618impl Default for CustodyManager {
619    fn default() -> Self {
620        Self::new()
621    }
622}
623
624/// Cold wallet information summary
625#[derive(Debug, Clone, Serialize, Deserialize)]
626pub struct ColdWalletInfo {
627    /// Multisig type (e.g., "2-of-3")
628    pub type_string: String,
629    /// Key holder labels
630    pub key_labels: Vec<String>,
631    /// Next address index
632    pub next_address_index: u32,
633}
634
635/// Type alias for transaction ready callback
636type TransactionReadyCallback = Box<dyn Fn(&MultisigTransaction) + Send + Sync>;
637
638/// Signature coordinator for collecting multisig signatures
639pub struct SignatureCoordinator {
640    /// Pending transactions awaiting signatures
641    pending_txs: HashMap<String, MultisigTransaction>,
642    /// Notification callbacks
643    #[allow(dead_code)]
644    on_ready: Option<TransactionReadyCallback>,
645}
646
647impl SignatureCoordinator {
648    /// Create a new coordinator
649    pub fn new() -> Self {
650        Self {
651            pending_txs: HashMap::new(),
652            on_ready: None,
653        }
654    }
655
656    /// Set callback for when transaction is ready to broadcast
657    pub fn on_ready<F>(mut self, callback: F) -> Self
658    where
659        F: Fn(&MultisigTransaction) + Send + Sync + 'static,
660    {
661        self.on_ready = Some(Box::new(callback));
662        self
663    }
664
665    /// Add a transaction for signature collection
666    pub fn add_transaction(&mut self, id: impl Into<String>, tx: MultisigTransaction) {
667        self.pending_txs.insert(id.into(), tx);
668    }
669
670    /// Add a signature to a pending transaction
671    pub fn add_signature(&mut self, tx_id: &str, signature: MultisigSignature) -> Result<bool> {
672        let tx = self
673            .pending_txs
674            .get_mut(tx_id)
675            .ok_or_else(|| BitcoinError::Wallet(format!("Transaction {} not found", tx_id)))?;
676
677        // Check if already signed by this signer
678        if tx
679            .signatures
680            .iter()
681            .any(|s| s.signer_label == signature.signer_label)
682        {
683            return Err(BitcoinError::Wallet(format!(
684                "Already signed by {}",
685                signature.signer_label
686            )));
687        }
688
689        tx.signatures.push(signature);
690
691        // Check if ready
692        if tx.has_enough_signatures() {
693            tx.status = MultisigTxStatus::ReadyToBroadcast;
694
695            if let Some(ref callback) = self.on_ready {
696                callback(tx);
697            }
698
699            return Ok(true);
700        }
701
702        Ok(false)
703    }
704
705    /// Get pending transaction status
706    pub fn get_status(&self, tx_id: &str) -> Option<&MultisigTransaction> {
707        self.pending_txs.get(tx_id)
708    }
709
710    /// Get all pending transactions
711    pub fn pending_transactions(&self) -> Vec<(&String, &MultisigTransaction)> {
712        self.pending_txs.iter().collect()
713    }
714
715    /// Remove a transaction (after broadcast or cancellation)
716    pub fn remove_transaction(&mut self, tx_id: &str) -> Option<MultisigTransaction> {
717        self.pending_txs.remove(tx_id)
718    }
719}
720
721impl Default for SignatureCoordinator {
722    fn default() -> Self {
723        Self::new()
724    }
725}
726
727#[cfg(test)]
728mod tests {
729    use super::*;
730
731    #[test]
732    fn test_multisig_config_validation() {
733        // Valid 2-of-3
734        let config = MultisigConfig::new_2of3(
735            vec![
736                "xpub1".to_string(),
737                "xpub2".to_string(),
738                "xpub3".to_string(),
739            ],
740            vec![
741                "platform".to_string(),
742                "user".to_string(),
743                "cold".to_string(),
744            ],
745            Network::Bitcoin,
746        )
747        .unwrap();
748
749        assert_eq!(config.type_string(), "2-of-3");
750        assert_eq!(config.required_signatures, 2);
751        assert_eq!(config.total_keys, 3);
752    }
753
754    #[test]
755    fn test_invalid_config() {
756        // Wrong number of xpubs
757        let result = MultisigConfig::new_2of3(
758            vec!["xpub1".to_string(), "xpub2".to_string()],
759            vec!["a".to_string(), "b".to_string(), "c".to_string()],
760            Network::Bitcoin,
761        );
762
763        assert!(result.is_err());
764    }
765
766    #[test]
767    fn test_multisig_tx_signatures() {
768        let tx = MultisigTransaction {
769            txid: None,
770            unsigned_tx: "test".to_string(),
771            psbt: "test".to_string(),
772            signatures: vec![],
773            required_signatures: 2,
774            total_signers: 3,
775            status: MultisigTxStatus::Pending,
776            inputs: vec![],
777            outputs: vec![],
778        };
779
780        assert!(!tx.has_enough_signatures());
781        assert_eq!(tx.signatures_needed(), 2);
782    }
783
784    #[test]
785    fn test_custody_manager() {
786        let manager = CustodyManager::new()
787            .auto_sweep_threshold(10_000_000)
788            .min_hot_balance(1_000_000);
789
790        assert!(manager.should_sweep(15_000_000));
791        assert!(!manager.should_sweep(5_000_000));
792
793        let sweep = manager.sweep_amount(15_000_000);
794        assert_eq!(sweep, 14_000_000); // 15M - 1M min balance
795    }
796}