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        Receipt,
20        Transaction,
21        TxId,
22        UniqueIdentifier,
23        UtxoId,
24        Witness,
25    },
26    fuel_types::ChainId,
27};
28use fuels::{
29    accounts::{
30        ViewOnlyAccount,
31        wallet::Unlocked,
32    },
33    prelude::{
34        BuildableTransaction,
35        ResourceFilter,
36        ScriptTransactionBuilder,
37        TransactionBuilder,
38        TxPolicies,
39        Wallet,
40    },
41    types::{
42        coin_type::CoinType,
43        input::Input,
44        output::Output,
45        transaction::ScriptTransaction,
46        tx_status::TxStatus,
47    },
48};
49use futures::{
50    StreamExt,
51    stream::FuturesUnordered,
52};
53use std::{
54    future::Future,
55    ops::Mul,
56    sync::Arc,
57    time::{
58        Duration,
59        Instant,
60    },
61};
62
63pub const SIGNATURE_MARGIN: usize = 100;
64
65#[derive(Clone, Debug)]
66pub struct SendResult<T = TxStatus> {
67    pub tx_id: TxId,
68    pub tx_status: T,
69    pub known_coins: Vec<FuelTxCoin>,
70    pub dynamic_coins: Vec<FuelTxCoin>,
71    pub preconf_rx_time: Option<Duration>,
72    /// Execution receipts for the transaction, captured from the (pre)confirmed
73    /// status on both the success and failure paths.
74    ///
75    /// Unlike [`TxStatus::take_receipts`], this is also populated for the
76    /// **preconfirmation** variants — so callers that submit with
77    /// preconfirmations (and therefore never see a final `Success`/`Failure`
78    /// before this returns) can still inspect the receipts synchronously,
79    /// without a racy `client.receipts(tx_id)` round-trip that may not be ready
80    /// yet. See [`receipts_of`].
81    pub receipts: Arc<Vec<Receipt>>,
82}
83
84/// Extract the receipts carried by a [`TxStatus`], including the
85/// **preconfirmation** variants (`PreconfirmationSuccess` / `Preconfirmation
86/// Failure`) that [`TxStatus::take_receipts`] drops. Statuses without receipts
87/// (`Submitted`, `SqueezedOut`) yield an empty list.
88pub fn receipts_of(status: &TxStatus) -> Arc<Vec<Receipt>> {
89    match status {
90        TxStatus::Success(success) | TxStatus::PreconfirmationSuccess(success) => {
91            success.receipts.clone()
92        }
93        TxStatus::Failure(failure) | TxStatus::PreconfirmationFailure(failure) => {
94            failure.receipts.clone()
95        }
96        TxStatus::Submitted | TxStatus::SqueezedOut(_) => Arc::new(Vec::new()),
97    }
98}
99
100#[derive(Clone)]
101pub struct BuilderData {
102    pub consensus_parameters: ConsensusParameters,
103    pub gas_price: u64,
104}
105
106impl BuilderData {
107    pub fn max_fee(&self) -> u64 {
108        let max_gas_limit = self.consensus_parameters.tx_params().max_gas_per_tx();
109        // Get the max fee based on the current info for the chain
110        max_gas_limit
111            .mul(self.gas_price)
112            .div_ceil(self.consensus_parameters.fee_params().gas_price_factor())
113    }
114}
115
116pub trait WalletExt {
117    fn builder_data(&self) -> impl Future<Output = anyhow::Result<BuilderData>> + Send;
118
119    fn build_transfer(
120        &self,
121        asset_id: AssetId,
122        transfers: &[(Address, u64)],
123        utxo_manager: &mut dyn UtxoProvider,
124        builder_data: &BuilderData,
125        fetch_coins: bool,
126    ) -> impl Future<Output = anyhow::Result<Transaction>> + Send;
127
128    fn build_transaction(
129        &self,
130        inputs: Vec<Input>,
131        outputs: Vec<Output>,
132        witnesses: Vec<Witness>,
133        tx_policies: TxPolicies,
134    ) -> impl Future<Output = anyhow::Result<Transaction>> + Send;
135
136    /// Submits `tx` and awaits its preconfirmation/final status.
137    ///
138    /// `submit_clients` lets callers fan out submission across several nodes;
139    /// the first client to return a valid preconfirmation/final status wins
140    /// and its result is returned. When every client errors, the returned
141    /// error aggregates each client's failure message. When a duplicate-tx
142    /// error is observed on any client, we fall back to awaiting the status
143    /// on the provider's client. When `submit_clients` is empty, the
144    /// provider's client is used as the sole submitter.
145    fn send_transaction(
146        &self,
147        chain_id: ChainId,
148        tx: &Transaction,
149        submit_clients: &[FuelClient],
150    ) -> impl Future<Output = anyhow::Result<SendResult>> + Send;
151
152    fn transfer_many(
153        &self,
154        asset_id: AssetId,
155        transfers: &[(Address, u64)],
156        utxo_manager: &mut dyn UtxoProvider,
157        builder_data: &BuilderData,
158        fetch_coins: bool,
159        chunk_size: Option<usize>,
160    ) -> impl Future<Output = anyhow::Result<Vec<FuelTxCoin>>> + Send;
161
162    fn transfer_many_and_wait(
163        &self,
164        asset_id: AssetId,
165        transfers: &[(Address, u64)],
166        utxo_manager: &mut dyn UtxoProvider,
167        builder_data: &BuilderData,
168        fetch_coins: bool,
169        chunk_size: Option<usize>,
170    ) -> impl Future<Output = anyhow::Result<Vec<FuelTxCoin>>> + Send;
171
172    fn await_send_result(
173        &self,
174        tx_id: &TxId,
175        tx: &Transaction,
176    ) -> impl Future<Output = anyhow::Result<SendResult>> + Send;
177}
178
179impl<S> WalletExt for Wallet<Unlocked<S>>
180where
181    S: fuels::core::traits::Signer + Clone + Send + Sync + std::fmt::Debug + 'static,
182{
183    async fn builder_data(&self) -> anyhow::Result<BuilderData> {
184        let provider = self.provider();
185        let consensus_parameters = provider.consensus_parameters().await?;
186        let gas_price = provider.estimate_gas_price(10).await?;
187
188        let builder_data = BuilderData {
189            consensus_parameters,
190            gas_price: gas_price.gas_price,
191        };
192
193        Ok(builder_data)
194    }
195
196    async fn build_transaction(
197        &self,
198        inputs: Vec<Input>,
199        outputs: Vec<Output>,
200        witnesses: Vec<Witness>,
201        mut tx_policies: TxPolicies,
202    ) -> anyhow::Result<Transaction> {
203        if tx_policies.witness_limit().is_none() {
204            let witness_size = witnesses
205                .iter()
206                .map(|w| w.as_vec().len() as u64)
207                .sum::<u64>()
208                + SIGNATURE_MARGIN as u64;
209
210            tx_policies = tx_policies.with_witness_limit(witness_size);
211        }
212
213        let mut tx_builder = ScriptTransactionBuilder::prepare_transfer(
214            inputs,
215            outputs.clone(),
216            tx_policies,
217        );
218        *tx_builder.witnesses_mut() = witnesses;
219        tx_builder = tx_builder.enable_burn(true);
220        tx_builder.add_signer(self.signer().clone())?;
221
222        let tx = tx_builder.build(self.provider()).await?;
223        Ok(tx.into())
224    }
225
226    #[tracing::instrument(skip_all)]
227    async fn send_transaction(
228        &self,
229        chain_id: ChainId,
230        tx: &Transaction,
231        submit_clients: &[FuelClient],
232    ) -> anyhow::Result<SendResult> {
233        let provider_client;
234        let clients: Vec<&FuelClient> = if submit_clients.is_empty() {
235            provider_client = self.provider().client();
236            vec![provider_client]
237        } else {
238            submit_clients.iter().collect()
239        };
240
241        let tx_id = tx.id(&chain_id);
242
243        let mut tasks: FuturesUnordered<_> = clients
244            .iter()
245            .copied()
246            .map(|client| submit_and_parse(client, tx, tx_id))
247            .collect();
248
249        let mut errors: Vec<String> = Vec::with_capacity(clients.len());
250        let mut any_duplicate = false;
251
252        while let Some(result) = tasks.next().await {
253            match result {
254                Ok(result) => return Ok(result),
255                Err(err) => {
256                    if err.is_duplicate() {
257                        any_duplicate = true;
258                    }
259                    errors.push(err.to_string());
260                }
261            }
262        }
263
264        if any_duplicate {
265            tracing::info!(
266                "Transaction {tx_id} already exists on at least one submit \
267                 client, awaiting confirmation. Submit errors: [{}]",
268                errors.join("; ")
269            );
270            return self.await_send_result(&tx_id, tx).await;
271        }
272
273        Err(anyhow::anyhow!(
274            "All {} submit client(s) failed for tx {tx_id}: [{}]",
275            clients.len(),
276            errors.join("; ")
277        ))
278    }
279
280    async fn build_transfer(
281        &self,
282        asset_id: AssetId,
283        transfers: &[(Address, u64)],
284        utxo_manager: &mut dyn UtxoProvider,
285        builder_data: &BuilderData,
286        fetch_coins: bool,
287    ) -> anyhow::Result<Transaction> {
288        // Get the max fee based on the current info for the chain
289        let max_fee = builder_data.max_fee();
290
291        let base_asset_id = *builder_data.consensus_parameters.base_asset_id();
292
293        let payer: Address = self.address();
294
295        let asset_total = transfers
296            .iter()
297            .map(|(_, amount)| u128::from(*amount))
298            .sum::<u128>();
299
300        let balance_of = utxo_manager.balance_of(payer, asset_id);
301        if fetch_coins && balance_of < asset_total {
302            let asset_coins = self
303                .provider()
304                .get_spendable_resources(ResourceFilter {
305                    from: self.address(),
306                    asset_id: Some(asset_id),
307                    amount: asset_total,
308                    excluded_utxos: vec![],
309                    excluded_message_nonces: vec![],
310                })
311                .await
312                .map_err(|e| {
313                    anyhow::anyhow!(
314                        "Failed to get spendable resources: \
315                        {e} for {asset_id:?} from {payer:?} with amount {asset_total}"
316                    )
317                })?
318                .into_iter()
319                .filter_map(|coin| match coin {
320                    CoinType::Coin(coin) => Some(coin.into()),
321                    _ => None,
322                });
323
324            utxo_manager.load_from_coins_vec(asset_coins.collect());
325        }
326
327        let fee_coins = if asset_id != base_asset_id {
328            // Transfers are not the sponsoring fee path; leave the coin count
329            // uncapped to preserve existing funding behavior.
330            utxo_manager.guaranteed_extract_coins(
331                payer,
332                base_asset_id,
333                max_fee as u128,
334                usize::MAX,
335            )?
336        } else {
337            vec![]
338        };
339
340        let mut total = transfers
341            .iter()
342            .map(|(_, amount)| u128::from(*amount))
343            .sum::<u128>();
344
345        if base_asset_id == asset_id {
346            total += max_fee as u128;
347        }
348
349        // Selection removes coins from the caller's manager. From here on any
350        // early return has to put them back, or the caller is silently short
351        // the inputs of a transfer that never happened.
352        let asset_coins = match utxo_manager.guaranteed_extract_coins(
353            payer,
354            asset_id,
355            total,
356            usize::MAX,
357        ) {
358            Ok(coins) => coins,
359            Err(err) => {
360                utxo_manager.load_from_coins_vec(fee_coins);
361                return Err(err);
362            }
363        };
364
365        let mut output_coins = vec![];
366        for (recipient, amount) in transfers {
367            let output = Output::Coin {
368                to: *recipient,
369                amount: *amount,
370                asset_id,
371            };
372            output_coins.push(output);
373        }
374
375        output_coins.push(Output::Change {
376            to: payer,
377            amount: 0,
378            asset_id: base_asset_id,
379        });
380
381        if asset_id != base_asset_id {
382            output_coins.push(Output::Change {
383                to: payer,
384                amount: 0,
385                asset_id,
386            });
387        }
388
389        let mut input_coins = asset_coins;
390        input_coins.extend(fee_coins);
391
392        let inputs = input_coins
393            .iter()
394            .map(|coin| Input::resource_signed(CoinType::Coin((*coin).into())))
395            .collect::<Vec<_>>();
396
397        match self
398            .build_transaction(
399                inputs,
400                output_coins,
401                vec![],
402                TxPolicies::default().with_max_fee(max_fee),
403            )
404            .await
405        {
406            Ok(tx) => Ok(tx),
407            Err(err) => {
408                // The transaction was never built, so these coins are still
409                // unspent. Hand them back rather than leaving the caller to
410                // rediscover them from the node.
411                utxo_manager.load_from_coins_vec(input_coins);
412                Err(err)
413            }
414        }
415    }
416
417    async fn transfer_many_and_wait(
418        &self,
419        asset_id: AssetId,
420        transfers: &[(Address, u64)],
421        utxo_manager: &mut dyn UtxoProvider,
422        builder_data: &BuilderData,
423        fetch_coins: bool,
424        chunk_size: Option<usize>,
425    ) -> anyhow::Result<Vec<FuelTxCoin>> {
426        let known_coins = self
427            .transfer_many(
428                asset_id,
429                transfers,
430                utxo_manager,
431                builder_data,
432                fetch_coins,
433                chunk_size,
434            )
435            .await?;
436
437        if let Some(last_tx_id) = known_coins.last().map(|coin| coin.utxo_id.tx_id()) {
438            let tx_id = TxId::new((*last_tx_id).into());
439            self.provider()
440                .await_transaction_commit::<ScriptTransaction>(tx_id)
441                .await?;
442        }
443
444        Ok(known_coins)
445    }
446
447    async fn transfer_many(
448        &self,
449        asset_id: AssetId,
450        transfers: &[(Address, u64)],
451        utxo_manager: &mut dyn UtxoProvider,
452        builder_data: &BuilderData,
453        fetch_coins: bool,
454        chunk_size: Option<usize>,
455    ) -> anyhow::Result<Vec<FuelTxCoin>> {
456        let chain_id = builder_data.consensus_parameters.chain_id();
457        match chunk_size {
458            None => {
459                let tx = self
460                    .build_transfer(
461                        asset_id,
462                        transfers,
463                        utxo_manager,
464                        builder_data,
465                        fetch_coins,
466                    )
467                    .await?;
468                let result = self.send_transaction(chain_id, &tx, &[]).await?;
469                Ok(result.known_coins)
470            }
471            Some(chunk_size) => {
472                let mut known_coins = vec![];
473                for chunk in transfers.chunks(chunk_size) {
474                    let tx = self
475                        .build_transfer(
476                            asset_id,
477                            chunk,
478                            utxo_manager,
479                            builder_data,
480                            fetch_coins,
481                        )
482                        .await?;
483                    let result = self.send_transaction(chain_id, &tx, &[]).await?;
484
485                    known_coins.extend(result.known_coins);
486                    utxo_manager.load_from_coins_vec(result.dynamic_coins);
487                }
488
489                Ok(known_coins)
490            }
491        }
492    }
493
494    #[tracing::instrument(skip(self, tx), fields(tx_id))]
495    async fn await_send_result(
496        &self,
497        tx_id: &TxId,
498        tx: &Transaction,
499    ) -> anyhow::Result<SendResult> {
500        let fuel_client = self.provider().client();
501
502        let include_preconfirmation = true;
503        let result = fuel_client
504            .subscribe_transaction_status_opt(tx_id, Some(include_preconfirmation))
505            .await;
506        let mut stream = match result {
507            Ok(stream) => stream,
508            Err(err) => {
509                tracing::error!("Failed to subscribe to transaction status: {err:?}");
510                return Err(err.into());
511            }
512        };
513
514        let mut status;
515        let mut preconf_rx_time = None;
516        loop {
517            let now = Instant::now();
518            status = stream.next().await.transpose()?.ok_or(anyhow::anyhow!(
519                "Failed to get transaction status from stream"
520            ))?;
521
522            match status {
523                TransactionStatus::PreconfirmationSuccess { .. }
524                | TransactionStatus::PreconfirmationFailure { .. } => {
525                    preconf_rx_time = Some(now.elapsed());
526                    break;
527                }
528                TransactionStatus::Success { .. } | TransactionStatus::Failure { .. } => {
529                    break;
530                }
531                TransactionStatus::SqueezedOut { reason } => {
532                    tracing::error!(%tx_id, "Transaction was squeezed out: {reason:?}");
533                    continue;
534                }
535                _ => continue,
536            }
537        }
538
539        let mut known_coins = vec![];
540        for (i, output) in tx.outputs().iter().enumerate() {
541            let utxo_id = UtxoId::new(*tx_id, i as u16);
542            if let Output::Coin {
543                amount,
544                to,
545                asset_id,
546            } = *output
547            {
548                let coin = FuelTxCoin {
549                    amount,
550                    asset_id,
551                    utxo_id,
552                    owner: to,
553                };
554
555                known_coins.push(coin);
556            }
557        }
558
559        let mut dynamic_coins = vec![];
560        match &status {
561            TransactionStatus::PreconfirmationSuccess {
562                resolved_outputs, ..
563            }
564            | TransactionStatus::PreconfirmationFailure {
565                resolved_outputs, ..
566            } => {
567                let resolved_outputs = resolved_outputs.clone().unwrap_or_default();
568
569                for output in resolved_outputs {
570                    let ResolvedOutput { utxo_id, output } = output;
571                    match output {
572                        Output::Change {
573                            amount,
574                            to,
575                            asset_id,
576                        } => {
577                            let coin = FuelTxCoin {
578                                amount,
579                                asset_id,
580                                utxo_id,
581                                owner: to,
582                            };
583
584                            dynamic_coins.push(coin);
585                        }
586                        Output::Variable {
587                            amount,
588                            to,
589                            asset_id,
590                        } => {
591                            let coin = FuelTxCoin {
592                                amount,
593                                asset_id,
594                                utxo_id,
595                                owner: to,
596                            };
597
598                            dynamic_coins.push(coin);
599                        }
600                        _ => {}
601                    }
602                }
603            }
604            TransactionStatus::Success { .. } | TransactionStatus::Failure { .. } => {
605                let tx = fuel_client
606                    .transaction(tx_id)
607                    .await?
608                    .ok_or(anyhow::anyhow!("Transaction not found"))?;
609
610                match tx.transaction {
611                    TransactionType::Known(tx) => {
612                        for (index, output) in tx.outputs().iter().enumerate() {
613                            let utxo_id = UtxoId::new(*tx_id, index as u16);
614
615                            match *output {
616                                Output::Change {
617                                    amount,
618                                    to,
619                                    asset_id,
620                                } => {
621                                    let coin = FuelTxCoin {
622                                        amount,
623                                        asset_id,
624                                        utxo_id,
625                                        owner: to,
626                                    };
627
628                                    dynamic_coins.push(coin);
629                                }
630                                Output::Variable {
631                                    amount,
632                                    to,
633                                    asset_id,
634                                } => {
635                                    let coin = FuelTxCoin {
636                                        amount,
637                                        asset_id,
638                                        utxo_id,
639                                        owner: to,
640                                    };
641
642                                    dynamic_coins.push(coin);
643                                }
644                                _ => {}
645                            }
646                        }
647                    }
648                    TransactionType::Unknown => {}
649                }
650            }
651            _ => {
652                return Err(anyhow::anyhow!(
653                    "Expected pre confirmation, but received: {status:?}"
654                ));
655            }
656        }
657
658        let tx_status: TxStatus = status.into();
659        let receipts = receipts_of(&tx_status);
660        let result = SendResult {
661            tx_id: *tx_id,
662            tx_status,
663            known_coins,
664            dynamic_coins,
665            preconf_rx_time,
666            receipts,
667        };
668
669        Ok(result)
670    }
671}
672
673/// Submit `tx` through `client`, await a preconfirmation/final status, and
674/// parse out the resolved outputs. Used as a single racer by
675/// [`WalletExt::send_transaction`] when multiple submit clients are provided.
676async fn submit_and_parse(
677    client: &FuelClient,
678    tx: &Transaction,
679    tx_id: TxId,
680) -> anyhow::Result<SendResult> {
681    let estimate_predicates = false;
682    let include_preconfirmation = true;
683    let mut stream = client
684        .submit_and_await_status_opt(
685            tx,
686            Some(estimate_predicates),
687            Some(include_preconfirmation),
688        )
689        .await?;
690
691    let now = Instant::now();
692    let status = loop {
693        let status = stream.next().await.transpose()?.ok_or(anyhow::anyhow!(
694            "Failed to get pre confirmation from the stream"
695        ))?;
696
697        if matches!(status, TransactionStatus::PreconfirmationSuccess { .. })
698            || matches!(status, TransactionStatus::PreconfirmationFailure { .. })
699            || matches!(status, TransactionStatus::Success { .. })
700            || matches!(status, TransactionStatus::Failure { .. })
701        {
702            break status;
703        }
704
705        if let TransactionStatus::SqueezedOut { reason } = &status {
706            return Err(anyhow::anyhow!("Transaction was squeezed out: {reason:?}"));
707        }
708    };
709    let preconf_rx_time = now.elapsed();
710
711    let resolved = match &status {
712        TransactionStatus::PreconfirmationSuccess {
713            resolved_outputs, ..
714        }
715        | TransactionStatus::PreconfirmationFailure {
716            resolved_outputs, ..
717        } => resolved_outputs.clone().expect("Expected resolved outputs"),
718        TransactionStatus::Success { .. } | TransactionStatus::Failure { .. } => {
719            let transaction = client
720                .transaction(&tx_id)
721                .await?
722                .ok_or(anyhow::anyhow!("Transaction not found"))?;
723
724            let TransactionType::Known(executed_tx) = transaction.transaction else {
725                return Err(anyhow::anyhow!("Expected known transaction type"));
726            };
727
728            executed_tx
729                .outputs()
730                .iter()
731                .enumerate()
732                .filter_map(|(index, output)| {
733                    if output.is_change()
734                        || output.is_variable() && output.amount() != Some(0)
735                    {
736                        Some(ResolvedOutput {
737                            utxo_id: UtxoId::new(tx_id, index as u16),
738                            output: *output,
739                        })
740                    } else {
741                        None
742                    }
743                })
744                .collect::<Vec<_>>()
745        }
746        _ => {
747            return Err(anyhow::anyhow!(
748                "Expected pre confirmation, but received: {status:?}"
749            ));
750        }
751    };
752
753    let mut known_coins = vec![];
754    for (i, output) in tx.outputs().iter().enumerate() {
755        let utxo_id = UtxoId::new(tx_id, i as u16);
756        if let Output::Coin {
757            amount,
758            to,
759            asset_id,
760        } = *output
761        {
762            known_coins.push(FuelTxCoin {
763                amount,
764                asset_id,
765                utxo_id,
766                owner: to,
767            });
768        }
769    }
770
771    let mut dynamic_coins = vec![];
772    for ResolvedOutput { utxo_id, output } in resolved {
773        match output {
774            Output::Change {
775                amount,
776                to,
777                asset_id,
778            }
779            | Output::Variable {
780                amount,
781                to,
782                asset_id,
783            } => {
784                dynamic_coins.push(FuelTxCoin {
785                    amount,
786                    asset_id,
787                    utxo_id,
788                    owner: to,
789                });
790            }
791            _ => {}
792        }
793    }
794
795    let tx_status: TxStatus = status.into();
796    let receipts = receipts_of(&tx_status);
797    Ok(SendResult {
798        tx_id,
799        tx_status,
800        known_coins,
801        dynamic_coins,
802        preconf_rx_time: Some(preconf_rx_time),
803        receipts,
804    })
805}
806
807pub(crate) trait ClientError {
808    fn is_duplicate(&self) -> bool;
809}
810
811impl<T> ClientError for T
812where
813    T: ToString,
814{
815    fn is_duplicate(&self) -> bool {
816        self.to_string().contains("Transaction id already exists")
817    }
818}
819
820/// Error patterns from fuel-core's TxPool that indicate the coins used
821/// in a transaction are invalid and must NOT be returned to the
822/// UtxoManager.
823///
824/// Matches errors from `txpool_v2::error`: `UtxoInputWasAlreadySpent`,
825/// `UtxoNotFound`, `NotInsertedIoCoinMismatch`, `NotInsertedIoWrongOwner`,
826/// `NotInsertedIoWrongAmount`, `NotInsertedIoWrongAssetId`,
827/// `BlacklistedUTXO`, `NotInsertedIoContractOutput`.
828const COIN_INVALID_PATTERNS: &[&str] = &[
829    "was already spent",
830    "does not exist",
831    "does not match the values from database",
832    "Coin owner is different from expected input",
833    "Coin output does not match expected input",
834    "asset_id does not match expected inputs",
835    "is blacklisted",
836    "Expected coin but output is contract",
837];
838
839/// Returns `true` if the error indicates that coins in the transaction
840/// are invalid (spent, missing, mismatched, blacklisted) and should NOT
841/// be returned to the UtxoManager.
842pub fn is_coin_invalid_error(error: &str) -> bool {
843    COIN_INVALID_PATTERNS
844        .iter()
845        .any(|pattern| error.contains(pattern))
846}
847
848#[cfg(test)]
849mod coin_error_tests {
850    use super::*;
851
852    #[test]
853    fn detects_coin_invalid_errors() {
854        let cases = [
855            "The UTXO input 0xabcd was already spent",
856            "UTXO (id: 0xabcd) does not exist",
857            "Input coin does not match the values from database",
858            "Input output mismatch. Coin owner is different from expected input",
859            "Input output mismatch. Coin output does not match expected input",
860            "Input output mismatch. Coin output asset_id does not match expected inputs",
861            "The UTXO `0xabcd` is blacklisted",
862            "Input output mismatch. Expected coin but output is contract",
863        ];
864        for msg in cases {
865            assert!(is_coin_invalid_error(msg), "Should detect: {msg}");
866        }
867    }
868
869    #[test]
870    fn does_not_flag_non_coin_errors() {
871        let cases = [
872            "Transaction was squeezed out",
873            "Pool limit is hit, try to increase gas_price",
874            "The provided max fee can't cover the transaction cost",
875            "Transaction id already exists",
876            "Transaction chain dependency is already too big",
877            "Too much transactions are in queue",
878        ];
879        for msg in cases {
880            assert!(!is_coin_invalid_error(msg), "Should NOT detect: {msg}");
881        }
882    }
883}
884
885#[cfg(test)]
886// `fn__scenario` names, which clippy reads as non-snake-case. Pre-existing
887// and deliberate: the doubled underscore separates the function under test
888// from the case, and renaming them would lose that.
889#[allow(non_snake_case)]
890mod restore_on_failure_tests {
891    use super::*;
892    use crate::utxo_manager::UtxoManager;
893    use fuels::{
894        prelude::Provider,
895        test_helpers::{
896            ChainConfig,
897            WalletsConfig,
898            launch_custom_provider_and_get_wallets,
899        },
900    };
901    use std::collections::HashSet;
902
903    /// A funded wallet plus a manager holding every one of its base-asset
904    /// coins, and the `BuilderData` the transfer functions need.
905    async fn funded_wallet() -> (Wallet, UtxoManager, BuilderData, AssetId) {
906        let mut wallets = launch_custom_provider_and_get_wallets(
907            // Several coins, so a failure has more than one input to lose.
908            WalletsConfig::new(Some(1), Some(10), Some(1_000_000_000)),
909            None,
910            Some(ChainConfig::local_testnet()),
911        )
912        .await
913        .expect("failed to launch test provider");
914        let wallet = wallets.pop().expect("no wallet");
915
916        let provider: &Provider = wallet.provider();
917        let base_asset_id = *provider
918            .consensus_parameters()
919            .await
920            .expect("consensus parameters")
921            .base_asset_id();
922
923        let coins = wallet
924            .get_coins(base_asset_id)
925            .await
926            .expect("failed to read coins");
927        let utxo_manager = UtxoManager::new_from_coins(coins.into_iter().map(Into::into));
928        let builder_data = wallet.builder_data().await.expect("builder data");
929
930        (wallet, utxo_manager, builder_data, base_asset_id)
931    }
932
933    fn snapshot(manager: &UtxoManager) -> (HashSet<UtxoId>, usize) {
934        (
935            manager.utxo_ids().into_iter().collect(),
936            manager.coin_count(),
937        )
938    }
939
940    /// Selecting the transfer asset can fail after the fee coins have already
941    /// been taken out of the manager. Those fee coins have to come back.
942    #[tokio::test]
943    async fn build_transfer__returns_fee_coins_when_the_asset_cannot_be_covered() {
944        let (wallet, mut utxo_manager, builder_data, base_asset_id) =
945            funded_wallet().await;
946        let before = snapshot(&utxo_manager);
947        assert!(before.1 > 0, "test needs a funded manager");
948
949        // A different asset the manager holds none of: fee selection succeeds
950        // against the base coins, then asset selection fails.
951        let other_asset = AssetId::new([7u8; 32]);
952        assert_ne!(other_asset, base_asset_id);
953
954        let result = wallet
955            .build_transfer(
956                other_asset,
957                &[(wallet.address(), 1)],
958                &mut utxo_manager,
959                &builder_data,
960                false,
961            )
962            .await;
963
964        assert!(result.is_err(), "expected the transfer build to fail");
965        assert_eq!(
966            snapshot(&utxo_manager),
967            before,
968            "fee coins were taken for a transfer that never happened"
969        );
970    }
971
972    /// A submitted transaction that the node refuses does **not** get its
973    /// inputs back. That is deliberate, and this test exists so it stays
974    /// deliberate: after submission a failure is ambiguous — a txpool
975    /// rejection spends nothing, but a timeout or a dropped status stream can
976    /// arrive after the node accepted the transaction, and returning inputs
977    /// then hands the next transaction a double-spend. Callers that know a
978    /// particular failure is safe are the ones who can act on it.
979    #[tokio::test]
980    async fn transfer_many__keeps_inputs_when_the_node_rejects_the_transaction() {
981        let (wallet, mut utxo_manager, builder_data, base_asset_id) =
982            funded_wallet().await;
983        let (before_ids, _) = snapshot(&utxo_manager);
984
985        // More outputs than a transaction may carry. The build succeeds — it
986        // is not validated locally — and the node rejects it on submission.
987        let max_outputs = builder_data.consensus_parameters.tx_params().max_outputs();
988        let transfers = vec![(wallet.address(), 1u64); max_outputs as usize + 8];
989
990        let result = wallet
991            .transfer_many(
992                base_asset_id,
993                &transfers,
994                &mut utxo_manager,
995                &builder_data,
996                false,
997                None,
998            )
999            .await;
1000
1001        assert!(result.is_err(), "expected the node to reject the transfer");
1002
1003        let (after_ids, _) = snapshot(&utxo_manager);
1004        let withheld = before_ids.difference(&after_ids).count();
1005        assert!(
1006            withheld > 0,
1007            "inputs of a submitted transaction must not be returned; the \
1008             manager still holds all {} of them",
1009            before_ids.len()
1010        );
1011    }
1012
1013    /// A transfer that succeeds must still consume its inputs — the restore
1014    /// must not fire on the happy path.
1015    #[tokio::test]
1016    async fn transfer_many__spends_its_inputs_when_the_transfer_succeeds() {
1017        let (wallet, mut utxo_manager, builder_data, base_asset_id) =
1018            funded_wallet().await;
1019        let before = snapshot(&utxo_manager);
1020
1021        let known_coins = wallet
1022            .transfer_many(
1023                base_asset_id,
1024                &[(wallet.address(), 1_000)],
1025                &mut utxo_manager,
1026                &builder_data,
1027                false,
1028                Some(1),
1029            )
1030            .await
1031            .expect("transfer should succeed");
1032
1033        assert!(!known_coins.is_empty(), "expected new coins");
1034
1035        // Not `assert_ne!` on the snapshot: a restore fired by mistake on the
1036        // success path would also change it, since the inputs would come back
1037        // alongside the new change coin. Ask the question that distinguishes
1038        // the two — is any coin that was here before now gone?
1039        let (after_ids, _) = snapshot(&utxo_manager);
1040        let (before_ids, _) = before;
1041        let consumed = before_ids.difference(&after_ids).count();
1042        assert!(
1043            consumed > 0,
1044            "a successful transfer must consume its inputs; all {} original \
1045             coins are still in the manager, so they were handed back",
1046            before_ids.len()
1047        );
1048    }
1049}