Skip to main content

o2_tools/
wallet_ext.rs

1use crate::utxo_manager::{
2    FuelTxCoin,
3    UtxoProvider,
4};
5use fuel_core_client::client::{
6    FuelClient,
7    types::{
8        ResolvedOutput,
9        TransactionStatus,
10        TransactionType,
11    },
12};
13use fuel_core_types::{
14    blockchain::transaction::TransactionExt,
15    fuel_tx::{
16        Address,
17        AssetId,
18        ConsensusParameters,
19        Transaction,
20        TxId,
21        UniqueIdentifier,
22        UtxoId,
23        Witness,
24    },
25    fuel_types::ChainId,
26};
27use fuels::{
28    accounts::{
29        ViewOnlyAccount,
30        wallet::Unlocked,
31    },
32    prelude::{
33        BuildableTransaction,
34        ResourceFilter,
35        ScriptTransactionBuilder,
36        TransactionBuilder,
37        TxPolicies,
38        Wallet,
39    },
40    types::{
41        coin_type::CoinType,
42        input::Input,
43        output::Output,
44        transaction::ScriptTransaction,
45        tx_status::TxStatus,
46    },
47};
48use futures::{
49    StreamExt,
50    stream::FuturesUnordered,
51};
52use std::{
53    future::Future,
54    ops::Mul,
55    time::{
56        Duration,
57        Instant,
58    },
59};
60
61pub const SIGNATURE_MARGIN: usize = 100;
62
63#[derive(Clone, Debug)]
64pub struct SendResult<T = TxStatus> {
65    pub tx_id: TxId,
66    pub tx_status: T,
67    pub known_coins: Vec<FuelTxCoin>,
68    pub dynamic_coins: Vec<FuelTxCoin>,
69    pub preconf_rx_time: Option<Duration>,
70}
71
72#[derive(Clone)]
73pub struct BuilderData {
74    pub consensus_parameters: ConsensusParameters,
75    pub gas_price: u64,
76}
77
78impl BuilderData {
79    pub fn max_fee(&self) -> u64 {
80        let max_gas_limit = self.consensus_parameters.tx_params().max_gas_per_tx();
81        // Get the max fee based on the current info for the chain
82        max_gas_limit
83            .mul(self.gas_price)
84            .div_ceil(self.consensus_parameters.fee_params().gas_price_factor())
85    }
86}
87
88pub trait WalletExt {
89    fn builder_data(&self) -> impl Future<Output = anyhow::Result<BuilderData>> + Send;
90
91    fn build_transfer(
92        &self,
93        asset_id: AssetId,
94        transfers: &[(Address, u64)],
95        utxo_manager: &mut dyn UtxoProvider,
96        builder_data: &BuilderData,
97        fetch_coins: bool,
98    ) -> impl Future<Output = anyhow::Result<Transaction>> + Send;
99
100    fn build_transaction(
101        &self,
102        inputs: Vec<Input>,
103        outputs: Vec<Output>,
104        witnesses: Vec<Witness>,
105        tx_policies: TxPolicies,
106    ) -> impl Future<Output = anyhow::Result<Transaction>> + Send;
107
108    /// Submits `tx` and awaits its preconfirmation/final status.
109    ///
110    /// `submit_clients` lets callers fan out submission across several nodes;
111    /// the first client to return a valid preconfirmation/final status wins
112    /// and its result is returned. When every client errors, the returned
113    /// error aggregates each client's failure message. When a duplicate-tx
114    /// error is observed on any client, we fall back to awaiting the status
115    /// on the provider's client. When `submit_clients` is empty, the
116    /// provider's client is used as the sole submitter.
117    fn send_transaction(
118        &self,
119        chain_id: ChainId,
120        tx: &Transaction,
121        submit_clients: &[FuelClient],
122    ) -> impl Future<Output = anyhow::Result<SendResult>> + Send;
123
124    fn transfer_many(
125        &self,
126        asset_id: AssetId,
127        transfers: &[(Address, u64)],
128        utxo_manager: &mut dyn UtxoProvider,
129        builder_data: &BuilderData,
130        fetch_coins: bool,
131        chunk_size: Option<usize>,
132    ) -> impl Future<Output = anyhow::Result<Vec<FuelTxCoin>>> + Send;
133
134    fn transfer_many_and_wait(
135        &self,
136        asset_id: AssetId,
137        transfers: &[(Address, u64)],
138        utxo_manager: &mut dyn UtxoProvider,
139        builder_data: &BuilderData,
140        fetch_coins: bool,
141        chunk_size: Option<usize>,
142    ) -> impl Future<Output = anyhow::Result<Vec<FuelTxCoin>>> + Send;
143
144    fn await_send_result(
145        &self,
146        tx_id: &TxId,
147        tx: &Transaction,
148    ) -> impl Future<Output = anyhow::Result<SendResult>> + Send;
149}
150
151impl<S> WalletExt for Wallet<Unlocked<S>>
152where
153    S: fuels::core::traits::Signer + Clone + Send + Sync + std::fmt::Debug + 'static,
154{
155    async fn builder_data(&self) -> anyhow::Result<BuilderData> {
156        let provider = self.provider();
157        let consensus_parameters = provider.consensus_parameters().await?;
158        let gas_price = provider.estimate_gas_price(10).await?;
159
160        let builder_data = BuilderData {
161            consensus_parameters,
162            gas_price: gas_price.gas_price,
163        };
164
165        Ok(builder_data)
166    }
167
168    async fn build_transaction(
169        &self,
170        inputs: Vec<Input>,
171        outputs: Vec<Output>,
172        witnesses: Vec<Witness>,
173        mut tx_policies: TxPolicies,
174    ) -> anyhow::Result<Transaction> {
175        if tx_policies.witness_limit().is_none() {
176            let witness_size = witnesses
177                .iter()
178                .map(|w| w.as_vec().len() as u64)
179                .sum::<u64>()
180                + SIGNATURE_MARGIN as u64;
181
182            tx_policies = tx_policies.with_witness_limit(witness_size);
183        }
184
185        let mut tx_builder = ScriptTransactionBuilder::prepare_transfer(
186            inputs,
187            outputs.clone(),
188            tx_policies,
189        );
190        *tx_builder.witnesses_mut() = witnesses;
191        tx_builder = tx_builder.enable_burn(true);
192        tx_builder.add_signer(self.signer().clone())?;
193
194        let tx = tx_builder.build(self.provider()).await?;
195        Ok(tx.into())
196    }
197
198    #[tracing::instrument(skip_all)]
199    async fn send_transaction(
200        &self,
201        chain_id: ChainId,
202        tx: &Transaction,
203        submit_clients: &[FuelClient],
204    ) -> anyhow::Result<SendResult> {
205        let provider_client;
206        let clients: Vec<&FuelClient> = if submit_clients.is_empty() {
207            provider_client = self.provider().client();
208            vec![provider_client]
209        } else {
210            submit_clients.iter().collect()
211        };
212
213        let tx_id = tx.id(&chain_id);
214
215        let mut tasks: FuturesUnordered<_> = clients
216            .iter()
217            .copied()
218            .map(|client| submit_and_parse(client, tx, tx_id))
219            .collect();
220
221        let mut errors: Vec<String> = Vec::with_capacity(clients.len());
222        let mut any_duplicate = false;
223
224        while let Some(result) = tasks.next().await {
225            match result {
226                Ok(result) => return Ok(result),
227                Err(err) => {
228                    if err.is_duplicate() {
229                        any_duplicate = true;
230                    }
231                    errors.push(err.to_string());
232                }
233            }
234        }
235
236        if any_duplicate {
237            tracing::info!(
238                "Transaction {tx_id} already exists on at least one submit \
239                 client, awaiting confirmation. Submit errors: [{}]",
240                errors.join("; ")
241            );
242            return self.await_send_result(&tx_id, tx).await;
243        }
244
245        Err(anyhow::anyhow!(
246            "All {} submit client(s) failed for tx {tx_id}: [{}]",
247            clients.len(),
248            errors.join("; ")
249        ))
250    }
251
252    async fn build_transfer(
253        &self,
254        asset_id: AssetId,
255        transfers: &[(Address, u64)],
256        utxo_manager: &mut dyn UtxoProvider,
257        builder_data: &BuilderData,
258        fetch_coins: bool,
259    ) -> anyhow::Result<Transaction> {
260        // Get the max fee based on the current info for the chain
261        let max_fee = builder_data.max_fee();
262
263        let base_asset_id = *builder_data.consensus_parameters.base_asset_id();
264
265        let payer: Address = self.address();
266
267        let asset_total = transfers
268            .iter()
269            .map(|(_, amount)| u128::from(*amount))
270            .sum::<u128>();
271
272        let balance_of = utxo_manager.balance_of(payer, asset_id);
273        if fetch_coins && balance_of < asset_total {
274            let asset_coins = self
275                .provider()
276                .get_spendable_resources(ResourceFilter {
277                    from: self.address(),
278                    asset_id: Some(asset_id),
279                    amount: asset_total,
280                    excluded_utxos: vec![],
281                    excluded_message_nonces: vec![],
282                })
283                .await
284                .map_err(|e| {
285                    anyhow::anyhow!(
286                        "Failed to get spendable resources: \
287                        {e} for {asset_id:?} from {payer:?} with amount {asset_total}"
288                    )
289                })?
290                .into_iter()
291                .filter_map(|coin| match coin {
292                    CoinType::Coin(coin) => Some(coin.into()),
293                    _ => None,
294                });
295
296            utxo_manager.load_from_coins_vec(asset_coins.collect());
297        }
298
299        let fee_coins = if asset_id != base_asset_id {
300            utxo_manager.guaranteed_extract_coins(
301                payer,
302                base_asset_id,
303                max_fee as u128,
304            )?
305        } else {
306            vec![]
307        };
308
309        let mut total = transfers
310            .iter()
311            .map(|(_, amount)| u128::from(*amount))
312            .sum::<u128>();
313
314        if base_asset_id == asset_id {
315            total += max_fee as u128;
316        }
317
318        let asset_coins =
319            utxo_manager.guaranteed_extract_coins(payer, asset_id, total)?;
320
321        let mut output_coins = vec![];
322        for (recipient, amount) in transfers {
323            let output = Output::Coin {
324                to: *recipient,
325                amount: *amount,
326                asset_id,
327            };
328            output_coins.push(output);
329        }
330
331        output_coins.push(Output::Change {
332            to: payer,
333            amount: 0,
334            asset_id: base_asset_id,
335        });
336
337        if asset_id != base_asset_id {
338            output_coins.push(Output::Change {
339                to: payer,
340                amount: 0,
341                asset_id,
342            });
343        }
344
345        let mut input_coins = asset_coins;
346        input_coins.extend(fee_coins);
347
348        let inputs = input_coins
349            .into_iter()
350            .map(|coin| Input::resource_signed(CoinType::Coin(coin.into())))
351            .collect::<Vec<_>>();
352
353        let tx = self
354            .build_transaction(
355                inputs,
356                output_coins,
357                vec![],
358                TxPolicies::default().with_max_fee(max_fee),
359            )
360            .await?;
361
362        Ok(tx)
363    }
364
365    async fn transfer_many_and_wait(
366        &self,
367        asset_id: AssetId,
368        transfers: &[(Address, u64)],
369        utxo_manager: &mut dyn UtxoProvider,
370        builder_data: &BuilderData,
371        fetch_coins: bool,
372        chunk_size: Option<usize>,
373    ) -> anyhow::Result<Vec<FuelTxCoin>> {
374        let known_coins = self
375            .transfer_many(
376                asset_id,
377                transfers,
378                utxo_manager,
379                builder_data,
380                fetch_coins,
381                chunk_size,
382            )
383            .await?;
384
385        if let Some(last_tx_id) = known_coins.last().map(|coin| coin.utxo_id.tx_id()) {
386            let tx_id = TxId::new((*last_tx_id).into());
387            self.provider()
388                .await_transaction_commit::<ScriptTransaction>(tx_id)
389                .await?;
390        }
391
392        Ok(known_coins)
393    }
394
395    async fn transfer_many(
396        &self,
397        asset_id: AssetId,
398        transfers: &[(Address, u64)],
399        utxo_manager: &mut dyn UtxoProvider,
400        builder_data: &BuilderData,
401        fetch_coins: bool,
402        chunk_size: Option<usize>,
403    ) -> anyhow::Result<Vec<FuelTxCoin>> {
404        let chain_id = builder_data.consensus_parameters.chain_id();
405        match chunk_size {
406            None => {
407                let tx = self
408                    .build_transfer(
409                        asset_id,
410                        transfers,
411                        utxo_manager,
412                        builder_data,
413                        fetch_coins,
414                    )
415                    .await?;
416                let result = self.send_transaction(chain_id, &tx, &[]).await?;
417                Ok(result.known_coins)
418            }
419            Some(chunk_size) => {
420                let mut known_coins = vec![];
421                for chunk in transfers.chunks(chunk_size) {
422                    let tx = self
423                        .build_transfer(
424                            asset_id,
425                            chunk,
426                            utxo_manager,
427                            builder_data,
428                            fetch_coins,
429                        )
430                        .await?;
431                    let result = self.send_transaction(chain_id, &tx, &[]).await?;
432
433                    known_coins.extend(result.known_coins);
434                    utxo_manager.load_from_coins_vec(result.dynamic_coins);
435                }
436
437                Ok(known_coins)
438            }
439        }
440    }
441
442    #[tracing::instrument(skip(self, tx), fields(tx_id))]
443    async fn await_send_result(
444        &self,
445        tx_id: &TxId,
446        tx: &Transaction,
447    ) -> anyhow::Result<SendResult> {
448        let fuel_client = self.provider().client();
449
450        let include_preconfirmation = true;
451        let result = fuel_client
452            .subscribe_transaction_status_opt(tx_id, Some(include_preconfirmation))
453            .await;
454        let mut stream = match result {
455            Ok(stream) => stream,
456            Err(err) => {
457                tracing::error!("Failed to subscribe to transaction status: {err:?}");
458                return Err(err.into());
459            }
460        };
461
462        let mut status;
463        let mut preconf_rx_time = None;
464        loop {
465            let now = Instant::now();
466            status = stream.next().await.transpose()?.ok_or(anyhow::anyhow!(
467                "Failed to get transaction status from stream"
468            ))?;
469
470            match status {
471                TransactionStatus::PreconfirmationSuccess { .. }
472                | TransactionStatus::PreconfirmationFailure { .. } => {
473                    preconf_rx_time = Some(now.elapsed());
474                    break;
475                }
476                TransactionStatus::Success { .. } | TransactionStatus::Failure { .. } => {
477                    break;
478                }
479                TransactionStatus::SqueezedOut { reason } => {
480                    tracing::error!(%tx_id, "Transaction was squeezed out: {reason:?}");
481                    continue;
482                }
483                _ => continue,
484            }
485        }
486
487        let mut known_coins = vec![];
488        for (i, output) in tx.outputs().iter().enumerate() {
489            let utxo_id = UtxoId::new(*tx_id, i as u16);
490            if let Output::Coin {
491                amount,
492                to,
493                asset_id,
494            } = *output
495            {
496                let coin = FuelTxCoin {
497                    amount,
498                    asset_id,
499                    utxo_id,
500                    owner: to,
501                };
502
503                known_coins.push(coin);
504            }
505        }
506
507        let mut dynamic_coins = vec![];
508        match &status {
509            TransactionStatus::PreconfirmationSuccess {
510                resolved_outputs, ..
511            }
512            | TransactionStatus::PreconfirmationFailure {
513                resolved_outputs, ..
514            } => {
515                let resolved_outputs = resolved_outputs.clone().unwrap_or_default();
516
517                for output in resolved_outputs {
518                    let ResolvedOutput { utxo_id, output } = output;
519                    match output {
520                        Output::Change {
521                            amount,
522                            to,
523                            asset_id,
524                        } => {
525                            let coin = FuelTxCoin {
526                                amount,
527                                asset_id,
528                                utxo_id,
529                                owner: to,
530                            };
531
532                            dynamic_coins.push(coin);
533                        }
534                        Output::Variable {
535                            amount,
536                            to,
537                            asset_id,
538                        } => {
539                            let coin = FuelTxCoin {
540                                amount,
541                                asset_id,
542                                utxo_id,
543                                owner: to,
544                            };
545
546                            dynamic_coins.push(coin);
547                        }
548                        _ => {}
549                    }
550                }
551            }
552            TransactionStatus::Success { .. } | TransactionStatus::Failure { .. } => {
553                let tx = fuel_client
554                    .transaction(tx_id)
555                    .await?
556                    .ok_or(anyhow::anyhow!("Transaction not found"))?;
557
558                match tx.transaction {
559                    TransactionType::Known(tx) => {
560                        for (index, output) in tx.outputs().iter().enumerate() {
561                            let utxo_id = UtxoId::new(*tx_id, index as u16);
562
563                            match *output {
564                                Output::Change {
565                                    amount,
566                                    to,
567                                    asset_id,
568                                } => {
569                                    let coin = FuelTxCoin {
570                                        amount,
571                                        asset_id,
572                                        utxo_id,
573                                        owner: to,
574                                    };
575
576                                    dynamic_coins.push(coin);
577                                }
578                                Output::Variable {
579                                    amount,
580                                    to,
581                                    asset_id,
582                                } => {
583                                    let coin = FuelTxCoin {
584                                        amount,
585                                        asset_id,
586                                        utxo_id,
587                                        owner: to,
588                                    };
589
590                                    dynamic_coins.push(coin);
591                                }
592                                _ => {}
593                            }
594                        }
595                    }
596                    TransactionType::Unknown => {}
597                }
598            }
599            _ => {
600                return Err(anyhow::anyhow!(
601                    "Expected pre confirmation, but received: {status:?}"
602                ));
603            }
604        }
605
606        let result = SendResult {
607            tx_id: *tx_id,
608            tx_status: status.into(),
609            known_coins,
610            dynamic_coins,
611            preconf_rx_time,
612        };
613
614        Ok(result)
615    }
616}
617
618/// Submit `tx` through `client`, await a preconfirmation/final status, and
619/// parse out the resolved outputs. Used as a single racer by
620/// [`WalletExt::send_transaction`] when multiple submit clients are provided.
621async fn submit_and_parse(
622    client: &FuelClient,
623    tx: &Transaction,
624    tx_id: TxId,
625) -> anyhow::Result<SendResult> {
626    let estimate_predicates = false;
627    let include_preconfirmation = true;
628    let mut stream = client
629        .submit_and_await_status_opt(
630            tx,
631            Some(estimate_predicates),
632            Some(include_preconfirmation),
633        )
634        .await?;
635
636    let now = Instant::now();
637    let status = loop {
638        let status = stream.next().await.transpose()?.ok_or(anyhow::anyhow!(
639            "Failed to get pre confirmation from the stream"
640        ))?;
641
642        if matches!(status, TransactionStatus::PreconfirmationSuccess { .. })
643            || matches!(status, TransactionStatus::PreconfirmationFailure { .. })
644            || matches!(status, TransactionStatus::Success { .. })
645            || matches!(status, TransactionStatus::Failure { .. })
646        {
647            break status;
648        }
649
650        if let TransactionStatus::SqueezedOut { reason } = &status {
651            return Err(anyhow::anyhow!("Transaction was squeezed out: {reason:?}"));
652        }
653    };
654    let preconf_rx_time = now.elapsed();
655
656    let resolved = match &status {
657        TransactionStatus::PreconfirmationSuccess {
658            resolved_outputs, ..
659        }
660        | TransactionStatus::PreconfirmationFailure {
661            resolved_outputs, ..
662        } => resolved_outputs.clone().expect("Expected resolved outputs"),
663        TransactionStatus::Success { .. } | TransactionStatus::Failure { .. } => {
664            let transaction = client
665                .transaction(&tx_id)
666                .await?
667                .ok_or(anyhow::anyhow!("Transaction not found"))?;
668
669            let TransactionType::Known(executed_tx) = transaction.transaction else {
670                return Err(anyhow::anyhow!("Expected known transaction type"));
671            };
672
673            executed_tx
674                .outputs()
675                .iter()
676                .enumerate()
677                .filter_map(|(index, output)| {
678                    if output.is_change()
679                        || output.is_variable() && output.amount() != Some(0)
680                    {
681                        Some(ResolvedOutput {
682                            utxo_id: UtxoId::new(tx_id, index as u16),
683                            output: *output,
684                        })
685                    } else {
686                        None
687                    }
688                })
689                .collect::<Vec<_>>()
690        }
691        _ => {
692            return Err(anyhow::anyhow!(
693                "Expected pre confirmation, but received: {status:?}"
694            ));
695        }
696    };
697
698    let mut known_coins = vec![];
699    for (i, output) in tx.outputs().iter().enumerate() {
700        let utxo_id = UtxoId::new(tx_id, i as u16);
701        if let Output::Coin {
702            amount,
703            to,
704            asset_id,
705        } = *output
706        {
707            known_coins.push(FuelTxCoin {
708                amount,
709                asset_id,
710                utxo_id,
711                owner: to,
712            });
713        }
714    }
715
716    let mut dynamic_coins = vec![];
717    for ResolvedOutput { utxo_id, output } in resolved {
718        match output {
719            Output::Change {
720                amount,
721                to,
722                asset_id,
723            }
724            | Output::Variable {
725                amount,
726                to,
727                asset_id,
728            } => {
729                dynamic_coins.push(FuelTxCoin {
730                    amount,
731                    asset_id,
732                    utxo_id,
733                    owner: to,
734                });
735            }
736            _ => {}
737        }
738    }
739
740    Ok(SendResult {
741        tx_id,
742        tx_status: status.into(),
743        known_coins,
744        dynamic_coins,
745        preconf_rx_time: Some(preconf_rx_time),
746    })
747}
748
749pub(crate) trait ClientError {
750    fn is_duplicate(&self) -> bool;
751}
752
753impl<T> ClientError for T
754where
755    T: ToString,
756{
757    fn is_duplicate(&self) -> bool {
758        self.to_string().contains("Transaction id already exists")
759    }
760}
761
762/// Error patterns from fuel-core's TxPool that indicate the coins used
763/// in a transaction are invalid and must NOT be returned to the
764/// UtxoManager.
765///
766/// Matches errors from `txpool_v2::error`: `UtxoInputWasAlreadySpent`,
767/// `UtxoNotFound`, `NotInsertedIoCoinMismatch`, `NotInsertedIoWrongOwner`,
768/// `NotInsertedIoWrongAmount`, `NotInsertedIoWrongAssetId`,
769/// `BlacklistedUTXO`, `NotInsertedIoContractOutput`.
770const COIN_INVALID_PATTERNS: &[&str] = &[
771    "was already spent",
772    "does not exist",
773    "does not match the values from database",
774    "Coin owner is different from expected input",
775    "Coin output does not match expected input",
776    "asset_id does not match expected inputs",
777    "is blacklisted",
778    "Expected coin but output is contract",
779];
780
781/// Returns `true` if the error indicates that coins in the transaction
782/// are invalid (spent, missing, mismatched, blacklisted) and should NOT
783/// be returned to the UtxoManager.
784pub fn is_coin_invalid_error(error: &str) -> bool {
785    COIN_INVALID_PATTERNS
786        .iter()
787        .any(|pattern| error.contains(pattern))
788}
789
790#[cfg(test)]
791mod coin_error_tests {
792    use super::*;
793
794    #[test]
795    fn detects_coin_invalid_errors() {
796        let cases = [
797            "The UTXO input 0xabcd was already spent",
798            "UTXO (id: 0xabcd) does not exist",
799            "Input coin does not match the values from database",
800            "Input output mismatch. Coin owner is different from expected input",
801            "Input output mismatch. Coin output does not match expected input",
802            "Input output mismatch. Coin output asset_id does not match expected inputs",
803            "The UTXO `0xabcd` is blacklisted",
804            "Input output mismatch. Expected coin but output is contract",
805        ];
806        for msg in cases {
807            assert!(is_coin_invalid_error(msg), "Should detect: {msg}");
808        }
809    }
810
811    #[test]
812    fn does_not_flag_non_coin_errors() {
813        let cases = [
814            "Transaction was squeezed out",
815            "Pool limit is hit, try to increase gas_price",
816            "The provided max fee can't cover the transaction cost",
817            "Transaction id already exists",
818            "Transaction chain dependency is already too big",
819            "Too much transactions are in queue",
820        ];
821        for msg in cases {
822            assert!(!is_coin_invalid_error(msg), "Should NOT detect: {msg}");
823        }
824    }
825}