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            // Transfers are not the sponsoring fee path; leave the coin count
301            // uncapped to preserve existing funding behavior.
302            utxo_manager.guaranteed_extract_coins(
303                payer,
304                base_asset_id,
305                max_fee as u128,
306                usize::MAX,
307            )?
308        } else {
309            vec![]
310        };
311
312        let mut total = transfers
313            .iter()
314            .map(|(_, amount)| u128::from(*amount))
315            .sum::<u128>();
316
317        if base_asset_id == asset_id {
318            total += max_fee as u128;
319        }
320
321        let asset_coins =
322            utxo_manager.guaranteed_extract_coins(payer, asset_id, total, usize::MAX)?;
323
324        let mut output_coins = vec![];
325        for (recipient, amount) in transfers {
326            let output = Output::Coin {
327                to: *recipient,
328                amount: *amount,
329                asset_id,
330            };
331            output_coins.push(output);
332        }
333
334        output_coins.push(Output::Change {
335            to: payer,
336            amount: 0,
337            asset_id: base_asset_id,
338        });
339
340        if asset_id != base_asset_id {
341            output_coins.push(Output::Change {
342                to: payer,
343                amount: 0,
344                asset_id,
345            });
346        }
347
348        let mut input_coins = asset_coins;
349        input_coins.extend(fee_coins);
350
351        let inputs = input_coins
352            .into_iter()
353            .map(|coin| Input::resource_signed(CoinType::Coin(coin.into())))
354            .collect::<Vec<_>>();
355
356        let tx = self
357            .build_transaction(
358                inputs,
359                output_coins,
360                vec![],
361                TxPolicies::default().with_max_fee(max_fee),
362            )
363            .await?;
364
365        Ok(tx)
366    }
367
368    async fn transfer_many_and_wait(
369        &self,
370        asset_id: AssetId,
371        transfers: &[(Address, u64)],
372        utxo_manager: &mut dyn UtxoProvider,
373        builder_data: &BuilderData,
374        fetch_coins: bool,
375        chunk_size: Option<usize>,
376    ) -> anyhow::Result<Vec<FuelTxCoin>> {
377        let known_coins = self
378            .transfer_many(
379                asset_id,
380                transfers,
381                utxo_manager,
382                builder_data,
383                fetch_coins,
384                chunk_size,
385            )
386            .await?;
387
388        if let Some(last_tx_id) = known_coins.last().map(|coin| coin.utxo_id.tx_id()) {
389            let tx_id = TxId::new((*last_tx_id).into());
390            self.provider()
391                .await_transaction_commit::<ScriptTransaction>(tx_id)
392                .await?;
393        }
394
395        Ok(known_coins)
396    }
397
398    async fn transfer_many(
399        &self,
400        asset_id: AssetId,
401        transfers: &[(Address, u64)],
402        utxo_manager: &mut dyn UtxoProvider,
403        builder_data: &BuilderData,
404        fetch_coins: bool,
405        chunk_size: Option<usize>,
406    ) -> anyhow::Result<Vec<FuelTxCoin>> {
407        let chain_id = builder_data.consensus_parameters.chain_id();
408        match chunk_size {
409            None => {
410                let tx = self
411                    .build_transfer(
412                        asset_id,
413                        transfers,
414                        utxo_manager,
415                        builder_data,
416                        fetch_coins,
417                    )
418                    .await?;
419                let result = self.send_transaction(chain_id, &tx, &[]).await?;
420                Ok(result.known_coins)
421            }
422            Some(chunk_size) => {
423                let mut known_coins = vec![];
424                for chunk in transfers.chunks(chunk_size) {
425                    let tx = self
426                        .build_transfer(
427                            asset_id,
428                            chunk,
429                            utxo_manager,
430                            builder_data,
431                            fetch_coins,
432                        )
433                        .await?;
434                    let result = self.send_transaction(chain_id, &tx, &[]).await?;
435
436                    known_coins.extend(result.known_coins);
437                    utxo_manager.load_from_coins_vec(result.dynamic_coins);
438                }
439
440                Ok(known_coins)
441            }
442        }
443    }
444
445    #[tracing::instrument(skip(self, tx), fields(tx_id))]
446    async fn await_send_result(
447        &self,
448        tx_id: &TxId,
449        tx: &Transaction,
450    ) -> anyhow::Result<SendResult> {
451        let fuel_client = self.provider().client();
452
453        let include_preconfirmation = true;
454        let result = fuel_client
455            .subscribe_transaction_status_opt(tx_id, Some(include_preconfirmation))
456            .await;
457        let mut stream = match result {
458            Ok(stream) => stream,
459            Err(err) => {
460                tracing::error!("Failed to subscribe to transaction status: {err:?}");
461                return Err(err.into());
462            }
463        };
464
465        let mut status;
466        let mut preconf_rx_time = None;
467        loop {
468            let now = Instant::now();
469            status = stream.next().await.transpose()?.ok_or(anyhow::anyhow!(
470                "Failed to get transaction status from stream"
471            ))?;
472
473            match status {
474                TransactionStatus::PreconfirmationSuccess { .. }
475                | TransactionStatus::PreconfirmationFailure { .. } => {
476                    preconf_rx_time = Some(now.elapsed());
477                    break;
478                }
479                TransactionStatus::Success { .. } | TransactionStatus::Failure { .. } => {
480                    break;
481                }
482                TransactionStatus::SqueezedOut { reason } => {
483                    tracing::error!(%tx_id, "Transaction was squeezed out: {reason:?}");
484                    continue;
485                }
486                _ => continue,
487            }
488        }
489
490        let mut known_coins = vec![];
491        for (i, output) in tx.outputs().iter().enumerate() {
492            let utxo_id = UtxoId::new(*tx_id, i as u16);
493            if let Output::Coin {
494                amount,
495                to,
496                asset_id,
497            } = *output
498            {
499                let coin = FuelTxCoin {
500                    amount,
501                    asset_id,
502                    utxo_id,
503                    owner: to,
504                };
505
506                known_coins.push(coin);
507            }
508        }
509
510        let mut dynamic_coins = vec![];
511        match &status {
512            TransactionStatus::PreconfirmationSuccess {
513                resolved_outputs, ..
514            }
515            | TransactionStatus::PreconfirmationFailure {
516                resolved_outputs, ..
517            } => {
518                let resolved_outputs = resolved_outputs.clone().unwrap_or_default();
519
520                for output in resolved_outputs {
521                    let ResolvedOutput { utxo_id, output } = output;
522                    match output {
523                        Output::Change {
524                            amount,
525                            to,
526                            asset_id,
527                        } => {
528                            let coin = FuelTxCoin {
529                                amount,
530                                asset_id,
531                                utxo_id,
532                                owner: to,
533                            };
534
535                            dynamic_coins.push(coin);
536                        }
537                        Output::Variable {
538                            amount,
539                            to,
540                            asset_id,
541                        } => {
542                            let coin = FuelTxCoin {
543                                amount,
544                                asset_id,
545                                utxo_id,
546                                owner: to,
547                            };
548
549                            dynamic_coins.push(coin);
550                        }
551                        _ => {}
552                    }
553                }
554            }
555            TransactionStatus::Success { .. } | TransactionStatus::Failure { .. } => {
556                let tx = fuel_client
557                    .transaction(tx_id)
558                    .await?
559                    .ok_or(anyhow::anyhow!("Transaction not found"))?;
560
561                match tx.transaction {
562                    TransactionType::Known(tx) => {
563                        for (index, output) in tx.outputs().iter().enumerate() {
564                            let utxo_id = UtxoId::new(*tx_id, index as u16);
565
566                            match *output {
567                                Output::Change {
568                                    amount,
569                                    to,
570                                    asset_id,
571                                } => {
572                                    let coin = FuelTxCoin {
573                                        amount,
574                                        asset_id,
575                                        utxo_id,
576                                        owner: to,
577                                    };
578
579                                    dynamic_coins.push(coin);
580                                }
581                                Output::Variable {
582                                    amount,
583                                    to,
584                                    asset_id,
585                                } => {
586                                    let coin = FuelTxCoin {
587                                        amount,
588                                        asset_id,
589                                        utxo_id,
590                                        owner: to,
591                                    };
592
593                                    dynamic_coins.push(coin);
594                                }
595                                _ => {}
596                            }
597                        }
598                    }
599                    TransactionType::Unknown => {}
600                }
601            }
602            _ => {
603                return Err(anyhow::anyhow!(
604                    "Expected pre confirmation, but received: {status:?}"
605                ));
606            }
607        }
608
609        let result = SendResult {
610            tx_id: *tx_id,
611            tx_status: status.into(),
612            known_coins,
613            dynamic_coins,
614            preconf_rx_time,
615        };
616
617        Ok(result)
618    }
619}
620
621/// Submit `tx` through `client`, await a preconfirmation/final status, and
622/// parse out the resolved outputs. Used as a single racer by
623/// [`WalletExt::send_transaction`] when multiple submit clients are provided.
624async fn submit_and_parse(
625    client: &FuelClient,
626    tx: &Transaction,
627    tx_id: TxId,
628) -> anyhow::Result<SendResult> {
629    let estimate_predicates = false;
630    let include_preconfirmation = true;
631    let mut stream = client
632        .submit_and_await_status_opt(
633            tx,
634            Some(estimate_predicates),
635            Some(include_preconfirmation),
636        )
637        .await?;
638
639    let now = Instant::now();
640    let status = loop {
641        let status = stream.next().await.transpose()?.ok_or(anyhow::anyhow!(
642            "Failed to get pre confirmation from the stream"
643        ))?;
644
645        if matches!(status, TransactionStatus::PreconfirmationSuccess { .. })
646            || matches!(status, TransactionStatus::PreconfirmationFailure { .. })
647            || matches!(status, TransactionStatus::Success { .. })
648            || matches!(status, TransactionStatus::Failure { .. })
649        {
650            break status;
651        }
652
653        if let TransactionStatus::SqueezedOut { reason } = &status {
654            return Err(anyhow::anyhow!("Transaction was squeezed out: {reason:?}"));
655        }
656    };
657    let preconf_rx_time = now.elapsed();
658
659    let resolved = match &status {
660        TransactionStatus::PreconfirmationSuccess {
661            resolved_outputs, ..
662        }
663        | TransactionStatus::PreconfirmationFailure {
664            resolved_outputs, ..
665        } => resolved_outputs.clone().expect("Expected resolved outputs"),
666        TransactionStatus::Success { .. } | TransactionStatus::Failure { .. } => {
667            let transaction = client
668                .transaction(&tx_id)
669                .await?
670                .ok_or(anyhow::anyhow!("Transaction not found"))?;
671
672            let TransactionType::Known(executed_tx) = transaction.transaction else {
673                return Err(anyhow::anyhow!("Expected known transaction type"));
674            };
675
676            executed_tx
677                .outputs()
678                .iter()
679                .enumerate()
680                .filter_map(|(index, output)| {
681                    if output.is_change()
682                        || output.is_variable() && output.amount() != Some(0)
683                    {
684                        Some(ResolvedOutput {
685                            utxo_id: UtxoId::new(tx_id, index as u16),
686                            output: *output,
687                        })
688                    } else {
689                        None
690                    }
691                })
692                .collect::<Vec<_>>()
693        }
694        _ => {
695            return Err(anyhow::anyhow!(
696                "Expected pre confirmation, but received: {status:?}"
697            ));
698        }
699    };
700
701    let mut known_coins = vec![];
702    for (i, output) in tx.outputs().iter().enumerate() {
703        let utxo_id = UtxoId::new(tx_id, i as u16);
704        if let Output::Coin {
705            amount,
706            to,
707            asset_id,
708        } = *output
709        {
710            known_coins.push(FuelTxCoin {
711                amount,
712                asset_id,
713                utxo_id,
714                owner: to,
715            });
716        }
717    }
718
719    let mut dynamic_coins = vec![];
720    for ResolvedOutput { utxo_id, output } in resolved {
721        match output {
722            Output::Change {
723                amount,
724                to,
725                asset_id,
726            }
727            | Output::Variable {
728                amount,
729                to,
730                asset_id,
731            } => {
732                dynamic_coins.push(FuelTxCoin {
733                    amount,
734                    asset_id,
735                    utxo_id,
736                    owner: to,
737                });
738            }
739            _ => {}
740        }
741    }
742
743    Ok(SendResult {
744        tx_id,
745        tx_status: status.into(),
746        known_coins,
747        dynamic_coins,
748        preconf_rx_time: Some(preconf_rx_time),
749    })
750}
751
752pub(crate) trait ClientError {
753    fn is_duplicate(&self) -> bool;
754}
755
756impl<T> ClientError for T
757where
758    T: ToString,
759{
760    fn is_duplicate(&self) -> bool {
761        self.to_string().contains("Transaction id already exists")
762    }
763}
764
765/// Error patterns from fuel-core's TxPool that indicate the coins used
766/// in a transaction are invalid and must NOT be returned to the
767/// UtxoManager.
768///
769/// Matches errors from `txpool_v2::error`: `UtxoInputWasAlreadySpent`,
770/// `UtxoNotFound`, `NotInsertedIoCoinMismatch`, `NotInsertedIoWrongOwner`,
771/// `NotInsertedIoWrongAmount`, `NotInsertedIoWrongAssetId`,
772/// `BlacklistedUTXO`, `NotInsertedIoContractOutput`.
773const COIN_INVALID_PATTERNS: &[&str] = &[
774    "was already spent",
775    "does not exist",
776    "does not match the values from database",
777    "Coin owner is different from expected input",
778    "Coin output does not match expected input",
779    "asset_id does not match expected inputs",
780    "is blacklisted",
781    "Expected coin but output is contract",
782];
783
784/// Returns `true` if the error indicates that coins in the transaction
785/// are invalid (spent, missing, mismatched, blacklisted) and should NOT
786/// be returned to the UtxoManager.
787pub fn is_coin_invalid_error(error: &str) -> bool {
788    COIN_INVALID_PATTERNS
789        .iter()
790        .any(|pattern| error.contains(pattern))
791}
792
793#[cfg(test)]
794mod coin_error_tests {
795    use super::*;
796
797    #[test]
798    fn detects_coin_invalid_errors() {
799        let cases = [
800            "The UTXO input 0xabcd was already spent",
801            "UTXO (id: 0xabcd) does not exist",
802            "Input coin does not match the values from database",
803            "Input output mismatch. Coin owner is different from expected input",
804            "Input output mismatch. Coin output does not match expected input",
805            "Input output mismatch. Coin output asset_id does not match expected inputs",
806            "The UTXO `0xabcd` is blacklisted",
807            "Input output mismatch. Expected coin but output is contract",
808        ];
809        for msg in cases {
810            assert!(is_coin_invalid_error(msg), "Should detect: {msg}");
811        }
812    }
813
814    #[test]
815    fn does_not_flag_non_coin_errors() {
816        let cases = [
817            "Transaction was squeezed out",
818            "Pool limit is hit, try to increase gas_price",
819            "The provided max fee can't cover the transaction cost",
820            "Transaction id already exists",
821            "Transaction chain dependency is already too big",
822            "Too much transactions are in queue",
823        ];
824        for msg in cases {
825            assert!(!is_coin_invalid_error(msg), "Should NOT detect: {msg}");
826        }
827    }
828}