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, 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))
}
}
pub type SharedUtxoManager = Arc<tokio::sync::Mutex<UtxoManager>>;
pub struct UtxoManager {
account_utxos: HashMap<(Address, AssetId), BTreeSet<FuelTxCoin>>,
coins: HashMap<UtxoId, FuelTxCoin>,
}
impl Default for UtxoManager {
fn default() -> Self {
Self::new()
}
}
impl UtxoManager {
pub fn new() -> Self {
Self {
account_utxos: HashMap::new(),
coins: HashMap::new(),
}
}
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>,
{
for coin in coins {
if coin.amount == 0 {
continue;
}
let key = (coin.owner, coin.asset_id);
self.account_utxos.entry(key).or_default().insert(coin);
self.coins.insert(coin.utxo_id, coin);
}
}
fn extract_utxos(&mut self, utxos: &[UtxoId]) -> anyhow::Result<Vec<FuelTxCoin>> {
let mut coins = vec![];
for utxo_id in utxos {
let coin = self.coins.remove(utxo_id).ok_or_else(|| {
anyhow::anyhow!("UTXO {utxo_id} not found in the UTXO manager")
})?;
let key = (coin.owner, coin.asset_id);
let account = self.account_utxos.entry(key);
match account {
Entry::Occupied(mut occupied) => {
occupied.get_mut().remove(&coin);
if occupied.get().is_empty() {
occupied.remove();
}
coins.push(coin);
}
Entry::Vacant(_) => {}
}
}
Ok(coins)
}
fn utxos_for(
&self,
owner: Address,
asset_id: AssetId,
amount: u128,
fail_if_not_enough: bool,
) -> anyhow::Result<Vec<UtxoId>> {
let Some(coins) = self.account_utxos.get(&(owner, asset_id)) else {
return Err(anyhow::anyhow!(
"No UTXOs found for the given {owner} and {asset_id}"
));
};
let mut total_amount = 0;
let mut utxos_to_remove = vec![];
for coin in coins.iter() {
if total_amount >= amount {
break;
}
utxos_to_remove.push(coin.utxo_id);
total_amount += coin.amount as u128;
}
if fail_if_not_enough && total_amount < amount {
return Err(anyhow::anyhow!(
"Not enough UTXOs({total_amount}) found \
for the given {owner} and {asset_id} to cover {amount}."
));
}
Ok(utxos_to_remove)
}
pub fn guaranteed_extract_coins(
&mut self,
owner: Address,
asset_id: AssetId,
amount: u128,
) -> anyhow::Result<Vec<FuelTxCoin>> {
let utxos = self.utxos_for(owner, asset_id, amount, true)?;
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 coin in coins.iter() {
if coin.amount as u128 >= amount {
count += 1;
total_balance += 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(|coin| coin.amount as u128).sum()
})
}
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 total = 0u128;
coins
.iter()
.rev() .take_while(|coin| {
if total >= max_value {
return false;
}
total += coin.amount as u128;
true
})
.map(|coin| coin.utxo_id)
.collect()
};
self.extract_utxos(&utxo_ids).unwrap_or_default()
}
}
#[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)
.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);
}
#[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);
}
}