qualia_client_core/wallet/
coin_select.rs1use crate::wallet::chronik::ChronikUtxo;
11
12pub const DUST_THRESHOLD_SATS: i64 = 546;
15
16pub const BASE_FEE_SATS: i64 = 400;
20
21pub const PER_INPUT_FEE_SATS: i64 = 150;
23
24#[derive(Debug, Clone)]
26pub struct CoinSelection {
27 pub selected: Vec<ChronikUtxo>,
29 pub total_input_sats: i64,
31 pub target_sats: i64,
33 pub fee_sats: i64,
35 pub change_sats: i64,
37}
38
39pub 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 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 };
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
90pub fn select_token_utxos(
93 utxos: &[ChronikUtxo],
94 token_id: &str,
95 token_amount: u64,
96) -> Result<(Vec<ChronikUtxo>, CoinSelection), String> {
97 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 let min_xec_needed =
124 DUST_THRESHOLD_SATS + BASE_FEE_SATS + PER_INPUT_FEE_SATS * (token_utxos.len() as i64 + 1); 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 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 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 let utxos = vec![make_utxo(6000, 1)];
205 let result = select_utxos(&utxos, 5000).unwrap();
206 if result.change_sats == 0 {
208 assert!(result.fee_sats > BASE_FEE_SATS + PER_INPUT_FEE_SATS);
209 }
210 }
211}