1use fuel_core_types::{
2 fuel_tx::UtxoId,
3 fuel_types::{
4 Address,
5 AssetId,
6 },
7};
8use fuels::types::{
9 coin::Coin,
10 coin_type::CoinType,
11 input::Input,
12};
13use std::{
14 collections::{
15 BTreeSet,
16 HashMap,
17 HashSet,
18 hash_map::Entry,
19 },
20 sync::Arc,
21};
22
23pub struct CoinsResult {
24 pub known_coins: Vec<FuelTxCoin>,
25 pub unknown_coins: HashSet<UtxoId>,
26}
27
28#[derive(Copy, Clone, Debug, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
29pub struct FuelTxCoin {
30 pub amount: u64,
31 pub asset_id: AssetId,
32 pub utxo_id: UtxoId,
33 pub owner: Address,
34}
35
36impl From<Coin> for FuelTxCoin {
37 fn from(value: Coin) -> Self {
38 Self {
39 amount: value.amount,
40 asset_id: value.asset_id,
41 utxo_id: value.utxo_id,
42 owner: value.owner,
43 }
44 }
45}
46
47impl From<FuelTxCoin> for Coin {
48 fn from(value: FuelTxCoin) -> Self {
49 Self {
50 amount: value.amount,
51 asset_id: value.asset_id,
52 utxo_id: value.utxo_id,
53 owner: value.owner,
54 }
55 }
56}
57
58impl TryFrom<&fuel_core_types::fuel_tx::Input> for FuelTxCoin {
59 type Error = anyhow::Error;
60
61 fn try_from(input: &fuel_core_types::fuel_tx::Input) -> Result<Self, Self::Error> {
62 if let fuel_core_types::fuel_tx::Input::CoinSigned(coin) = input {
63 return Ok(FuelTxCoin {
64 utxo_id: coin.utxo_id,
65 owner: coin.owner,
66 amount: coin.amount,
67 asset_id: coin.asset_id,
68 })
69 }
70 anyhow::bail!("Invalid input type")
71 }
72}
73
74impl From<FuelTxCoin> for Input {
75 fn from(value: FuelTxCoin) -> Self {
76 Input::resource_signed(CoinType::Coin(value.into()))
77 }
78}
79
80impl Ord for FuelTxCoin {
81 fn cmp(&self, other: &Self) -> std::cmp::Ordering {
82 self.amount
83 .cmp(&other.amount)
84 .then_with(|| self.asset_id.cmp(&other.asset_id))
85 .then_with(|| self.utxo_id.cmp(&other.utxo_id))
86 .then_with(|| self.owner.cmp(&other.owner))
87 }
88}
89
90impl PartialOrd for FuelTxCoin {
91 fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
92 Some(self.cmp(other))
93 }
94}
95
96pub trait UtxoProvider: Send + 'static {
100 fn balance_of(&self, owner: Address, asset_id: AssetId) -> u128;
101 fn len_per_address(&self, asset_id: AssetId, address: Address) -> usize;
102 fn guaranteed_extract_coins(
103 &mut self,
104 owner: Address,
105 asset_id: AssetId,
106 amount: u128,
107 ) -> anyhow::Result<Vec<FuelTxCoin>>;
108 fn load_from_coins_vec(&mut self, coins: Vec<FuelTxCoin>);
109 fn number_of_coins_with_amount_greater_or_equal(
110 &self,
111 owner: Address,
112 asset_id: AssetId,
113 amount: u128,
114 ) -> (u128, usize);
115 fn extract_largest_coins(
116 &mut self,
117 owner: Address,
118 asset_id: AssetId,
119 max_value: u128,
120 ) -> Vec<FuelTxCoin>;
121 fn coin_count(&self) -> usize;
122 fn utxo_ids(&self) -> Vec<UtxoId>;
123 fn remove_coin(&mut self, utxo_id: &UtxoId) -> bool;
124 fn total_balance(&self, asset_id: &AssetId) -> u128;
125}
126
127pub type SharedUtxoManager = Arc<tokio::sync::Mutex<dyn UtxoProvider>>;
128
129pub struct UtxoManager {
132 account_utxos: HashMap<(Address, AssetId), BTreeSet<FuelTxCoin>>,
133 coins: HashMap<UtxoId, FuelTxCoin>,
134}
135
136impl Default for UtxoManager {
137 fn default() -> Self {
138 Self::new()
139 }
140}
141
142impl UtxoManager {
143 pub fn new() -> Self {
144 Self {
145 account_utxos: HashMap::new(),
146 coins: HashMap::new(),
147 }
148 }
149
150 pub fn len_per_address(&self, asset_id: AssetId, address: Address) -> usize {
151 self.account_utxos
152 .get(&(address, asset_id))
153 .map_or(0, |utxos| utxos.len())
154 }
155
156 pub fn new_from_coins<I>(coins: I) -> Self
157 where
158 I: Iterator<Item = FuelTxCoin>,
159 {
160 let mut _self = Self::new();
161
162 _self.load_from_coins(coins);
163
164 _self
165 }
166
167 pub fn load_from_coins<I>(&mut self, coins: I)
168 where
169 I: Iterator<Item = FuelTxCoin>,
170 {
171 for coin in coins {
172 if coin.amount == 0 {
173 continue;
174 }
175
176 let key = (coin.owner, coin.asset_id);
177 self.account_utxos.entry(key).or_default().insert(coin);
178 self.coins.insert(coin.utxo_id, coin);
179 }
180 }
181
182 fn extract_utxos(&mut self, utxos: &[UtxoId]) -> anyhow::Result<Vec<FuelTxCoin>> {
183 let mut coins = vec![];
184
185 for utxo_id in utxos {
186 let coin = self.coins.remove(utxo_id).ok_or_else(|| {
187 anyhow::anyhow!("UTXO {utxo_id} not found in the UTXO manager")
188 })?;
189
190 let key = (coin.owner, coin.asset_id);
191 let account = self.account_utxos.entry(key);
192
193 match account {
194 Entry::Occupied(mut occupied) => {
195 occupied.get_mut().remove(&coin);
196
197 if occupied.get().is_empty() {
198 occupied.remove();
199 }
200
201 coins.push(coin);
202 }
203 Entry::Vacant(_) => {}
204 }
205 }
206
207 Ok(coins)
208 }
209
210 fn utxos_for(
211 &self,
212 owner: Address,
213 asset_id: AssetId,
214 amount: u128,
215 fail_if_not_enough: bool,
216 ) -> anyhow::Result<Vec<UtxoId>> {
217 let Some(coins) = self.account_utxos.get(&(owner, asset_id)) else {
218 return Err(anyhow::anyhow!(
219 "No UTXOs found for the given {owner} and {asset_id}"
220 ));
221 };
222
223 let mut total_amount = 0;
224 let mut utxos_to_remove = vec![];
225
226 for coin in coins.iter() {
228 if total_amount >= amount {
229 break;
230 }
231
232 utxos_to_remove.push(coin.utxo_id);
233 total_amount += coin.amount as u128;
234 }
235
236 if fail_if_not_enough && total_amount < amount {
237 return Err(anyhow::anyhow!(
238 "Not enough UTXOs({total_amount}) found \
239 for the given {owner} and {asset_id} to cover {amount}."
240 ));
241 }
242
243 Ok(utxos_to_remove)
244 }
245
246 pub fn guaranteed_extract_coins(
247 &mut self,
248 owner: Address,
249 asset_id: AssetId,
250 amount: u128,
251 ) -> anyhow::Result<Vec<FuelTxCoin>> {
252 let utxos = self.utxos_for(owner, asset_id, amount, true)?;
253 self.extract_utxos(&utxos)
254 }
255
256 pub fn number_of_coins_with_amount_greater_or_equal(
257 &self,
258 owner: Address,
259 asset_id: AssetId,
260 amount: u128,
261 ) -> (u128, usize) {
262 self.account_utxos
263 .get(&(owner, asset_id))
264 .map_or((0, 0), |coins| {
265 let mut count = 0;
266 let mut total_balance = 0;
267
268 for coin in coins.iter() {
269 if coin.amount as u128 >= amount {
270 count += 1;
271 total_balance += coin.amount as u128;
272 }
273 }
274
275 (total_balance, count)
276 })
277 }
278
279 pub fn balance_of(&self, owner: Address, asset_id: AssetId) -> u128 {
280 self.account_utxos
281 .get(&(owner, asset_id))
282 .map_or(0, |coins| {
283 coins.iter().map(|coin| coin.amount as u128).sum()
284 })
285 }
286
287 pub fn coins(&self) -> &HashMap<UtxoId, FuelTxCoin> {
289 &self.coins
290 }
291
292 pub fn coin_count(&self) -> usize {
294 self.coins.len()
295 }
296
297 pub fn utxo_ids(&self) -> Vec<UtxoId> {
299 self.coins.keys().copied().collect()
300 }
301
302 pub fn contains(&self, utxo_id: &UtxoId) -> bool {
304 self.coins.contains_key(utxo_id)
305 }
306
307 pub fn total_balance(&self, asset_id: &AssetId) -> u128 {
309 self.coins
310 .values()
311 .filter(|coin| &coin.asset_id == asset_id)
312 .map(|coin| coin.amount as u128)
313 .sum()
314 }
315
316 pub fn remove_coin(&mut self, utxo_id: &UtxoId) -> bool {
318 if let Some(coin) = self.coins.remove(utxo_id) {
319 let key = (coin.owner, coin.asset_id);
320 if let Entry::Occupied(mut entry) = self.account_utxos.entry(key) {
321 entry.get_mut().remove(&coin);
322 if entry.get().is_empty() {
323 entry.remove();
324 }
325 }
326 true
327 } else {
328 false
329 }
330 }
331
332 pub fn extract_largest_coins(
336 &mut self,
337 owner: Address,
338 asset_id: AssetId,
339 max_value: u128,
340 ) -> Vec<FuelTxCoin> {
341 let utxo_ids: Vec<UtxoId> = {
342 let Some(coins) = self.account_utxos.get(&(owner, asset_id)) else {
343 return vec![];
344 };
345 let mut total = 0u128;
346 coins
347 .iter()
348 .rev() .take_while(|coin| {
350 if total >= max_value {
351 return false;
352 }
353 total += coin.amount as u128;
354 true
355 })
356 .map(|coin| coin.utxo_id)
357 .collect()
358 };
359 self.extract_utxos(&utxo_ids).unwrap_or_default()
360 }
361}
362
363impl UtxoProvider for UtxoManager {
364 fn balance_of(&self, owner: Address, asset_id: AssetId) -> u128 {
365 UtxoManager::balance_of(self, owner, asset_id)
366 }
367
368 fn len_per_address(&self, asset_id: AssetId, address: Address) -> usize {
369 UtxoManager::len_per_address(self, asset_id, address)
370 }
371
372 fn guaranteed_extract_coins(
373 &mut self,
374 owner: Address,
375 asset_id: AssetId,
376 amount: u128,
377 ) -> anyhow::Result<Vec<FuelTxCoin>> {
378 UtxoManager::guaranteed_extract_coins(self, owner, asset_id, amount)
379 }
380
381 fn load_from_coins_vec(&mut self, coins: Vec<FuelTxCoin>) {
382 self.load_from_coins(coins.into_iter());
383 }
384
385 fn number_of_coins_with_amount_greater_or_equal(
386 &self,
387 owner: Address,
388 asset_id: AssetId,
389 amount: u128,
390 ) -> (u128, usize) {
391 UtxoManager::number_of_coins_with_amount_greater_or_equal(
392 self, owner, asset_id, amount,
393 )
394 }
395
396 fn extract_largest_coins(
397 &mut self,
398 owner: Address,
399 asset_id: AssetId,
400 max_value: u128,
401 ) -> Vec<FuelTxCoin> {
402 UtxoManager::extract_largest_coins(self, owner, asset_id, max_value)
403 }
404
405 fn coin_count(&self) -> usize {
406 UtxoManager::coin_count(self)
407 }
408
409 fn utxo_ids(&self) -> Vec<UtxoId> {
410 UtxoManager::utxo_ids(self)
411 }
412
413 fn remove_coin(&mut self, utxo_id: &UtxoId) -> bool {
414 UtxoManager::remove_coin(self, utxo_id)
415 }
416
417 fn total_balance(&self, asset_id: &AssetId) -> u128 {
418 UtxoManager::total_balance(self, asset_id)
419 }
420}
421
422#[cfg(test)]
423#[allow(non_snake_case)]
424mod tests {
425 use super::*;
426
427 #[test]
428 fn guaranteed_extract_coins__returns_coins_in_ascending_order_by_amount__when_coins_inserted_in_random_order()
429 {
430 let owner = Address::from([1u8; 32]);
432 let asset_id = AssetId::from([2u8; 32]);
433
434 let coin1 = FuelTxCoin {
435 amount: 100,
436 asset_id,
437 utxo_id: UtxoId::new(fuel_core_types::fuel_tx::TxId::from([1u8; 32]), 0),
438 owner,
439 };
440 let coin2 = FuelTxCoin {
441 amount: 50,
442 asset_id,
443 utxo_id: UtxoId::new(fuel_core_types::fuel_tx::TxId::from([2u8; 32]), 0),
444 owner,
445 };
446 let coin3 = FuelTxCoin {
447 amount: 200,
448 asset_id,
449 utxo_id: UtxoId::new(fuel_core_types::fuel_tx::TxId::from([3u8; 32]), 0),
450 owner,
451 };
452 let coin4 = FuelTxCoin {
453 amount: 75,
454 asset_id,
455 utxo_id: UtxoId::new(fuel_core_types::fuel_tx::TxId::from([4u8; 32]), 0),
456 owner,
457 };
458
459 let mut manager = UtxoManager::new();
460 manager.load_from_coins(vec![coin1, coin2, coin3, coin4].into_iter());
461
462 let total_amount = 100 + 50 + 200 + 75;
464 let extracted = manager
465 .guaranteed_extract_coins(owner, asset_id, total_amount)
466 .unwrap();
467
468 assert_eq!(extracted.len(), 4);
470 assert_eq!(extracted[0].amount, 50); assert_eq!(extracted[1].amount, 75); assert_eq!(extracted[2].amount, 100); assert_eq!(extracted[3].amount, 200); assert_eq!(manager.balance_of(owner, asset_id), 0);
475 }
476
477 #[test]
478 fn extract_largest_coins__takes_largest_first_up_to_max_value() {
479 let owner = Address::from([1u8; 32]);
480 let asset_id = AssetId::from([2u8; 32]);
481
482 let coins: Vec<FuelTxCoin> = (1..=5)
483 .map(|i| FuelTxCoin {
484 amount: i * 100, asset_id,
486 utxo_id: UtxoId::new(
487 fuel_core_types::fuel_tx::TxId::from([i as u8; 32]),
488 0,
489 ),
490 owner,
491 })
492 .collect();
493 let mut manager = UtxoManager::new();
494 manager.load_from_coins(coins.into_iter());
495
496 let extracted = manager.extract_largest_coins(owner, asset_id, 600);
503 let amounts: Vec<u64> = extracted.iter().map(|c| c.amount).collect();
504 assert_eq!(amounts.len(), 2);
506 assert!(amounts.contains(&500));
507 assert!(amounts.contains(&400));
508 assert_eq!(manager.balance_of(owner, asset_id), 600);
510 }
511
512 #[test]
513 fn extract_largest_coins__returns_empty_when_no_coins() {
514 let owner = Address::from([1u8; 32]);
515 let asset_id = AssetId::from([2u8; 32]);
516 let mut manager = UtxoManager::new();
517 let extracted = manager.extract_largest_coins(owner, asset_id, 1000);
518 assert!(extracted.is_empty());
519 }
520
521 #[test]
522 fn extract_largest_coins__extracts_single_coin_when_only_one() {
523 let owner = Address::from([1u8; 32]);
524 let asset_id = AssetId::from([2u8; 32]);
525 let coin = FuelTxCoin {
526 amount: 1_000_000,
527 asset_id,
528 utxo_id: UtxoId::new(fuel_core_types::fuel_tx::TxId::from([1u8; 32]), 0),
529 owner,
530 };
531 let mut manager = UtxoManager::new();
532 manager.load_from_coins(vec![coin].into_iter());
533
534 let extracted = manager.extract_largest_coins(owner, asset_id, 500_000);
535 assert_eq!(extracted.len(), 1);
536 assert_eq!(extracted[0].amount, 1_000_000);
537 assert_eq!(manager.balance_of(owner, asset_id), 0);
538 }
539}