Skip to main content

o2_tools/
utxo_manager.rs

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/// Order in which the per-account UTXO set is walked when selecting coins.
29#[derive(Copy, Clone, Debug, PartialEq, Eq)]
30enum SelectionOrder {
31    /// Oldest insertion first — drains old, untouched UTXOs before new change.
32    Fifo,
33    /// Largest amount first — fewest, most valuable coins.
34    Largest,
35}
36
37#[derive(Copy, Clone, Debug, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
38pub struct FuelTxCoin {
39    pub amount: u64,
40    pub asset_id: AssetId,
41    pub utxo_id: UtxoId,
42    pub owner: Address,
43}
44
45impl From<Coin> for FuelTxCoin {
46    fn from(value: Coin) -> Self {
47        Self {
48            amount: value.amount,
49            asset_id: value.asset_id,
50            utxo_id: value.utxo_id,
51            owner: value.owner,
52        }
53    }
54}
55
56impl From<FuelTxCoin> for Coin {
57    fn from(value: FuelTxCoin) -> Self {
58        Self {
59            amount: value.amount,
60            asset_id: value.asset_id,
61            utxo_id: value.utxo_id,
62            owner: value.owner,
63        }
64    }
65}
66
67impl TryFrom<&fuel_core_types::fuel_tx::Input> for FuelTxCoin {
68    type Error = anyhow::Error;
69
70    fn try_from(input: &fuel_core_types::fuel_tx::Input) -> Result<Self, Self::Error> {
71        if let fuel_core_types::fuel_tx::Input::CoinSigned(coin) = input {
72            return Ok(FuelTxCoin {
73                utxo_id: coin.utxo_id,
74                owner: coin.owner,
75                amount: coin.amount,
76                asset_id: coin.asset_id,
77            });
78        }
79        anyhow::bail!("Invalid input type")
80    }
81}
82
83impl From<FuelTxCoin> for Input {
84    fn from(value: FuelTxCoin) -> Self {
85        Input::resource_signed(CoinType::Coin(value.into()))
86    }
87}
88
89impl Ord for FuelTxCoin {
90    fn cmp(&self, other: &Self) -> std::cmp::Ordering {
91        self.amount
92            .cmp(&other.amount)
93            .then_with(|| self.asset_id.cmp(&other.asset_id))
94            .then_with(|| self.utxo_id.cmp(&other.utxo_id))
95            .then_with(|| self.owner.cmp(&other.owner))
96    }
97}
98
99impl PartialOrd for FuelTxCoin {
100    fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
101        Some(self.cmp(other))
102    }
103}
104
105/// Internal wrapper that pins each coin to its insertion order so the
106/// per-account set can be walked oldest-first (FIFO).
107///
108/// `seq` is a monotonically increasing counter assigned per `load_from_coins`
109/// batch: every coin added in the same call shares a `seq`, and each later
110/// batch gets a higher one. Ordering is therefore "oldest batch first", with
111/// ties (coins added together) broken by amount, then `utxo_id` for a stable
112/// total order. This makes fee selection drain old, untouched UTXOs first and
113/// pushes freshly-added change coins to the back of the queue.
114#[derive(Copy, Clone, Debug, PartialEq, Eq)]
115struct OrderedCoin {
116    seq: u64,
117    coin: FuelTxCoin,
118}
119
120impl Ord for OrderedCoin {
121    fn cmp(&self, other: &Self) -> std::cmp::Ordering {
122        self.seq
123            .cmp(&other.seq)
124            .then_with(|| self.coin.amount.cmp(&other.coin.amount))
125            .then_with(|| self.coin.utxo_id.cmp(&other.coin.utxo_id))
126    }
127}
128
129impl PartialOrd for OrderedCoin {
130    fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
131        Some(self.cmp(other))
132    }
133}
134
135/// Trait for types that can provide UTXO management capabilities.
136/// This allows decoupling the concrete UtxoManager implementation from the
137/// o2-tools crate, enabling consumers to provide their own implementation.
138pub trait UtxoProvider: Send + 'static {
139    fn balance_of(&self, owner: Address, asset_id: AssetId) -> u128;
140    fn len_per_address(&self, asset_id: AssetId, address: Address) -> usize;
141    fn guaranteed_extract_coins(
142        &mut self,
143        owner: Address,
144        asset_id: AssetId,
145        amount: u128,
146        max_coins: usize,
147    ) -> anyhow::Result<Vec<FuelTxCoin>>;
148    fn load_from_coins_vec(&mut self, coins: Vec<FuelTxCoin>);
149    fn number_of_coins_with_amount_greater_or_equal(
150        &self,
151        owner: Address,
152        asset_id: AssetId,
153        amount: u128,
154    ) -> (u128, usize);
155    fn extract_largest_coins(
156        &mut self,
157        owner: Address,
158        asset_id: AssetId,
159        max_value: u128,
160    ) -> Vec<FuelTxCoin>;
161    fn coin_count(&self) -> usize;
162    fn utxo_ids(&self) -> Vec<UtxoId>;
163    fn remove_coin(&mut self, utxo_id: &UtxoId) -> bool;
164    fn total_balance(&self, asset_id: &AssetId) -> u128;
165}
166
167pub type SharedUtxoManager = Arc<tokio::sync::Mutex<dyn UtxoProvider>>;
168
169/// A struct to manage UTXOs of internal accounts used to pay fee, match transactions, and
170/// users predicate based accounts.
171pub struct UtxoManager {
172    account_utxos: HashMap<(Address, AssetId), BTreeSet<OrderedCoin>>,
173    coins: HashMap<UtxoId, OrderedCoin>,
174    /// Next insertion-order sequence handed out to a `load_from_coins` batch.
175    next_seq: u64,
176}
177
178impl Default for UtxoManager {
179    fn default() -> Self {
180        Self::new()
181    }
182}
183
184impl UtxoManager {
185    pub fn new() -> Self {
186        Self {
187            account_utxos: HashMap::new(),
188            coins: HashMap::new(),
189            next_seq: 0,
190        }
191    }
192
193    pub fn len_per_address(&self, asset_id: AssetId, address: Address) -> usize {
194        self.account_utxos
195            .get(&(address, asset_id))
196            .map_or(0, |utxos| utxos.len())
197    }
198
199    pub fn new_from_coins<I>(coins: I) -> Self
200    where
201        I: Iterator<Item = FuelTxCoin>,
202    {
203        let mut _self = Self::new();
204
205        _self.load_from_coins(coins);
206
207        _self
208    }
209
210    pub fn load_from_coins<I>(&mut self, coins: I)
211    where
212        I: Iterator<Item = FuelTxCoin>,
213    {
214        // Every coin in this call shares one sequence so the batch lands at the
215        // back of the FIFO queue together; the counter only advances if at
216        // least one coin was actually added.
217        let seq = self.next_seq;
218        let mut used = false;
219
220        for coin in coins {
221            if coin.amount == 0 {
222                continue;
223            }
224
225            // Re-adding an already-tracked coin must not move it to the back of
226            // the queue, so keep its original position.
227            if self.coins.contains_key(&coin.utxo_id) {
228                continue;
229            }
230
231            let ordered = OrderedCoin { seq, coin };
232            let key = (coin.owner, coin.asset_id);
233            self.account_utxos.entry(key).or_default().insert(ordered);
234            self.coins.insert(coin.utxo_id, ordered);
235            used = true;
236        }
237
238        if used {
239            self.next_seq += 1;
240        }
241    }
242
243    fn extract_utxos(&mut self, utxos: &[UtxoId]) -> anyhow::Result<Vec<FuelTxCoin>> {
244        let mut coins = vec![];
245
246        for utxo_id in utxos {
247            let ordered = self.coins.remove(utxo_id).ok_or_else(|| {
248                anyhow::anyhow!("UTXO {utxo_id} not found in the UTXO manager")
249            })?;
250
251            let key = (ordered.coin.owner, ordered.coin.asset_id);
252            let account = self.account_utxos.entry(key);
253
254            match account {
255                Entry::Occupied(mut occupied) => {
256                    occupied.get_mut().remove(&ordered);
257
258                    if occupied.get().is_empty() {
259                        occupied.remove();
260                    }
261
262                    coins.push(ordered.coin);
263                }
264                Entry::Vacant(_) => {}
265            }
266        }
267
268        Ok(coins)
269    }
270
271    /// Selects up to `max_coins` UTXOs covering `amount`, walking the
272    /// per-account set in `order`. Returns `Some(utxo_ids)` only when the
273    /// selected coins (which never exceed `max_coins`) reach `amount`;
274    /// otherwise `None`, so callers can try another strategy.
275    fn select_within_cap(
276        &self,
277        owner: Address,
278        asset_id: AssetId,
279        amount: u128,
280        max_coins: usize,
281        order: SelectionOrder,
282    ) -> Option<Vec<UtxoId>> {
283        let coins = self.account_utxos.get(&(owner, asset_id))?;
284
285        // FIFO walks the set in insertion order (oldest first); Largest
286        // re-sorts by amount so the fewest, most valuable coins are tried.
287        let ordered: Vec<&OrderedCoin> = match order {
288            SelectionOrder::Fifo => coins.iter().collect(),
289            SelectionOrder::Largest => {
290                let mut by_amount: Vec<&OrderedCoin> = coins.iter().collect();
291                by_amount.sort_by(|a, b| {
292                    b.coin
293                        .amount
294                        .cmp(&a.coin.amount)
295                        .then_with(|| a.coin.utxo_id.cmp(&b.coin.utxo_id))
296                });
297                by_amount
298            }
299        };
300
301        let mut total = 0u128;
302        let mut selected = Vec::new();
303        for oc in ordered {
304            if total >= amount || selected.len() >= max_coins {
305                break;
306            }
307            selected.push(oc.coin.utxo_id);
308            total += oc.coin.amount as u128;
309        }
310
311        (total >= amount).then_some(selected)
312    }
313
314    /// Extracts coins covering `amount`, returning at most `max_coins`.
315    ///
316    /// Prefers old, untouched UTXOs first (FIFO). If FIFO cannot reach the
317    /// target within the cap, it falls back to the largest coins, which pack
318    /// the most value into the fewest inputs. Errors only when neither
319    /// strategy can cover `amount` within `max_coins`.
320    pub fn guaranteed_extract_coins(
321        &mut self,
322        owner: Address,
323        asset_id: AssetId,
324        amount: u128,
325        max_coins: usize,
326    ) -> anyhow::Result<Vec<FuelTxCoin>> {
327        let utxos = self
328            .select_within_cap(owner, asset_id, amount, max_coins, SelectionOrder::Fifo)
329            .or_else(|| {
330                self.select_within_cap(
331                    owner,
332                    asset_id,
333                    amount,
334                    max_coins,
335                    SelectionOrder::Largest,
336                )
337            })
338            .ok_or_else(|| {
339                anyhow::anyhow!(
340                    "Not enough UTXOs found for the given {owner} and \
341                    {asset_id} to cover {amount} within {max_coins} coins."
342                )
343            })?;
344
345        self.extract_utxos(&utxos)
346    }
347
348    pub fn number_of_coins_with_amount_greater_or_equal(
349        &self,
350        owner: Address,
351        asset_id: AssetId,
352        amount: u128,
353    ) -> (u128, usize) {
354        self.account_utxos
355            .get(&(owner, asset_id))
356            .map_or((0, 0), |coins| {
357                let mut count = 0;
358                let mut total_balance = 0;
359
360                for ordered in coins.iter() {
361                    if ordered.coin.amount as u128 >= amount {
362                        count += 1;
363                        total_balance += ordered.coin.amount as u128;
364                    }
365                }
366
367                (total_balance, count)
368            })
369    }
370
371    pub fn balance_of(&self, owner: Address, asset_id: AssetId) -> u128 {
372        self.account_utxos
373            .get(&(owner, asset_id))
374            .map_or(0, |coins| {
375                coins
376                    .iter()
377                    .map(|ordered| ordered.coin.amount as u128)
378                    .sum()
379            })
380    }
381
382    /// Returns all tracked coins keyed by their `UtxoId`.
383    pub fn coins(&self) -> HashMap<UtxoId, FuelTxCoin> {
384        self.coins
385            .iter()
386            .map(|(utxo_id, ordered)| (*utxo_id, ordered.coin))
387            .collect()
388    }
389
390    /// Returns the number of coins tracked.
391    pub fn coin_count(&self) -> usize {
392        self.coins.len()
393    }
394
395    /// Returns the UtxoIds of all tracked coins.
396    pub fn utxo_ids(&self) -> Vec<UtxoId> {
397        self.coins.keys().copied().collect()
398    }
399
400    /// Checks if a specific coin is tracked.
401    pub fn contains(&self, utxo_id: &UtxoId) -> bool {
402        self.coins.contains_key(utxo_id)
403    }
404
405    /// Returns the total balance for a given asset across all owners.
406    pub fn total_balance(&self, asset_id: &AssetId) -> u128 {
407        self.coins
408            .values()
409            .filter(|ordered| &ordered.coin.asset_id == asset_id)
410            .map(|ordered| ordered.coin.amount as u128)
411            .sum()
412    }
413
414    /// Removes a specific coin by UtxoId. Returns true if it was found and removed.
415    pub fn remove_coin(&mut self, utxo_id: &UtxoId) -> bool {
416        if let Some(ordered) = self.coins.remove(utxo_id) {
417            let key = (ordered.coin.owner, ordered.coin.asset_id);
418            if let Entry::Occupied(mut entry) = self.account_utxos.entry(key) {
419                entry.get_mut().remove(&ordered);
420                if entry.get().is_empty() {
421                    entry.remove();
422                }
423            }
424            true
425        } else {
426            false
427        }
428    }
429
430    /// Extracts the largest coins for the given owner/asset up to
431    /// `max_value` total, in descending order of amount. Used by coin
432    /// management (split/drain), independently of the FIFO fee-paying path,
433    /// so it re-sorts by amount rather than relying on the set's insertion
434    /// order. Returns the extracted coins (removed from the manager).
435    pub fn extract_largest_coins(
436        &mut self,
437        owner: Address,
438        asset_id: AssetId,
439        max_value: u128,
440    ) -> Vec<FuelTxCoin> {
441        let utxo_ids: Vec<UtxoId> = {
442            let Some(coins) = self.account_utxos.get(&(owner, asset_id)) else {
443                return vec![];
444            };
445            let mut by_amount: Vec<&OrderedCoin> = coins.iter().collect();
446            // Largest amount first; utxo_id keeps the order deterministic.
447            by_amount.sort_by(|a, b| {
448                b.coin
449                    .amount
450                    .cmp(&a.coin.amount)
451                    .then_with(|| a.coin.utxo_id.cmp(&b.coin.utxo_id))
452            });
453            let mut total = 0u128;
454            by_amount
455                .into_iter()
456                .take_while(|ordered| {
457                    if total >= max_value {
458                        return false;
459                    }
460                    total += ordered.coin.amount as u128;
461                    true
462                })
463                .map(|ordered| ordered.coin.utxo_id)
464                .collect()
465        };
466        self.extract_utxos(&utxo_ids).unwrap_or_default()
467    }
468}
469
470impl UtxoProvider for UtxoManager {
471    fn balance_of(&self, owner: Address, asset_id: AssetId) -> u128 {
472        UtxoManager::balance_of(self, owner, asset_id)
473    }
474
475    fn len_per_address(&self, asset_id: AssetId, address: Address) -> usize {
476        UtxoManager::len_per_address(self, asset_id, address)
477    }
478
479    fn guaranteed_extract_coins(
480        &mut self,
481        owner: Address,
482        asset_id: AssetId,
483        amount: u128,
484        max_coins: usize,
485    ) -> anyhow::Result<Vec<FuelTxCoin>> {
486        UtxoManager::guaranteed_extract_coins(self, owner, asset_id, amount, max_coins)
487    }
488
489    fn load_from_coins_vec(&mut self, coins: Vec<FuelTxCoin>) {
490        self.load_from_coins(coins.into_iter());
491    }
492
493    fn number_of_coins_with_amount_greater_or_equal(
494        &self,
495        owner: Address,
496        asset_id: AssetId,
497        amount: u128,
498    ) -> (u128, usize) {
499        UtxoManager::number_of_coins_with_amount_greater_or_equal(
500            self, owner, asset_id, amount,
501        )
502    }
503
504    fn extract_largest_coins(
505        &mut self,
506        owner: Address,
507        asset_id: AssetId,
508        max_value: u128,
509    ) -> Vec<FuelTxCoin> {
510        UtxoManager::extract_largest_coins(self, owner, asset_id, max_value)
511    }
512
513    fn coin_count(&self) -> usize {
514        UtxoManager::coin_count(self)
515    }
516
517    fn utxo_ids(&self) -> Vec<UtxoId> {
518        UtxoManager::utxo_ids(self)
519    }
520
521    fn remove_coin(&mut self, utxo_id: &UtxoId) -> bool {
522        UtxoManager::remove_coin(self, utxo_id)
523    }
524
525    fn total_balance(&self, asset_id: &AssetId) -> u128 {
526        UtxoManager::total_balance(self, asset_id)
527    }
528}
529
530#[cfg(test)]
531#[allow(non_snake_case)]
532mod tests {
533    use super::*;
534
535    #[test]
536    fn guaranteed_extract_coins__returns_coins_in_ascending_order_by_amount__when_coins_inserted_in_random_order()
537     {
538        // given
539        let owner = Address::from([1u8; 32]);
540        let asset_id = AssetId::from([2u8; 32]);
541
542        let coin1 = FuelTxCoin {
543            amount: 100,
544            asset_id,
545            utxo_id: UtxoId::new(fuel_core_types::fuel_tx::TxId::from([1u8; 32]), 0),
546            owner,
547        };
548        let coin2 = FuelTxCoin {
549            amount: 50,
550            asset_id,
551            utxo_id: UtxoId::new(fuel_core_types::fuel_tx::TxId::from([2u8; 32]), 0),
552            owner,
553        };
554        let coin3 = FuelTxCoin {
555            amount: 200,
556            asset_id,
557            utxo_id: UtxoId::new(fuel_core_types::fuel_tx::TxId::from([3u8; 32]), 0),
558            owner,
559        };
560        let coin4 = FuelTxCoin {
561            amount: 75,
562            asset_id,
563            utxo_id: UtxoId::new(fuel_core_types::fuel_tx::TxId::from([4u8; 32]), 0),
564            owner,
565        };
566
567        let mut manager = UtxoManager::new();
568        manager.load_from_coins(vec![coin1, coin2, coin3, coin4].into_iter());
569
570        // when
571        let total_amount = 100 + 50 + 200 + 75;
572        let extracted = manager
573            .guaranteed_extract_coins(owner, asset_id, total_amount, usize::MAX)
574            .unwrap();
575
576        // then
577        assert_eq!(extracted.len(), 4);
578        assert_eq!(extracted[0].amount, 50); // coin2 (smallest)
579        assert_eq!(extracted[1].amount, 75); // coin4
580        assert_eq!(extracted[2].amount, 100); // coin1
581        assert_eq!(extracted[3].amount, 200); // coin3 (largest)
582        assert_eq!(manager.balance_of(owner, asset_id), 0);
583    }
584
585    #[test]
586    fn extract_largest_coins__takes_largest_first_up_to_max_value() {
587        let owner = Address::from([1u8; 32]);
588        let asset_id = AssetId::from([2u8; 32]);
589
590        let coins: Vec<FuelTxCoin> = (1..=5)
591            .map(|i| FuelTxCoin {
592                amount: i * 100, // 100, 200, 300, 400, 500
593                asset_id,
594                utxo_id: UtxoId::new(
595                    fuel_core_types::fuel_tx::TxId::from([i as u8; 32]),
596                    0,
597                ),
598                owner,
599            })
600            .collect();
601        let mut manager = UtxoManager::new();
602        manager.load_from_coins(coins.into_iter());
603
604        // Extract up to 600 in value (largest first: 500, then 400 -> total 900 > 600)
605        // take_while stops after accumulating >= max_value, so we get 500 (total=500 < 600)
606        // then 400 (total=900 >= 600, but take_while already entered), so we get 500 + 400.
607        // Actually take_while: first 500 → total=500 < 600 → true; next 400 → total=900 → true
608        // (check is at start: total >= max_value? 500 >= 600? no, so take it);
609        // next iteration: total=900 >= 600? yes → stop.
610        let extracted = manager.extract_largest_coins(owner, asset_id, 600);
611        let amounts: Vec<u64> = extracted.iter().map(|c| c.amount).collect();
612        // Should have extracted the two largest coins (500 and 400)
613        assert_eq!(amounts.len(), 2);
614        assert!(amounts.contains(&500));
615        assert!(amounts.contains(&400));
616        // Remaining: 100+200+300 = 600
617        assert_eq!(manager.balance_of(owner, asset_id), 600);
618    }
619
620    fn coin(amount: u64, tag: u8, owner: Address, asset_id: AssetId) -> FuelTxCoin {
621        FuelTxCoin {
622            amount,
623            asset_id,
624            utxo_id: UtxoId::new(fuel_core_types::fuel_tx::TxId::from([tag; 32]), 0),
625            owner,
626        }
627    }
628
629    #[test]
630    fn guaranteed_extract_coins__spends_oldest_batch_first__even_when_newer_coin_is_smaller()
631     {
632        // given: an old large coin loaded first, then a newer small coin.
633        let owner = Address::from([1u8; 32]);
634        let asset_id = AssetId::from([2u8; 32]);
635        let old_large = coin(1_000, 1, owner, asset_id);
636        let new_small = coin(10, 2, owner, asset_id);
637
638        let mut manager = UtxoManager::new();
639        manager.load_from_coins(vec![old_large].into_iter()); // batch 0
640        manager.load_from_coins(vec![new_small].into_iter()); // batch 1
641
642        // when: we need less than the old coin alone provides.
643        let extracted = manager
644            .guaranteed_extract_coins(owner, asset_id, 5, usize::MAX)
645            .unwrap();
646
647        // then: FIFO picks the old coin despite the newer one being smaller.
648        assert_eq!(extracted.len(), 1);
649        assert_eq!(extracted[0].utxo_id, old_large.utxo_id);
650        assert_eq!(manager.balance_of(owner, asset_id), 10);
651    }
652
653    #[test]
654    fn guaranteed_extract_coins__prefers_old_small_coin_over_new_large_coin() {
655        // given: an old *small* coin, then a new *large* coin. This is the
656        // case that distinguishes FIFO from largest-first selection.
657        let owner = Address::from([1u8; 32]);
658        let asset_id = AssetId::from([2u8; 32]);
659        let old_small = coin(100, 1, owner, asset_id);
660        let new_large = coin(900, 2, owner, asset_id);
661
662        let mut manager = UtxoManager::new();
663        manager.load_from_coins(vec![old_small].into_iter()); // batch 0
664        manager.load_from_coins(vec![new_large].into_iter()); // batch 1
665
666        // when: the target fits in either single coin.
667        let extracted = manager
668            .guaranteed_extract_coins(owner, asset_id, 100, usize::MAX)
669            .unwrap();
670
671        // then: FIFO takes the old small coin, not the larger newer one.
672        assert_eq!(extracted.len(), 1);
673        assert_eq!(extracted[0].utxo_id, old_small.utxo_id);
674    }
675
676    #[test]
677    fn load_from_coins__breaks_ties_within_a_batch_by_amount() {
678        // given: three coins added in a single batch (same age).
679        let owner = Address::from([1u8; 32]);
680        let asset_id = AssetId::from([2u8; 32]);
681        let big = coin(300, 1, owner, asset_id);
682        let small = coin(100, 2, owner, asset_id);
683        let mid = coin(200, 3, owner, asset_id);
684
685        let mut manager = UtxoManager::new();
686        manager.load_from_coins(vec![big, small, mid].into_iter());
687
688        // when: extract just enough for the smallest.
689        let extracted = manager
690            .guaranteed_extract_coins(owner, asset_id, 50, usize::MAX)
691            .unwrap();
692
693        // then: same-age coins are spent smallest-amount first.
694        assert_eq!(extracted.len(), 1);
695        assert_eq!(extracted[0].utxo_id, small.utxo_id);
696    }
697
698    #[test]
699    fn load_from_coins__re_adding_existing_coin_keeps_its_queue_position() {
700        // given: an old coin, then a new coin, then the old coin re-added.
701        let owner = Address::from([1u8; 32]);
702        let asset_id = AssetId::from([2u8; 32]);
703        let old = coin(100, 1, owner, asset_id);
704        let new = coin(100, 2, owner, asset_id);
705
706        let mut manager = UtxoManager::new();
707        manager.load_from_coins(vec![old].into_iter()); // batch 0
708        manager.load_from_coins(vec![new].into_iter()); // batch 1
709        manager.load_from_coins(vec![old].into_iter()); // re-add: must not move
710
711        assert_eq!(manager.coin_count(), 2);
712
713        // when: extract one coin's worth.
714        let extracted = manager
715            .guaranteed_extract_coins(owner, asset_id, 100, usize::MAX)
716            .unwrap();
717
718        // then: the old coin is still first despite being re-added last.
719        assert_eq!(extracted.len(), 1);
720        assert_eq!(extracted[0].utxo_id, old.utxo_id);
721    }
722
723    #[test]
724    fn extract_largest_coins__still_takes_largest_first__across_batches() {
725        // given: a small coin in an older batch, a large coin in a newer batch.
726        let owner = Address::from([1u8; 32]);
727        let asset_id = AssetId::from([2u8; 32]);
728        let old_small = coin(100, 1, owner, asset_id);
729        let new_large = coin(900, 2, owner, asset_id);
730
731        let mut manager = UtxoManager::new();
732        manager.load_from_coins(vec![old_small].into_iter()); // batch 0
733        manager.load_from_coins(vec![new_large].into_iter()); // batch 1
734
735        // when / then: split/drain path ignores age and takes the largest.
736        let extracted = manager.extract_largest_coins(owner, asset_id, 500);
737        assert_eq!(extracted.len(), 1);
738        assert_eq!(extracted[0].utxo_id, new_large.utxo_id);
739    }
740
741    #[test]
742    fn guaranteed_extract_coins__never_returns_more_than_max_coins() {
743        // given: five equal coins in one batch.
744        let owner = Address::from([1u8; 32]);
745        let asset_id = AssetId::from([2u8; 32]);
746        let coins: Vec<FuelTxCoin> =
747            (1..=5).map(|i| coin(100, i, owner, asset_id)).collect();
748
749        let mut manager = UtxoManager::new();
750        manager.load_from_coins(coins.into_iter());
751
752        // when: the target would need 4 coins but the cap is 2.
753        let result = manager.guaranteed_extract_coins(owner, asset_id, 400, 2);
754
755        // then: it errors rather than exceeding the cap.
756        assert!(result.is_err());
757        // and nothing was removed.
758        assert_eq!(manager.coin_count(), 5);
759    }
760
761    #[test]
762    fn guaranteed_extract_coins__falls_back_to_largest_when_fifo_cannot_meet_target_within_cap()
763     {
764        // given: many old dust coins, then one new large coin.
765        let owner = Address::from([1u8; 32]);
766        let asset_id = AssetId::from([2u8; 32]);
767        let dust: Vec<FuelTxCoin> =
768            (1..=5).map(|i| coin(10, i, owner, asset_id)).collect();
769        let big = coin(1_000, 100, owner, asset_id);
770
771        let mut manager = UtxoManager::new();
772        manager.load_from_coins(dust.into_iter()); // batch 0 (old)
773        manager.load_from_coins(vec![big].into_iter()); // batch 1 (new)
774
775        // when: need 500 but cap is 2; the 2 oldest dust coins total only 20,
776        // so FIFO fails and we fall back to largest (the single big coin).
777        let extracted = manager
778            .guaranteed_extract_coins(owner, asset_id, 500, 2)
779            .unwrap();
780
781        // then: the big coin alone satisfies the target within the cap.
782        assert_eq!(extracted.len(), 1);
783        assert_eq!(extracted[0].utxo_id, big.utxo_id);
784    }
785
786    #[test]
787    fn guaranteed_extract_coins__errors_when_neither_strategy_can_cover_target() {
788        // given: total balance below the target.
789        let owner = Address::from([1u8; 32]);
790        let asset_id = AssetId::from([2u8; 32]);
791        let small = coin(100, 1, owner, asset_id);
792
793        let mut manager = UtxoManager::new();
794        manager.load_from_coins(vec![small].into_iter());
795
796        // when / then: even uncapped, there isn't enough to cover 1_000.
797        let result = manager.guaranteed_extract_coins(owner, asset_id, 1_000, usize::MAX);
798        assert!(result.is_err());
799        assert_eq!(manager.coin_count(), 1);
800    }
801
802    #[test]
803    fn extract_largest_coins__returns_empty_when_no_coins() {
804        let owner = Address::from([1u8; 32]);
805        let asset_id = AssetId::from([2u8; 32]);
806        let mut manager = UtxoManager::new();
807        let extracted = manager.extract_largest_coins(owner, asset_id, 1000);
808        assert!(extracted.is_empty());
809    }
810
811    #[test]
812    fn extract_largest_coins__extracts_single_coin_when_only_one() {
813        let owner = Address::from([1u8; 32]);
814        let asset_id = AssetId::from([2u8; 32]);
815        let coin = FuelTxCoin {
816            amount: 1_000_000,
817            asset_id,
818            utxo_id: UtxoId::new(fuel_core_types::fuel_tx::TxId::from([1u8; 32]), 0),
819            owner,
820        };
821        let mut manager = UtxoManager::new();
822        manager.load_from_coins(vec![coin].into_iter());
823
824        let extracted = manager.extract_largest_coins(owner, asset_id, 500_000);
825        assert_eq!(extracted.len(), 1);
826        assert_eq!(extracted[0].amount, 1_000_000);
827        assert_eq!(manager.balance_of(owner, asset_id), 0);
828    }
829}