fuel_core/schema/
coins.rs

1use std::{
2    borrow::Cow,
3    collections::HashSet,
4};
5
6use crate::{
7    coins_query::{
8        CoinsQueryError,
9        SpendQuery,
10        random_improve,
11        select_coins_to_spend,
12    },
13    database::database_description::IndexationKind,
14    fuel_core_graphql_api::{
15        IntoApiResult,
16        query_costs,
17        storage::coins::CoinsToSpendIndexKey,
18    },
19    graphql_api::{
20        api_service::ChainInfoProvider,
21        database::ReadView,
22    },
23    query::asset_query::{
24        AssetSpendTarget,
25        Exclude,
26    },
27    schema::{
28        ReadViewProvider,
29        scalars::{
30            Address,
31            AssetId,
32            Nonce,
33            U16,
34            U32,
35            U64,
36            U128,
37            UtxoId,
38        },
39    },
40};
41use async_graphql::{
42    Context,
43    connection::{
44        Connection,
45        EmptyFields,
46    },
47};
48use fuel_core_types::{
49    entities::coins::{
50        self,
51        CoinId,
52        coin::Coin as CoinModel,
53        message_coin::{
54            self,
55            MessageCoin as MessageCoinModel,
56        },
57    },
58    fuel_tx::{
59        self,
60        ConsensusParameters,
61    },
62};
63use itertools::Itertools;
64use tokio_stream::StreamExt;
65
66pub struct Coin(pub(crate) CoinModel);
67
68#[async_graphql::Object]
69impl Coin {
70    async fn utxo_id(&self) -> UtxoId {
71        self.0.utxo_id.into()
72    }
73
74    async fn owner(&self) -> Address {
75        self.0.owner.into()
76    }
77
78    async fn amount(&self) -> U64 {
79        self.0.amount.into()
80    }
81
82    async fn asset_id(&self) -> AssetId {
83        self.0.asset_id.into()
84    }
85
86    /// TxPointer - the height of the block this coin was created in
87    async fn block_created(&self) -> U32 {
88        u32::from(self.0.tx_pointer.block_height()).into()
89    }
90
91    /// TxPointer - the index of the transaction that created this coin
92    async fn tx_created_idx(&self) -> U16 {
93        self.0.tx_pointer.tx_index().into()
94    }
95}
96
97impl From<CoinModel> for Coin {
98    fn from(value: CoinModel) -> Self {
99        Coin(value)
100    }
101}
102
103pub struct MessageCoin(pub(crate) MessageCoinModel);
104
105#[async_graphql::Object]
106impl MessageCoin {
107    async fn sender(&self) -> Address {
108        self.0.sender.into()
109    }
110
111    async fn recipient(&self) -> Address {
112        self.0.recipient.into()
113    }
114
115    async fn nonce(&self) -> Nonce {
116        self.0.nonce.into()
117    }
118
119    async fn amount(&self) -> U64 {
120        self.0.amount.into()
121    }
122
123    #[graphql(complexity = "query_costs().storage_read")]
124    async fn asset_id(&self, ctx: &Context<'_>) -> AssetId {
125        let params = ctx
126            .data_unchecked::<ChainInfoProvider>()
127            .current_consensus_params();
128
129        let base_asset_id = *params.base_asset_id();
130        base_asset_id.into()
131    }
132
133    async fn da_height(&self) -> U64 {
134        self.0.da_height.0.into()
135    }
136}
137
138impl From<MessageCoinModel> for MessageCoin {
139    fn from(value: MessageCoinModel) -> Self {
140        MessageCoin(value)
141    }
142}
143
144/// The schema analog of the [`coins::CoinType`].
145#[derive(async_graphql::Union)]
146pub enum CoinType {
147    /// The regular coins generated by the transaction output.
148    Coin(Coin),
149    /// The bridged coin from the DA layer.
150    MessageCoin(MessageCoin),
151}
152
153impl CoinType {
154    pub fn amount(&self) -> u64 {
155        match self {
156            CoinType::Coin(coin) => coin.0.amount,
157            CoinType::MessageCoin(coin) => coin.0.amount,
158        }
159    }
160}
161
162impl From<coins::CoinType> for CoinType {
163    fn from(value: coins::CoinType) -> Self {
164        match value {
165            coins::CoinType::Coin(coin) => CoinType::Coin(coin.into()),
166            coins::CoinType::MessageCoin(coin) => CoinType::MessageCoin(coin.into()),
167        }
168    }
169}
170
171#[derive(async_graphql::InputObject)]
172struct CoinFilterInput {
173    /// Returns coins owned by the `owner`.
174    owner: Address,
175    /// Returns coins only with `asset_id`.
176    asset_id: Option<AssetId>,
177}
178
179#[derive(async_graphql::InputObject)]
180pub struct SpendQueryElementInput {
181    /// Identifier of the asset to spend.
182    pub asset_id: AssetId,
183    /// Target amount for the query.
184    pub amount: U128,
185    /// The maximum number of currencies for selection.
186    pub max: Option<U16>,
187    /// If true, returns available coins instead of failing when the requested amount is unavailable.
188    pub allow_partial: Option<bool>,
189}
190
191#[derive(async_graphql::InputObject)]
192pub struct ExcludeInput {
193    /// Utxos to exclude from the selection.
194    pub utxos: Vec<UtxoId>,
195    /// Messages to exclude from the selection.
196    pub messages: Vec<Nonce>,
197}
198
199impl From<Option<ExcludeInput>> for Exclude {
200    fn from(value: Option<ExcludeInput>) -> Self {
201        let excluded_ids: Option<Vec<_>> = value.map(|exclude| {
202            let utxos = exclude
203                .utxos
204                .into_iter()
205                .map(|utxo| coins::CoinId::Utxo(utxo.into()));
206            let messages = exclude
207                .messages
208                .into_iter()
209                .map(|message| coins::CoinId::Message(message.into()));
210            utxos.chain(messages).collect()
211        });
212
213        Exclude::new(excluded_ids.unwrap_or_default())
214    }
215}
216
217#[derive(Default)]
218pub struct CoinQuery;
219
220#[async_graphql::Object]
221impl CoinQuery {
222    /// Gets the coin by `utxo_id`.
223    #[graphql(complexity = "query_costs().storage_read + child_complexity")]
224    async fn coin(
225        &self,
226        ctx: &Context<'_>,
227        #[graphql(desc = "The ID of the coin")] utxo_id: UtxoId,
228    ) -> async_graphql::Result<Option<Coin>> {
229        let query = ctx.read_view()?;
230        query.coin(utxo_id.0).into_api_result()
231    }
232
233    /// Gets all unspent coins of some `owner` maybe filtered with by `asset_id` per page.
234    #[graphql(complexity = "{\
235        query_costs().storage_iterator\
236        + (query_costs().storage_read + first.unwrap_or_default() as usize) * child_complexity \
237        + (query_costs().storage_read + last.unwrap_or_default() as usize) * child_complexity\
238    }")]
239    async fn coins(
240        &self,
241        ctx: &Context<'_>,
242        filter: CoinFilterInput,
243        first: Option<i32>,
244        after: Option<String>,
245        last: Option<i32>,
246        before: Option<String>,
247    ) -> async_graphql::Result<Connection<UtxoId, Coin, EmptyFields, EmptyFields>> {
248        let query = ctx.read_view()?;
249        let owner: fuel_tx::Address = filter.owner.into();
250        crate::schema::query_pagination(after, before, first, last, |start, direction| {
251            let coins = query
252                .owned_coins(&owner, (*start).map(Into::into), direction)
253                .filter_map(|result| {
254                    if let (Ok(coin), Some(filter_asset_id)) = (&result, &filter.asset_id)
255                        && coin.asset_id != filter_asset_id.0
256                    {
257                        return None;
258                    }
259
260                    Some(result)
261                })
262                .map(|res| res.map(|coin| (coin.utxo_id.into(), coin.into())));
263
264            Ok(coins)
265        })
266        .await
267    }
268
269    /// For each `query_per_asset`, get some spendable coins(of asset specified by the query) owned by
270    /// `owner` that add up at least the query amount. The returned coins can be spent.
271    /// The number of coins is optimized to prevent dust accumulation.
272    ///
273    /// The query supports excluding and maximum the number of coins.
274    ///
275    /// Returns:
276    ///     The list of spendable coins per asset from the query. The length of the result is
277    ///     the same as the length of `query_per_asset`. The ordering of assets and `query_per_asset`
278    ///     is the same.
279    #[graphql(complexity = "query_costs().coins_to_spend")]
280    async fn coins_to_spend(
281        &self,
282        ctx: &Context<'_>,
283        #[graphql(desc = "The `Address` of the coins owner.")] owner: Address,
284        #[graphql(desc = "\
285            The list of requested assets` coins with asset ids, `target` amount the user wants \
286            to reach, and the `max` number of coins in the selection. Several entries with the \
287            same asset id are not allowed. The result can't contain more coins than `max_inputs`.")]
288        mut query_per_asset: Vec<SpendQueryElementInput>,
289        #[graphql(desc = "The excluded coins from the selection.")] excluded_ids: Option<
290            ExcludeInput,
291        >,
292    ) -> async_graphql::Result<Vec<Vec<CoinType>>> {
293        let params = ctx
294            .data_unchecked::<ChainInfoProvider>()
295            .current_consensus_params();
296        let max_input = params.tx_params().max_inputs();
297
298        let excluded_id_count = excluded_ids.as_ref().map_or(0, |exclude| {
299            exclude.utxos.len().saturating_add(exclude.messages.len())
300        });
301        if excluded_id_count > max_input as usize {
302            return Err(CoinsQueryError::TooManyExcludedId {
303                provided: excluded_id_count,
304                allowed: max_input,
305            }
306            .into());
307        }
308
309        let exclude: Exclude = excluded_ids.into();
310
311        let mut duplicate_checker = HashSet::with_capacity(query_per_asset.len());
312        for query in &query_per_asset {
313            let asset_id: fuel_tx::AssetId = query.asset_id.into();
314            if !duplicate_checker.insert(asset_id) {
315                return Err(CoinsQueryError::DuplicateAssets(asset_id).into());
316            }
317        }
318
319        let owner: fuel_tx::Address = owner.0;
320
321        // `coins_to_spend` exists to help select inputs for the transactions.
322        // It doesn't make sense to allow the user to request more than the maximum number
323        // of inputs.
324        // TODO: To avoid breaking changes, we will truncate request for now.
325        //  In the future, we should return an error if the input is too large.
326        //  https://github.com/FuelLabs/fuel-core/issues/2343
327        query_per_asset.truncate(max_input as usize);
328
329        let read_view = ctx.read_view()?;
330        let result = read_view
331            .coins_to_spend(owner, &query_per_asset, &exclude, &params, max_input)
332            .await?;
333
334        Ok(result)
335    }
336}
337
338impl ReadView {
339    pub async fn coins_to_spend(
340        &self,
341        owner: fuel_tx::Address,
342        query_per_asset: &[SpendQueryElementInput],
343        excluded: &Exclude,
344        params: &ConsensusParameters,
345        max_input: u16,
346    ) -> Result<Vec<Vec<CoinType>>, CoinsQueryError> {
347        let indexation_available = self
348            .indexation_flags
349            .contains(&IndexationKind::CoinsToSpend);
350        if indexation_available {
351            coins_to_spend_with_cache(owner, query_per_asset, excluded, max_input, self)
352                .await
353        } else {
354            let base_asset_id = params.base_asset_id();
355            coins_to_spend_without_cache(
356                owner,
357                query_per_asset,
358                excluded,
359                max_input,
360                base_asset_id,
361                self,
362            )
363            .await
364        }
365    }
366}
367
368async fn coins_to_spend_without_cache(
369    owner: fuel_tx::Address,
370    query_per_asset: &[SpendQueryElementInput],
371    exclude: &Exclude,
372    max_input: u16,
373    base_asset_id: &fuel_tx::AssetId,
374    db: &ReadView,
375) -> Result<Vec<Vec<CoinType>>, CoinsQueryError> {
376    let query_per_asset = query_per_asset
377        .iter()
378        .map(|e| {
379            AssetSpendTarget::new(
380                e.asset_id.0,
381                e.amount.0,
382                e.max.map(|max| max.0).unwrap_or(max_input).min(max_input),
383                e.allow_partial.unwrap_or(false),
384            )
385        })
386        .collect_vec();
387
388    let spend_query = SpendQuery::new(
389        owner,
390        &query_per_asset,
391        Cow::Borrowed(exclude),
392        *base_asset_id,
393    )?;
394
395    let all_coins = random_improve(db, &spend_query)
396        .await?
397        .into_iter()
398        .map(|coins| {
399            coins
400                .into_iter()
401                .map(|coin| match coin {
402                    coins::CoinType::Coin(coin) => CoinType::Coin(coin.into()),
403                    coins::CoinType::MessageCoin(coin) => {
404                        CoinType::MessageCoin(coin.into())
405                    }
406                })
407                .collect_vec()
408        })
409        .collect();
410
411    Ok(all_coins)
412}
413
414async fn coins_to_spend_with_cache(
415    owner: fuel_tx::Address,
416    query_per_asset: &[SpendQueryElementInput],
417    excluded: &Exclude,
418    max_input: u16,
419    db: &ReadView,
420) -> Result<Vec<Vec<CoinType>>, CoinsQueryError> {
421    let mut all_coins = Vec::with_capacity(query_per_asset.len());
422
423    for element in query_per_asset {
424        let asset_id = element.asset_id.0;
425        let target = element.amount.0;
426        let max = element
427            .max
428            .map(|max| max.0)
429            .unwrap_or(max_input)
430            .min(max_input);
431        let allow_partial = element.allow_partial.unwrap_or(false);
432
433        let asset_target = AssetSpendTarget::new(asset_id, target, max, allow_partial);
434
435        let selected_coins = select_coins_to_spend(
436            db.off_chain.coins_to_spend_index(&owner, &asset_id),
437            asset_target,
438            excluded,
439            db.batch_size,
440            owner,
441        )
442        .await?;
443
444        let mut coins_per_asset = Vec::with_capacity(selected_coins.len());
445        for coin_or_message_id in into_coin_id(&selected_coins) {
446            let coin_type = match coin_or_message_id {
447                coins::CoinId::Utxo(utxo_id) => {
448                    db.coin(utxo_id).map(|coin| CoinType::Coin(coin.into()))?
449                }
450                coins::CoinId::Message(nonce) => {
451                    let message = db.message(&nonce)?;
452                    let message_coin: message_coin::MessageCoin = message.try_into()?;
453                    CoinType::MessageCoin(message_coin.into())
454                }
455            };
456
457            coins_per_asset.push(coin_type);
458        }
459
460        all_coins.push(coins_per_asset);
461    }
462    Ok(all_coins)
463}
464
465fn into_coin_id(selected: &[CoinsToSpendIndexKey]) -> Vec<CoinId> {
466    let mut coins = Vec::with_capacity(selected.len());
467    for coin in selected {
468        let coin = match coin {
469            CoinsToSpendIndexKey::Coin { utxo_id, .. } => CoinId::Utxo(*utxo_id),
470            CoinsToSpendIndexKey::Message { nonce, .. } => CoinId::Message(*nonce),
471        };
472        coins.push(coin);
473    }
474    coins
475}