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 pub receipts: Arc<Vec<Receipt>>,
82}
83
84pub 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 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 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 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 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
652async 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
799const 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
818pub 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}