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 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 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 let max_fee = builder_data.max_fee();
262
263 let base_asset_id = *builder_data.consensus_parameters.base_asset_id();
264
265 let payer: Address = self.address();
266
267 let asset_total = transfers
268 .iter()
269 .map(|(_, amount)| u128::from(*amount))
270 .sum::<u128>();
271
272 let balance_of = utxo_manager.balance_of(payer, asset_id);
273 if fetch_coins && balance_of < asset_total {
274 let asset_coins = self
275 .provider()
276 .get_spendable_resources(ResourceFilter {
277 from: self.address(),
278 asset_id: Some(asset_id),
279 amount: asset_total,
280 excluded_utxos: vec![],
281 excluded_message_nonces: vec![],
282 })
283 .await
284 .map_err(|e| {
285 anyhow::anyhow!(
286 "Failed to get spendable resources: \
287 {e} for {asset_id:?} from {payer:?} with amount {asset_total}"
288 )
289 })?
290 .into_iter()
291 .filter_map(|coin| match coin {
292 CoinType::Coin(coin) => Some(coin.into()),
293 _ => None,
294 });
295
296 utxo_manager.load_from_coins_vec(asset_coins.collect());
297 }
298
299 let fee_coins = if asset_id != base_asset_id {
300 utxo_manager.guaranteed_extract_coins(
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
621async 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
765const 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
784pub 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}