Skip to main content

qualia_client_core/wallet/
coin_select.rs

1//! Simple UTXO coin selection for eCash (XEC) transactions.
2//!
3//! Strategy: largest-first. Pick the biggest UTXOs until we have enough to cover
4//! the target amount + estimated fee. Produces change output if there's surplus
5//! above a dust threshold.
6//!
7//! This is a cold-path construction utility (Tier-2). It allocates internally
8//! but the public output is caller-buffered via return values.
9
10use crate::wallet::chronik::ChronikUtxo;
11
12/// Minimum output value (in satoshis) below which we skip creating a change output.
13/// For XEC: 546 satoshis = 5.46 XEC dust threshold.
14pub const DUST_THRESHOLD_SATS: i64 = 546;
15
16/// Estimated transaction fee in satoshis for a simple P2PKH tx.
17/// ~1 input + 2 outputs ≈ 226 bytes × 1 sat/byte = 226 sats.
18/// We use a conservative estimate.
19pub const BASE_FEE_SATS: i64 = 400;
20
21/// Per-input fee contribution in satoshis (~148 bytes per additional input).
22pub const PER_INPUT_FEE_SATS: i64 = 150;
23
24/// Result of coin selection.
25#[derive(Debug, Clone)]
26pub struct CoinSelection {
27    /// The selected UTXOs to spend.
28    pub selected: Vec<ChronikUtxo>,
29    /// Total value of selected UTXOs in satoshis.
30    pub total_input_sats: i64,
31    /// Target send amount in satoshis.
32    pub target_sats: i64,
33    /// Estimated fee in satoshis.
34    pub fee_sats: i64,
35    /// Change amount in satoshis (0 if below dust).
36    pub change_sats: i64,
37}
38
39/// Select UTXOs to cover `target_sats` using largest-first strategy.
40///
41/// Only considers UTXOs that have no SLP/ALP token metadata (plain XEC UTXOs).
42/// Returns `Err` if insufficient funds.
43pub fn select_utxos(utxos: &[ChronikUtxo], target_sats: i64) -> Result<CoinSelection, String> {
44    if target_sats <= 0 {
45        return Err("Target amount must be positive".into());
46    }
47
48    // Filter to plain XEC UTXOs (no token metadata) and sort largest-first
49    let mut candidates: Vec<&ChronikUtxo> = utxos
50        .iter()
51        .filter(|u| u.slp_meta.is_none() && u.value > 0)
52        .collect();
53    candidates.sort_by(|a, b| b.value.cmp(&a.value));
54
55    let mut selected = Vec::new();
56    let mut total_input: i64 = 0;
57
58    for utxo in candidates {
59        selected.push(utxo.clone());
60        total_input += utxo.value;
61
62        let fee = BASE_FEE_SATS + PER_INPUT_FEE_SATS * selected.len() as i64;
63        let needed = target_sats + fee;
64
65        if total_input >= needed {
66            let change = total_input - target_sats - fee;
67            let change_sats = if change >= DUST_THRESHOLD_SATS {
68                change
69            } else {
70                0 // Absorb sub-dust remainder into fee
71            };
72            let actual_fee = total_input - target_sats - change_sats;
73
74            return Ok(CoinSelection {
75                selected,
76                total_input_sats: total_input,
77                target_sats,
78                fee_sats: actual_fee,
79                change_sats,
80            });
81        }
82    }
83
84    Err(format!(
85        "Insufficient funds: have {} sats, need {} + fee",
86        total_input, target_sats
87    ))
88}
89
90/// Select UTXOs that contain a specific SLP/ALP token.
91/// Returns the token UTXOs + plain XEC UTXOs needed to cover the fee.
92pub fn select_token_utxos(
93    utxos: &[ChronikUtxo],
94    token_id: &str,
95    token_amount: u64,
96) -> Result<(Vec<ChronikUtxo>, CoinSelection), String> {
97    // Find token UTXOs matching the requested token_id
98    let mut token_utxos = Vec::new();
99    let mut token_total: u64 = 0;
100
101    for utxo in utxos {
102        if let Some(ref meta) = utxo.slp_meta {
103            if meta.token_id == token_id {
104                if let Some(ref token) = utxo.slp_token {
105                    if let Ok(amt) = token.amount.parse::<u64>() {
106                        token_utxos.push(utxo.clone());
107                        token_total += amt;
108                    }
109                }
110            }
111        }
112    }
113
114    if token_total < token_amount {
115        return Err(format!(
116            "Insufficient token balance: have {}, need {}",
117            token_total, token_amount
118        ));
119    }
120
121    // We also need plain XEC UTXOs to pay the transaction fee
122    // Token txs need: OP_RETURN output (0 sats) + token output (546 sats) + change output + fee
123    let min_xec_needed =
124        DUST_THRESHOLD_SATS + BASE_FEE_SATS + PER_INPUT_FEE_SATS * (token_utxos.len() as i64 + 1); // +1 for XEC funding input
125
126    let xec_selection = select_utxos(utxos, min_xec_needed)?;
127
128    Ok((token_utxos, xec_selection))
129}
130
131#[cfg(test)]
132mod tests {
133    use super::*;
134    use crate::wallet::chronik::{ChronikUtxo, Outpoint};
135
136    fn make_utxo(value: i64, idx: u32) -> ChronikUtxo {
137        ChronikUtxo {
138            outpoint: Outpoint {
139                txid: format!("{:064x}", idx),
140                out_idx: 0,
141            },
142            block_height: 800000,
143            is_coinbase: false,
144            value,
145            slp_meta: None,
146            slp_token: None,
147        }
148    }
149
150    #[test]
151    fn test_select_single_utxo() {
152        let utxos = vec![make_utxo(10000, 1)];
153        let result = select_utxos(&utxos, 5000).unwrap();
154        assert_eq!(result.selected.len(), 1);
155        assert_eq!(result.target_sats, 5000);
156        assert!(result.fee_sats > 0);
157        assert_eq!(
158            result.total_input_sats,
159            result.target_sats + result.fee_sats + result.change_sats
160        );
161    }
162
163    #[test]
164    fn test_select_multiple_utxos() {
165        let utxos = vec![make_utxo(3000, 1), make_utxo(4000, 2), make_utxo(5000, 3)];
166        let result = select_utxos(&utxos, 8000).unwrap();
167        // Should pick 5000 + 4000 = 9000 (largest first)
168        assert!(result.selected.len() >= 2);
169        assert!(result.total_input_sats >= 8000);
170    }
171
172    #[test]
173    fn test_insufficient_funds() {
174        let utxos = vec![make_utxo(1000, 1)];
175        assert!(select_utxos(&utxos, 5000).is_err());
176    }
177
178    #[test]
179    fn test_skips_token_utxos() {
180        use crate::wallet::chronik::{SlpMeta, SlpToken};
181        let mut token_utxo = make_utxo(100000, 1);
182        token_utxo.slp_meta = Some(SlpMeta {
183            token_type: "FUNGIBLE".into(),
184            tx_type: "SEND".into(),
185            token_id: "abc".into(),
186            group_token_id: None,
187        });
188        token_utxo.slp_token = Some(SlpToken {
189            amount: "1000".into(),
190            is_mint_baton: false,
191        });
192        let plain_utxo = make_utxo(5000, 2);
193
194        let utxos = vec![token_utxo, plain_utxo];
195        let result = select_utxos(&utxos, 3000).unwrap();
196        // Should only pick the plain UTXO, not the token one
197        assert_eq!(result.selected.len(), 1);
198        assert_eq!(result.selected[0].value, 5000);
199    }
200
201    #[test]
202    fn test_dust_change_absorbed() {
203        // If change would be < 546, it gets absorbed into fee
204        let utxos = vec![make_utxo(6000, 1)];
205        let result = select_utxos(&utxos, 5000).unwrap();
206        // 6000 - 5000 - ~550 fee = ~450 change (below dust) → absorbed
207        if result.change_sats == 0 {
208            assert!(result.fee_sats > BASE_FEE_SATS + PER_INPUT_FEE_SATS);
209        }
210    }
211}