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