diff --git a/.changes/added/2912.md b/.changes/added/2912.md new file mode 100644 index 00000000000..bc8a2f4ddf0 --- /dev/null +++ b/.changes/added/2912.md @@ -0,0 +1 @@ +Add the `allow_partial` parameter to the `coinsToSpend` query. The default value of this parameters is `false` to preserve the old behavior. If set to `true`, the query returns available coins instead of failing when the requested amount is unavailable. \ No newline at end of file diff --git a/crates/client/assets/schema.sdl b/crates/client/assets/schema.sdl index 2d5a811ce9f..d373c5e9e0c 100644 --- a/crates/client/assets/schema.sdl +++ b/crates/client/assets/schema.sdl @@ -1281,6 +1281,10 @@ input SpendQueryElementInput { The maximum number of currencies for selection. """ max: U16 + """ + If true, returns available coins instead of failing when the requested amount is unavailable. + """ + allowPartial: Boolean } type SqueezedOutStatus { diff --git a/crates/fuel-core/src/coins_query.rs b/crates/fuel-core/src/coins_query.rs index e14ff47ddaa..ca34b329060 100644 --- a/crates/fuel-core/src/coins_query.rs +++ b/crates/fuel-core/src/coins_query.rs @@ -138,6 +138,7 @@ pub async fn largest_first( let target = query.asset.target; let max = query.asset.max; let asset_id = query.asset.id; + let allow_partial = query.asset.allow_partial; let mut inputs: Vec = query.coins().try_collect().await?; inputs.sort_by_key(|coin| Reverse(coin.amount())); @@ -150,13 +151,17 @@ pub async fn largest_first( break } - // Error if we can't fit more coins if coins.len() >= max as usize { - return Err(CoinsQueryError::InsufficientCoinsForTheMax { - asset_id, - collected_amount, - max, - }) + if allow_partial { + return Ok(coins) + } else { + // Error if we can't fit more coins + return Err(CoinsQueryError::InsufficientCoinsForTheMax { + asset_id, + collected_amount, + max, + }) + } } // Add to list @@ -165,11 +170,15 @@ pub async fn largest_first( } if collected_amount < target { - return Err(CoinsQueryError::InsufficientCoinsForTheMax { - asset_id, - collected_amount, - max, - }) + if allow_partial && collected_amount > 0 { + return Ok(coins); + } else { + return Err(CoinsQueryError::InsufficientCoinsForTheMax { + asset_id, + collected_amount, + max, + }) + } } Ok(coins) @@ -242,6 +251,7 @@ pub async fn select_coins_to_spend( total: u128, max: u16, asset_id: &AssetId, + allow_partial: bool, exclude: &Exclude, batch_size: usize, ) -> Result, CoinsQueryError> { @@ -277,12 +287,14 @@ pub async fn select_coins_to_spend( let (selected_big_coins_total, selected_big_coins) = big_coins(big_coins_stream, adjusted_total, max, exclude).await?; - if selected_big_coins_total < total { + if selected_big_coins_total == 0 + || (selected_big_coins_total < total && !allow_partial) + { 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 { @@ -419,6 +431,7 @@ impl From for CoinsQueryError { } #[allow(clippy::arithmetic_side_effects)] +#[allow(non_snake_case)] #[cfg(test)] mod tests { use crate::{ @@ -579,7 +592,7 @@ mod tests { // Query some targets, including higher than the owner's balance for target in 0..20 { let coins = query( - &[AssetSpendTarget::new(asset_id, target, u16::MAX)], + &[AssetSpendTarget::new(asset_id, target, u16::MAX, false)], &owner, base_asset_id, &db.service_database(), @@ -640,7 +653,7 @@ mod tests { // Query with too small max_inputs let coins = query( - &[AssetSpendTarget::new(asset_id, 6, 1)], + &[AssetSpendTarget::new(asset_id, 6, 1, false)], &owner, base_asset_id, &db.service_database(), @@ -681,8 +694,8 @@ mod tests { ) { let coins = query( &[ - AssetSpendTarget::new(asset_ids[0], 3, u16::MAX), - AssetSpendTarget::new(asset_ids[1], 6, u16::MAX), + AssetSpendTarget::new(asset_ids[0], 3, u16::MAX, false), + AssetSpendTarget::new(asset_ids[1], 6, u16::MAX, false), ], &owner, base_asset_id, @@ -709,6 +722,70 @@ mod tests { let (owner, asset_ids, base_asset_id, db) = setup_coins_and_messages(); multiple_assets_helper(owner, &asset_ids, &base_asset_id, db).await; } + + mod allow_partial { + use crate::{ + coins_query::tests::{ + largest_first::query, + setup_coins, + }, + query::asset_query::AssetSpendTarget, + }; + + #[tokio::test] + async fn query__error_when_not_enough_coins_and_allow_partial_false() { + // Given + let (owner, asset_ids, base_asset_id, db) = setup_coins(); + let asset_id = asset_ids[0]; + let target = 20_000_000; + let allow_partial = false; + + // When + let coins = query( + &[AssetSpendTarget::new( + asset_id, + target, + u16::MAX, + allow_partial, + )], + &owner, + &base_asset_id, + &db.service_database(), + ) + .await; + + // Then + assert!(coins.is_err()); + } + + #[tokio::test] + async fn query__ok_when_not_enough_coins_and_allow_partial_true() { + // Given + let (owner, asset_ids, base_asset_id, db) = setup_coins(); + let asset_id = asset_ids[0]; + let target = 20_000_000; + let allow_partial = true; + + // When + let coins = query( + &[AssetSpendTarget::new( + asset_id, + target, + u16::MAX, + allow_partial, + )], + &owner, + &base_asset_id, + &db.service_database(), + ) + .await + .expect("should return coins"); + + // Then + let coins: Vec<_> = coins[0].iter().map(|(_, amount)| *amount).collect(); + assert_eq!(coins, vec![5, 4, 3, 2, 1]); + } + } } mod random_improve { @@ -764,7 +841,7 @@ mod tests { // Query some amounts, including higher than the owner's balance for amount in 0..20 { let coins = query( - vec![AssetSpendTarget::new(asset_id, amount, u16::MAX)], + vec![AssetSpendTarget::new(asset_id, amount, u16::MAX, false)], owner, asset_ids, base_asset_id, @@ -816,8 +893,9 @@ mod tests { // Query with too small max_inputs let coins = query( vec![AssetSpendTarget::new( - asset_id, 6, // target - 1, // max + asset_id, 6, // target + 1, // max + false, // allow_partial )], owner, asset_ids, @@ -863,13 +941,15 @@ mod tests { vec![ AssetSpendTarget::new( asset_ids[0], - 3, // target - 3, // max + 3, // target + 3, // max + false, // allow_partial ), AssetSpendTarget::new( asset_ids[1], - 6, // target - 3, // max + 6, // target + 3, // max + false, // allow_partial ), ], owner, @@ -969,7 +1049,7 @@ mod tests { owner, base_asset_id, asset_ids, - vec![AssetSpendTarget::new(asset_id, amount, u16::MAX)], + vec![AssetSpendTarget::new(asset_id, amount, u16::MAX, false)], excluded_ids.clone(), ) .await; @@ -1245,6 +1325,7 @@ mod tests { TOTAL, MAX, &AssetId::default(), + false, &exclude, BATCH_SIZE, ) @@ -1307,6 +1388,7 @@ mod tests { TOTAL, MAX, &AssetId::default(), + false, &exclude, BATCH_SIZE, ) @@ -1354,6 +1436,7 @@ mod tests { TOTAL, MAX, &AssetId::default(), + false, &exclude, BATCH_SIZE, ) @@ -1382,6 +1465,7 @@ mod tests { TOTAL, MAX, &AssetId::default(), + false, &exclude, BATCH_SIZE, ) @@ -1409,6 +1493,7 @@ mod tests { TOTAL, MAX, &AssetId::default(), + false, &exclude, BATCH_SIZE, ) @@ -1444,6 +1529,7 @@ mod tests { TOTAL, MAX, &asset_id, + false, &exclude, BATCH_SIZE, ) @@ -1455,6 +1541,95 @@ mod tests { assert!(matches!(result, Err(actual_error) if CoinsQueryError::InsufficientCoinsForTheMax { asset_id, collected_amount: EXPECTED_COLLECTED_AMOUNT, max: MAX } == actual_error)); } + + mod allow_partial { + use fuel_core_storage::iter::IntoBoxedIter; + use fuel_core_types::fuel_tx::AssetId; + + use crate::{ + coins_query::tests::indexed_coins_to_spend::{ + select_coins_to_spend, + setup_test_coins, + BATCH_SIZE, + }, + graphql_api::ports::CoinsToSpendIndexIter, + query::asset_query::Exclude, + }; + + #[tokio::test] + async fn query__error_when_not_enough_coins_and_allow_partial_false() { + // Given + const MAX: u16 = 3; + const TOTAL: u128 = 2137; + + let coins = setup_test_coins([1, 1]); + let (coins, _): (Vec<_>, Vec<_>) = coins + .into_iter() + .map(|spec| (spec.index_entry, spec.utxo_id)) + .unzip(); + + let exclude = Exclude::default(); + + 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(); + + // When + let result = select_coins_to_spend( + coins_to_spend_iter, + TOTAL, + MAX, + &asset_id, + false, + &exclude, + BATCH_SIZE, + ) + .await; + + // Then + assert!(result.is_err()); + } + + #[tokio::test] + async fn query__ok_when_not_enough_coins_and_allow_partial_true() { + // Given + const MAX: u16 = 3; + const TOTAL: u128 = 2137; + + let coins = setup_test_coins([1, 1]); + let (coins, _): (Vec<_>, Vec<_>) = coins + .into_iter() + .map(|spec| (spec.index_entry, spec.utxo_id)) + .unzip(); + + let exclude = Exclude::default(); + + 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(); + + // When + let result = select_coins_to_spend( + coins_to_spend_iter, + TOTAL, + MAX, + &asset_id, + true, + &exclude, + BATCH_SIZE, + ) + .await + .expect("should return coins"); + + // Then + let coins: Vec<_> = result.into_iter().map(|key| key.amount()).collect(); + assert_eq!(coins, vec![1, 1]); + } + } } #[derive(Clone, Debug)] @@ -1501,6 +1676,7 @@ mod tests { id: asset_ids[0], target: target_amount, max: max_coins, + allow_partial: false, }], Cow::Owned(Exclude::default()), base_asset_id, diff --git a/crates/fuel-core/src/query/balance.rs b/crates/fuel-core/src/query/balance.rs index fe6f43bf797..416d0acf5da 100644 --- a/crates/fuel-core/src/query/balance.rs +++ b/crates/fuel-core/src/query/balance.rs @@ -47,7 +47,7 @@ impl ReadView { } else { AssetQuery::new( &owner, - &AssetSpendTarget::new(asset_id, u128::MAX, u16::MAX), + &AssetSpendTarget::new(asset_id, u128::MAX, u16::MAX, false), &base_asset_id, None, self, diff --git a/crates/fuel-core/src/query/balance/asset_query.rs b/crates/fuel-core/src/query/balance/asset_query.rs index 9f4f244305d..b0e0d076737 100644 --- a/crates/fuel-core/src/query/balance/asset_query.rs +++ b/crates/fuel-core/src/query/balance/asset_query.rs @@ -30,11 +30,17 @@ pub struct AssetSpendTarget { pub id: AssetId, pub target: u128, pub max: u16, + pub allow_partial: bool, } impl AssetSpendTarget { - pub fn new(id: AssetId, target: u128, max: u16) -> Self { - Self { id, target, max } + pub fn new(id: AssetId, target: u128, max: u16, allow_partial: bool) -> Self { + Self { + id, + target, + max, + allow_partial, + } } } diff --git a/crates/fuel-core/src/schema/coins.rs b/crates/fuel-core/src/schema/coins.rs index 65154863d8b..fe57b05219e 100644 --- a/crates/fuel-core/src/schema/coins.rs +++ b/crates/fuel-core/src/schema/coins.rs @@ -184,6 +184,8 @@ pub struct SpendQueryElementInput { pub amount: U128, /// The maximum number of currencies for selection. pub max: Option, + /// If true, returns available coins instead of failing when the requested amount is unavailable. + pub allow_partial: Option, } #[derive(async_graphql::InputObject)] @@ -379,6 +381,7 @@ async fn coins_to_spend_without_cache( e.asset_id.0, e.amount.0, e.max.map(|max| max.0).unwrap_or(max_input).min(max_input), + e.allow_partial.unwrap_or(false), ) }) .collect_vec(); @@ -432,6 +435,7 @@ async fn coins_to_spend_with_cache( total_amount, max, &asset_id, + asset.allow_partial.unwrap_or(false), excluded, db.batch_size, ) diff --git a/crates/fuel-core/src/schema/tx/assemble_tx.rs b/crates/fuel-core/src/schema/tx/assemble_tx.rs index 44e14d275dd..ca164995afa 100644 --- a/crates/fuel-core/src/schema/tx/assemble_tx.rs +++ b/crates/fuel-core/src/schema/tx/assemble_tx.rs @@ -100,6 +100,7 @@ impl<'a> AssembleArguments<'a> { asset_id: AssetId, amount: u64, remaining_input_slots: u16, + allow_partial: bool, ) -> anyhow::Result> { if amount == 0 { return Ok(Vec::new()); @@ -109,6 +110,7 @@ impl<'a> AssembleArguments<'a> { asset_id: asset_id.into(), amount: (amount as u128).into(), max: None, + allow_partial: Some(allow_partial), }; let result = self @@ -424,7 +426,7 @@ where let selected_coins = self .arguments - .coins(owner, asset_id, amount, remaining_input_slots) + .coins(owner, asset_id, amount, remaining_input_slots, false) .await?; for coin in selected_coins @@ -958,6 +960,7 @@ where base_asset_id, how_much_to_add, remaining_input_slots, + true, ) .await?;