use fuel_core_types::{
fuel_tx::UtxoId,
fuel_types::{
Address,
AssetId,
},
};
use fuels::types::{
coin::Coin,
coin_type::CoinType,
input::Input,
};
use std::{
collections::{
BTreeSet,
HashMap,
HashSet,
hash_map::Entry,
},
sync::Arc,
};
pub struct CoinsResult {
pub known_coins: Vec<FuelTxCoin>,
pub unknown_coins: HashSet<UtxoId>,
}
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
enum SelectionOrder {
Fifo,
Largest,
}
#[derive(Copy, Clone, Debug, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
pub struct FuelTxCoin {
pub amount: u64,
pub asset_id: AssetId,
pub utxo_id: UtxoId,
pub owner: Address,
}
impl From<Coin> for FuelTxCoin {
fn from(value: Coin) -> Self {
Self {
amount: value.amount,
asset_id: value.asset_id,
utxo_id: value.utxo_id,
owner: value.owner,
}
}
}
impl From<FuelTxCoin> for Coin {
fn from(value: FuelTxCoin) -> Self {
Self {
amount: value.amount,
asset_id: value.asset_id,
utxo_id: value.utxo_id,
owner: value.owner,
}
}
}
impl TryFrom<&fuel_core_types::fuel_tx::Input> for FuelTxCoin {
type Error = anyhow::Error;
fn try_from(input: &fuel_core_types::fuel_tx::Input) -> Result<Self, Self::Error> {
if let fuel_core_types::fuel_tx::Input::CoinSigned(coin) = input {
return Ok(FuelTxCoin {
utxo_id: coin.utxo_id,
owner: coin.owner,
amount: coin.amount,
asset_id: coin.asset_id,
});
}
anyhow::bail!("Invalid input type")
}
}
impl From<FuelTxCoin> for Input {
fn from(value: FuelTxCoin) -> Self {
Input::resource_signed(CoinType::Coin(value.into()))
}
}
impl Ord for FuelTxCoin {
fn cmp(&self, other: &Self) -> std::cmp::Ordering {
self.amount
.cmp(&other.amount)
.then_with(|| self.asset_id.cmp(&other.asset_id))
.then_with(|| self.utxo_id.cmp(&other.utxo_id))
.then_with(|| self.owner.cmp(&other.owner))
}
}
impl PartialOrd for FuelTxCoin {
fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
Some(self.cmp(other))
}
}
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
struct OrderedCoin {
seq: u64,
coin: FuelTxCoin,
}
impl Ord for OrderedCoin {
fn cmp(&self, other: &Self) -> std::cmp::Ordering {
self.seq
.cmp(&other.seq)
.then_with(|| self.coin.amount.cmp(&other.coin.amount))
.then_with(|| self.coin.utxo_id.cmp(&other.coin.utxo_id))
}
}
impl PartialOrd for OrderedCoin {
fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
Some(self.cmp(other))
}
}
pub trait UtxoProvider: Send + 'static {
fn balance_of(&self, owner: Address, asset_id: AssetId) -> u128;
fn len_per_address(&self, asset_id: AssetId, address: Address) -> usize;
fn guaranteed_extract_coins(
&mut self,
owner: Address,
asset_id: AssetId,
amount: u128,
max_coins: usize,
) -> anyhow::Result<Vec<FuelTxCoin>>;
fn load_from_coins_vec(&mut self, coins: Vec<FuelTxCoin>);
fn number_of_coins_with_amount_greater_or_equal(
&self,
owner: Address,
asset_id: AssetId,
amount: u128,
) -> (u128, usize);
fn extract_largest_coins(
&mut self,
owner: Address,
asset_id: AssetId,
max_value: u128,
) -> Vec<FuelTxCoin>;
fn coin_count(&self) -> usize;
fn utxo_ids(&self) -> Vec<UtxoId>;
fn remove_coin(&mut self, utxo_id: &UtxoId) -> bool;
fn total_balance(&self, asset_id: &AssetId) -> u128;
}
pub type SharedUtxoManager = Arc<tokio::sync::Mutex<dyn UtxoProvider>>;
pub struct UtxoManager {
account_utxos: HashMap<(Address, AssetId), BTreeSet<OrderedCoin>>,
coins: HashMap<UtxoId, OrderedCoin>,
next_seq: u64,
}
impl Default for UtxoManager {
fn default() -> Self {
Self::new()
}
}
impl UtxoManager {
pub fn new() -> Self {
Self {
account_utxos: HashMap::new(),
coins: HashMap::new(),
next_seq: 0,
}
}
pub fn len_per_address(&self, asset_id: AssetId, address: Address) -> usize {
self.account_utxos
.get(&(address, asset_id))
.map_or(0, |utxos| utxos.len())
}
pub fn new_from_coins<I>(coins: I) -> Self
where
I: Iterator<Item = FuelTxCoin>,
{
let mut _self = Self::new();
_self.load_from_coins(coins);
_self
}
pub fn load_from_coins<I>(&mut self, coins: I)
where
I: Iterator<Item = FuelTxCoin>,
{
let seq = self.next_seq;
let mut used = false;
for coin in coins {
if coin.amount == 0 {
continue;
}
if self.coins.contains_key(&coin.utxo_id) {
continue;
}
let ordered = OrderedCoin { seq, coin };
let key = (coin.owner, coin.asset_id);
self.account_utxos.entry(key).or_default().insert(ordered);
self.coins.insert(coin.utxo_id, ordered);
used = true;
}
if used {
self.next_seq += 1;
}
}
fn extract_utxos(&mut self, utxos: &[UtxoId]) -> anyhow::Result<Vec<FuelTxCoin>> {
let mut coins = vec![];
for utxo_id in utxos {
let ordered = self.coins.remove(utxo_id).ok_or_else(|| {
anyhow::anyhow!("UTXO {utxo_id} not found in the UTXO manager")
})?;
let key = (ordered.coin.owner, ordered.coin.asset_id);
let account = self.account_utxos.entry(key);
match account {
Entry::Occupied(mut occupied) => {
occupied.get_mut().remove(&ordered);
if occupied.get().is_empty() {
occupied.remove();
}
coins.push(ordered.coin);
}
Entry::Vacant(_) => {}
}
}
Ok(coins)
}
fn select_within_cap(
&self,
owner: Address,
asset_id: AssetId,
amount: u128,
max_coins: usize,
order: SelectionOrder,
) -> Option<Vec<UtxoId>> {
let coins = self.account_utxos.get(&(owner, asset_id))?;
let ordered: Vec<&OrderedCoin> = match order {
SelectionOrder::Fifo => coins.iter().collect(),
SelectionOrder::Largest => {
let mut by_amount: Vec<&OrderedCoin> = coins.iter().collect();
by_amount.sort_by(|a, b| {
b.coin
.amount
.cmp(&a.coin.amount)
.then_with(|| a.coin.utxo_id.cmp(&b.coin.utxo_id))
});
by_amount
}
};
let mut total = 0u128;
let mut selected = Vec::new();
for oc in ordered {
if total >= amount || selected.len() >= max_coins {
break;
}
selected.push(oc.coin.utxo_id);
total += oc.coin.amount as u128;
}
(total >= amount).then_some(selected)
}
pub fn guaranteed_extract_coins(
&mut self,
owner: Address,
asset_id: AssetId,
amount: u128,
max_coins: usize,
) -> anyhow::Result<Vec<FuelTxCoin>> {
let utxos = self
.select_within_cap(owner, asset_id, amount, max_coins, SelectionOrder::Fifo)
.or_else(|| {
self.select_within_cap(
owner,
asset_id,
amount,
max_coins,
SelectionOrder::Largest,
)
})
.ok_or_else(|| {
anyhow::anyhow!(
"Not enough UTXOs found for the given {owner} and \
{asset_id} to cover {amount} within {max_coins} coins."
)
})?;
self.extract_utxos(&utxos)
}
pub fn number_of_coins_with_amount_greater_or_equal(
&self,
owner: Address,
asset_id: AssetId,
amount: u128,
) -> (u128, usize) {
self.account_utxos
.get(&(owner, asset_id))
.map_or((0, 0), |coins| {
let mut count = 0;
let mut total_balance = 0;
for ordered in coins.iter() {
if ordered.coin.amount as u128 >= amount {
count += 1;
total_balance += ordered.coin.amount as u128;
}
}
(total_balance, count)
})
}
pub fn balance_of(&self, owner: Address, asset_id: AssetId) -> u128 {
self.account_utxos
.get(&(owner, asset_id))
.map_or(0, |coins| {
coins
.iter()
.map(|ordered| ordered.coin.amount as u128)
.sum()
})
}
pub fn coins(&self) -> HashMap<UtxoId, FuelTxCoin> {
self.coins
.iter()
.map(|(utxo_id, ordered)| (*utxo_id, ordered.coin))
.collect()
}
pub fn coin_count(&self) -> usize {
self.coins.len()
}
pub fn utxo_ids(&self) -> Vec<UtxoId> {
self.coins.keys().copied().collect()
}
pub fn contains(&self, utxo_id: &UtxoId) -> bool {
self.coins.contains_key(utxo_id)
}
pub fn total_balance(&self, asset_id: &AssetId) -> u128 {
self.coins
.values()
.filter(|ordered| &ordered.coin.asset_id == asset_id)
.map(|ordered| ordered.coin.amount as u128)
.sum()
}
pub fn remove_coin(&mut self, utxo_id: &UtxoId) -> bool {
if let Some(ordered) = self.coins.remove(utxo_id) {
let key = (ordered.coin.owner, ordered.coin.asset_id);
if let Entry::Occupied(mut entry) = self.account_utxos.entry(key) {
entry.get_mut().remove(&ordered);
if entry.get().is_empty() {
entry.remove();
}
}
true
} else {
false
}
}
pub fn extract_largest_coins(
&mut self,
owner: Address,
asset_id: AssetId,
max_value: u128,
) -> Vec<FuelTxCoin> {
let utxo_ids: Vec<UtxoId> = {
let Some(coins) = self.account_utxos.get(&(owner, asset_id)) else {
return vec![];
};
let mut by_amount: Vec<&OrderedCoin> = coins.iter().collect();
by_amount.sort_by(|a, b| {
b.coin
.amount
.cmp(&a.coin.amount)
.then_with(|| a.coin.utxo_id.cmp(&b.coin.utxo_id))
});
let mut total = 0u128;
by_amount
.into_iter()
.take_while(|ordered| {
if total >= max_value {
return false;
}
total += ordered.coin.amount as u128;
true
})
.map(|ordered| ordered.coin.utxo_id)
.collect()
};
self.extract_utxos(&utxo_ids).unwrap_or_default()
}
}
impl UtxoProvider for UtxoManager {
fn balance_of(&self, owner: Address, asset_id: AssetId) -> u128 {
UtxoManager::balance_of(self, owner, asset_id)
}
fn len_per_address(&self, asset_id: AssetId, address: Address) -> usize {
UtxoManager::len_per_address(self, asset_id, address)
}
fn guaranteed_extract_coins(
&mut self,
owner: Address,
asset_id: AssetId,
amount: u128,
max_coins: usize,
) -> anyhow::Result<Vec<FuelTxCoin>> {
UtxoManager::guaranteed_extract_coins(self, owner, asset_id, amount, max_coins)
}
fn load_from_coins_vec(&mut self, coins: Vec<FuelTxCoin>) {
self.load_from_coins(coins.into_iter());
}
fn number_of_coins_with_amount_greater_or_equal(
&self,
owner: Address,
asset_id: AssetId,
amount: u128,
) -> (u128, usize) {
UtxoManager::number_of_coins_with_amount_greater_or_equal(
self, owner, asset_id, amount,
)
}
fn extract_largest_coins(
&mut self,
owner: Address,
asset_id: AssetId,
max_value: u128,
) -> Vec<FuelTxCoin> {
UtxoManager::extract_largest_coins(self, owner, asset_id, max_value)
}
fn coin_count(&self) -> usize {
UtxoManager::coin_count(self)
}
fn utxo_ids(&self) -> Vec<UtxoId> {
UtxoManager::utxo_ids(self)
}
fn remove_coin(&mut self, utxo_id: &UtxoId) -> bool {
UtxoManager::remove_coin(self, utxo_id)
}
fn total_balance(&self, asset_id: &AssetId) -> u128 {
UtxoManager::total_balance(self, asset_id)
}
}
#[cfg(test)]
#[allow(non_snake_case)]
mod tests {
use super::*;
#[test]
fn guaranteed_extract_coins__returns_coins_in_ascending_order_by_amount__when_coins_inserted_in_random_order()
{
let owner = Address::from([1u8; 32]);
let asset_id = AssetId::from([2u8; 32]);
let coin1 = FuelTxCoin {
amount: 100,
asset_id,
utxo_id: UtxoId::new(fuel_core_types::fuel_tx::TxId::from([1u8; 32]), 0),
owner,
};
let coin2 = FuelTxCoin {
amount: 50,
asset_id,
utxo_id: UtxoId::new(fuel_core_types::fuel_tx::TxId::from([2u8; 32]), 0),
owner,
};
let coin3 = FuelTxCoin {
amount: 200,
asset_id,
utxo_id: UtxoId::new(fuel_core_types::fuel_tx::TxId::from([3u8; 32]), 0),
owner,
};
let coin4 = FuelTxCoin {
amount: 75,
asset_id,
utxo_id: UtxoId::new(fuel_core_types::fuel_tx::TxId::from([4u8; 32]), 0),
owner,
};
let mut manager = UtxoManager::new();
manager.load_from_coins(vec![coin1, coin2, coin3, coin4].into_iter());
let total_amount = 100 + 50 + 200 + 75;
let extracted = manager
.guaranteed_extract_coins(owner, asset_id, total_amount, usize::MAX)
.unwrap();
assert_eq!(extracted.len(), 4);
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);
}
#[test]
fn extract_largest_coins__takes_largest_first_up_to_max_value() {
let owner = Address::from([1u8; 32]);
let asset_id = AssetId::from([2u8; 32]);
let coins: Vec<FuelTxCoin> = (1..=5)
.map(|i| FuelTxCoin {
amount: i * 100, asset_id,
utxo_id: UtxoId::new(
fuel_core_types::fuel_tx::TxId::from([i as u8; 32]),
0,
),
owner,
})
.collect();
let mut manager = UtxoManager::new();
manager.load_from_coins(coins.into_iter());
let extracted = manager.extract_largest_coins(owner, asset_id, 600);
let amounts: Vec<u64> = extracted.iter().map(|c| c.amount).collect();
assert_eq!(amounts.len(), 2);
assert!(amounts.contains(&500));
assert!(amounts.contains(&400));
assert_eq!(manager.balance_of(owner, asset_id), 600);
}
fn coin(amount: u64, tag: u8, owner: Address, asset_id: AssetId) -> FuelTxCoin {
FuelTxCoin {
amount,
asset_id,
utxo_id: UtxoId::new(fuel_core_types::fuel_tx::TxId::from([tag; 32]), 0),
owner,
}
}
#[test]
fn guaranteed_extract_coins__spends_oldest_batch_first__even_when_newer_coin_is_smaller()
{
let owner = Address::from([1u8; 32]);
let asset_id = AssetId::from([2u8; 32]);
let old_large = coin(1_000, 1, owner, asset_id);
let new_small = coin(10, 2, owner, asset_id);
let mut manager = UtxoManager::new();
manager.load_from_coins(vec![old_large].into_iter()); manager.load_from_coins(vec![new_small].into_iter());
let extracted = manager
.guaranteed_extract_coins(owner, asset_id, 5, usize::MAX)
.unwrap();
assert_eq!(extracted.len(), 1);
assert_eq!(extracted[0].utxo_id, old_large.utxo_id);
assert_eq!(manager.balance_of(owner, asset_id), 10);
}
#[test]
fn guaranteed_extract_coins__prefers_old_small_coin_over_new_large_coin() {
let owner = Address::from([1u8; 32]);
let asset_id = AssetId::from([2u8; 32]);
let old_small = coin(100, 1, owner, asset_id);
let new_large = coin(900, 2, owner, asset_id);
let mut manager = UtxoManager::new();
manager.load_from_coins(vec![old_small].into_iter()); manager.load_from_coins(vec![new_large].into_iter());
let extracted = manager
.guaranteed_extract_coins(owner, asset_id, 100, usize::MAX)
.unwrap();
assert_eq!(extracted.len(), 1);
assert_eq!(extracted[0].utxo_id, old_small.utxo_id);
}
#[test]
fn load_from_coins__breaks_ties_within_a_batch_by_amount() {
let owner = Address::from([1u8; 32]);
let asset_id = AssetId::from([2u8; 32]);
let big = coin(300, 1, owner, asset_id);
let small = coin(100, 2, owner, asset_id);
let mid = coin(200, 3, owner, asset_id);
let mut manager = UtxoManager::new();
manager.load_from_coins(vec![big, small, mid].into_iter());
let extracted = manager
.guaranteed_extract_coins(owner, asset_id, 50, usize::MAX)
.unwrap();
assert_eq!(extracted.len(), 1);
assert_eq!(extracted[0].utxo_id, small.utxo_id);
}
#[test]
fn load_from_coins__re_adding_existing_coin_keeps_its_queue_position() {
let owner = Address::from([1u8; 32]);
let asset_id = AssetId::from([2u8; 32]);
let old = coin(100, 1, owner, asset_id);
let new = coin(100, 2, owner, asset_id);
let mut manager = UtxoManager::new();
manager.load_from_coins(vec![old].into_iter()); manager.load_from_coins(vec![new].into_iter()); manager.load_from_coins(vec![old].into_iter());
assert_eq!(manager.coin_count(), 2);
let extracted = manager
.guaranteed_extract_coins(owner, asset_id, 100, usize::MAX)
.unwrap();
assert_eq!(extracted.len(), 1);
assert_eq!(extracted[0].utxo_id, old.utxo_id);
}
#[test]
fn extract_largest_coins__still_takes_largest_first__across_batches() {
let owner = Address::from([1u8; 32]);
let asset_id = AssetId::from([2u8; 32]);
let old_small = coin(100, 1, owner, asset_id);
let new_large = coin(900, 2, owner, asset_id);
let mut manager = UtxoManager::new();
manager.load_from_coins(vec![old_small].into_iter()); manager.load_from_coins(vec![new_large].into_iter());
let extracted = manager.extract_largest_coins(owner, asset_id, 500);
assert_eq!(extracted.len(), 1);
assert_eq!(extracted[0].utxo_id, new_large.utxo_id);
}
#[test]
fn guaranteed_extract_coins__never_returns_more_than_max_coins() {
let owner = Address::from([1u8; 32]);
let asset_id = AssetId::from([2u8; 32]);
let coins: Vec<FuelTxCoin> =
(1..=5).map(|i| coin(100, i, owner, asset_id)).collect();
let mut manager = UtxoManager::new();
manager.load_from_coins(coins.into_iter());
let result = manager.guaranteed_extract_coins(owner, asset_id, 400, 2);
assert!(result.is_err());
assert_eq!(manager.coin_count(), 5);
}
#[test]
fn guaranteed_extract_coins__falls_back_to_largest_when_fifo_cannot_meet_target_within_cap()
{
let owner = Address::from([1u8; 32]);
let asset_id = AssetId::from([2u8; 32]);
let dust: Vec<FuelTxCoin> =
(1..=5).map(|i| coin(10, i, owner, asset_id)).collect();
let big = coin(1_000, 100, owner, asset_id);
let mut manager = UtxoManager::new();
manager.load_from_coins(dust.into_iter()); manager.load_from_coins(vec![big].into_iter());
let extracted = manager
.guaranteed_extract_coins(owner, asset_id, 500, 2)
.unwrap();
assert_eq!(extracted.len(), 1);
assert_eq!(extracted[0].utxo_id, big.utxo_id);
}
#[test]
fn guaranteed_extract_coins__errors_when_neither_strategy_can_cover_target() {
let owner = Address::from([1u8; 32]);
let asset_id = AssetId::from([2u8; 32]);
let small = coin(100, 1, owner, asset_id);
let mut manager = UtxoManager::new();
manager.load_from_coins(vec![small].into_iter());
let result = manager.guaranteed_extract_coins(owner, asset_id, 1_000, usize::MAX);
assert!(result.is_err());
assert_eq!(manager.coin_count(), 1);
}
#[test]
fn extract_largest_coins__returns_empty_when_no_coins() {
let owner = Address::from([1u8; 32]);
let asset_id = AssetId::from([2u8; 32]);
let mut manager = UtxoManager::new();
let extracted = manager.extract_largest_coins(owner, asset_id, 1000);
assert!(extracted.is_empty());
}
#[test]
fn extract_largest_coins__extracts_single_coin_when_only_one() {
let owner = Address::from([1u8; 32]);
let asset_id = AssetId::from([2u8; 32]);
let coin = FuelTxCoin {
amount: 1_000_000,
asset_id,
utxo_id: UtxoId::new(fuel_core_types::fuel_tx::TxId::from([1u8; 32]), 0),
owner,
};
let mut manager = UtxoManager::new();
manager.load_from_coins(vec![coin].into_iter());
let extracted = manager.extract_largest_coins(owner, asset_id, 500_000);
assert_eq!(extracted.len(), 1);
assert_eq!(extracted[0].amount, 1_000_000);
assert_eq!(manager.balance_of(owner, asset_id), 0);
}
}