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