diff --git a/CHANGELOG.md b/CHANGELOG.md index 363a90c3463..a6c46e737fd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -53,6 +53,8 @@ and this project adheres to [Semantic Versioning](http://semver.org/). - [2429](https://github.com/FuelLabs/fuel-core/pull/2429): Introduce custom enum for representing result of running service tasks - [2377](https://github.com/FuelLabs/fuel-core/pull/2377): Add more errors that can be returned as responses when using protocol `/fuel/req_res/0.0.2`. The errors supported are `ProtocolV1EmptyResponse` (status code `0`) for converting empty responses sent via protocol `/fuel/req_res/0.0.1`, `RequestedRangeTooLarge`(status code `1`) if the client requests a range of objects such as sealed block headers or transactions too large, `Timeout` (status code `2`) if the remote peer takes too long to fulfill a request, or `SyncProcessorOutOfCapacity` if the remote peer is fulfilling too many requests concurrently. - [2233](https://github.com/FuelLabs/fuel-core/pull/2233): Introduce a new column `modification_history_v2` for storing the modification history in the historical rocksDB. Keys in this column are stored in big endian order. Changed the behaviour of the historical rocksDB to write changes for new block heights to the new column, and to perform lookup of values from the `modification_history_v2` table first, and then from the `modification_history` table, performing a migration upon access if necessary. +- [2383](https://github.com/FuelLabs/fuel-core/pull/2383): The `balance` and `balances` GraphQL query handlers now use index to provide the response in a more performant way. As the index is not created retroactively, the client must be initialized with an empty database and synced from the genesis block to utilize it. Otherwise, the legacy way of retrieving data will be used. +- [2463](https://github.com/FuelLabs/fuel-core/pull/2463): The `coinsToSpend` GraphQL query handler now uses index to provide the response in a more performant way. As the index is not created retroactively, the client must be initialized with an empty database and synced from the genesis block to utilize it. Otherwise, the legacy way of retrieving data will be used. #### Breaking - [2438](https://github.com/FuelLabs/fuel-core/pull/2438): The `fuel-core-client` can only work with new version of the `fuel-core`. The `0.40` and all older versions are not supported. @@ -61,6 +63,9 @@ and this project adheres to [Semantic Versioning](http://semver.org/). - [2154](https://github.com/FuelLabs/fuel-core/pull/2154): Transaction graphql endpoints use `TransactionType` instead of `fuel_tx::Transaction`. - [2446](https://github.com/FuelLabs/fuel-core/pull/2446): Use graphiql instead of graphql-playground due to known vulnerability and stale development. - [2379](https://github.com/FuelLabs/fuel-core/issues/2379): Change `kv_store::Value` to be `Arc<[u8]>` instead of `Arc>`. +- [2463](https://github.com/FuelLabs/fuel-core/pull/2463): 'CoinsQueryError::MaxCoinsReached` variant has been removed. The `InsufficientCoins` variant has been renamed to `InsufficientCoinsForTheMax` and it now contains the additional `max` field +- [2463](https://github.com/FuelLabs/fuel-core/pull/2463): The number of excluded ids in the `coinsToSpend` GraphQL query is now limited to the maximum number of inputs allowed in transaction. +- [2463](https://github.com/FuelLabs/fuel-core/pull/2463): The `coinsToSpend` GraphQL query may now return different coins, depending whether the indexation is enabled or not. However, regardless of the differences, the returned coins will accurately reflect the current state of the database within the context of the query. - [2526](https://github.com/FuelLabs/fuel-core/pull/2526): By default the cache of RocksDB is now disabled instead of being `1024 * 1024 * 1024`. ## [Version 0.40.2] diff --git a/crates/chain-config/src/config/chain.rs b/crates/chain-config/src/config/chain.rs index b8a204e584f..fe972bd97f7 100644 --- a/crates/chain-config/src/config/chain.rs +++ b/crates/chain-config/src/config/chain.rs @@ -123,6 +123,15 @@ impl ChainConfig { ..Default::default() } } + + #[cfg(feature = "test-helpers")] + pub fn local_testnet_with_consensus_parameters(cp: &ConsensusParameters) -> Self { + Self { + chain_name: LOCAL_TESTNET.to_string(), + consensus_parameters: cp.clone(), + ..Default::default() + } + } } impl GenesisCommitment for ChainConfig { diff --git a/crates/client/src/client/schema/coins.rs b/crates/client/src/client/schema/coins.rs index c8ff7cb238e..00d84bd8dc8 100644 --- a/crates/client/src/client/schema/coins.rs +++ b/crates/client/src/client/schema/coins.rs @@ -144,7 +144,7 @@ impl From<(Vec, Vec)> for ExcludeInput { pub struct SpendQueryElementInput { /// asset ID of the coins pub asset_id: AssetId, - /// address of the owner + /// the amount to cover with this asset pub amount: U64, /// the maximum number of coins per asset from the owner to return. pub max: Option, diff --git a/crates/client/src/client/types/balance.rs b/crates/client/src/client/types/balance.rs index 5afc79a470f..0cb5f6929b1 100644 --- a/crates/client/src/client/types/balance.rs +++ b/crates/client/src/client/types/balance.rs @@ -22,7 +22,7 @@ impl From for Balance { owner: value.owner.into(), amount: { let amount: u64 = value.amount.into(); - amount as u128 + u128::from(amount) }, asset_id: value.asset_id.into(), } diff --git a/crates/fuel-core/src/coins_query.rs b/crates/fuel-core/src/coins_query.rs index cc93928b5ed..4eea9bc0336 100644 --- a/crates/fuel-core/src/coins_query.rs +++ b/crates/fuel-core/src/coins_query.rs @@ -1,5 +1,12 @@ use crate::{ fuel_core_graphql_api::database::ReadView, + graphql_api::{ + ports::CoinsToSpendIndexIter, + storage::coins::{ + CoinsToSpendIndexEntry, + IndexedCoinType, + }, + }, query::asset_query::{ AssetQuery, AssetSpendTarget, @@ -7,23 +14,34 @@ use crate::{ }, }; use core::mem::swap; -use fuel_core_storage::Error as StorageError; +use fuel_core_services::yield_stream::StreamYieldExt; +use fuel_core_storage::{ + Error as StorageError, + Result as StorageResult, +}; use fuel_core_types::{ entities::coins::{ CoinId, CoinType, }, + fuel_tx::UtxoId, fuel_types::{ Address, AssetId, + Nonce, Word, }, }; -use futures::TryStreamExt; +use futures::{ + Stream, + StreamExt, + TryStreamExt, +}; use rand::prelude::*; use std::{ cmp::Reverse, collections::HashSet, + ops::Deref, }; use thiserror::Error; @@ -31,15 +49,33 @@ use thiserror::Error; pub enum CoinsQueryError { #[error("store error occurred: {0}")] StorageError(StorageError), - #[error("not enough coins to fit the target")] - InsufficientCoins { + #[error("the target cannot be met due to no coins available or exceeding the {max} coin limit.")] + InsufficientCoinsForTheMax { asset_id: AssetId, collected_amount: Word, + max: u16, }, - #[error("max number of coins is reached while trying to fit the target")] - MaxCoinsReached, #[error("the query contains duplicate assets")] DuplicateAssets(AssetId), + #[error( + "too many excluded ids: provided ({provided}) is > than allowed ({allowed})" + )] + TooManyExcludedId { provided: usize, allowed: u16 }, + #[error("the query requires more coins than the max allowed coins: required ({required}) > max ({max})")] + TooManyCoinsSelected { required: usize, max: u16 }, + #[error("coins to spend index entry contains wrong coin foreign key")] + IncorrectCoinForeignKeyInIndex, + #[error("coins to spend index entry contains wrong message foreign key")] + IncorrectMessageForeignKeyInIndex, + #[error("error while processing the query: {0}")] + UnexpectedInternalState(&'static str), + #[error("both total and max must be greater than 0 (provided total: {provided_total}, provided max: {provided_max})")] + IncorrectQueryParameters { + provided_total: u64, + provided_max: u16, + }, + #[error("coins to spend index contains incorrect key")] + IncorrectCoinsToSpendIndexKey, } #[cfg(test)] @@ -49,6 +85,31 @@ impl PartialEq for CoinsQueryError { } } +pub struct ExcludedCoinIds<'a> { + coins: HashSet<&'a UtxoId>, + messages: HashSet<&'a Nonce>, +} + +impl<'a> ExcludedCoinIds<'a> { + pub(crate) fn new( + coins: impl Iterator, + messages: impl Iterator, + ) -> Self { + Self { + coins: coins.collect(), + messages: messages.collect(), + } + } + + pub(crate) fn is_coin_excluded(&self, coin: &UtxoId) -> bool { + self.coins.contains(&coin) + } + + pub(crate) fn is_message_excluded(&self, message: &Nonce) -> bool { + self.messages.contains(&message) + } +} + /// The prepared spend queries. pub struct SpendQuery { owner: Address, @@ -66,14 +127,6 @@ impl SpendQuery { exclude_vec: Option>, base_asset_id: AssetId, ) -> Result { - let mut duplicate_checker = HashSet::with_capacity(query_per_asset.len()); - - for query in query_per_asset { - if !duplicate_checker.insert(query.id) { - return Err(CoinsQueryError::DuplicateAssets(query.id)); - } - } - let exclude = exclude_vec.map_or_else(Default::default, Exclude::new); Ok(Self { @@ -139,7 +192,11 @@ pub async fn largest_first( // Error if we can't fit more coins if coins.len() >= max as usize { - return Err(CoinsQueryError::MaxCoinsReached) + return Err(CoinsQueryError::InsufficientCoinsForTheMax { + asset_id, + collected_amount, + max, + }) } // Add to list @@ -148,9 +205,10 @@ pub async fn largest_first( } if collected_amount < target { - return Err(CoinsQueryError::InsufficientCoins { + return Err(CoinsQueryError::InsufficientCoinsForTheMax { asset_id, collected_amount, + max, }) } @@ -158,13 +216,6 @@ pub async fn largest_first( } // An implementation of the method described on: https://iohk.io/en/blog/posts/2018/07/03/self-organisation-in-coin-selection/ -// TODO: Reimplement this algorithm to be simpler and faster: -// Instead of selecting random coins first, we can sort them. -// After that, we can split the coins into the part that covers the -// target and the part that does not(by choosing the most expensive coins). -// When the target is satisfied, we can select random coins from the remaining -// coins not used in the target. -// https://github.com/FuelLabs/fuel-core/issues/1965 pub async fn random_improve( db: &ReadView, spend_query: &SpendQuery, @@ -221,6 +272,227 @@ pub async fn random_improve( Ok(coins_per_asset) } +pub async fn select_coins_to_spend( + CoinsToSpendIndexIter { + big_coins_iter, + dust_coins_iter, + }: CoinsToSpendIndexIter<'_>, + total: u64, + max: u16, + asset_id: &AssetId, + excluded_ids: &ExcludedCoinIds<'_>, + batch_size: usize, +) -> Result, CoinsQueryError> { + // We aim to reduce dust creation by targeting twice the required amount for selection, + // inspired by the random-improve approach. This increases the likelihood of generating + // useful change outputs for future transactions, minimizing unusable dust outputs. + // See also "let upper_target = target.saturating_mul(2);" in "fn random_improve()". + const TOTAL_AMOUNT_ADJUSTMENT_FACTOR: u64 = 2; + + if total == 0 || max == 0 { + return Err(CoinsQueryError::IncorrectQueryParameters { + provided_total: total, + provided_max: max, + }); + } + + let adjusted_total = total.saturating_mul(TOTAL_AMOUNT_ADJUSTMENT_FACTOR); + + let big_coins_stream = futures::stream::iter(big_coins_iter).yield_each(batch_size); + let dust_coins_stream = futures::stream::iter(dust_coins_iter).yield_each(batch_size); + + let (selected_big_coins_total, selected_big_coins) = + big_coins(big_coins_stream, adjusted_total, max, excluded_ids).await?; + + if selected_big_coins_total < total { + return Err(CoinsQueryError::InsufficientCoinsForTheMax { + asset_id: *asset_id, + collected_amount: selected_big_coins_total, + max, + }); + } + + let Some(last_selected_big_coin) = selected_big_coins.last() else { + // Should never happen, because at this stage we know that: + // 1) selected_big_coins_total >= total + // 2) total > 0 + // hence: selected_big_coins_total > 0 + // therefore, at least one coin is selected - if not, it's a bug + return Err(CoinsQueryError::UnexpectedInternalState( + "at least one coin should be selected", + )); + }; + + let selected_big_coins_len = selected_big_coins.len(); + let number_of_big_coins: u16 = selected_big_coins_len.try_into().map_err(|_| { + CoinsQueryError::TooManyCoinsSelected { + required: selected_big_coins_len, + max: u16::MAX, + } + })?; + + let max_dust_count = max_dust_count(max, number_of_big_coins); + let (dust_coins_total, selected_dust_coins) = dust_coins( + dust_coins_stream, + last_selected_big_coin, + max_dust_count, + excluded_ids, + ) + .await?; + + let retained_big_coins_iter = + skip_big_coins_up_to_amount(selected_big_coins, dust_coins_total); + + Ok((retained_big_coins_iter + .map(Into::into) + .chain(selected_dust_coins)) + .collect()) +} + +// This is the `CoinsToSpendIndexEntry` which is guaranteed to have a key +// which allows to properly decode the amount. +struct CheckedCoinsToSpendIndexEntry { + inner: CoinsToSpendIndexEntry, + amount: u64, +} + +impl TryFrom for CheckedCoinsToSpendIndexEntry { + type Error = CoinsQueryError; + + fn try_from(value: CoinsToSpendIndexEntry) -> Result { + let amount = value + .0 + .amount() + .ok_or(CoinsQueryError::IncorrectCoinsToSpendIndexKey)?; + Ok(Self { + inner: value, + amount, + }) + } +} + +impl From for CoinsToSpendIndexEntry { + fn from(value: CheckedCoinsToSpendIndexEntry) -> Self { + value.inner + } +} + +impl Deref for CheckedCoinsToSpendIndexEntry { + type Target = CoinsToSpendIndexEntry; + + fn deref(&self) -> &Self::Target { + &self.inner + } +} + +async fn big_coins( + big_coins_stream: impl Stream> + Unpin, + total: u64, + max: u16, + excluded_ids: &ExcludedCoinIds<'_>, +) -> Result<(u64, Vec), CoinsQueryError> { + select_coins_until( + big_coins_stream, + max, + excluded_ids, + |_, total_so_far| total_so_far >= total, + CheckedCoinsToSpendIndexEntry::try_from, + ) + .await +} + +async fn dust_coins( + dust_coins_stream: impl Stream> + Unpin, + last_big_coin: &CoinsToSpendIndexEntry, + max_dust_count: u16, + excluded_ids: &ExcludedCoinIds<'_>, +) -> Result<(u64, Vec), CoinsQueryError> { + select_coins_until( + dust_coins_stream, + max_dust_count, + excluded_ids, + |coin, _| coin == last_big_coin, + Ok::, + ) + .await +} + +async fn select_coins_until( + mut coins_stream: impl Stream> + Unpin, + max: u16, + excluded_ids: &ExcludedCoinIds<'_>, + predicate: Pred, + mapper: Mapper, +) -> Result<(u64, Vec), CoinsQueryError> +where + Pred: Fn(&CoinsToSpendIndexEntry, u64) -> bool, + Mapper: Fn(CoinsToSpendIndexEntry) -> Result, + E: From, +{ + let mut coins_total_value: u64 = 0; + let mut coins = Vec::with_capacity(max as usize); + while let Some(coin) = coins_stream.next().await { + let coin = coin?; + if !is_excluded(&coin, excluded_ids)? { + if coins.len() >= max as usize || predicate(&coin, coins_total_value) { + break; + } + let amount = coin + .0 + .amount() + .ok_or(CoinsQueryError::IncorrectCoinsToSpendIndexKey)?; + coins_total_value = coins_total_value.saturating_add(amount); + coins.push( + mapper(coin) + .map_err(|_| CoinsQueryError::IncorrectCoinsToSpendIndexKey)?, + ); + } + } + Ok((coins_total_value, coins)) +} + +fn is_excluded( + (key, coin_type): &CoinsToSpendIndexEntry, + excluded_ids: &ExcludedCoinIds, +) -> Result { + match coin_type { + IndexedCoinType::Coin => { + let utxo = key + .try_into() + .map_err(|_| CoinsQueryError::IncorrectCoinForeignKeyInIndex)?; + Ok(excluded_ids.is_coin_excluded(&utxo)) + } + IndexedCoinType::Message => { + let nonce = key + .try_into() + .map_err(|_| CoinsQueryError::IncorrectMessageForeignKeyInIndex)?; + Ok(excluded_ids.is_message_excluded(&nonce)) + } + } +} + +fn max_dust_count(max: u16, big_coins_len: u16) -> u16 { + let mut rng = rand::thread_rng(); + rng.gen_range(0..=max.saturating_sub(big_coins_len)) +} + +fn skip_big_coins_up_to_amount( + big_coins: impl IntoIterator, + skipped_amount: u64, +) -> impl Iterator { + let mut current_dust_coins_value = skipped_amount; + big_coins.into_iter().skip_while(move |item| { + let item_amount = item.amount; + current_dust_coins_value + .checked_sub(item_amount) + .map(|new_value| { + current_dust_coins_value = new_value; + true + }) + .unwrap_or(false) + }) +} + impl From for CoinsQueryError { fn from(e: StorageError) -> Self { CoinsQueryError::StorageError(e) @@ -428,9 +700,10 @@ mod tests { _ => { assert_matches!( coins, - Err(CoinsQueryError::InsufficientCoins { + Err(CoinsQueryError::InsufficientCoinsForTheMax { asset_id: _, collected_amount: 15, + max: u16::MAX }) ) } @@ -445,7 +718,10 @@ mod tests { &db.service_database(), ) .await; - assert_matches!(coins, Err(CoinsQueryError::MaxCoinsReached)); + assert_matches!( + coins, + Err(CoinsQueryError::InsufficientCoinsForTheMax { .. }) + ); } #[tokio::test] @@ -592,9 +868,10 @@ mod tests { _ => { assert_matches!( coins, - Err(CoinsQueryError::InsufficientCoins { + Err(CoinsQueryError::InsufficientCoinsForTheMax { asset_id: _, collected_amount: 15, + max: u16::MAX }) ) } @@ -613,7 +890,10 @@ mod tests { &db.service_database(), ) .await; - assert_matches!(coins, Err(CoinsQueryError::MaxCoinsReached)); + assert_matches!( + coins, + Err(CoinsQueryError::InsufficientCoinsForTheMax { .. }) + ); } #[tokio::test] @@ -787,9 +1067,10 @@ mod tests { _ => { assert_matches!( coins, - Err(CoinsQueryError::InsufficientCoins { + Err(CoinsQueryError::InsufficientCoinsForTheMax { asset_id: _, collected_amount: 10, + max: u16::MAX }) ) } @@ -850,6 +1131,411 @@ mod tests { } } + mod indexed_coins_to_spend { + use fuel_core_storage::iter::IntoBoxedIter; + use fuel_core_types::{ + entities::coins::coin::Coin, + fuel_tx::{ + AssetId, + TxId, + UtxoId, + Word, + }, + }; + + use crate::{ + coins_query::{ + select_coins_to_spend, + select_coins_until, + CoinsQueryError, + CoinsToSpendIndexEntry, + ExcludedCoinIds, + }, + graphql_api::{ + ports::CoinsToSpendIndexIter, + storage::coins::{ + CoinsToSpendIndexKey, + IndexedCoinType, + }, + }, + }; + + const BATCH_SIZE: usize = 1; + + struct TestCoinSpec { + index_entry: Result, + utxo_id: UtxoId, + } + + fn setup_test_coins(coins: impl IntoIterator) -> Vec { + coins + .into_iter() + .map(|i| { + let tx_id: TxId = [i; 32].into(); + let output_index = i as u16; + let utxo_id = UtxoId::new(tx_id, output_index); + + let coin = Coin { + utxo_id, + owner: Default::default(), + amount: i as u64, + asset_id: Default::default(), + tx_pointer: Default::default(), + }; + + TestCoinSpec { + index_entry: Ok(( + CoinsToSpendIndexKey::from_coin(&coin), + IndexedCoinType::Coin, + )), + utxo_id, + } + }) + .collect() + } + + #[tokio::test] + async fn select_coins_until_respects_max() { + // Given + const MAX: u16 = 3; + + let coins = setup_test_coins([1, 2, 3, 4, 5]); + let (coins, _): (Vec<_>, Vec<_>) = coins + .into_iter() + .map(|spec| (spec.index_entry, spec.utxo_id)) + .unzip(); + + let excluded = ExcludedCoinIds::new(std::iter::empty(), std::iter::empty()); + + // When + let result = select_coins_until( + futures::stream::iter(coins), + MAX, + &excluded, + |_, _| false, + Ok::, + ) + .await + .expect("should select coins"); + + // Then + assert_eq!(result.0, 1 + 2 + 3); // Limit is set at 3 coins + assert_eq!(result.1.len(), 3); + } + + #[tokio::test] + async fn select_coins_until_respects_excluded_ids() { + // Given + const MAX: u16 = u16::MAX; + + let coins = setup_test_coins([1, 2, 3, 4, 5]); + let (coins, utxo_ids): (Vec<_>, Vec<_>) = coins + .into_iter() + .map(|spec| (spec.index_entry, spec.utxo_id)) + .unzip(); + + // Exclude coin with amount '2'. + let utxo_id = utxo_ids[1]; + let excluded = + ExcludedCoinIds::new(std::iter::once(&utxo_id), std::iter::empty()); + + // When + let result = select_coins_until( + futures::stream::iter(coins), + MAX, + &excluded, + |_, _| false, + Ok::, + ) + .await + .expect("should select coins"); + + // Then + assert_eq!(result.0, 1 + 3 + 4 + 5); // '2' is skipped. + assert_eq!(result.1.len(), 4); + } + + #[tokio::test] + async fn select_coins_until_respects_predicate() { + // Given + const MAX: u16 = u16::MAX; + const TOTAL: u64 = 7; + + let coins = setup_test_coins([1, 2, 3, 4, 5]); + let (coins, _): (Vec<_>, Vec<_>) = coins + .into_iter() + .map(|spec| (spec.index_entry, spec.utxo_id)) + .unzip(); + + let excluded = ExcludedCoinIds::new(std::iter::empty(), std::iter::empty()); + + let predicate: fn(&CoinsToSpendIndexEntry, u64) -> bool = + |_, total| total > TOTAL; + + // When + let result = select_coins_until( + futures::stream::iter(coins), + MAX, + &excluded, + predicate, + Ok::, + ) + .await + .expect("should select coins"); + + // Then + assert_eq!(result.0, 1 + 2 + 3 + 4); // Keep selecting until total is greater than 7. + assert_eq!(result.1.len(), 4); + } + + #[tokio::test] + async fn already_selected_big_coins_are_never_reselected_as_dust() { + // Given + const MAX: u16 = u16::MAX; + const TOTAL: u64 = 101; + + let test_coins = [100, 100, 4, 3, 2]; + let big_coins_iter = setup_test_coins(test_coins) + .into_iter() + .map(|spec| spec.index_entry) + .into_boxed(); + + let dust_coins_iter = setup_test_coins(test_coins) + .into_iter() + .rev() + .map(|spec| spec.index_entry) + .into_boxed(); + + let coins_to_spend_iter = CoinsToSpendIndexIter { + big_coins_iter, + dust_coins_iter, + }; + + let excluded = ExcludedCoinIds::new(std::iter::empty(), std::iter::empty()); + + // When + let result = select_coins_to_spend( + coins_to_spend_iter, + TOTAL, + MAX, + &AssetId::default(), + &excluded, + BATCH_SIZE, + ) + .await + .expect("should not error"); + + let mut results = result + .into_iter() + .map(|(key, _)| key.amount()) + .collect::>(); + + // Then + + // Because we select a total of 202 (TOTAL * 2), first 3 coins should always selected (100, 100, 4). + let expected = vec![100, 100, 4]; + let actual: Vec<_> = results.drain(..3).map(Option::unwrap).collect(); + assert_eq!(expected, actual); + + // The number of dust coins is selected randomly, so we might have: + // - 0 dust coins + // - 1 dust coin [2] + // - 2 dust coins [2, 3] + // Even though in majority of cases we will have 2 dust coins selected (due to + // MAX being huge), we can't guarantee that, hence we assert against all possible cases. + // The important fact is that neither 100 nor 4 are selected as dust coins. + let expected_1: Vec = vec![]; + let expected_2: Vec = vec![2]; + let expected_3: Vec = vec![2, 3]; + let actual: Vec<_> = results.drain(..).map(Option::unwrap).collect(); + + assert!( + actual == expected_1 || actual == expected_2 || actual == expected_3, + "Unexpected dust coins: {:?}", + actual, + ); + } + + #[tokio::test] + async fn selects_double_the_value_of_coins() { + // Given + const MAX: u16 = u16::MAX; + const TOTAL: u64 = 10; + + let coins = setup_test_coins([10, 10, 9, 8, 7]); + let (coins, _): (Vec<_>, Vec<_>) = coins + .into_iter() + .map(|spec| (spec.index_entry, spec.utxo_id)) + .unzip(); + + let excluded = ExcludedCoinIds::new(std::iter::empty(), std::iter::empty()); + + let coins_to_spend_iter = CoinsToSpendIndexIter { + big_coins_iter: coins.into_iter().into_boxed(), + dust_coins_iter: std::iter::empty().into_boxed(), + }; + + // When + let result = select_coins_to_spend( + coins_to_spend_iter, + TOTAL, + MAX, + &AssetId::default(), + &excluded, + BATCH_SIZE, + ) + .await + .expect("should not error"); + + // Then + let results: Vec<_> = result + .into_iter() + .map(|(key, _)| key.amount().unwrap()) + .collect(); + assert_eq!(results, vec![10, 10]); + } + + #[tokio::test] + async fn selection_algorithm_should_bail_on_storage_error() { + // Given + const MAX: u16 = u16::MAX; + const TOTAL: u64 = 101; + + let coins = setup_test_coins([10, 9, 8, 7]); + let (mut coins, _): (Vec<_>, Vec<_>) = coins + .into_iter() + .map(|spec| (spec.index_entry, spec.utxo_id)) + .unzip(); + let error = fuel_core_storage::Error::NotFound("S1", "S2"); + + let first_2: Vec<_> = coins.drain(..2).collect(); + let last_2: Vec<_> = std::mem::take(&mut coins); + + let excluded = ExcludedCoinIds::new(std::iter::empty(), std::iter::empty()); + + // Inject an error into the middle of coins. + let coins: Vec<_> = first_2 + .into_iter() + .take(2) + .chain(std::iter::once(Err(error))) + .chain(last_2) + .collect(); + let coins_to_spend_iter = CoinsToSpendIndexIter { + big_coins_iter: coins.into_iter().into_boxed(), + dust_coins_iter: std::iter::empty().into_boxed(), + }; + + // When + let result = select_coins_to_spend( + coins_to_spend_iter, + TOTAL, + MAX, + &AssetId::default(), + &excluded, + BATCH_SIZE, + ) + .await; + + // Then + assert!(matches!(result, Err(actual_error) + if CoinsQueryError::StorageError(fuel_core_storage::Error::NotFound("S1", "S2")) == actual_error)); + } + + #[tokio::test] + async fn selection_algorithm_should_bail_on_incorrect_max() { + // Given + const MAX: u16 = 0; + const TOTAL: u64 = 101; + + let excluded = ExcludedCoinIds::new(std::iter::empty(), std::iter::empty()); + + let coins_to_spend_iter = CoinsToSpendIndexIter { + big_coins_iter: std::iter::empty().into_boxed(), + dust_coins_iter: std::iter::empty().into_boxed(), + }; + + let result = select_coins_to_spend( + coins_to_spend_iter, + TOTAL, + MAX, + &AssetId::default(), + &excluded, + BATCH_SIZE, + ) + .await; + + // Then + assert!(matches!(result, Err(actual_error) + if CoinsQueryError::IncorrectQueryParameters{ provided_total: 101, provided_max: 0 } == actual_error)); + } + + #[tokio::test] + async fn selection_algorithm_should_bail_on_incorrect_total() { + // Given + const MAX: u16 = 101; + const TOTAL: u64 = 0; + + let excluded = ExcludedCoinIds::new(std::iter::empty(), std::iter::empty()); + + let coins_to_spend_iter = CoinsToSpendIndexIter { + big_coins_iter: std::iter::empty().into_boxed(), + dust_coins_iter: std::iter::empty().into_boxed(), + }; + + let result = select_coins_to_spend( + coins_to_spend_iter, + TOTAL, + MAX, + &AssetId::default(), + &excluded, + BATCH_SIZE, + ) + .await; + + // Then + assert!(matches!(result, Err(actual_error) + if CoinsQueryError::IncorrectQueryParameters{ provided_total: 0, provided_max: 101 } == actual_error)); + } + + #[tokio::test] + async fn selection_algorithm_should_bail_on_not_enough_coins() { + // Given + const MAX: u16 = 3; + const TOTAL: u64 = 2137; + + let coins = setup_test_coins([10, 9, 8, 7]); + let (coins, _): (Vec<_>, Vec<_>) = coins + .into_iter() + .map(|spec| (spec.index_entry, spec.utxo_id)) + .unzip(); + + let excluded = ExcludedCoinIds::new(std::iter::empty(), std::iter::empty()); + + let coins_to_spend_iter = CoinsToSpendIndexIter { + big_coins_iter: coins.into_iter().into_boxed(), + dust_coins_iter: std::iter::empty().into_boxed(), + }; + + let asset_id = AssetId::default(); + + let result = select_coins_to_spend( + coins_to_spend_iter, + TOTAL, + MAX, + &asset_id, + &excluded, + BATCH_SIZE, + ) + .await; + + const EXPECTED_COLLECTED_AMOUNT: Word = 10 + 9 + 8; // Because MAX == 3 + + // Then + assert!(matches!(result, Err(actual_error) + if CoinsQueryError::InsufficientCoinsForTheMax { asset_id, collected_amount: EXPECTED_COLLECTED_AMOUNT, max: MAX } == actual_error)); + } + } + #[derive(Clone, Debug)] struct TestCase { db_amount: Vec, @@ -921,9 +1607,10 @@ mod tests { assert_eq!(coin_result, message_result); assert_matches!( coin_result, - Err(CoinsQueryError::InsufficientCoins { + Err(CoinsQueryError::InsufficientCoinsForTheMax { asset_id: _base_asset_id, - collected_amount: 0 + collected_amount: 0, + max: u16::MAX }) ) } @@ -946,15 +1633,6 @@ mod tests { => Ok(2) ; "Enough coins in the DB to reach target(u64::MAX) by 2 coins" )] - #[test_case::test_case( - TestCase { - db_amount: vec![u64::MAX, u64::MAX], - target_amount: u64::MAX, - max_coins: 0, - } - => Err(CoinsQueryError::MaxCoinsReached) - ; "Enough coins in the DB to reach target(u64::MAX) but limit is zero" - )] #[tokio::test] async fn corner_cases(case: TestCase) -> Result { let mut rng = StdRng::seed_from_u64(0xF00DF00D); @@ -966,6 +1644,27 @@ mod tests { coin_result } + #[tokio::test] + async fn enough_coins_in_the_db_to_reach_target_u64_max_but_limit_is_zero() { + let mut rng = StdRng::seed_from_u64(0xF00DF00D); + + let case = TestCase { + db_amount: vec![u64::MAX, u64::MAX], + target_amount: u64::MAX, + max_coins: 0, + }; + + let base_asset_id = rng.gen(); + let coin_result = + test_case_run(case.clone(), CoinType::Coin, base_asset_id).await; + let message_result = test_case_run(case, CoinType::Message, base_asset_id).await; + assert_eq!(coin_result, message_result); + assert!(matches!( + coin_result, + Err(CoinsQueryError::InsufficientCoinsForTheMax { .. }) + )); + } + // TODO: Should use any mock database instead of the `fuel_core::CombinedDatabase`. pub struct TestDatabase { database: CombinedDatabase, diff --git a/crates/fuel-core/src/database/database_description.rs b/crates/fuel-core/src/database/database_description.rs index efc0f48b5bf..a666ffa523c 100644 --- a/crates/fuel-core/src/database/database_description.rs +++ b/crates/fuel-core/src/database/database_description.rs @@ -82,6 +82,7 @@ pub trait DatabaseDescription: 'static + Copy + Debug + Send + Sync { )] pub enum IndexationKind { Balances, + CoinsToSpend, } impl IndexationKind { diff --git a/crates/fuel-core/src/graphql_api.rs b/crates/fuel-core/src/graphql_api.rs index 63a6efcf0de..7a4f3746c2b 100644 --- a/crates/fuel-core/src/graphql_api.rs +++ b/crates/fuel-core/src/graphql_api.rs @@ -80,9 +80,9 @@ impl Default for Costs { } pub const DEFAULT_QUERY_COSTS: Costs = Costs { - // TODO: The cost of the `balance` and `balances` query should depend on the - // `OffChainDatabase::balances_enabled` value. If additional indexation is enabled, - // the cost should be cheaper. + // TODO: The cost of the `balance`, `balances` and `coins_to_spend` query should depend on the + // values of respective flags in the OffChainDatabase. If additional indexation is enabled, + // the cost should be cheaper (https://github.com/FuelLabs/fuel-core/issues/2496) balance_query: 40001, coins_to_spend: 40001, get_peers: 40001, diff --git a/crates/fuel-core/src/graphql_api/database.rs b/crates/fuel-core/src/graphql_api/database.rs index 36f02562ca6..263fafa3cd3 100644 --- a/crates/fuel-core/src/graphql_api/database.rs +++ b/crates/fuel-core/src/graphql_api/database.rs @@ -88,8 +88,10 @@ pub struct ReadDatabase { on_chain: Box>, /// The off-chain database view provider. off_chain: Box>, - /// The flag that indicates whether the Balances cache table is enabled. - balances_enabled: bool, + /// The flag that indicates whether the Balances indexation is enabled. + balances_indexation_enabled: bool, + /// The flag that indicates whether the CoinsToSpend indexation is enabled. + coins_to_spend_indexation_enabled: bool, } impl ReadDatabase { @@ -106,14 +108,17 @@ impl ReadDatabase { OnChain::LatestView: OnChainDatabase, OffChain::LatestView: OffChainDatabase, { - let balances_enabled = off_chain.balances_enabled()?; + let balances_indexation_enabled = off_chain.balances_indexation_enabled()?; + let coins_to_spend_indexation_enabled = + off_chain.coins_to_spend_indexation_enabled()?; Ok(Self { batch_size, genesis_height, on_chain: Box::new(ArcWrapper::new(on_chain)), off_chain: Box::new(ArcWrapper::new(off_chain)), - balances_enabled, + balances_indexation_enabled, + coins_to_spend_indexation_enabled, }) } @@ -127,7 +132,8 @@ impl ReadDatabase { genesis_height: self.genesis_height, on_chain: self.on_chain.latest_view()?, off_chain: self.off_chain.latest_view()?, - balances_enabled: self.balances_enabled, + balances_indexation_enabled: self.balances_indexation_enabled, + coins_to_spend_indexation_enabled: self.coins_to_spend_indexation_enabled, }) } @@ -143,7 +149,8 @@ pub struct ReadView { pub(crate) genesis_height: BlockHeight, pub(crate) on_chain: OnChainView, pub(crate) off_chain: OffChainView, - pub(crate) balances_enabled: bool, + pub(crate) balances_indexation_enabled: bool, + pub(crate) coins_to_spend_indexation_enabled: bool, } impl ReadView { diff --git a/crates/fuel-core/src/graphql_api/indexation.rs b/crates/fuel-core/src/graphql_api/indexation.rs index d515504863c..96c0b2a3039 100644 --- a/crates/fuel-core/src/graphql_api/indexation.rs +++ b/crates/fuel-core/src/graphql_api/indexation.rs @@ -1,733 +1,5 @@ -use fuel_core_storage::{ - Error as StorageError, - StorageAsMut, -}; -use fuel_core_types::{ - entities::{ - coins::coin::Coin, - Message, - }, - fuel_tx::{ - Address, - AssetId, - }, - services::executor::Event, -}; - -use super::{ - ports::worker::OffChainDatabaseTransaction, - storage::balances::{ - CoinBalances, - CoinBalancesKey, - MessageBalance, - MessageBalances, - }, -}; - -#[derive(derive_more::From, derive_more::Display, Debug)] -pub enum IndexationError { - #[display( - fmt = "Coin balance would underflow for owner: {}, asset_id: {}, current_amount: {}, requested_deduction: {}", - owner, - asset_id, - current_amount, - requested_deduction - )] - CoinBalanceWouldUnderflow { - owner: Address, - asset_id: AssetId, - current_amount: u128, - requested_deduction: u128, - }, - #[display( - fmt = "Message balance would underflow for owner: {}, current_amount: {}, requested_deduction: {}, retryable: {}", - owner, - current_amount, - requested_deduction, - retryable - )] - MessageBalanceWouldUnderflow { - owner: Address, - current_amount: u128, - requested_deduction: u128, - retryable: bool, - }, - #[from] - StorageError(StorageError), -} - -fn increase_message_balance( - block_st_transaction: &mut T, - message: &Message, -) -> Result<(), IndexationError> -where - T: OffChainDatabaseTransaction, -{ - let key = message.recipient(); - let storage = block_st_transaction.storage::(); - let current_balance = storage.get(key)?.unwrap_or_default().into_owned(); - let MessageBalance { - mut retryable, - mut non_retryable, - } = current_balance; - if message.is_retryable_message() { - retryable = retryable.saturating_add(message.amount() as u128); - } else { - non_retryable = non_retryable.saturating_add(message.amount() as u128); - } - let new_balance = MessageBalance { - retryable, - non_retryable, - }; - - block_st_transaction - .storage::() - .insert(key, &new_balance) - .map_err(Into::into) -} - -fn decrease_message_balance( - block_st_transaction: &mut T, - message: &Message, -) -> Result<(), IndexationError> -where - T: OffChainDatabaseTransaction, -{ - let key = message.recipient(); - let storage = block_st_transaction.storage::(); - let MessageBalance { - retryable, - non_retryable, - } = storage.get(key)?.unwrap_or_default().into_owned(); - let current_balance = if message.is_retryable_message() { - retryable - } else { - non_retryable - }; - - let new_amount = current_balance - .checked_sub(message.amount() as u128) - .ok_or_else(|| IndexationError::MessageBalanceWouldUnderflow { - owner: *message.recipient(), - current_amount: current_balance, - requested_deduction: message.amount() as u128, - retryable: message.is_retryable_message(), - })?; - - let new_balance = if message.is_retryable_message() { - MessageBalance { - retryable: new_amount, - non_retryable, - } - } else { - MessageBalance { - retryable, - non_retryable: new_amount, - } - }; - block_st_transaction - .storage::() - .insert(key, &new_balance) - .map_err(Into::into) -} - -fn increase_coin_balance( - block_st_transaction: &mut T, - coin: &Coin, -) -> Result<(), IndexationError> -where - T: OffChainDatabaseTransaction, -{ - let key = CoinBalancesKey::new(&coin.owner, &coin.asset_id); - let storage = block_st_transaction.storage::(); - let current_amount = storage.get(&key)?.unwrap_or_default().into_owned(); - let new_amount = current_amount.saturating_add(coin.amount as u128); - - block_st_transaction - .storage::() - .insert(&key, &new_amount) - .map_err(Into::into) -} - -fn decrease_coin_balance( - block_st_transaction: &mut T, - coin: &Coin, -) -> Result<(), IndexationError> -where - T: OffChainDatabaseTransaction, -{ - let key = CoinBalancesKey::new(&coin.owner, &coin.asset_id); - let storage = block_st_transaction.storage::(); - let current_amount = storage.get(&key)?.unwrap_or_default().into_owned(); - - let new_amount = - current_amount - .checked_sub(coin.amount as u128) - .ok_or_else(|| IndexationError::CoinBalanceWouldUnderflow { - owner: coin.owner, - asset_id: coin.asset_id, - current_amount, - requested_deduction: coin.amount as u128, - })?; - - block_st_transaction - .storage::() - .insert(&key, &new_amount) - .map_err(Into::into) -} - -pub(crate) fn process_balances_update( - event: &Event, - block_st_transaction: &mut T, - balances_enabled: bool, -) -> Result<(), IndexationError> -where - T: OffChainDatabaseTransaction, -{ - if !balances_enabled { - return Ok(()); - } - - match event { - Event::MessageImported(message) => { - increase_message_balance(block_st_transaction, message) - } - Event::MessageConsumed(message) => { - decrease_message_balance(block_st_transaction, message) - } - Event::CoinCreated(coin) => increase_coin_balance(block_st_transaction, coin), - Event::CoinConsumed(coin) => decrease_coin_balance(block_st_transaction, coin), - Event::ForcedTransactionFailed { .. } => Ok(()), - } -} - +pub(crate) mod balances; +pub(crate) mod coins_to_spend; +pub(crate) mod error; #[cfg(test)] -mod tests { - use fuel_core_storage::{ - transactional::WriteTransaction, - StorageAsMut, - }; - use fuel_core_types::{ - entities::{ - coins::coin::Coin, - relayer::message::MessageV1, - Message, - }, - fuel_tx::{ - Address, - AssetId, - }, - services::executor::Event, - }; - - use crate::{ - database::{ - database_description::off_chain::OffChain, - Database, - }, - graphql_api::{ - indexation::{ - process_balances_update, - IndexationError, - }, - ports::worker::OffChainDatabaseTransaction, - storage::balances::{ - CoinBalances, - CoinBalancesKey, - MessageBalance, - MessageBalances, - }, - }, - state::rocks_db::DatabaseConfig, - }; - - impl PartialEq for IndexationError { - fn eq(&self, other: &Self) -> bool { - match (self, other) { - ( - Self::CoinBalanceWouldUnderflow { - owner: l_owner, - asset_id: l_asset_id, - current_amount: l_current_amount, - requested_deduction: l_requested_deduction, - }, - Self::CoinBalanceWouldUnderflow { - owner: r_owner, - asset_id: r_asset_id, - current_amount: r_current_amount, - requested_deduction: r_requested_deduction, - }, - ) => { - l_owner == r_owner - && l_asset_id == r_asset_id - && l_current_amount == r_current_amount - && l_requested_deduction == r_requested_deduction - } - ( - Self::MessageBalanceWouldUnderflow { - owner: l_owner, - current_amount: l_current_amount, - requested_deduction: l_requested_deduction, - retryable: l_retryable, - }, - Self::MessageBalanceWouldUnderflow { - owner: r_owner, - current_amount: r_current_amount, - requested_deduction: r_requested_deduction, - retryable: r_retryable, - }, - ) => { - l_owner == r_owner - && l_current_amount == r_current_amount - && l_requested_deduction == r_requested_deduction - && l_retryable == r_retryable - } - (Self::StorageError(l0), Self::StorageError(r0)) => l0 == r0, - _ => false, - } - } - } - - fn make_coin(owner: &Address, asset_id: &AssetId, amount: u64) -> Coin { - Coin { - utxo_id: Default::default(), - owner: *owner, - amount, - asset_id: *asset_id, - tx_pointer: Default::default(), - } - } - - fn make_retryable_message(owner: &Address, amount: u64) -> Message { - Message::V1(MessageV1 { - sender: Default::default(), - recipient: *owner, - nonce: Default::default(), - amount, - data: vec![1], - da_height: Default::default(), - }) - } - - fn make_nonretryable_message(owner: &Address, amount: u64) -> Message { - let mut message = make_retryable_message(owner, amount); - message.set_data(vec![]); - message - } - - fn assert_coin_balance( - tx: &mut T, - owner: Address, - asset_id: AssetId, - expected_balance: u128, - ) where - T: OffChainDatabaseTransaction, - { - let key = CoinBalancesKey::new(&owner, &asset_id); - let balance = tx - .storage::() - .get(&key) - .expect("should correctly query db") - .expect("should have balance"); - - assert_eq!(*balance, expected_balance); - } - - fn assert_message_balance( - tx: &mut T, - owner: Address, - expected_balance: MessageBalance, - ) where - T: OffChainDatabaseTransaction, - { - let balance = tx - .storage::() - .get(&owner) - .expect("should correctly query db") - .expect("should have balance"); - - assert_eq!(*balance, expected_balance); - } - - #[test] - fn balances_enabled_flag_is_respected() { - use tempfile::TempDir; - let tmp_dir = TempDir::new().unwrap(); - let mut db: Database = Database::open_rocksdb( - tmp_dir.path(), - Default::default(), - DatabaseConfig::config_for_tests(), - ) - .unwrap(); - let mut tx = db.write_transaction(); - - const BALANCES_ARE_DISABLED: bool = false; - - let owner_1 = Address::from([1; 32]); - let owner_2 = Address::from([2; 32]); - - let asset_id_1 = AssetId::from([11; 32]); - let asset_id_2 = AssetId::from([12; 32]); - - // Initial set of coins - let events: Vec = vec![ - Event::CoinCreated(make_coin(&owner_1, &asset_id_1, 100)), - Event::CoinConsumed(make_coin(&owner_1, &asset_id_2, 200)), - Event::MessageImported(make_retryable_message(&owner_1, 300)), - Event::MessageConsumed(make_nonretryable_message(&owner_2, 400)), - ]; - - events.iter().for_each(|event| { - process_balances_update(event, &mut tx, BALANCES_ARE_DISABLED) - .expect("should process balance"); - }); - - let key = CoinBalancesKey::new(&owner_1, &asset_id_1); - let balance = tx - .storage::() - .get(&key) - .expect("should correctly query db"); - assert!(balance.is_none()); - - let key = CoinBalancesKey::new(&owner_1, &asset_id_2); - let balance = tx - .storage::() - .get(&key) - .expect("should correctly query db"); - assert!(balance.is_none()); - - let balance = tx - .storage::() - .get(&owner_1) - .expect("should correctly query db"); - assert!(balance.is_none()); - - let balance = tx - .storage::() - .get(&owner_2) - .expect("should correctly query db"); - assert!(balance.is_none()); - } - - #[test] - fn coins() { - use tempfile::TempDir; - let tmp_dir = TempDir::new().unwrap(); - let mut db: Database = Database::open_rocksdb( - tmp_dir.path(), - Default::default(), - DatabaseConfig::config_for_tests(), - ) - .unwrap(); - let mut tx = db.write_transaction(); - - const BALANCES_ARE_ENABLED: bool = true; - - let owner_1 = Address::from([1; 32]); - let owner_2 = Address::from([2; 32]); - - let asset_id_1 = AssetId::from([11; 32]); - let asset_id_2 = AssetId::from([12; 32]); - - // Initial set of coins - let events: Vec = vec![ - Event::CoinCreated(make_coin(&owner_1, &asset_id_1, 100)), - Event::CoinCreated(make_coin(&owner_1, &asset_id_2, 200)), - Event::CoinCreated(make_coin(&owner_2, &asset_id_1, 300)), - Event::CoinCreated(make_coin(&owner_2, &asset_id_2, 400)), - ]; - - events.iter().for_each(|event| { - process_balances_update(event, &mut tx, BALANCES_ARE_ENABLED) - .expect("should process balance"); - }); - - assert_coin_balance(&mut tx, owner_1, asset_id_1, 100); - assert_coin_balance(&mut tx, owner_1, asset_id_2, 200); - assert_coin_balance(&mut tx, owner_2, asset_id_1, 300); - assert_coin_balance(&mut tx, owner_2, asset_id_2, 400); - - // Add some more coins - let events: Vec = vec![ - Event::CoinCreated(make_coin(&owner_1, &asset_id_1, 1)), - Event::CoinCreated(make_coin(&owner_1, &asset_id_2, 2)), - Event::CoinCreated(make_coin(&owner_2, &asset_id_1, 3)), - Event::CoinCreated(make_coin(&owner_2, &asset_id_2, 4)), - ]; - - events.iter().for_each(|event| { - process_balances_update(event, &mut tx, BALANCES_ARE_ENABLED) - .expect("should process balance"); - }); - - assert_coin_balance(&mut tx, owner_1, asset_id_1, 101); - assert_coin_balance(&mut tx, owner_1, asset_id_2, 202); - assert_coin_balance(&mut tx, owner_2, asset_id_1, 303); - assert_coin_balance(&mut tx, owner_2, asset_id_2, 404); - - // Consume some coins - let events: Vec = vec![ - Event::CoinConsumed(make_coin(&owner_1, &asset_id_1, 100)), - Event::CoinConsumed(make_coin(&owner_1, &asset_id_2, 200)), - Event::CoinConsumed(make_coin(&owner_2, &asset_id_1, 300)), - Event::CoinConsumed(make_coin(&owner_2, &asset_id_2, 400)), - ]; - - events.iter().for_each(|event| { - process_balances_update(event, &mut tx, BALANCES_ARE_ENABLED) - .expect("should process balance"); - }); - - assert_coin_balance(&mut tx, owner_1, asset_id_1, 1); - assert_coin_balance(&mut tx, owner_1, asset_id_2, 2); - assert_coin_balance(&mut tx, owner_2, asset_id_1, 3); - assert_coin_balance(&mut tx, owner_2, asset_id_2, 4); - } - - #[test] - fn messages() { - use tempfile::TempDir; - let tmp_dir = TempDir::new().unwrap(); - let mut db: Database = Database::open_rocksdb( - tmp_dir.path(), - Default::default(), - DatabaseConfig::config_for_tests(), - ) - .unwrap(); - let mut tx = db.write_transaction(); - - const BALANCES_ARE_ENABLED: bool = true; - - let owner_1 = Address::from([1; 32]); - let owner_2 = Address::from([2; 32]); - - // Initial set of messages - let events: Vec = vec![ - Event::MessageImported(make_retryable_message(&owner_1, 100)), - Event::MessageImported(make_retryable_message(&owner_2, 200)), - Event::MessageImported(make_nonretryable_message(&owner_1, 300)), - Event::MessageImported(make_nonretryable_message(&owner_2, 400)), - ]; - - events.iter().for_each(|event| { - process_balances_update(event, &mut tx, BALANCES_ARE_ENABLED) - .expect("should process balance"); - }); - - assert_message_balance( - &mut tx, - owner_1, - MessageBalance { - retryable: 100, - non_retryable: 300, - }, - ); - - assert_message_balance( - &mut tx, - owner_2, - MessageBalance { - retryable: 200, - non_retryable: 400, - }, - ); - - // Add some messages - let events: Vec = vec![ - Event::MessageImported(make_retryable_message(&owner_1, 1)), - Event::MessageImported(make_retryable_message(&owner_2, 2)), - Event::MessageImported(make_nonretryable_message(&owner_1, 3)), - Event::MessageImported(make_nonretryable_message(&owner_2, 4)), - ]; - - events.iter().for_each(|event| { - process_balances_update(event, &mut tx, BALANCES_ARE_ENABLED) - .expect("should process balance"); - }); - - assert_message_balance( - &mut tx, - owner_1, - MessageBalance { - retryable: 101, - non_retryable: 303, - }, - ); - - assert_message_balance( - &mut tx, - owner_2, - MessageBalance { - retryable: 202, - non_retryable: 404, - }, - ); - - // Consume some messages - let events: Vec = vec![ - Event::MessageConsumed(make_retryable_message(&owner_1, 100)), - Event::MessageConsumed(make_retryable_message(&owner_2, 200)), - Event::MessageConsumed(make_nonretryable_message(&owner_1, 300)), - Event::MessageConsumed(make_nonretryable_message(&owner_2, 400)), - ]; - - events.iter().for_each(|event| { - process_balances_update(event, &mut tx, BALANCES_ARE_ENABLED) - .expect("should process balance"); - }); - - assert_message_balance( - &mut tx, - owner_1, - MessageBalance { - retryable: 1, - non_retryable: 3, - }, - ); - - assert_message_balance( - &mut tx, - owner_2, - MessageBalance { - retryable: 2, - non_retryable: 4, - }, - ); - } - - #[test] - fn coin_balance_overflow_does_not_error() { - use tempfile::TempDir; - let tmp_dir = TempDir::new().unwrap(); - let mut db: Database = Database::open_rocksdb( - tmp_dir.path(), - Default::default(), - DatabaseConfig::config_for_tests(), - ) - .unwrap(); - let mut tx = db.write_transaction(); - - const BALANCES_ARE_ENABLED: bool = true; - - let owner = Address::from([1; 32]); - let asset_id = AssetId::from([11; 32]); - - // Make the initial balance huge - let key = CoinBalancesKey::new(&owner, &asset_id); - tx.storage::() - .insert(&key, &u128::MAX) - .expect("should correctly query db"); - - assert_coin_balance(&mut tx, owner, asset_id, u128::MAX); - - // Try to add more coins - let events: Vec = - vec![Event::CoinCreated(make_coin(&owner, &asset_id, 1))]; - - events.iter().for_each(|event| { - process_balances_update(event, &mut tx, BALANCES_ARE_ENABLED) - .expect("should process balance"); - }); - - assert_coin_balance(&mut tx, owner, asset_id, u128::MAX); - } - - #[test] - fn message_balance_overflow_does_not_error() { - use tempfile::TempDir; - let tmp_dir = TempDir::new().unwrap(); - let mut db: Database = Database::open_rocksdb( - tmp_dir.path(), - Default::default(), - DatabaseConfig::config_for_tests(), - ) - .unwrap(); - let mut tx = db.write_transaction(); - - const BALANCES_ARE_ENABLED: bool = true; - const MAX_BALANCES: MessageBalance = MessageBalance { - retryable: u128::MAX, - non_retryable: u128::MAX, - }; - - let owner = Address::from([1; 32]); - - // Make the initial balance huge - tx.storage::() - .insert(&owner, &MAX_BALANCES) - .expect("should correctly query db"); - - assert_message_balance(&mut tx, owner, MAX_BALANCES); - - // Try to add more coins - let events: Vec = vec![ - Event::MessageImported(make_retryable_message(&owner, 1)), - Event::MessageImported(make_nonretryable_message(&owner, 1)), - ]; - - events.iter().for_each(|event| { - process_balances_update(event, &mut tx, BALANCES_ARE_ENABLED) - .expect("should process balance"); - }); - - assert_message_balance(&mut tx, owner, MAX_BALANCES); - } - - #[test] - fn coin_balance_underflow_causes_error() { - use tempfile::TempDir; - let tmp_dir = TempDir::new().unwrap(); - let mut db: Database = Database::open_rocksdb( - tmp_dir.path(), - Default::default(), - DatabaseConfig::config_for_tests(), - ) - .unwrap(); - let mut tx = db.write_transaction(); - - const BALANCES_ARE_ENABLED: bool = true; - - let owner = Address::from([1; 32]); - let asset_id_1 = AssetId::from([11; 32]); - let asset_id_2 = AssetId::from([12; 32]); - - // Initial set of coins - let events: Vec = - vec![Event::CoinCreated(make_coin(&owner, &asset_id_1, 100))]; - - events.iter().for_each(|event| { - process_balances_update(event, &mut tx, BALANCES_ARE_ENABLED) - .expect("should process balance"); - }); - - // Consume more coins than available - let events: Vec = vec![ - Event::CoinConsumed(make_coin(&owner, &asset_id_1, 10000)), - Event::CoinConsumed(make_coin(&owner, &asset_id_2, 20000)), - ]; - - let expected_errors = vec![ - IndexationError::CoinBalanceWouldUnderflow { - owner, - asset_id: asset_id_1, - current_amount: 100, - requested_deduction: 10000, - }, - IndexationError::CoinBalanceWouldUnderflow { - owner, - asset_id: asset_id_2, - current_amount: 0, - requested_deduction: 20000, - }, - ]; - - let actual_errors: Vec<_> = events - .iter() - .map(|event| { - process_balances_update(event, &mut tx, BALANCES_ARE_ENABLED).unwrap_err() - }) - .collect(); - - assert_eq!(expected_errors, actual_errors); - } -} +pub(crate) mod test_utils; diff --git a/crates/fuel-core/src/graphql_api/indexation/balances.rs b/crates/fuel-core/src/graphql_api/indexation/balances.rs new file mode 100644 index 00000000000..29bc22bc3d9 --- /dev/null +++ b/crates/fuel-core/src/graphql_api/indexation/balances.rs @@ -0,0 +1,616 @@ +use fuel_core_storage::StorageAsMut; +use fuel_core_types::{ + entities::{ + coins::coin::Coin, + Message, + }, + services::executor::Event, +}; + +use crate::graphql_api::{ + ports::worker::OffChainDatabaseTransaction, + storage::balances::{ + CoinBalances, + CoinBalancesKey, + MessageBalance, + MessageBalances, + }, +}; + +use super::error::IndexationError; + +fn increase_message_balance( + block_st_transaction: &mut T, + message: &Message, +) -> Result<(), IndexationError> +where + T: OffChainDatabaseTransaction, +{ + let key = message.recipient(); + let storage = block_st_transaction.storage::(); + let current_balance = storage.get(key)?.unwrap_or_default().into_owned(); + let MessageBalance { + mut retryable, + mut non_retryable, + } = current_balance; + if message.is_retryable_message() { + retryable = retryable.saturating_add(u128::from(message.amount())); + } else { + non_retryable = non_retryable.saturating_add(u128::from(message.amount())); + } + let new_balance = MessageBalance { + retryable, + non_retryable, + }; + + block_st_transaction + .storage::() + .insert(key, &new_balance) + .map_err(Into::into) +} + +fn decrease_message_balance( + block_st_transaction: &mut T, + message: &Message, +) -> Result<(), IndexationError> +where + T: OffChainDatabaseTransaction, +{ + let key = message.recipient(); + let storage = block_st_transaction.storage::(); + let MessageBalance { + retryable, + non_retryable, + } = storage.get(key)?.unwrap_or_default().into_owned(); + let current_balance = if message.is_retryable_message() { + retryable + } else { + non_retryable + }; + + let new_amount = current_balance + .checked_sub(u128::from(message.amount())) + .ok_or_else(|| IndexationError::MessageBalanceWouldUnderflow { + owner: *message.recipient(), + current_amount: current_balance, + requested_deduction: u128::from(message.amount()), + retryable: message.is_retryable_message(), + })?; + + let new_balance = if message.is_retryable_message() { + MessageBalance { + retryable: new_amount, + non_retryable, + } + } else { + MessageBalance { + retryable, + non_retryable: new_amount, + } + }; + block_st_transaction + .storage::() + .insert(key, &new_balance) + .map_err(Into::into) +} + +fn increase_coin_balance( + block_st_transaction: &mut T, + coin: &Coin, +) -> Result<(), IndexationError> +where + T: OffChainDatabaseTransaction, +{ + let key = CoinBalancesKey::new(&coin.owner, &coin.asset_id); + let storage = block_st_transaction.storage::(); + let current_amount = storage.get(&key)?.unwrap_or_default().into_owned(); + let new_amount = current_amount.saturating_add(u128::from(coin.amount)); + + block_st_transaction + .storage::() + .insert(&key, &new_amount) + .map_err(Into::into) +} + +fn decrease_coin_balance( + block_st_transaction: &mut T, + coin: &Coin, +) -> Result<(), IndexationError> +where + T: OffChainDatabaseTransaction, +{ + let key = CoinBalancesKey::new(&coin.owner, &coin.asset_id); + let storage = block_st_transaction.storage::(); + let current_amount = storage.get(&key)?.unwrap_or_default().into_owned(); + + let new_amount = current_amount + .checked_sub(u128::from(coin.amount)) + .ok_or_else(|| IndexationError::CoinBalanceWouldUnderflow { + owner: coin.owner, + asset_id: coin.asset_id, + current_amount, + requested_deduction: u128::from(coin.amount), + })?; + + block_st_transaction + .storage::() + .insert(&key, &new_amount) + .map_err(Into::into) +} + +pub(crate) fn update( + event: &Event, + block_st_transaction: &mut T, + enabled: bool, +) -> Result<(), IndexationError> +where + T: OffChainDatabaseTransaction, +{ + if !enabled { + return Ok(()); + } + + match event { + Event::MessageImported(message) => { + increase_message_balance(block_st_transaction, message) + } + Event::MessageConsumed(message) => { + decrease_message_balance(block_st_transaction, message) + } + Event::CoinCreated(coin) => increase_coin_balance(block_st_transaction, coin), + Event::CoinConsumed(coin) => decrease_coin_balance(block_st_transaction, coin), + Event::ForcedTransactionFailed { .. } => Ok(()), + } +} + +#[cfg(test)] +mod tests { + use fuel_core_storage::{ + transactional::WriteTransaction, + StorageAsMut, + }; + use fuel_core_types::{ + fuel_tx::{ + Address, + AssetId, + }, + services::executor::Event, + }; + + use crate::{ + database::{ + database_description::off_chain::OffChain, + Database, + }, + graphql_api::{ + indexation::{ + balances::update, + error::IndexationError, + test_utils::{ + make_coin, + make_nonretryable_message, + make_retryable_message, + }, + }, + ports::worker::OffChainDatabaseTransaction, + storage::balances::{ + CoinBalances, + CoinBalancesKey, + MessageBalance, + MessageBalances, + }, + }, + state::rocks_db::DatabaseConfig, + }; + + fn assert_coin_balance( + tx: &mut T, + owner: Address, + asset_id: AssetId, + expected_balance: u128, + ) where + T: OffChainDatabaseTransaction, + { + let key = CoinBalancesKey::new(&owner, &asset_id); + let balance = tx + .storage::() + .get(&key) + .expect("should correctly query db") + .expect("should have balance"); + + assert_eq!(*balance, expected_balance); + } + + fn assert_message_balance( + tx: &mut T, + owner: Address, + expected_balance: MessageBalance, + ) where + T: OffChainDatabaseTransaction, + { + let balance = tx + .storage::() + .get(&owner) + .expect("should correctly query db") + .expect("should have balance"); + + assert_eq!(*balance, expected_balance); + } + + #[test] + fn balances_indexation_enabled_flag_is_respected() { + use tempfile::TempDir; + let tmp_dir = TempDir::new().unwrap(); + let mut db: Database = Database::open_rocksdb( + tmp_dir.path(), + Default::default(), + DatabaseConfig::config_for_tests(), + ) + .unwrap(); + let mut tx = db.write_transaction(); + + const BALANCES_ARE_DISABLED: bool = false; + + let owner_1 = Address::from([1; 32]); + let owner_2 = Address::from([2; 32]); + + let asset_id_1 = AssetId::from([11; 32]); + let asset_id_2 = AssetId::from([12; 32]); + + // Initial set of coins + let events: Vec = vec![ + Event::CoinCreated(make_coin(&owner_1, &asset_id_1, 100)), + Event::CoinConsumed(make_coin(&owner_1, &asset_id_2, 200)), + Event::MessageImported(make_retryable_message(&owner_1, 300)), + Event::MessageConsumed(make_nonretryable_message(&owner_2, 400)), + ]; + + events.iter().for_each(|event| { + update(event, &mut tx, BALANCES_ARE_DISABLED) + .expect("should process balance"); + }); + + let key = CoinBalancesKey::new(&owner_1, &asset_id_1); + let balance = tx + .storage::() + .get(&key) + .expect("should correctly query db"); + assert!(balance.is_none()); + + let key = CoinBalancesKey::new(&owner_1, &asset_id_2); + let balance = tx + .storage::() + .get(&key) + .expect("should correctly query db"); + assert!(balance.is_none()); + + let balance = tx + .storage::() + .get(&owner_1) + .expect("should correctly query db"); + assert!(balance.is_none()); + + let balance = tx + .storage::() + .get(&owner_2) + .expect("should correctly query db"); + assert!(balance.is_none()); + } + + #[test] + fn coins() { + use tempfile::TempDir; + let tmp_dir = TempDir::new().unwrap(); + let mut db: Database = Database::open_rocksdb( + tmp_dir.path(), + Default::default(), + DatabaseConfig::config_for_tests(), + ) + .unwrap(); + let mut tx = db.write_transaction(); + + const BALANCES_ARE_ENABLED: bool = true; + + let owner_1 = Address::from([1; 32]); + let owner_2 = Address::from([2; 32]); + + let asset_id_1 = AssetId::from([11; 32]); + let asset_id_2 = AssetId::from([12; 32]); + + // Initial set of coins + let events: Vec = vec![ + Event::CoinCreated(make_coin(&owner_1, &asset_id_1, 100)), + Event::CoinCreated(make_coin(&owner_1, &asset_id_2, 200)), + Event::CoinCreated(make_coin(&owner_2, &asset_id_1, 300)), + Event::CoinCreated(make_coin(&owner_2, &asset_id_2, 400)), + ]; + + events.iter().for_each(|event| { + update(event, &mut tx, BALANCES_ARE_ENABLED).expect("should process balance"); + }); + + assert_coin_balance(&mut tx, owner_1, asset_id_1, 100); + assert_coin_balance(&mut tx, owner_1, asset_id_2, 200); + assert_coin_balance(&mut tx, owner_2, asset_id_1, 300); + assert_coin_balance(&mut tx, owner_2, asset_id_2, 400); + + // Add some more coins + let events: Vec = vec![ + Event::CoinCreated(make_coin(&owner_1, &asset_id_1, 1)), + Event::CoinCreated(make_coin(&owner_1, &asset_id_2, 2)), + Event::CoinCreated(make_coin(&owner_2, &asset_id_1, 3)), + Event::CoinCreated(make_coin(&owner_2, &asset_id_2, 4)), + ]; + + events.iter().for_each(|event| { + update(event, &mut tx, BALANCES_ARE_ENABLED).expect("should process balance"); + }); + + assert_coin_balance(&mut tx, owner_1, asset_id_1, 101); + assert_coin_balance(&mut tx, owner_1, asset_id_2, 202); + assert_coin_balance(&mut tx, owner_2, asset_id_1, 303); + assert_coin_balance(&mut tx, owner_2, asset_id_2, 404); + + // Consume some coins + let events: Vec = vec![ + Event::CoinConsumed(make_coin(&owner_1, &asset_id_1, 100)), + Event::CoinConsumed(make_coin(&owner_1, &asset_id_2, 200)), + Event::CoinConsumed(make_coin(&owner_2, &asset_id_1, 300)), + Event::CoinConsumed(make_coin(&owner_2, &asset_id_2, 400)), + ]; + + events.iter().for_each(|event| { + update(event, &mut tx, BALANCES_ARE_ENABLED).expect("should process balance"); + }); + + assert_coin_balance(&mut tx, owner_1, asset_id_1, 1); + assert_coin_balance(&mut tx, owner_1, asset_id_2, 2); + assert_coin_balance(&mut tx, owner_2, asset_id_1, 3); + assert_coin_balance(&mut tx, owner_2, asset_id_2, 4); + } + + #[test] + fn messages() { + use tempfile::TempDir; + let tmp_dir = TempDir::new().unwrap(); + let mut db: Database = Database::open_rocksdb( + tmp_dir.path(), + Default::default(), + DatabaseConfig::config_for_tests(), + ) + .unwrap(); + let mut tx = db.write_transaction(); + + const BALANCES_ARE_ENABLED: bool = true; + + let owner_1 = Address::from([1; 32]); + let owner_2 = Address::from([2; 32]); + + // Initial set of messages + let events: Vec = vec![ + Event::MessageImported(make_retryable_message(&owner_1, 100)), + Event::MessageImported(make_retryable_message(&owner_2, 200)), + Event::MessageImported(make_nonretryable_message(&owner_1, 300)), + Event::MessageImported(make_nonretryable_message(&owner_2, 400)), + ]; + + events.iter().for_each(|event| { + update(event, &mut tx, BALANCES_ARE_ENABLED).expect("should process balance"); + }); + + assert_message_balance( + &mut tx, + owner_1, + MessageBalance { + retryable: 100, + non_retryable: 300, + }, + ); + + assert_message_balance( + &mut tx, + owner_2, + MessageBalance { + retryable: 200, + non_retryable: 400, + }, + ); + + // Add some messages + let events: Vec = vec![ + Event::MessageImported(make_retryable_message(&owner_1, 1)), + Event::MessageImported(make_retryable_message(&owner_2, 2)), + Event::MessageImported(make_nonretryable_message(&owner_1, 3)), + Event::MessageImported(make_nonretryable_message(&owner_2, 4)), + ]; + + events.iter().for_each(|event| { + update(event, &mut tx, BALANCES_ARE_ENABLED).expect("should process balance"); + }); + + assert_message_balance( + &mut tx, + owner_1, + MessageBalance { + retryable: 101, + non_retryable: 303, + }, + ); + + assert_message_balance( + &mut tx, + owner_2, + MessageBalance { + retryable: 202, + non_retryable: 404, + }, + ); + + // Consume some messages + let events: Vec = vec![ + Event::MessageConsumed(make_retryable_message(&owner_1, 100)), + Event::MessageConsumed(make_retryable_message(&owner_2, 200)), + Event::MessageConsumed(make_nonretryable_message(&owner_1, 300)), + Event::MessageConsumed(make_nonretryable_message(&owner_2, 400)), + ]; + + events.iter().for_each(|event| { + update(event, &mut tx, BALANCES_ARE_ENABLED).expect("should process balance"); + }); + + assert_message_balance( + &mut tx, + owner_1, + MessageBalance { + retryable: 1, + non_retryable: 3, + }, + ); + + assert_message_balance( + &mut tx, + owner_2, + MessageBalance { + retryable: 2, + non_retryable: 4, + }, + ); + } + + #[test] + fn coin_balance_overflow_does_not_error() { + use tempfile::TempDir; + let tmp_dir = TempDir::new().unwrap(); + let mut db: Database = Database::open_rocksdb( + tmp_dir.path(), + Default::default(), + DatabaseConfig::config_for_tests(), + ) + .unwrap(); + let mut tx = db.write_transaction(); + + const BALANCES_ARE_ENABLED: bool = true; + + let owner = Address::from([1; 32]); + let asset_id = AssetId::from([11; 32]); + + // Make the initial balance huge + let key = CoinBalancesKey::new(&owner, &asset_id); + tx.storage::() + .insert(&key, &u128::MAX) + .expect("should correctly query db"); + + assert_coin_balance(&mut tx, owner, asset_id, u128::MAX); + + // Try to add more coins + let events: Vec = + vec![Event::CoinCreated(make_coin(&owner, &asset_id, 1))]; + + events.iter().for_each(|event| { + update(event, &mut tx, BALANCES_ARE_ENABLED).expect("should process balance"); + }); + + assert_coin_balance(&mut tx, owner, asset_id, u128::MAX); + } + + #[test] + fn message_balance_overflow_does_not_error() { + use tempfile::TempDir; + let tmp_dir = TempDir::new().unwrap(); + let mut db: Database = Database::open_rocksdb( + tmp_dir.path(), + Default::default(), + DatabaseConfig::config_for_tests(), + ) + .unwrap(); + let mut tx = db.write_transaction(); + + const BALANCES_ARE_ENABLED: bool = true; + const MAX_BALANCES: MessageBalance = MessageBalance { + retryable: u128::MAX, + non_retryable: u128::MAX, + }; + + let owner = Address::from([1; 32]); + + // Make the initial balance huge + tx.storage::() + .insert(&owner, &MAX_BALANCES) + .expect("should correctly query db"); + + assert_message_balance(&mut tx, owner, MAX_BALANCES); + + // Try to add more coins + let events: Vec = vec![ + Event::MessageImported(make_retryable_message(&owner, 1)), + Event::MessageImported(make_nonretryable_message(&owner, 1)), + ]; + + events.iter().for_each(|event| { + update(event, &mut tx, BALANCES_ARE_ENABLED).expect("should process balance"); + }); + + assert_message_balance(&mut tx, owner, MAX_BALANCES); + } + + #[test] + fn coin_balance_underflow_causes_error() { + use tempfile::TempDir; + let tmp_dir = TempDir::new().unwrap(); + let mut db: Database = Database::open_rocksdb( + tmp_dir.path(), + Default::default(), + DatabaseConfig::config_for_tests(), + ) + .unwrap(); + let mut tx = db.write_transaction(); + + const BALANCES_ARE_ENABLED: bool = true; + + let owner = Address::from([1; 32]); + let asset_id_1 = AssetId::from([11; 32]); + let asset_id_2 = AssetId::from([12; 32]); + + // Initial set of coins + let events: Vec = + vec![Event::CoinCreated(make_coin(&owner, &asset_id_1, 100))]; + + events.iter().for_each(|event| { + update(event, &mut tx, BALANCES_ARE_ENABLED).expect("should process balance"); + }); + + // Consume more coins than available + let events: Vec = vec![ + Event::CoinConsumed(make_coin(&owner, &asset_id_1, 10000)), + Event::CoinConsumed(make_coin(&owner, &asset_id_2, 20000)), + ]; + + let expected_errors = vec![ + IndexationError::CoinBalanceWouldUnderflow { + owner, + asset_id: asset_id_1, + current_amount: 100, + requested_deduction: 10000, + } + .to_string(), + IndexationError::CoinBalanceWouldUnderflow { + owner, + asset_id: asset_id_2, + current_amount: 0, + requested_deduction: 20000, + } + .to_string(), + ]; + + let actual_errors: Vec<_> = events + .iter() + .map(|event| { + update(event, &mut tx, BALANCES_ARE_ENABLED) + .unwrap_err() + .to_string() + }) + .collect(); + + assert_eq!(expected_errors, actual_errors); + } +} diff --git a/crates/fuel-core/src/graphql_api/indexation/coins_to_spend.rs b/crates/fuel-core/src/graphql_api/indexation/coins_to_spend.rs new file mode 100644 index 00000000000..a27128434e7 --- /dev/null +++ b/crates/fuel-core/src/graphql_api/indexation/coins_to_spend.rs @@ -0,0 +1,767 @@ +use fuel_core_storage::StorageAsMut; + +use fuel_core_types::{ + entities::{ + coins::coin::Coin, + Message, + }, + fuel_tx::AssetId, + services::executor::Event, +}; + +use crate::graphql_api::{ + ports::worker::OffChainDatabaseTransaction, + storage::coins::{ + CoinsToSpendIndex, + CoinsToSpendIndexKey, + IndexedCoinType, + }, +}; + +use super::error::IndexationError; + +// Indicates that a message is retryable. +pub(crate) const RETRYABLE_BYTE: [u8; 1] = [0x00]; + +// Indicates that a message is non-retryable (also, all coins use this byte). +pub(crate) const NON_RETRYABLE_BYTE: [u8; 1] = [0x01]; + +fn add_coin(block_st_transaction: &mut T, coin: &Coin) -> Result<(), IndexationError> +where + T: OffChainDatabaseTransaction, +{ + let key = CoinsToSpendIndexKey::from_coin(coin); + let storage = block_st_transaction.storage::(); + let maybe_old_value = storage.replace(&key, &IndexedCoinType::Coin)?; + if maybe_old_value.is_some() { + return Err(IndexationError::CoinToSpendAlreadyIndexed { + owner: coin.owner, + asset_id: coin.asset_id, + amount: coin.amount, + utxo_id: coin.utxo_id, + }); + } + Ok(()) +} + +fn remove_coin( + block_st_transaction: &mut T, + coin: &Coin, +) -> Result<(), IndexationError> +where + T: OffChainDatabaseTransaction, +{ + let key = CoinsToSpendIndexKey::from_coin(coin); + let storage = block_st_transaction.storage::(); + let maybe_old_value = storage.take(&key)?; + if maybe_old_value.is_none() { + return Err(IndexationError::CoinToSpendNotFound { + owner: coin.owner, + asset_id: coin.asset_id, + amount: coin.amount, + utxo_id: coin.utxo_id, + }); + } + Ok(()) +} + +fn add_message( + block_st_transaction: &mut T, + message: &Message, + base_asset_id: &AssetId, +) -> Result<(), IndexationError> +where + T: OffChainDatabaseTransaction, +{ + let key = CoinsToSpendIndexKey::from_message(message, base_asset_id); + let storage = block_st_transaction.storage::(); + let maybe_old_value = storage.replace(&key, &IndexedCoinType::Message)?; + if maybe_old_value.is_some() { + return Err(IndexationError::MessageToSpendAlreadyIndexed { + owner: *message.recipient(), + amount: message.amount(), + nonce: *message.nonce(), + }); + } + Ok(()) +} + +fn remove_message( + block_st_transaction: &mut T, + message: &Message, + base_asset_id: &AssetId, +) -> Result<(), IndexationError> +where + T: OffChainDatabaseTransaction, +{ + let key = CoinsToSpendIndexKey::from_message(message, base_asset_id); + let storage = block_st_transaction.storage::(); + let maybe_old_value = storage.take(&key)?; + if maybe_old_value.is_none() { + return Err(IndexationError::MessageToSpendNotFound { + owner: *message.recipient(), + amount: message.amount(), + nonce: *message.nonce(), + }); + } + Ok(()) +} + +pub(crate) fn update( + event: &Event, + block_st_transaction: &mut T, + enabled: bool, + base_asset_id: &AssetId, +) -> Result<(), IndexationError> +where + T: OffChainDatabaseTransaction, +{ + if !enabled { + return Ok(()); + } + + match event { + Event::MessageImported(message) => { + add_message(block_st_transaction, message, base_asset_id) + } + Event::MessageConsumed(message) => { + remove_message(block_st_transaction, message, base_asset_id) + } + Event::CoinCreated(coin) => add_coin(block_st_transaction, coin), + Event::CoinConsumed(coin) => remove_coin(block_st_transaction, coin), + Event::ForcedTransactionFailed { .. } => Ok(()), + } +} + +#[cfg(test)] +mod tests { + use fuel_core_storage::{ + iter::IterDirection, + transactional::WriteTransaction, + StorageAsMut, + }; + use fuel_core_types::{ + fuel_tx::{ + Address, + AssetId, + }, + services::executor::Event, + }; + use rand::seq::SliceRandom; + + use itertools::Itertools; + use proptest::{ + collection::vec, + prelude::*, + }; + + use crate::{ + database::{ + database_description::off_chain::OffChain, + Database, + }, + graphql_api::{ + indexation::{ + coins_to_spend::{ + update, + RETRYABLE_BYTE, + }, + error::IndexationError, + test_utils::{ + make_coin, + make_nonretryable_message, + make_retryable_message, + }, + }, + storage::coins::{ + CoinsToSpendIndex, + CoinsToSpendIndexKey, + }, + }, + state::rocks_db::DatabaseConfig, + }; + + use super::NON_RETRYABLE_BYTE; + + fn assert_index_entries( + db: &Database, + expected_entries: &[(Address, AssetId, [u8; 1], u64)], + ) { + let actual_entries: Vec<_> = db + .entries::(None, IterDirection::Forward) + .map(|entry| entry.expect("should read entries")) + .map(|entry| { + ( + entry.key.owner().unwrap(), + entry.key.asset_id().unwrap(), + [entry.key.retryable_flag().unwrap()], + entry.key.amount().unwrap(), + ) + }) + .collect(); + + assert_eq!(expected_entries, actual_entries.as_slice()); + } + + #[test] + fn coins_to_spend_indexation_enabled_flag_is_respected() { + use tempfile::TempDir; + let tmp_dir = TempDir::new().unwrap(); + let mut db: Database = Database::open_rocksdb( + tmp_dir.path(), + Default::default(), + DatabaseConfig::config_for_tests(), + ) + .unwrap(); + let mut tx = db.write_transaction(); + + // Given + const COINS_TO_SPEND_INDEX_IS_DISABLED: bool = false; + let base_asset_id = AssetId::from([0; 32]); + + let owner_1 = Address::from([1; 32]); + let owner_2 = Address::from([2; 32]); + + let asset_id_1 = AssetId::from([11; 32]); + let asset_id_2 = AssetId::from([12; 32]); + + let coin_1 = make_coin(&owner_1, &asset_id_1, 100); + let coin_2 = make_coin(&owner_1, &asset_id_2, 200); + let message_1 = make_retryable_message(&owner_1, 300); + let message_2 = make_nonretryable_message(&owner_2, 400); + + // TODO[RC]: No clone() required for coins? Double check the types used, + // maybe we want `MessageCoin` (which is Copy) for messages? + // 1) Currently we use the same types as embedded in the executor `Event`. + // 2) `MessageCoin` will refuse to construct itself from a `Message` if the data is empty + // impl TryFrom for MessageCoin { ... if !data.is_empty() ... } + // Actually it shouldn't matter from the indexation perspective, as we just need + // to read data from the type and don't care about which data type we took it from. + + // Initial set of coins + let events: Vec = vec![ + Event::CoinCreated(coin_1), + Event::CoinConsumed(coin_2), + Event::MessageImported(message_1.clone()), + Event::MessageConsumed(message_2.clone()), + ]; + + // When + events.iter().for_each(|event| { + update( + event, + &mut tx, + COINS_TO_SPEND_INDEX_IS_DISABLED, + &base_asset_id, + ) + .expect("should process balance"); + }); + + // Then + let key = CoinsToSpendIndexKey::from_coin(&coin_1); + let coin = tx + .storage::() + .get(&key) + .expect("should correctly query db"); + assert!(coin.is_none()); + + let key = CoinsToSpendIndexKey::from_coin(&coin_2); + let coin = tx + .storage::() + .get(&key) + .expect("should correctly query db"); + assert!(coin.is_none()); + + let key = CoinsToSpendIndexKey::from_message(&message_1, &base_asset_id); + let message = tx + .storage::() + .get(&key) + .expect("should correctly query db"); + assert!(message.is_none()); + + let key = CoinsToSpendIndexKey::from_message(&message_2, &base_asset_id); + let message = tx + .storage::() + .get(&key) + .expect("should correctly query db"); + assert!(message.is_none()); + } + + #[test] + fn coin_owner_and_asset_id_is_respected() { + use tempfile::TempDir; + let tmp_dir = TempDir::new().unwrap(); + let mut db: Database = Database::open_rocksdb( + tmp_dir.path(), + Default::default(), + DatabaseConfig::config_for_tests(), + ) + .unwrap(); + let mut tx = db.write_transaction(); + + // Given + const COINS_TO_SPEND_INDEX_IS_ENABLED: bool = true; + let base_asset_id = AssetId::from([0; 32]); + + let owner_1 = Address::from([1; 32]); + let owner_2 = Address::from([2; 32]); + + let asset_id_1 = AssetId::from([11; 32]); + let asset_id_2 = AssetId::from([12; 32]); + + // Initial set of coins of the same asset id - mind the random order of amounts + let mut events: Vec = vec![ + Event::CoinCreated(make_coin(&owner_1, &asset_id_1, 100)), + Event::CoinCreated(make_coin(&owner_1, &asset_id_1, 300)), + Event::CoinCreated(make_coin(&owner_1, &asset_id_1, 200)), + ]; + + // Add more coins, some of them for the new asset id - mind the random order of amounts + events.extend([ + Event::CoinCreated(make_coin(&owner_1, &asset_id_2, 10)), + Event::CoinCreated(make_coin(&owner_1, &asset_id_2, 12)), + Event::CoinCreated(make_coin(&owner_1, &asset_id_2, 11)), + Event::CoinCreated(make_coin(&owner_1, &asset_id_1, 150)), + ]); + + // Add another owner into the mix + events.extend([ + Event::CoinCreated(make_coin(&owner_2, &asset_id_1, 1000)), + Event::CoinCreated(make_coin(&owner_2, &asset_id_1, 2000)), + Event::CoinCreated(make_coin(&owner_2, &asset_id_1, 200000)), + Event::CoinCreated(make_coin(&owner_2, &asset_id_1, 1500)), + Event::CoinCreated(make_coin(&owner_2, &asset_id_2, 900)), + Event::CoinCreated(make_coin(&owner_2, &asset_id_2, 800)), + Event::CoinCreated(make_coin(&owner_2, &asset_id_2, 700)), + ]); + + // Consume some coins + events.extend([ + Event::CoinConsumed(make_coin(&owner_1, &asset_id_1, 300)), + Event::CoinConsumed(make_coin(&owner_2, &asset_id_1, 200000)), + ]); + + // When + + // Process all events + events.iter().for_each(|event| { + update( + event, + &mut tx, + COINS_TO_SPEND_INDEX_IS_ENABLED, + &base_asset_id, + ) + .expect("should process coins to spend"); + }); + tx.commit().expect("should commit transaction"); + + // Then + + // Mind the sorted amounts + let expected_index_entries = &[ + (owner_1, asset_id_1, NON_RETRYABLE_BYTE, 100), + (owner_1, asset_id_1, NON_RETRYABLE_BYTE, 150), + (owner_1, asset_id_1, NON_RETRYABLE_BYTE, 200), + (owner_1, asset_id_2, NON_RETRYABLE_BYTE, 10), + (owner_1, asset_id_2, NON_RETRYABLE_BYTE, 11), + (owner_1, asset_id_2, NON_RETRYABLE_BYTE, 12), + (owner_2, asset_id_1, NON_RETRYABLE_BYTE, 1000), + (owner_2, asset_id_1, NON_RETRYABLE_BYTE, 1500), + (owner_2, asset_id_1, NON_RETRYABLE_BYTE, 2000), + (owner_2, asset_id_2, NON_RETRYABLE_BYTE, 700), + (owner_2, asset_id_2, NON_RETRYABLE_BYTE, 800), + (owner_2, asset_id_2, NON_RETRYABLE_BYTE, 900), + ]; + + assert_index_entries(&db, expected_index_entries); + } + + #[test] + fn message_owner_is_respected() { + use tempfile::TempDir; + let tmp_dir = TempDir::new().unwrap(); + let mut db: Database = Database::open_rocksdb( + tmp_dir.path(), + Default::default(), + DatabaseConfig::config_for_tests(), + ) + .unwrap(); + let mut tx = db.write_transaction(); + + // Given + const COINS_TO_SPEND_INDEX_IS_ENABLED: bool = true; + let base_asset_id = AssetId::from([0; 32]); + + let owner_1 = Address::from([1; 32]); + let owner_2 = Address::from([2; 32]); + + // Initial set of coins of the same asset id - mind the random order of amounts + let mut events: Vec = vec![ + Event::MessageImported(make_nonretryable_message(&owner_1, 100)), + Event::MessageImported(make_nonretryable_message(&owner_1, 300)), + Event::MessageImported(make_nonretryable_message(&owner_1, 200)), + ]; + + // Add another owner into the mix + events.extend([ + Event::MessageImported(make_nonretryable_message(&owner_2, 1000)), + Event::MessageImported(make_nonretryable_message(&owner_2, 2000)), + Event::MessageImported(make_nonretryable_message(&owner_2, 200000)), + Event::MessageImported(make_nonretryable_message(&owner_2, 800)), + Event::MessageImported(make_nonretryable_message(&owner_2, 700)), + ]); + + // Consume some coins + events.extend([ + Event::MessageConsumed(make_nonretryable_message(&owner_1, 300)), + Event::MessageConsumed(make_nonretryable_message(&owner_2, 200000)), + ]); + + // When + + // Process all events + events.iter().for_each(|event| { + update( + event, + &mut tx, + COINS_TO_SPEND_INDEX_IS_ENABLED, + &base_asset_id, + ) + .expect("should process coins to spend"); + }); + tx.commit().expect("should commit transaction"); + + // Then + + // Mind the sorted amounts + let expected_index_entries = &[ + (owner_1, base_asset_id, NON_RETRYABLE_BYTE, 100), + (owner_1, base_asset_id, NON_RETRYABLE_BYTE, 200), + (owner_2, base_asset_id, NON_RETRYABLE_BYTE, 700), + (owner_2, base_asset_id, NON_RETRYABLE_BYTE, 800), + (owner_2, base_asset_id, NON_RETRYABLE_BYTE, 1000), + (owner_2, base_asset_id, NON_RETRYABLE_BYTE, 2000), + ]; + + assert_index_entries(&db, expected_index_entries); + } + + #[test] + fn coins_with_retryable_and_non_retryable_messages_are_not_mixed() { + use tempfile::TempDir; + let tmp_dir = TempDir::new().unwrap(); + let mut db: Database = Database::open_rocksdb( + tmp_dir.path(), + Default::default(), + DatabaseConfig::config_for_tests(), + ) + .unwrap(); + let mut tx = db.write_transaction(); + + // Given + const COINS_TO_SPEND_INDEX_IS_ENABLED: bool = true; + let base_asset_id = AssetId::from([0; 32]); + let owner = Address::from([1; 32]); + let asset_id = AssetId::from([11; 32]); + + let mut events = vec![ + Event::CoinCreated(make_coin(&owner, &asset_id, 101)), + Event::CoinCreated(make_coin(&owner, &asset_id, 100)), + Event::CoinCreated(make_coin(&owner, &base_asset_id, 200000)), + Event::CoinCreated(make_coin(&owner, &base_asset_id, 201)), + Event::CoinCreated(make_coin(&owner, &base_asset_id, 200)), + Event::MessageImported(make_retryable_message(&owner, 301)), + Event::MessageImported(make_retryable_message(&owner, 200000)), + Event::MessageImported(make_retryable_message(&owner, 300)), + Event::MessageImported(make_nonretryable_message(&owner, 401)), + Event::MessageImported(make_nonretryable_message(&owner, 200000)), + Event::MessageImported(make_nonretryable_message(&owner, 400)), + ]; + events.shuffle(&mut rand::thread_rng()); + + // Delete the "big" coins + events.extend([ + Event::CoinConsumed(make_coin(&owner, &base_asset_id, 200000)), + Event::MessageConsumed(make_retryable_message(&owner, 200000)), + Event::MessageConsumed(make_nonretryable_message(&owner, 200000)), + ]); + + // When + + // Process all events + events.iter().for_each(|event| { + update( + event, + &mut tx, + COINS_TO_SPEND_INDEX_IS_ENABLED, + &base_asset_id, + ) + .expect("should process coins to spend"); + }); + tx.commit().expect("should commit transaction"); + + // Then + + // Mind the amounts are always correctly sorted + let expected_index_entries = &[ + (owner, base_asset_id, RETRYABLE_BYTE, 300), + (owner, base_asset_id, RETRYABLE_BYTE, 301), + (owner, base_asset_id, NON_RETRYABLE_BYTE, 200), + (owner, base_asset_id, NON_RETRYABLE_BYTE, 201), + (owner, base_asset_id, NON_RETRYABLE_BYTE, 400), + (owner, base_asset_id, NON_RETRYABLE_BYTE, 401), + (owner, asset_id, NON_RETRYABLE_BYTE, 100), + (owner, asset_id, NON_RETRYABLE_BYTE, 101), + ]; + + assert_index_entries(&db, expected_index_entries); + } + + #[test] + fn double_insertion_of_message_causes_error() { + use tempfile::TempDir; + let tmp_dir = TempDir::new().unwrap(); + let mut db: Database = Database::open_rocksdb( + tmp_dir.path(), + Default::default(), + DatabaseConfig::config_for_tests(), + ) + .unwrap(); + let mut tx = db.write_transaction(); + + // Given + const COINS_TO_SPEND_INDEX_IS_ENABLED: bool = true; + let base_asset_id = AssetId::from([0; 32]); + let owner = Address::from([1; 32]); + + let message = make_nonretryable_message(&owner, 400); + let message_event = Event::MessageImported(message.clone()); + assert!(update( + &message_event, + &mut tx, + COINS_TO_SPEND_INDEX_IS_ENABLED, + &base_asset_id, + ) + .is_ok()); + + // When + let result = update( + &message_event, + &mut tx, + COINS_TO_SPEND_INDEX_IS_ENABLED, + &base_asset_id, + ); + + // Then + assert_eq!( + result.unwrap_err().to_string(), + IndexationError::MessageToSpendAlreadyIndexed { + owner, + amount: 400, + nonce: *message.nonce(), + } + .to_string() + ); + } + + #[test] + fn double_insertion_of_coin_causes_error() { + use tempfile::TempDir; + let tmp_dir = TempDir::new().unwrap(); + let mut db: Database = Database::open_rocksdb( + tmp_dir.path(), + Default::default(), + DatabaseConfig::config_for_tests(), + ) + .unwrap(); + let mut tx = db.write_transaction(); + + // Given + const COINS_TO_SPEND_INDEX_IS_ENABLED: bool = true; + let base_asset_id = AssetId::from([0; 32]); + let owner = Address::from([1; 32]); + let asset_id = AssetId::from([11; 32]); + + let coin = make_coin(&owner, &asset_id, 100); + let coin_event = Event::CoinCreated(coin); + + assert!(update( + &coin_event, + &mut tx, + COINS_TO_SPEND_INDEX_IS_ENABLED, + &base_asset_id, + ) + .is_ok()); + + // When + let result = update( + &coin_event, + &mut tx, + COINS_TO_SPEND_INDEX_IS_ENABLED, + &base_asset_id, + ); + + // Then + assert_eq!( + result.unwrap_err().to_string(), + IndexationError::CoinToSpendAlreadyIndexed { + owner, + asset_id, + amount: 100, + utxo_id: coin.utxo_id, + } + .to_string() + ); + } + + #[test] + fn removal_of_non_existing_coin_causes_error() { + use tempfile::TempDir; + let tmp_dir = TempDir::new().unwrap(); + let mut db: Database = Database::open_rocksdb( + tmp_dir.path(), + Default::default(), + DatabaseConfig::config_for_tests(), + ) + .unwrap(); + let mut tx = db.write_transaction(); + + // Given + const COINS_TO_SPEND_INDEX_IS_ENABLED: bool = true; + let base_asset_id = AssetId::from([0; 32]); + let owner = Address::from([1; 32]); + let asset_id = AssetId::from([11; 32]); + + let coin = make_coin(&owner, &asset_id, 100); + let coin_event = Event::CoinConsumed(coin); + + // When + let result = update( + &coin_event, + &mut tx, + COINS_TO_SPEND_INDEX_IS_ENABLED, + &base_asset_id, + ); + + // Then + assert_eq!( + result.unwrap_err().to_string(), + IndexationError::CoinToSpendNotFound { + owner, + asset_id, + amount: 100, + utxo_id: coin.utxo_id, + } + .to_string() + ); + + let message = make_nonretryable_message(&owner, 400); + let message_event = Event::MessageConsumed(message.clone()); + assert_eq!( + update( + &message_event, + &mut tx, + COINS_TO_SPEND_INDEX_IS_ENABLED, + &base_asset_id, + ) + .unwrap_err() + .to_string(), + IndexationError::MessageToSpendNotFound { + owner, + amount: 400, + nonce: *message.nonce(), + } + .to_string() + ); + } + + #[test] + fn removal_of_non_existing_message_causes_error() { + use tempfile::TempDir; + let tmp_dir = TempDir::new().unwrap(); + let mut db: Database = Database::open_rocksdb( + tmp_dir.path(), + Default::default(), + DatabaseConfig::config_for_tests(), + ) + .unwrap(); + let mut tx = db.write_transaction(); + + // Given + const COINS_TO_SPEND_INDEX_IS_ENABLED: bool = true; + let base_asset_id = AssetId::from([0; 32]); + let owner = Address::from([1; 32]); + + let message = make_nonretryable_message(&owner, 400); + let message_event = Event::MessageConsumed(message.clone()); + + // When + let result = update( + &message_event, + &mut tx, + COINS_TO_SPEND_INDEX_IS_ENABLED, + &base_asset_id, + ); + + // Then + assert_eq!( + result.unwrap_err().to_string(), + IndexationError::MessageToSpendNotFound { + owner, + amount: 400, + nonce: *message.nonce(), + } + .to_string() + ); + } + + proptest! { + #[test] + fn test_coin_index_is_sorted( + amounts in vec(any::(), 1..100), + ) { + use tempfile::TempDir; + let tmp_dir = TempDir::new().unwrap(); + let mut db: Database = Database::open_rocksdb( + tmp_dir.path(), + Default::default(), + DatabaseConfig::config_for_tests(), + ) + .unwrap(); + let mut tx = db.write_transaction(); + let base_asset_id = AssetId::from([0; 32]); + + const COINS_TO_SPEND_INDEX_IS_ENABLED: bool = true; + + let events: Vec<_> = amounts.iter() + // Given + .map(|&amount| Event::CoinCreated(make_coin(&Address::from([1; 32]), &AssetId::from([11; 32]), amount))) + .collect(); + + // When + events.iter().for_each(|event| { + update( + event, + &mut tx, + COINS_TO_SPEND_INDEX_IS_ENABLED, + &base_asset_id, + ) + .expect("should process coins to spend"); + }); + tx.commit().expect("should commit transaction"); + + // Then + let actual_amounts: Vec<_> = db + .entries::(None, IterDirection::Forward) + .map(|entry| entry.expect("should read entries")) + .map(|entry| + entry.key.amount().unwrap(), + ) + .collect(); + + let sorted_amounts = amounts.iter().copied().sorted().collect::>(); + + prop_assert_eq!(sorted_amounts, actual_amounts); + } + } +} diff --git a/crates/fuel-core/src/graphql_api/indexation/error.rs b/crates/fuel-core/src/graphql_api/indexation/error.rs new file mode 100644 index 00000000000..6745293f38b --- /dev/null +++ b/crates/fuel-core/src/graphql_api/indexation/error.rs @@ -0,0 +1,103 @@ +use fuel_core_storage::Error as StorageError; + +use fuel_core_types::{ + fuel_tx::{ + Address, + AssetId, + UtxoId, + }, + fuel_types::Nonce, +}; + +#[derive(derive_more::From, derive_more::Display, Debug)] +pub enum IndexationError { + #[display( + fmt = "Coin balance would underflow for owner: {}, asset_id: {}, current_amount: {}, requested_deduction: {}", + owner, + asset_id, + current_amount, + requested_deduction + )] + CoinBalanceWouldUnderflow { + owner: Address, + asset_id: AssetId, + current_amount: u128, + requested_deduction: u128, + }, + #[display( + fmt = "Message balance would underflow for owner: {}, current_amount: {}, requested_deduction: {}, retryable: {}", + owner, + current_amount, + requested_deduction, + retryable + )] + MessageBalanceWouldUnderflow { + owner: Address, + current_amount: u128, + requested_deduction: u128, + retryable: bool, + }, + #[display( + fmt = "Coin not found in coins to spend index for owner: {}, asset_id: {}, amount: {}, utxo_id: {}", + owner, + asset_id, + amount, + utxo_id + )] + CoinToSpendNotFound { + owner: Address, + asset_id: AssetId, + amount: u64, + utxo_id: UtxoId, + }, + #[display( + fmt = "Coin already in the coins to spend index for owner: {}, asset_id: {}, amount: {}, utxo_id: {}", + owner, + asset_id, + amount, + utxo_id + )] + CoinToSpendAlreadyIndexed { + owner: Address, + asset_id: AssetId, + amount: u64, + utxo_id: UtxoId, + }, + #[display( + fmt = "Message not found in coins to spend index for owner: {}, amount: {}, nonce: {}", + owner, + amount, + nonce + )] + MessageToSpendNotFound { + owner: Address, + amount: u64, + nonce: Nonce, + }, + #[display( + fmt = "Message already in the coins to spend index for owner: {}, amount: {}, nonce: {}", + owner, + amount, + nonce + )] + MessageToSpendAlreadyIndexed { + owner: Address, + amount: u64, + nonce: Nonce, + }, + #[display(fmt = "Invalid coin type encountered in the index: {:?}", coin_type)] + InvalidIndexedCoinType { coin_type: Option }, + #[from] + StorageError(StorageError), +} + +impl std::error::Error for IndexationError {} + +impl From for StorageError { + fn from(error: IndexationError) -> Self { + match error { + IndexationError::StorageError(e) => e, + e => StorageError::Other(anyhow::anyhow!(e)), + } + } +} diff --git a/crates/fuel-core/src/graphql_api/indexation/test_utils.rs b/crates/fuel-core/src/graphql_api/indexation/test_utils.rs new file mode 100644 index 00000000000..bf210ea16f7 --- /dev/null +++ b/crates/fuel-core/src/graphql_api/indexation/test_utils.rs @@ -0,0 +1,38 @@ +use fuel_core_types::{ + entities::{ + coins::coin::Coin, + relayer::message::MessageV1, + Message, + }, + fuel_tx::{ + Address, + AssetId, + }, +}; + +pub(crate) fn make_coin(owner: &Address, asset_id: &AssetId, amount: u64) -> Coin { + Coin { + utxo_id: Default::default(), + owner: *owner, + amount, + asset_id: *asset_id, + tx_pointer: Default::default(), + } +} + +pub(crate) fn make_retryable_message(owner: &Address, amount: u64) -> Message { + Message::V1(MessageV1 { + sender: Default::default(), + recipient: *owner, + nonce: Default::default(), + amount, + data: vec![1], + da_height: Default::default(), + }) +} + +pub(crate) fn make_nonretryable_message(owner: &Address, amount: u64) -> Message { + let mut message = make_retryable_message(owner, amount); + message.set_data(vec![]); + message +} diff --git a/crates/fuel-core/src/graphql_api/ports.rs b/crates/fuel-core/src/graphql_api/ports.rs index 0463ee3992e..7227e5961ad 100644 --- a/crates/fuel-core/src/graphql_api/ports.rs +++ b/crates/fuel-core/src/graphql_api/ports.rs @@ -64,7 +64,15 @@ use fuel_core_types::{ }; use std::sync::Arc; -use super::storage::balances::TotalBalanceAmount; +use super::storage::{ + balances::TotalBalanceAmount, + coins::CoinsToSpendIndexEntry, +}; + +pub struct CoinsToSpendIndexIter<'a> { + pub big_coins_iter: BoxedIter<'a, Result>, + pub dust_coins_iter: BoxedIter<'a, Result>, +} pub trait OffChainDatabase: Send + Sync { fn block_height(&self, block_id: &BlockId) -> StorageResult; @@ -108,6 +116,12 @@ pub trait OffChainDatabase: Send + Sync { direction: IterDirection, ) -> BoxedIter>; + fn coins_to_spend_index( + &self, + owner: &Address, + asset_id: &AssetId, + ) -> CoinsToSpendIndexIter; + fn contract_salt(&self, contract_id: &ContractId) -> StorageResult; fn old_block(&self, height: &BlockHeight) -> StorageResult; @@ -293,6 +307,7 @@ pub mod worker { CoinBalances, MessageBalances, }, + coins::CoinsToSpendIndex, da_compression::*, old::{ OldFuelBlockConsensus, @@ -337,8 +352,11 @@ pub mod worker { /// Creates a write database transaction. fn transaction(&mut self) -> Self::Transaction<'_>; - /// Checks if Balances cache functionality is available. - fn balances_enabled(&self) -> StorageResult; + /// Checks if Balances indexation functionality is available. + fn balances_indexation_enabled(&self) -> StorageResult; + + /// Checks if CoinsToSpend indexation functionality is available. + fn coins_to_spend_indexation_enabled(&self) -> StorageResult; } /// Represents either the Genesis Block or a block at a specific height @@ -362,6 +380,7 @@ pub mod worker { + StorageMutate + StorageMutate + StorageMutate + + StorageMutate + StorageMutate + StorageMutate + StorageMutate diff --git a/crates/fuel-core/src/graphql_api/storage.rs b/crates/fuel-core/src/graphql_api/storage.rs index 8ebe615b30a..e0fafc79609 100644 --- a/crates/fuel-core/src/graphql_api/storage.rs +++ b/crates/fuel-core/src/graphql_api/storage.rs @@ -118,6 +118,8 @@ pub enum Column { CoinBalances = 23, /// Message balances per account. MessageBalances = 24, + /// Index of the coins that are available to spend. + CoinsToSpend = 25, } impl Column { diff --git a/crates/fuel-core/src/graphql_api/storage/coins.rs b/crates/fuel-core/src/graphql_api/storage/coins.rs index 42d22ba94ec..67226c4a7af 100644 --- a/crates/fuel-core/src/graphql_api/storage/coins.rs +++ b/crates/fuel-core/src/graphql_api/storage/coins.rs @@ -8,21 +8,248 @@ use fuel_core_storage::{ structured_storage::TableWithBlueprint, Mappable, }; -use fuel_core_types::fuel_tx::{ - Address, - TxId, - UtxoId, +use fuel_core_types::{ + entities::{ + coins::coin::Coin, + Message, + }, + fuel_tx::{ + self, + Address, + AssetId, + TxId, + UtxoId, + }, + fuel_types::{ + self, + Nonce, + }, +}; + +use crate::graphql_api::indexation; + +use self::indexation::{ + coins_to_spend::{ + NON_RETRYABLE_BYTE, + RETRYABLE_BYTE, + }, + error::IndexationError, }; +const AMOUNT_SIZE: usize = size_of::(); +const UTXO_ID_SIZE: usize = size_of::(); +const RETRYABLE_FLAG_SIZE: usize = size_of::(); + // TODO: Reuse `fuel_vm::storage::double_key` macro. pub fn owner_coin_id_key(owner: &Address, coin_id: &UtxoId) -> OwnedCoinKey { - let mut default = [0u8; Address::LEN + TxId::LEN + 2]; + let mut default = [0u8; Address::LEN + UTXO_ID_SIZE]; default[0..Address::LEN].copy_from_slice(owner.as_ref()); - let utxo_id_bytes: [u8; TxId::LEN + 2] = utxo_id_to_bytes(coin_id); + let utxo_id_bytes: [u8; UTXO_ID_SIZE] = utxo_id_to_bytes(coin_id); default[Address::LEN..].copy_from_slice(utxo_id_bytes.as_ref()); default } +/// The storage table for the index of coins to spend. +pub struct CoinsToSpendIndex; + +impl Mappable for CoinsToSpendIndex { + type Key = Self::OwnedKey; + type OwnedKey = CoinsToSpendIndexKey; + type Value = Self::OwnedValue; + type OwnedValue = IndexedCoinType; +} + +impl TableWithBlueprint for CoinsToSpendIndex { + type Blueprint = Plain; + type Column = super::Column; + + fn column() -> Self::Column { + Self::Column::CoinsToSpend + } +} + +// For coins, the foreign key is the UtxoId (34 bytes). +pub(crate) const COIN_FOREIGN_KEY_LEN: usize = UTXO_ID_SIZE; + +// For messages, the foreign key is the nonce (32 bytes). +pub(crate) const MESSAGE_FOREIGN_KEY_LEN: usize = Nonce::LEN; + +#[repr(u8)] +#[derive(Debug, Clone, PartialEq)] +pub enum IndexedCoinType { + Coin, + Message, +} + +impl AsRef<[u8]> for IndexedCoinType { + fn as_ref(&self) -> &[u8] { + match self { + IndexedCoinType::Coin => &[IndexedCoinType::Coin as u8], + IndexedCoinType::Message => &[IndexedCoinType::Message as u8], + } + } +} + +impl TryFrom<&[u8]> for IndexedCoinType { + type Error = IndexationError; + + fn try_from(value: &[u8]) -> Result { + match value { + [0] => Ok(IndexedCoinType::Coin), + [1] => Ok(IndexedCoinType::Message), + [] => Err(IndexationError::InvalidIndexedCoinType { coin_type: None }), + x => Err(IndexationError::InvalidIndexedCoinType { + coin_type: Some(x[0]), + }), + } + } +} + +pub type CoinsToSpendIndexEntry = (CoinsToSpendIndexKey, IndexedCoinType); + +// TODO: Convert this key from Vec to strongly typed struct: https://github.com/FuelLabs/fuel-core/issues/2498 +#[derive(Debug, Clone, Default, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct CoinsToSpendIndexKey(Vec); + +impl core::fmt::Display for CoinsToSpendIndexKey { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + write!( + f, + "retryable_flag={:?}, owner={:?}, asset_id={:?}, amount={:?}", + self.retryable_flag(), + self.owner(), + self.asset_id(), + self.amount() + ) + } +} + +impl TryFrom<&CoinsToSpendIndexKey> for fuel_tx::UtxoId { + type Error = (); + + fn try_from(value: &CoinsToSpendIndexKey) -> Result { + let bytes: [u8; COIN_FOREIGN_KEY_LEN] = value + .foreign_key_bytes() + .ok_or(())? + .try_into() + .map_err(|_| ())?; + let (tx_id_bytes, output_index_bytes) = bytes.split_at(TxId::LEN); + let tx_id = TxId::try_from(tx_id_bytes).map_err(|_| ())?; + let output_index = + u16::from_be_bytes(output_index_bytes.try_into().map_err(|_| ())?); + Ok(fuel_tx::UtxoId::new(tx_id, output_index)) + } +} + +impl TryFrom<&CoinsToSpendIndexKey> for fuel_types::Nonce { + type Error = (); + + fn try_from(value: &CoinsToSpendIndexKey) -> Result { + value + .foreign_key_bytes() + .and_then(|bytes| <[u8; MESSAGE_FOREIGN_KEY_LEN]>::try_from(bytes).ok()) + .map(fuel_types::Nonce::from) + .ok_or(()) + } +} + +impl CoinsToSpendIndexKey { + pub fn from_coin(coin: &Coin) -> Self { + let retryable_flag_bytes = NON_RETRYABLE_BYTE; + let address_bytes = coin.owner.as_ref(); + let asset_id_bytes = coin.asset_id.as_ref(); + let amount_bytes = coin.amount.to_be_bytes(); + let utxo_id_bytes = utxo_id_to_bytes(&coin.utxo_id); + + Self( + retryable_flag_bytes + .iter() + .chain(address_bytes) + .chain(asset_id_bytes) + .chain(amount_bytes.iter()) + .chain(utxo_id_bytes.iter()) + .copied() + .collect(), + ) + } + + pub fn from_message(message: &Message, base_asset_id: &AssetId) -> Self { + let retryable_flag_bytes = if message.is_retryable_message() { + RETRYABLE_BYTE + } else { + NON_RETRYABLE_BYTE + }; + let address_bytes = message.recipient().as_ref(); + let asset_id_bytes = base_asset_id.as_ref(); + let amount_bytes = message.amount().to_be_bytes(); + let nonce_bytes = message.nonce().as_slice(); + + Self( + retryable_flag_bytes + .iter() + .chain(address_bytes) + .chain(asset_id_bytes) + .chain(amount_bytes.iter()) + .chain(nonce_bytes) + .copied() + .collect(), + ) + } + + fn from_slice(slice: &[u8]) -> Self { + Self(slice.into()) + } + + pub fn owner(&self) -> Option
{ + const ADDRESS_START: usize = RETRYABLE_FLAG_SIZE; + const ADDRESS_END: usize = ADDRESS_START + Address::LEN; + + let bytes = self.0.get(ADDRESS_START..ADDRESS_END)?; + bytes.try_into().ok().map(Address::new) + } + + pub fn asset_id(&self) -> Option { + const OFFSET: usize = RETRYABLE_FLAG_SIZE + Address::LEN; + const ASSET_ID_START: usize = OFFSET; + const ASSET_ID_END: usize = ASSET_ID_START + AssetId::LEN; + + let bytes = self.0.get(ASSET_ID_START..ASSET_ID_END)?; + bytes.try_into().ok().map(AssetId::new) + } + + pub fn retryable_flag(&self) -> Option { + const OFFSET: usize = 0; + self.0.get(OFFSET).copied() + } + + pub fn amount(&self) -> Option { + const OFFSET: usize = RETRYABLE_FLAG_SIZE + Address::LEN + AssetId::LEN; + const AMOUNT_START: usize = OFFSET; + const AMOUNT_END: usize = AMOUNT_START + AMOUNT_SIZE; + + let bytes = self.0.get(AMOUNT_START..AMOUNT_END)?; + bytes.try_into().ok().map(u64::from_be_bytes) + } + + pub fn foreign_key_bytes(&self) -> Option<&[u8]> { + const OFFSET: usize = + RETRYABLE_FLAG_SIZE + Address::LEN + AssetId::LEN + AMOUNT_SIZE; + self.0.get(OFFSET..) + } +} + +impl From<&[u8]> for CoinsToSpendIndexKey { + fn from(slice: &[u8]) -> Self { + CoinsToSpendIndexKey::from_slice(slice) + } +} + +impl AsRef<[u8]> for CoinsToSpendIndexKey { + fn as_ref(&self) -> &[u8] { + self.0.as_ref() + } +} + /// The storage table of owned coin ids. Maps addresses to owned coins. pub struct OwnedCoins; /// The storage key for owned coins: `Address ++ UtxoId` @@ -46,8 +273,42 @@ impl TableWithBlueprint for OwnedCoins { #[cfg(test)] mod test { + use fuel_core_types::{ + entities::relayer::message::MessageV1, + fuel_types::Nonce, + }; + use super::*; + impl rand::distributions::Distribution + for rand::distributions::Standard + { + fn sample(&self, rng: &mut R) -> CoinsToSpendIndexKey { + let bytes: Vec<_> = if rng.gen() { + (0..COIN_TO_SPEND_COIN_KEY_LEN) + .map(|_| rng.gen::()) + .collect() + } else { + (0..COIN_TO_SPEND_MESSAGE_KEY_LEN) + .map(|_| rng.gen::()) + .collect() + }; + CoinsToSpendIndexKey(bytes) + } + } + + // Base part of the coins to spend index key. + const COIN_TO_SPEND_BASE_KEY_LEN: usize = + RETRYABLE_FLAG_SIZE + Address::LEN + AssetId::LEN + AMOUNT_SIZE; + + // Total length of the coins to spend index key for coins. + const COIN_TO_SPEND_COIN_KEY_LEN: usize = + COIN_TO_SPEND_BASE_KEY_LEN + COIN_FOREIGN_KEY_LEN; + + // Total length of the coins to spend index key for messages. + const COIN_TO_SPEND_MESSAGE_KEY_LEN: usize = + COIN_TO_SPEND_BASE_KEY_LEN + MESSAGE_FOREIGN_KEY_LEN; + fn generate_key(rng: &mut impl rand::Rng) -> ::Key { let mut bytes = [0u8; 66]; rng.fill(bytes.as_mut()); @@ -61,4 +322,221 @@ mod test { ::Value::default(), generate_key ); + + fuel_core_storage::basic_storage_tests!( + CoinsToSpendIndex, + ::Key::default(), + IndexedCoinType::Coin + ); + + fn merge_foreign_key_bytes(a: A, b: B) -> [u8; N] + where + A: AsRef<[u8]>, + B: AsRef<[u8]>, + { + a.as_ref() + .iter() + .copied() + .chain(b.as_ref().iter().copied()) + .collect::>() + .try_into() + .expect("should have correct length") + } + + #[test] + fn key_from_coin() { + // Given + let retryable_flag = NON_RETRYABLE_BYTE; + + let owner = Address::new([ + 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0A, 0x0B, 0x0C, + 0x0D, 0x0E, 0x0F, 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, + 0x1A, 0x1B, 0x1C, 0x1D, 0x1E, 0x1F, + ]); + + let asset_id = AssetId::new([ + 0x20, 0x21, 0x22, 0x23, 0x24, 0x25, 0x26, 0x27, 0x28, 0x29, 0x2A, 0x2B, 0x2C, + 0x2D, 0x2E, 0x2F, 0x30, 0x31, 0x32, 0x33, 0x34, 0x35, 0x36, 0x37, 0x38, 0x39, + 0x3A, 0x3B, 0x3C, 0x3D, 0x3E, 0x3F, + ]); + + let amount = [0x40, 0x41, 0x42, 0x43, 0x44, 0x45, 0x46, 0x47]; + assert_eq!(amount.len(), AMOUNT_SIZE); + + let tx_id = TxId::new([ + 0x50, 0x51, 0x52, 0x53, 0x54, 0x55, 0x56, 0x57, 0x58, 0x59, 0x5A, 0x5B, 0x5C, + 0x5D, 0x5E, 0x5F, 0x60, 0x61, 0x62, 0x63, 0x64, 0x65, 0x66, 0x67, 0x68, 0x69, + 0x6A, 0x6B, 0x6C, 0x6D, 0x6E, 0x6F, + ]); + + let output_index = [0xFE, 0xFF]; + let utxo_id = UtxoId::new(tx_id, u16::from_be_bytes(output_index)); + + let coin = Coin { + owner, + asset_id, + amount: u64::from_be_bytes(amount), + utxo_id, + tx_pointer: Default::default(), + }; + + // When + let key = CoinsToSpendIndexKey::from_coin(&coin); + + // Then + let key_bytes: [u8; COIN_TO_SPEND_COIN_KEY_LEN] = + key.as_ref().try_into().expect("should have correct length"); + + assert_eq!( + key_bytes, + [ + 0x01, 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0A, + 0x0B, 0x0C, 0x0D, 0x0E, 0x0F, 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, + 0x17, 0x18, 0x19, 0x1A, 0x1B, 0x1C, 0x1D, 0x1E, 0x1F, 0x20, 0x21, 0x22, + 0x23, 0x24, 0x25, 0x26, 0x27, 0x28, 0x29, 0x2A, 0x2B, 0x2C, 0x2D, 0x2E, + 0x2F, 0x30, 0x31, 0x32, 0x33, 0x34, 0x35, 0x36, 0x37, 0x38, 0x39, 0x3A, + 0x3B, 0x3C, 0x3D, 0x3E, 0x3F, 0x40, 0x41, 0x42, 0x43, 0x44, 0x45, 0x46, + 0x47, 0x50, 0x51, 0x52, 0x53, 0x54, 0x55, 0x56, 0x57, 0x58, 0x59, 0x5A, + 0x5B, 0x5C, 0x5D, 0x5E, 0x5F, 0x60, 0x61, 0x62, 0x63, 0x64, 0x65, 0x66, + 0x67, 0x68, 0x69, 0x6A, 0x6B, 0x6C, 0x6D, 0x6E, 0x6F, 0xFE, 0xFF, + ] + ); + + assert_eq!(key.owner().unwrap(), owner); + assert_eq!(key.asset_id().unwrap(), asset_id); + assert_eq!(key.retryable_flag().unwrap(), retryable_flag[0]); + assert_eq!(key.amount().unwrap(), u64::from_be_bytes(amount)); + assert_eq!( + key.foreign_key_bytes().unwrap(), + &merge_foreign_key_bytes::<_, _, COIN_FOREIGN_KEY_LEN>(tx_id, output_index) + ); + } + + #[test] + fn key_from_non_retryable_message() { + // Given + let retryable_flag = NON_RETRYABLE_BYTE; + + let owner = Address::new([ + 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0A, 0x0B, 0x0C, + 0x0D, 0x0E, 0x0F, 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, + 0x1A, 0x1B, 0x1C, 0x1D, 0x1E, 0x1F, + ]); + + let base_asset_id = AssetId::new([ + 0x20, 0x21, 0x22, 0x23, 0x24, 0x25, 0x26, 0x27, 0x28, 0x29, 0x2A, 0x2B, 0x2C, + 0x2D, 0x2E, 0x2F, 0x30, 0x31, 0x32, 0x33, 0x34, 0x35, 0x36, 0x37, 0x38, 0x39, + 0x3A, 0x3B, 0x3C, 0x3D, 0x3E, 0x3F, + ]); + + let amount = [0x40, 0x41, 0x42, 0x43, 0x44, 0x45, 0x46, 0x47]; + assert_eq!(amount.len(), AMOUNT_SIZE); + + let nonce = Nonce::new([ + 0x50, 0x51, 0x52, 0x53, 0x54, 0x55, 0x56, 0x57, 0x58, 0x59, 0x5A, 0x5B, 0x5C, + 0x5D, 0x5E, 0x5F, 0x60, 0x61, 0x62, 0x63, 0x64, 0x65, 0x66, 0x67, 0x68, 0x69, + 0x6A, 0x6B, 0x6C, 0x6D, 0x6E, 0x6F, + ]); + + let message = Message::V1(MessageV1 { + recipient: owner, + amount: u64::from_be_bytes(amount), + nonce, + sender: Default::default(), + data: vec![], + da_height: Default::default(), + }); + + // When + let key = CoinsToSpendIndexKey::from_message(&message, &base_asset_id); + + // Then + let key_bytes: [u8; COIN_TO_SPEND_MESSAGE_KEY_LEN] = + key.as_ref().try_into().expect("should have correct length"); + + assert_eq!( + key_bytes, + [ + 0x01, 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0A, + 0x0B, 0x0C, 0x0D, 0x0E, 0x0F, 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, + 0x17, 0x18, 0x19, 0x1A, 0x1B, 0x1C, 0x1D, 0x1E, 0x1F, 0x20, 0x21, 0x22, + 0x23, 0x24, 0x25, 0x26, 0x27, 0x28, 0x29, 0x2A, 0x2B, 0x2C, 0x2D, 0x2E, + 0x2F, 0x30, 0x31, 0x32, 0x33, 0x34, 0x35, 0x36, 0x37, 0x38, 0x39, 0x3A, + 0x3B, 0x3C, 0x3D, 0x3E, 0x3F, 0x40, 0x41, 0x42, 0x43, 0x44, 0x45, 0x46, + 0x47, 0x50, 0x51, 0x52, 0x53, 0x54, 0x55, 0x56, 0x57, 0x58, 0x59, 0x5A, + 0x5B, 0x5C, 0x5D, 0x5E, 0x5F, 0x60, 0x61, 0x62, 0x63, 0x64, 0x65, 0x66, + 0x67, 0x68, 0x69, 0x6A, 0x6B, 0x6C, 0x6D, 0x6E, 0x6F, + ] + ); + + assert_eq!(key.owner().unwrap(), owner); + assert_eq!(key.asset_id().unwrap(), base_asset_id); + assert_eq!(key.retryable_flag().unwrap(), retryable_flag[0]); + assert_eq!(key.amount().unwrap(), u64::from_be_bytes(amount)); + assert_eq!(key.foreign_key_bytes().unwrap(), nonce.as_ref()); + } + + #[test] + fn key_from_retryable_message() { + // Given + let retryable_flag = RETRYABLE_BYTE; + + let owner = Address::new([ + 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0A, 0x0B, 0x0C, + 0x0D, 0x0E, 0x0F, 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, + 0x1A, 0x1B, 0x1C, 0x1D, 0x1E, 0x1F, + ]); + + let base_asset_id = AssetId::new([ + 0x20, 0x21, 0x22, 0x23, 0x24, 0x25, 0x26, 0x27, 0x28, 0x29, 0x2A, 0x2B, 0x2C, + 0x2D, 0x2E, 0x2F, 0x30, 0x31, 0x32, 0x33, 0x34, 0x35, 0x36, 0x37, 0x38, 0x39, + 0x3A, 0x3B, 0x3C, 0x3D, 0x3E, 0x3F, + ]); + + let amount = [0x40, 0x41, 0x42, 0x43, 0x44, 0x45, 0x46, 0x47]; + assert_eq!(amount.len(), AMOUNT_SIZE); + + let nonce = Nonce::new([ + 0x50, 0x51, 0x52, 0x53, 0x54, 0x55, 0x56, 0x57, 0x58, 0x59, 0x5A, 0x5B, 0x5C, + 0x5D, 0x5E, 0x5F, 0x60, 0x61, 0x62, 0x63, 0x64, 0x65, 0x66, 0x67, 0x68, 0x69, + 0x6A, 0x6B, 0x6C, 0x6D, 0x6E, 0x6F, + ]); + + let message = Message::V1(MessageV1 { + recipient: owner, + amount: u64::from_be_bytes(amount), + nonce, + sender: Default::default(), + data: vec![1], + da_height: Default::default(), + }); + + // When + let key = CoinsToSpendIndexKey::from_message(&message, &base_asset_id); + + // Then + let key_bytes: [u8; COIN_TO_SPEND_MESSAGE_KEY_LEN] = + key.as_ref().try_into().expect("should have correct length"); + + assert_eq!( + key_bytes, + [ + 0x00, 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0A, + 0x0B, 0x0C, 0x0D, 0x0E, 0x0F, 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, + 0x17, 0x18, 0x19, 0x1A, 0x1B, 0x1C, 0x1D, 0x1E, 0x1F, 0x20, 0x21, 0x22, + 0x23, 0x24, 0x25, 0x26, 0x27, 0x28, 0x29, 0x2A, 0x2B, 0x2C, 0x2D, 0x2E, + 0x2F, 0x30, 0x31, 0x32, 0x33, 0x34, 0x35, 0x36, 0x37, 0x38, 0x39, 0x3A, + 0x3B, 0x3C, 0x3D, 0x3E, 0x3F, 0x40, 0x41, 0x42, 0x43, 0x44, 0x45, 0x46, + 0x47, 0x50, 0x51, 0x52, 0x53, 0x54, 0x55, 0x56, 0x57, 0x58, 0x59, 0x5A, + 0x5B, 0x5C, 0x5D, 0x5E, 0x5F, 0x60, 0x61, 0x62, 0x63, 0x64, 0x65, 0x66, + 0x67, 0x68, 0x69, 0x6A, 0x6B, 0x6C, 0x6D, 0x6E, 0x6F + ] + ); + + assert_eq!(key.owner().unwrap(), owner); + assert_eq!(key.asset_id().unwrap(), base_asset_id); + assert_eq!(key.retryable_flag().unwrap(), retryable_flag[0]); + assert_eq!(key.amount().unwrap(), u64::from_be_bytes(amount)); + assert_eq!(key.foreign_key_bytes().unwrap(), nonce.as_ref()); + } } diff --git a/crates/fuel-core/src/graphql_api/worker_service.rs b/crates/fuel-core/src/graphql_api/worker_service.rs index 68b3c4fa109..b2c060628fd 100644 --- a/crates/fuel-core/src/graphql_api/worker_service.rs +++ b/crates/fuel-core/src/graphql_api/worker_service.rs @@ -1,4 +1,4 @@ -use self::indexation::IndexationError; +use self::indexation::error::IndexationError; use super::{ da_compression::da_compress_block, @@ -69,6 +69,8 @@ use fuel_core_types::{ CoinPredicate, CoinSigned, }, + AssetId, + ConsensusParameters, Contract, Input, Output, @@ -121,6 +123,7 @@ pub struct InitializeTask { block_importer: BlockImporter, on_chain_database: OnChain, off_chain_database: OffChain, + base_asset_id: AssetId, } /// The off-chain GraphQL API worker task processes the imported blocks @@ -132,7 +135,9 @@ pub struct Task { chain_id: ChainId, da_compression_config: DaCompressionConfig, continue_on_error: bool, - balances_enabled: bool, + balances_indexation_enabled: bool, + coins_to_spend_indexation_enabled: bool, + base_asset_id: AssetId, } impl Task @@ -165,7 +170,9 @@ where process_executor_events( result.events.iter().map(Cow::Borrowed), &mut transaction, - self.balances_enabled, + self.balances_indexation_enabled, + self.coins_to_spend_indexation_enabled, + &self.base_asset_id, )?; match self.da_compression_config { @@ -194,29 +201,31 @@ where pub fn process_executor_events<'a, Iter, T>( events: Iter, block_st_transaction: &mut T, - balances_enabled: bool, + balances_indexation_enabled: bool, + coins_to_spend_indexation_enabled: bool, + base_asset_id: &AssetId, ) -> anyhow::Result<()> where Iter: Iterator>, T: OffChainDatabaseTransaction, { for event in events { - match indexation::process_balances_update( - event.deref(), + match update_indexation( + &event, block_st_transaction, - balances_enabled, + balances_indexation_enabled, + coins_to_spend_indexation_enabled, + base_asset_id, ) { Ok(()) => (), Err(IndexationError::StorageError(err)) => { return Err(err.into()); } - Err(err @ IndexationError::CoinBalanceWouldUnderflow { .. }) - | Err(err @ IndexationError::MessageBalanceWouldUnderflow { .. }) => { - // TODO[RC]: Balances overflow to be correctly handled. See: https://github.com/FuelLabs/fuel-core/issues/2428 - tracing::error!("Balances underflow detected: {}", err); + Err(err) => { + // TODO[RC]: Indexation errors to be correctly handled. See: https://github.com/FuelLabs/fuel-core/issues/2428 + tracing::error!("Indexation error: {}", err); } - } - + }; match event.deref() { Event::MessageImported(message) => { block_st_transaction @@ -268,6 +277,32 @@ where Ok(()) } +fn update_indexation( + event: &Event, + block_st_transaction: &mut T, + balances_indexation_enabled: bool, + coins_to_spend_indexation_enabled: bool, + base_asset_id: &AssetId, +) -> Result<(), IndexationError> +where + T: OffChainDatabaseTransaction, +{ + indexation::balances::update( + event, + block_st_transaction, + balances_indexation_enabled, + )?; + + indexation::coins_to_spend::update( + event, + block_st_transaction, + coins_to_spend_indexation_enabled, + base_asset_id, + )?; + + Ok(()) +} + /// Associate all transactions within a block to their respective UTXO owners fn index_tx_owners_for_block( block: &Block, @@ -499,8 +534,16 @@ where graphql_metrics().total_txs_count.set(total_tx_count as i64); } - let balances_enabled = self.off_chain_database.balances_enabled()?; - tracing::info!("Balances cache available: {}", balances_enabled); + let balances_indexation_enabled = + self.off_chain_database.balances_indexation_enabled()?; + let coins_to_spend_indexation_enabled = self + .off_chain_database + .coins_to_spend_indexation_enabled()?; + tracing::info!( + balances_indexation_enabled, + coins_to_spend_indexation_enabled, + "Indexation availability status" + ); let InitializeTask { chain_id, @@ -511,6 +554,7 @@ where on_chain_database, off_chain_database, continue_on_error, + base_asset_id, } = self; let mut task = Task { @@ -520,7 +564,9 @@ where chain_id, da_compression_config, continue_on_error, - balances_enabled, + balances_indexation_enabled, + coins_to_spend_indexation_enabled, + base_asset_id, }; let mut target_chain_height = on_chain_database.latest_height()?; @@ -636,9 +682,9 @@ pub fn new_service( block_importer: BlockImporter, on_chain_database: OnChain, off_chain_database: OffChain, - chain_id: ChainId, da_compression_config: DaCompressionConfig, continue_on_error: bool, + consensus_parameters: &ConsensusParameters, ) -> ServiceRunner> where TxPool: ports::worker::TxPool, @@ -652,8 +698,9 @@ where block_importer, on_chain_database, off_chain_database, - chain_id, + chain_id: consensus_parameters.chain_id(), da_compression_config, continue_on_error, + base_asset_id: *consensus_parameters.base_asset_id(), }) } diff --git a/crates/fuel-core/src/graphql_api/worker_service/tests.rs b/crates/fuel-core/src/graphql_api/worker_service/tests.rs index 5a59073702a..790245adb47 100644 --- a/crates/fuel-core/src/graphql_api/worker_service/tests.rs +++ b/crates/fuel-core/src/graphql_api/worker_service/tests.rs @@ -83,6 +83,8 @@ fn worker_task_with_block_importer_and_db( chain_id, da_compression_config: DaCompressionConfig::Disabled, continue_on_error: false, - balances_enabled: true, + balances_indexation_enabled: true, + coins_to_spend_indexation_enabled: true, + base_asset_id: Default::default(), } } diff --git a/crates/fuel-core/src/query/balance.rs b/crates/fuel-core/src/query/balance.rs index 706fcf02569..2eecc5258f6 100644 --- a/crates/fuel-core/src/query/balance.rs +++ b/crates/fuel-core/src/query/balance.rs @@ -41,7 +41,7 @@ impl ReadView { asset_id: AssetId, base_asset_id: AssetId, ) -> StorageResult { - let amount = if self.balances_enabled { + let amount = if self.balances_indexation_enabled { self.off_chain.balance(&owner, &asset_id, &base_asset_id)? } else { AssetQuery::new( @@ -72,7 +72,7 @@ impl ReadView { direction: IterDirection, base_asset_id: &'a AssetId, ) -> impl Stream> + 'a { - if self.balances_enabled { + if self.balances_indexation_enabled { futures::future::Either::Left(self.balances_with_cache( owner, base_asset_id, diff --git a/crates/fuel-core/src/schema/coins.rs b/crates/fuel-core/src/schema/coins.rs index ab9b0ca8959..68893c7ca26 100644 --- a/crates/fuel-core/src/schema/coins.rs +++ b/crates/fuel-core/src/schema/coins.rs @@ -1,13 +1,25 @@ +use std::collections::HashSet; + use crate::{ coins_query::{ random_improve, + select_coins_to_spend, + CoinsQueryError, + ExcludedCoinIds, SpendQuery, }, fuel_core_graphql_api::{ query_costs, IntoApiResult, }, - graphql_api::api_service::ConsensusProvider, + graphql_api::{ + api_service::ConsensusProvider, + database::ReadView, + storage::coins::{ + CoinsToSpendIndexEntry, + IndexedCoinType, + }, + }, query::asset_query::AssetSpendTarget, schema::{ scalars::{ @@ -30,14 +42,18 @@ use async_graphql::{ Context, }; use fuel_core_types::{ - entities::{ - coins, - coins::{ - coin::Coin as CoinModel, - message_coin::MessageCoin as MessageCoinModel, + entities::coins::{ + self, + coin::Coin as CoinModel, + message_coin::{ + self, + MessageCoin as MessageCoinModel, }, + CoinId, + }, + fuel_tx::{ + self, }, - fuel_tx, }; use itertools::Itertools; use tokio_stream::StreamExt; @@ -73,6 +89,12 @@ impl Coin { } } +impl From for Coin { + fn from(value: CoinModel) -> Self { + Coin(value) + } +} + pub struct MessageCoin(pub(crate) MessageCoinModel); #[async_graphql::Object] @@ -108,6 +130,12 @@ impl MessageCoin { } } +impl From for MessageCoin { + fn from(value: MessageCoinModel) -> Self { + MessageCoin(value) + } +} + /// The schema analog of the [`coins::CoinType`]. #[derive(async_graphql::Union)] pub enum CoinType { @@ -117,6 +145,15 @@ pub enum CoinType { MessageCoin(MessageCoin), } +impl From for CoinType { + fn from(value: coins::CoinType) -> Self { + match value { + coins::CoinType::Coin(coin) => CoinType::Coin(coin.into()), + coins::CoinType::MessageCoin(coin) => CoinType::MessageCoin(coin.into()), + } + } +} + #[derive(async_graphql::InputObject)] struct CoinFilterInput { /// Returns coins owned by the `owner`. @@ -225,6 +262,27 @@ impl CoinQuery { .latest_consensus_params(); let max_input = params.tx_params().max_inputs(); + let excluded_id_count = excluded_ids.as_ref().map_or(0, |exclude| { + exclude.utxos.len().saturating_add(exclude.messages.len()) + }); + if excluded_id_count > max_input as usize { + return Err(CoinsQueryError::TooManyExcludedId { + provided: excluded_id_count, + allowed: max_input, + } + .into()); + } + + let mut duplicate_checker = HashSet::with_capacity(query_per_asset.len()); + for query in &query_per_asset { + let asset_id: fuel_tx::AssetId = query.asset_id.into(); + if !duplicate_checker.insert(asset_id) { + return Err(CoinsQueryError::DuplicateAssets(asset_id).into()); + } + } + + let owner: fuel_tx::Address = owner.0; + // `coins_to_spend` exists to help select inputs for the transactions. // It doesn't make sense to allow the user to request more than the maximum number // of inputs. @@ -233,75 +291,167 @@ impl CoinQuery { // https://github.com/FuelLabs/fuel-core/issues/2343 query_per_asset.truncate(max_input as usize); - let owner: fuel_tx::Address = owner.0; - let query_per_asset = query_per_asset - .into_iter() - .map(|e| { - AssetSpendTarget::new( - e.asset_id.0, - e.amount.0, - e.max - .and_then(|max| u16::try_from(max.0).ok()) - .unwrap_or(max_input) - .min(max_input), - ) - }) - .collect_vec(); - let excluded_ids: Option> = excluded_ids.map(|exclude| { - let utxos = exclude - .utxos - .into_iter() - .map(|utxo| coins::CoinId::Utxo(utxo.into())); - let messages = exclude - .messages - .into_iter() - .map(|message| coins::CoinId::Message(message.into())); - utxos.chain(messages).collect() - }); - - let base_asset_id = params.base_asset_id(); - let spend_query = - SpendQuery::new(owner, &query_per_asset, excluded_ids, *base_asset_id)?; - - let query = ctx.read_view()?; + let read_view = ctx.read_view()?; + let indexation_available = read_view.coins_to_spend_indexation_enabled; + if indexation_available { + coins_to_spend_with_cache( + owner, + query_per_asset, + excluded_ids, + max_input, + read_view.as_ref(), + ) + .await + } else { + let base_asset_id = params.base_asset_id(); + coins_to_spend_without_cache( + owner, + query_per_asset, + excluded_ids, + max_input, + base_asset_id, + read_view.as_ref(), + ) + .await + } + } +} - let coins = random_improve(query.as_ref(), &spend_query) - .await? +async fn coins_to_spend_without_cache( + owner: fuel_tx::Address, + query_per_asset: Vec, + excluded_ids: Option, + max_input: u16, + base_asset_id: &fuel_tx::AssetId, + db: &ReadView, +) -> async_graphql::Result>> { + let query_per_asset = query_per_asset + .into_iter() + .map(|e| { + AssetSpendTarget::new( + e.asset_id.0, + e.amount.0, + e.max + .and_then(|max| u16::try_from(max.0).ok()) + .unwrap_or(max_input) + .min(max_input), + ) + }) + .collect_vec(); + let excluded_ids: Option> = excluded_ids.map(|exclude| { + let utxos = exclude + .utxos .into_iter() - .map(|coins| { - coins - .into_iter() - .map(|coin| match coin { - coins::CoinType::Coin(coin) => CoinType::Coin(coin.into()), - coins::CoinType::MessageCoin(coin) => { - CoinType::MessageCoin(coin.into()) - } - }) - .collect_vec() - }) - .collect(); + .map(|utxo| coins::CoinId::Utxo(utxo.into())); + let messages = exclude + .messages + .into_iter() + .map(|message| coins::CoinId::Message(message.into())); + utxos.chain(messages).collect() + }); + + let spend_query = + SpendQuery::new(owner, &query_per_asset, excluded_ids, *base_asset_id)?; + + let all_coins = random_improve(db, &spend_query) + .await? + .into_iter() + .map(|coins| { + coins + .into_iter() + .map(|coin| match coin { + coins::CoinType::Coin(coin) => CoinType::Coin(coin.into()), + coins::CoinType::MessageCoin(coin) => { + CoinType::MessageCoin(coin.into()) + } + }) + .collect_vec() + }) + .collect(); - Ok(coins) - } + Ok(all_coins) } -impl From for Coin { - fn from(value: CoinModel) -> Self { - Coin(value) - } -} +async fn coins_to_spend_with_cache( + owner: fuel_tx::Address, + query_per_asset: Vec, + excluded_ids: Option, + max_input: u16, + db: &ReadView, +) -> async_graphql::Result>> { + let mut all_coins = Vec::with_capacity(query_per_asset.len()); + + let excluded = ExcludedCoinIds::new( + excluded_ids + .iter() + .flat_map(|exclude| exclude.utxos.iter()) + .map(|utxo_id| &utxo_id.0), + excluded_ids + .iter() + .flat_map(|exclude| exclude.messages.iter()) + .map(|nonce| &nonce.0), + ); + + for asset in query_per_asset { + let asset_id = asset.asset_id.0; + let total_amount = asset.amount.0; + let max = asset + .max + .and_then(|max| u16::try_from(max.0).ok()) + .unwrap_or(max_input) + .min(max_input); + + let selected_coins = select_coins_to_spend( + db.off_chain.coins_to_spend_index(&owner, &asset_id), + total_amount, + max, + &asset_id, + &excluded, + db.batch_size, + ) + .await?; + + let mut coins_per_asset = Vec::with_capacity(selected_coins.len()); + for coin_or_message_id in into_coin_id(&selected_coins)? { + let coin_type = match coin_or_message_id { + coins::CoinId::Utxo(utxo_id) => { + db.coin(utxo_id).map(|coin| CoinType::Coin(coin.into()))? + } + coins::CoinId::Message(nonce) => { + let message = db.message(&nonce)?; + let message_coin: message_coin::MessageCoin = message.try_into()?; + CoinType::MessageCoin(message_coin.into()) + } + }; + + coins_per_asset.push(coin_type); + } -impl From for MessageCoin { - fn from(value: MessageCoinModel) -> Self { - MessageCoin(value) + all_coins.push(coins_per_asset); } + Ok(all_coins) } -impl From for CoinType { - fn from(value: coins::CoinType) -> Self { - match value { - coins::CoinType::Coin(coin) => CoinType::Coin(coin.into()), - coins::CoinType::MessageCoin(coin) => CoinType::MessageCoin(coin.into()), - } +fn into_coin_id( + selected: &[CoinsToSpendIndexEntry], +) -> Result, CoinsQueryError> { + let mut coins = Vec::with_capacity(selected.len()); + for (key, coin_type) in selected { + let coin = match coin_type { + IndexedCoinType::Coin => { + let utxo = key + .try_into() + .map_err(|_| CoinsQueryError::IncorrectCoinForeignKeyInIndex)?; + CoinId::Utxo(utxo) + } + IndexedCoinType::Message => { + let nonce = key + .try_into() + .map_err(|_| CoinsQueryError::IncorrectMessageForeignKeyInIndex)?; + CoinId::Message(nonce) + } + }; + coins.push(coin); } + Ok(coins) } diff --git a/crates/fuel-core/src/service/adapters/graphql_api/off_chain.rs b/crates/fuel-core/src/service/adapters/graphql_api/off_chain.rs index 6397182ed0e..77b931a0223 100644 --- a/crates/fuel-core/src/service/adapters/graphql_api/off_chain.rs +++ b/crates/fuel-core/src/service/adapters/graphql_api/off_chain.rs @@ -19,18 +19,23 @@ use crate::{ transactions::OwnedTransactionIndexCursor, }, }, - graphql_api::storage::{ - balances::{ - CoinBalances, - CoinBalancesKey, - MessageBalance, - MessageBalances, - TotalBalanceAmount, - }, - old::{ - OldFuelBlockConsensus, - OldFuelBlocks, - OldTransactions, + graphql_api::{ + indexation::coins_to_spend::NON_RETRYABLE_BYTE, + ports::CoinsToSpendIndexIter, + storage::{ + balances::{ + CoinBalances, + CoinBalancesKey, + MessageBalance, + MessageBalances, + TotalBalanceAmount, + }, + coins::CoinsToSpendIndex, + old::{ + OldFuelBlockConsensus, + OldFuelBlocks, + OldTransactions, + }, }, }, }; @@ -282,6 +287,33 @@ impl OffChainDatabase for OffChainIterableKeyValueView { .into_boxed() } } + // TODO: Return error if indexation is not available: https://github.com/FuelLabs/fuel-core/issues/2499 + fn coins_to_spend_index( + &self, + owner: &Address, + asset_id: &AssetId, + ) -> CoinsToSpendIndexIter { + let prefix: Vec<_> = NON_RETRYABLE_BYTE + .as_ref() + .iter() + .copied() + .chain(owner.iter().copied()) + .chain(asset_id.iter().copied()) + .collect(); + + CoinsToSpendIndexIter { + big_coins_iter: self.iter_all_filtered::( + Some(&prefix), + None, + Some(IterDirection::Reverse), + ), + dust_coins_iter: self.iter_all_filtered::( + Some(&prefix), + None, + Some(IterDirection::Forward), + ), + } + } } impl worker::OffChainDatabase for Database { @@ -295,7 +327,11 @@ impl worker::OffChainDatabase for Database { self.into_transaction() } - fn balances_enabled(&self) -> StorageResult { + fn balances_indexation_enabled(&self) -> StorageResult { self.indexation_available(IndexationKind::Balances) } + + fn coins_to_spend_indexation_enabled(&self) -> StorageResult { + self.indexation_available(IndexationKind::CoinsToSpend) + } } diff --git a/crates/fuel-core/src/service/genesis/importer.rs b/crates/fuel-core/src/service/genesis/importer.rs index 16e4bed76b4..d3364cdd762 100644 --- a/crates/fuel-core/src/service/genesis/importer.rs +++ b/crates/fuel-core/src/service/genesis/importer.rs @@ -58,6 +58,7 @@ use fuel_core_types::{ block::Block, primitives::DaBlockHeight, }, + fuel_tx::AssetId, fuel_types::BlockHeight, fuel_vm::BlobData, }; @@ -187,7 +188,14 @@ impl SnapshotImporter { .table_reporter(Some(num_groups), migration_name); let task = ImportTask::new( - Handler::new(block_height, da_block_height), + Handler::new( + block_height, + da_block_height, + self.snapshot_reader + .chain_config() + .consensus_parameters + .base_asset_id(), + ), groups, db, progress_reporter, @@ -235,7 +243,14 @@ impl SnapshotImporter { .table_reporter(Some(num_groups), migration_name); let task = ImportTask::new( - Handler::new(block_height, da_block_height), + Handler::new( + block_height, + da_block_height, + self.snapshot_reader + .chain_config() + .consensus_parameters + .base_asset_id(), + ), groups, db, progress_reporter, @@ -255,15 +270,21 @@ impl SnapshotImporter { pub struct Handler { pub block_height: BlockHeight, pub da_block_height: DaBlockHeight, + pub base_asset_id: AssetId, _table_being_written: PhantomData, _table_in_snapshot: PhantomData, } impl Handler { - pub fn new(block_height: BlockHeight, da_block_height: DaBlockHeight) -> Self { + pub fn new( + block_height: BlockHeight, + da_block_height: DaBlockHeight, + base_asset_id: &AssetId, + ) -> Self { Self { block_height, da_block_height, + base_asset_id: *base_asset_id, _table_being_written: PhantomData, _table_in_snapshot: PhantomData, } diff --git a/crates/fuel-core/src/service/genesis/importer/off_chain.rs b/crates/fuel-core/src/service/genesis/importer/off_chain.rs index 8726c73a235..42c9c320f3b 100644 --- a/crates/fuel-core/src/service/genesis/importer/off_chain.rs +++ b/crates/fuel-core/src/service/genesis/importer/off_chain.rs @@ -49,7 +49,7 @@ fn balances_indexation_enabled() -> bool { static BALANCES_INDEXATION_ENABLED: OnceLock = OnceLock::new(); *BALANCES_INDEXATION_ENABLED.get_or_init(|| { - // During re-genesis process the metadata is always doesn't exist. + // During re-genesis process the metadata never exist. let metadata = None; let indexation_availability = crate::database::database_description::indexation_availability::( @@ -60,6 +60,24 @@ fn balances_indexation_enabled() -> bool { }) } +fn coins_to_spend_indexation_enabled() -> bool { + use std::sync::OnceLock; + + static COINS_TO_SPEND_INDEXATION_ENABLED: OnceLock = OnceLock::new(); + + *COINS_TO_SPEND_INDEXATION_ENABLED.get_or_init(|| { + // During re-genesis process the metadata never exist. + let metadata = None; + let indexation_availability = + crate::database::database_description::indexation_availability::( + metadata, + ); + indexation_availability.contains( + &crate::database::database_description::IndexationKind::CoinsToSpend, + ) + }) +} + impl ImportTable for Handler { type TableInSnapshot = TransactionStatuses; type TableBeingWritten = TransactionStatuses; @@ -131,6 +149,8 @@ impl ImportTable for Handler { events, tx, balances_indexation_enabled(), + coins_to_spend_indexation_enabled(), + &self.base_asset_id, )?; Ok(()) } @@ -153,6 +173,8 @@ impl ImportTable for Handler { events, tx, balances_indexation_enabled(), + coins_to_spend_indexation_enabled(), + &self.base_asset_id, )?; Ok(()) } diff --git a/crates/fuel-core/src/service/sub_services.rs b/crates/fuel-core/src/service/sub_services.rs index 03003319235..49754a5e5a5 100644 --- a/crates/fuel-core/src/service/sub_services.rs +++ b/crates/fuel-core/src/service/sub_services.rs @@ -285,9 +285,9 @@ pub fn init_sub_services( graphql_block_importer, database.on_chain().clone(), database.off_chain().clone(), - chain_id, config.da_compression.clone(), config.continue_on_error, + &chain_config.consensus_parameters, ); let graphql_config = GraphQLConfig { diff --git a/crates/metrics/src/config.rs b/crates/metrics/src/config.rs index 77c7fd297ff..107c9fb91eb 100644 --- a/crates/metrics/src/config.rs +++ b/crates/metrics/src/config.rs @@ -13,7 +13,7 @@ pub enum Module { Importer, P2P, Producer, - TxPool, /* TODO[RC]: Not used. Add support in https://github.com/FuelLabs/fuel-core/pull/2321 */ + TxPool, GraphQL, // TODO[RC]: Not used... yet. } diff --git a/crates/storage/src/codec.rs b/crates/storage/src/codec.rs index 751a8bf1727..d9636f4dc9d 100644 --- a/crates/storage/src/codec.rs +++ b/crates/storage/src/codec.rs @@ -22,7 +22,7 @@ pub trait Encoder { } /// The trait encodes the type to the bytes and passes it to the `Encoder`, -/// which stores it and provides a reference to it. That allows gives more +/// which stores it and provides a reference to it. That gives more /// flexibility and more performant encoding, allowing the use of slices and arrays /// instead of vectors in some cases. Since the [`Encoder`] returns `Cow<[u8]>`, /// it is always possible to take ownership of the serialized value. diff --git a/tests/tests/coins.rs b/tests/tests/coins.rs index 6a03bcd791d..bfab743814b 100644 --- a/tests/tests/coins.rs +++ b/tests/tests/coins.rs @@ -23,7 +23,10 @@ use rand::{ mod coin { use super::*; - use fuel_core::chain_config::CoinConfigGenerator; + use fuel_core::chain_config::{ + ChainConfig, + CoinConfigGenerator, + }; use fuel_core_client::client::types::CoinType; use fuel_core_types::fuel_crypto::SecretKey; use rand::Rng; @@ -32,6 +35,7 @@ mod coin { owner: Address, asset_id_a: AssetId, asset_id_b: AssetId, + consensus_parameters: &ConsensusParameters, ) -> TestContext { // setup config let mut coin_generator = CoinConfigGenerator::new(); @@ -56,9 +60,10 @@ mod coin { messages: vec![], ..Default::default() }; - let config = Config::local_node_with_state_config(state); + let chain = + ChainConfig::local_testnet_with_consensus_parameters(consensus_parameters); + let config = Config::local_node_with_configs(chain, state); - // setup server & client let srv = FuelService::new_node(config).await.unwrap(); let client = FuelClient::from(srv.bound_address); @@ -92,7 +97,8 @@ mod coin { let secret_key: SecretKey = SecretKey::random(&mut rng); let pk = secret_key.public_key(); let owner = Input::owner(&pk); - let context = setup(owner, asset_id_a, asset_id_b).await; + let cp = ConsensusParameters::default(); + let context = setup(owner, asset_id_a, asset_id_b, &cp).await; // select all available coins to spend let coins_per_asset = context .client @@ -145,47 +151,56 @@ mod coin { } async fn query_target_1(owner: Address, asset_id_a: AssetId, asset_id_b: AssetId) { - let context = setup(owner, asset_id_a, asset_id_b).await; + let cp = ConsensusParameters::default(); + let context = setup(owner, asset_id_a, asset_id_b, &cp).await; // spend_query for 1 a and 1 b let coins_per_asset = context .client .coins_to_spend( &owner, - vec![(asset_id_a, 1, None), (asset_id_b, 1, None)], + vec![(asset_id_a, 1, None), (asset_id_b, 1, Some(1))], None, ) .await .unwrap(); assert_eq!(coins_per_asset.len(), 2); - assert_eq!(coins_per_asset[0].len(), 1); + assert!(coins_per_asset[0].len() >= 1); assert!(coins_per_asset[0].amount() >= 1); assert_eq!(coins_per_asset[1].len(), 1); - assert!(coins_per_asset[1].amount() >= 1); } async fn query_target_300(owner: Address, asset_id_a: AssetId, asset_id_b: AssetId) { - let context = setup(owner, asset_id_a, asset_id_b).await; + let cp = ConsensusParameters::default(); + let context = setup(owner, asset_id_a, asset_id_b, &cp).await; // spend_query for 300 a and 300 b let coins_per_asset = context .client .coins_to_spend( &owner, - vec![(asset_id_a, 300, None), (asset_id_b, 300, None)], + vec![(asset_id_a, 300, None), (asset_id_b, 300, Some(3))], None, ) .await .unwrap(); assert_eq!(coins_per_asset.len(), 2); - assert_eq!(coins_per_asset[0].len(), 3); + assert!(coins_per_asset[0].len() >= 3); assert!(coins_per_asset[0].amount() >= 300); assert_eq!(coins_per_asset[1].len(), 3); - assert!(coins_per_asset[1].amount() >= 300); + } + + fn consensus_parameters_with_max_inputs(max_inputs: u16) -> ConsensusParameters { + let mut cp = ConsensusParameters::default(); + let tx_params = TxParameters::default().with_max_inputs(max_inputs); + cp.set_tx_params(tx_params); + cp } async fn exclude_all(owner: Address, asset_id_a: AssetId, asset_id_b: AssetId) { - let context = setup(owner, asset_id_a, asset_id_b).await; + const MAX_INPUTS: u16 = 255; + let cp = consensus_parameters_with_max_inputs(MAX_INPUTS); + let context = setup(owner, asset_id_a, asset_id_b, &cp).await; // query all coins let coins_per_asset = context @@ -220,9 +235,10 @@ mod coin { assert!(coins_per_asset.is_err()); assert_eq!( coins_per_asset.unwrap_err().to_string(), - CoinsQueryError::InsufficientCoins { + CoinsQueryError::InsufficientCoinsForTheMax { asset_id: asset_id_a, collected_amount: 0, + max: MAX_INPUTS } .to_str_error_string() ); @@ -233,7 +249,9 @@ mod coin { asset_id_a: AssetId, asset_id_b: AssetId, ) { - let context = setup(owner, asset_id_a, asset_id_b).await; + const MAX_INPUTS: u16 = 255; + let cp = consensus_parameters_with_max_inputs(MAX_INPUTS); + let context = setup(owner, asset_id_a, asset_id_b, &cp).await; // not enough coins let coins_per_asset = context @@ -247,30 +265,42 @@ mod coin { assert!(coins_per_asset.is_err()); assert_eq!( coins_per_asset.unwrap_err().to_string(), - CoinsQueryError::InsufficientCoins { + CoinsQueryError::InsufficientCoinsForTheMax { asset_id: asset_id_a, collected_amount: 300, + max: MAX_INPUTS } .to_str_error_string() ); } async fn query_limit_coins(owner: Address, asset_id_a: AssetId, asset_id_b: AssetId) { - let context = setup(owner, asset_id_a, asset_id_b).await; + let cp = ConsensusParameters::default(); + let context = setup(owner, asset_id_a, asset_id_b, &cp).await; + + const MAX: u16 = 2; // not enough inputs let coins_per_asset = context .client .coins_to_spend( &owner, - vec![(asset_id_a, 300, Some(2)), (asset_id_b, 300, Some(2))], + vec![ + (asset_id_a, 300, Some(MAX as u32)), + (asset_id_b, 300, Some(MAX as u32)), + ], None, ) .await; assert!(coins_per_asset.is_err()); assert_eq!( coins_per_asset.unwrap_err().to_string(), - CoinsQueryError::MaxCoinsReached.to_str_error_string() + CoinsQueryError::InsufficientCoinsForTheMax { + asset_id: asset_id_a, + collected_amount: 0, + max: MAX + } + .to_str_error_string() ); } } @@ -285,7 +315,7 @@ mod message_coin { use super::*; - async fn setup(owner: Address) -> (AssetId, TestContext) { + async fn setup(owner: Address) -> (AssetId, TestContext, u16) { let base_asset_id = AssetId::BASE; // setup config @@ -307,6 +337,12 @@ mod message_coin { ..Default::default() }; let config = Config::local_node_with_state_config(state); + let max_inputs = config + .snapshot_reader + .chain_config() + .consensus_parameters + .tx_params() + .max_inputs(); // setup server & client let srv = FuelService::new_node(config).await.unwrap(); @@ -317,7 +353,7 @@ mod message_coin { client, }; - (base_asset_id, context) + (base_asset_id, context, max_inputs) } #[rstest::rstest] @@ -340,7 +376,7 @@ mod message_coin { let secret_key: SecretKey = SecretKey::random(&mut rng); let pk = secret_key.public_key(); let owner = Input::owner(&pk); - let (base_asset_id, context) = setup(owner).await; + let (base_asset_id, context, _) = setup(owner).await; // select all available coins to spend let coins_per_asset = context .client @@ -378,7 +414,7 @@ mod message_coin { } async fn query_target_1(owner: Address) { - let (base_asset_id, context) = setup(owner).await; + let (base_asset_id, context, _) = setup(owner).await; // query coins for `base_asset_id` and target 1 let coins_per_asset = context @@ -390,7 +426,7 @@ mod message_coin { } async fn query_target_300(owner: Address) { - let (base_asset_id, context) = setup(owner).await; + let (base_asset_id, context, _) = setup(owner).await; // query for 300 base assets let coins_per_asset = context @@ -403,7 +439,7 @@ mod message_coin { } async fn exclude_all(owner: Address) { - let (base_asset_id, context) = setup(owner).await; + let (base_asset_id, context, max_inputs) = setup(owner).await; // query for 300 base assets let coins_per_asset = context @@ -434,16 +470,17 @@ mod message_coin { assert!(coins_per_asset.is_err()); assert_eq!( coins_per_asset.unwrap_err().to_string(), - CoinsQueryError::InsufficientCoins { + CoinsQueryError::InsufficientCoinsForTheMax { asset_id: base_asset_id, collected_amount: 0, + max: max_inputs } .to_str_error_string() ); } async fn query_more_than_we_have(owner: Address) { - let (base_asset_id, context) = setup(owner).await; + let (base_asset_id, context, max_inputs) = setup(owner).await; // max coins reached let coins_per_asset = context @@ -453,26 +490,34 @@ mod message_coin { assert!(coins_per_asset.is_err()); assert_eq!( coins_per_asset.unwrap_err().to_string(), - CoinsQueryError::InsufficientCoins { + CoinsQueryError::InsufficientCoinsForTheMax { asset_id: base_asset_id, collected_amount: 300, + max: max_inputs } .to_str_error_string() ); } async fn query_limit_coins(owner: Address) { - let (base_asset_id, context) = setup(owner).await; + let (base_asset_id, context, _) = setup(owner).await; + + const MAX: u16 = 2; // not enough inputs let coins_per_asset = context .client - .coins_to_spend(&owner, vec![(base_asset_id, 300, Some(2))], None) + .coins_to_spend(&owner, vec![(base_asset_id, 300, Some(MAX as u32))], None) .await; assert!(coins_per_asset.is_err()); assert_eq!( coins_per_asset.unwrap_err().to_string(), - CoinsQueryError::MaxCoinsReached.to_str_error_string() + CoinsQueryError::InsufficientCoinsForTheMax { + asset_id: base_asset_id, + collected_amount: 0, + max: MAX + } + .to_str_error_string() ); } } @@ -485,7 +530,7 @@ mod all_coins { use super::*; - async fn setup(owner: Address, asset_id_b: AssetId) -> (AssetId, TestContext) { + async fn setup(owner: Address, asset_id_b: AssetId) -> (AssetId, TestContext, u16) { let asset_id_a = AssetId::BASE; // setup config @@ -521,6 +566,12 @@ mod all_coins { ..Default::default() }; let config = Config::local_node_with_state_config(state); + let max_inputs = config + .snapshot_reader + .chain_config() + .consensus_parameters + .tx_params() + .max_inputs(); // setup server & client let srv = FuelService::new_node(config).await.unwrap(); @@ -531,7 +582,7 @@ mod all_coins { client, }; - (asset_id_a, context) + (asset_id_a, context, max_inputs) } #[rstest::rstest] @@ -549,47 +600,45 @@ mod all_coins { } async fn query_target_1(owner: Address, asset_id_b: AssetId) { - let (asset_id_a, context) = setup(owner, asset_id_b).await; + let (asset_id_a, context, _) = setup(owner, asset_id_b).await; // query coins for `base_asset_id` and target 1 let coins_per_asset = context .client .coins_to_spend( &owner, - vec![(asset_id_a, 1, None), (asset_id_b, 1, None)], + vec![(asset_id_a, 1, None), (asset_id_b, 1, Some(1))], None, ) .await .unwrap(); assert_eq!(coins_per_asset.len(), 2); - assert_eq!(coins_per_asset[0].len(), 1); + assert!(coins_per_asset[0].len() >= 1); assert!(coins_per_asset[0].amount() >= 1); assert_eq!(coins_per_asset[1].len(), 1); - assert!(coins_per_asset[1].amount() >= 1); } async fn query_target_300(owner: Address, asset_id_b: AssetId) { - let (asset_id_a, context) = setup(owner, asset_id_b).await; + let (asset_id_a, context, _) = setup(owner, asset_id_b).await; // query for 300 base assets let coins_per_asset = context .client .coins_to_spend( &owner, - vec![(asset_id_a, 300, None), (asset_id_b, 300, None)], + vec![(asset_id_a, 300, None), (asset_id_b, 300, Some(3))], None, ) .await .unwrap(); assert_eq!(coins_per_asset.len(), 2); - assert_eq!(coins_per_asset[0].len(), 3); + assert!(coins_per_asset[0].len() >= 3); assert!(coins_per_asset[0].amount() >= 300); assert_eq!(coins_per_asset[1].len(), 3); - assert!(coins_per_asset[1].amount() >= 300); } async fn exclude_all(owner: Address, asset_id_b: AssetId) { - let (asset_id_a, context) = setup(owner, asset_id_b).await; + let (asset_id_a, context, max_inputs) = setup(owner, asset_id_b).await; // query for 300 base assets let coins_per_asset = context @@ -639,16 +688,17 @@ mod all_coins { assert!(coins_per_asset.is_err()); assert_eq!( coins_per_asset.unwrap_err().to_string(), - CoinsQueryError::InsufficientCoins { + CoinsQueryError::InsufficientCoinsForTheMax { asset_id: asset_id_a, collected_amount: 0, + max: max_inputs } .to_str_error_string() ); } async fn query_more_than_we_have(owner: Address, asset_id_b: AssetId) { - let (asset_id_a, context) = setup(owner, asset_id_b).await; + let (asset_id_a, context, max_inputs) = setup(owner, asset_id_b).await; // max coins reached let coins_per_asset = context @@ -662,30 +712,41 @@ mod all_coins { assert!(coins_per_asset.is_err()); assert_eq!( coins_per_asset.unwrap_err().to_string(), - CoinsQueryError::InsufficientCoins { + CoinsQueryError::InsufficientCoinsForTheMax { asset_id: asset_id_a, collected_amount: 300, + max: max_inputs } .to_str_error_string() ); } async fn query_limit_coins(owner: Address, asset_id_b: AssetId) { - let (asset_id_a, context) = setup(owner, asset_id_b).await; + let (asset_id_a, context, _) = setup(owner, asset_id_b).await; + + const MAX: u16 = 2; // not enough inputs let coins_per_asset = context .client .coins_to_spend( &owner, - vec![(asset_id_a, 300, Some(2)), (asset_id_b, 300, Some(2))], + vec![ + (asset_id_a, 300, Some(MAX as u32)), + (asset_id_b, 300, Some(MAX as u32)), + ], None, ) .await; assert!(coins_per_asset.is_err()); assert_eq!( coins_per_asset.unwrap_err().to_string(), - CoinsQueryError::MaxCoinsReached.to_str_error_string() + CoinsQueryError::InsufficientCoinsForTheMax { + asset_id: asset_id_a, + collected_amount: 0, + max: MAX + } + .to_str_error_string() ); } } @@ -709,10 +770,9 @@ async fn empty_setup() -> TestContext { #[tokio::test] async fn coins_to_spend_empty( #[values(Address::default(), Address::from([5; 32]), Address::from([16; 32]))] - owner: Address, + owner: Address, ) { let context = empty_setup().await; - // empty spend_query let coins_per_asset = context .client