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#[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
96/// Trait for types that can provide UTXO management capabilities.
97/// This allows decoupling the concrete UtxoManager implementation from the
98/// o2-tools crate, enabling consumers to provide their own implementation.
99pub 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
129/// A struct to manage UTXOs of internal accounts used to pay fee, match transactions, and
130/// users predicate based accounts.
131pub 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        // Iterate through entries (already sorted by amount ascending)
227        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    /// Returns a reference to all tracked coins.
288    pub fn coins(&self) -> &HashMap<UtxoId, FuelTxCoin> {
289        &self.coins
290    }
291
292    /// Returns the number of coins tracked.
293    pub fn coin_count(&self) -> usize {
294        self.coins.len()
295    }
296
297    /// Returns the UtxoIds of all tracked coins.
298    pub fn utxo_ids(&self) -> Vec<UtxoId> {
299        self.coins.keys().copied().collect()
300    }
301
302    /// Checks if a specific coin is tracked.
303    pub fn contains(&self, utxo_id: &UtxoId) -> bool {
304        self.coins.contains_key(utxo_id)
305    }
306
307    /// Returns the total balance for a given asset across all owners.
308    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    /// Removes a specific coin by UtxoId. Returns true if it was found and removed.
317    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    /// Extracts the largest coins for the given owner/asset up to
333    /// `max_value` total. Iterates coins in descending order of amount.
334    /// Returns the extracted coins (removed from the manager).
335    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() // largest first
349                .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        // given
431        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        // when
463        let total_amount = 100 + 50 + 200 + 75;
464        let extracted = manager
465            .guaranteed_extract_coins(owner, asset_id, total_amount)
466            .unwrap();
467
468        // then
469        assert_eq!(extracted.len(), 4);
470        assert_eq!(extracted[0].amount, 50); // coin2 (smallest)
471        assert_eq!(extracted[1].amount, 75); // coin4
472        assert_eq!(extracted[2].amount, 100); // coin1
473        assert_eq!(extracted[3].amount, 200); // coin3 (largest)
474        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, // 100, 200, 300, 400, 500
485                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        // Extract up to 600 in value (largest first: 500, then 400 -> total 900 > 600)
497        // take_while stops after accumulating >= max_value, so we get 500 (total=500 < 600)
498        // then 400 (total=900 >= 600, but take_while already entered), so we get 500 + 400.
499        // Actually take_while: first 500 → total=500 < 600 → true; next 400 → total=900 → true
500        // (check is at start: total >= max_value? 500 >= 600? no, so take it);
501        // next iteration: total=900 >= 600? yes → stop.
502        let extracted = manager.extract_largest_coins(owner, asset_id, 600);
503        let amounts: Vec<u64> = extracted.iter().map(|c| c.amount).collect();
504        // Should have extracted the two largest coins (500 and 400)
505        assert_eq!(amounts.len(), 2);
506        assert!(amounts.contains(&500));
507        assert!(amounts.contains(&400));
508        // Remaining: 100+200+300 = 600
509        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}