Skip to main content

o2_tools/
call_handler_ext.rs

1use crate::{
2    order_book::DEFAULT_METHOD_GAS,
3    utxo_manager::{
4        FuelTxCoin,
5        SharedUtxoManager,
6    },
7    wallet_ext::{
8        BuilderData,
9        SendResult,
10        WalletExt,
11    },
12};
13use fuel_core_client::client::{
14    FuelClient,
15    types::TransactionStatus,
16};
17use fuel_core_types::{
18    blockchain::transaction::TransactionExt,
19    fuel_tx::{
20        Chargeable,
21        Finalizable,
22        Input,
23        Output,
24        Receipt,
25        Script,
26        Transaction,
27        TxId,
28        TxPointer,
29        UniqueIdentifier,
30    },
31    fuel_types::ContractId,
32    services::executor::TransactionExecutionResult,
33};
34use fuels::{
35    accounts::ViewOnlyAccount,
36    core::traits::{
37        Parameterize,
38        Tokenizable,
39    },
40    prelude::{
41        CallHandler,
42        Wallet,
43    },
44    programs::{
45        calls::{
46            traits::{
47                ContractDependencyConfigurator,
48                ResponseParser,
49                TransactionTuner,
50            },
51            utils::find_ids_of_missing_contracts,
52        },
53        responses::CallResponse,
54    },
55    types::{
56        BlockHeight,
57        errors::{
58            Error as FuelsError,
59            Result as FuelsResult,
60        },
61        transaction_builders::VariableOutputPolicy,
62        tx_status::TxStatus,
63    },
64};
65use std::{
66    collections::HashSet,
67    fmt::Debug,
68    future::Future,
69};
70
71pub trait CallHandlerExt<T> {
72    /// Assemble, dry-run-estimate (if configured), submit, and await the
73    /// status of a contract call.
74    ///
75    /// `dry_run_client`, when `Some`, is used for gas-estimation dry-runs;
76    /// `submit_clients`, when non-empty, is fanned out for the actual
77    /// submission — the first client to return a valid status wins. When
78    /// `dry_run_client` is `None` or `submit_clients` is empty, the
79    /// account's provider client is used as a fallback. This lets callers
80    /// route dry-runs to a general-purpose sentry and submissions to one
81    /// or more dedicated nodes.
82    fn almost_sync_call(
83        self,
84        builder_date: &BuilderData,
85        utxo_manager: &SharedUtxoManager,
86        tx_config: &Option<TransactionConfig>,
87        dry_run_client: Option<&FuelClient>,
88        submit_clients: &[FuelClient],
89    ) -> impl Future<Output = FuelsResult<SendResult<FuelsResult<CallResponse<T>>>>>;
90}
91
92#[derive(Debug, Clone, Copy, Default)]
93pub struct TransactionConfig {
94    pub min_gas_limit: u64,
95    pub estimate_gas_usage: bool,
96    pub expiration_height: Option<BlockHeight>,
97    /// Maximum number of coins the fee selection may include. `None` means
98    /// unbounded. The cap is provided by the caller, not defaulted here.
99    pub max_fee_coins: Option<usize>,
100}
101
102impl TransactionConfig {
103    pub fn builder() -> TransactionConfigBuilder {
104        TransactionConfigBuilder::new()
105    }
106}
107
108#[derive(Debug, Clone, Copy, Default)]
109pub struct TransactionConfigBuilder {
110    min_gas_limit: Option<u64>,
111    estimate_gas_usage: Option<bool>,
112    expiration_height: Option<BlockHeight>,
113    max_fee_coins: Option<usize>,
114}
115
116impl TransactionConfigBuilder {
117    pub fn new() -> Self {
118        TransactionConfigBuilder {
119            min_gas_limit: None,
120            estimate_gas_usage: None,
121            expiration_height: None,
122            max_fee_coins: None,
123        }
124    }
125
126    pub fn with_min_gas_limit(&mut self, min_gas_limit: u64) -> &mut Self {
127        self.min_gas_limit = Some(min_gas_limit);
128        self
129    }
130
131    pub fn with_estimate_gas_usage(&mut self, estimate_gas_usage: bool) -> &mut Self {
132        self.estimate_gas_usage = Some(estimate_gas_usage);
133        self
134    }
135
136    pub fn min_gas_limit(&self) -> Option<u64> {
137        self.min_gas_limit
138    }
139
140    pub fn estimate_gas_usage(&self) -> Option<bool> {
141        self.estimate_gas_usage
142    }
143
144    pub fn with_expiration_height(
145        &mut self,
146        expiration_height: BlockHeight,
147    ) -> &mut Self {
148        self.expiration_height = Some(expiration_height);
149        self
150    }
151
152    pub fn expiration_height(&self) -> Option<BlockHeight> {
153        self.expiration_height
154    }
155
156    pub fn with_max_fee_coins(&mut self, max_fee_coins: usize) -> &mut Self {
157        self.max_fee_coins = Some(max_fee_coins);
158        self
159    }
160
161    pub fn max_fee_coins(&self) -> Option<usize> {
162        self.max_fee_coins
163    }
164
165    pub fn build(self) -> TransactionConfig {
166        TransactionConfig {
167            min_gas_limit: self.min_gas_limit.unwrap_or(DEFAULT_METHOD_GAS),
168            estimate_gas_usage: self.estimate_gas_usage.unwrap_or(true),
169            expiration_height: self.expiration_height,
170            max_fee_coins: self.max_fee_coins,
171        }
172    }
173}
174
175impl<C, T> CallHandlerExt<T> for CallHandler<Wallet, C, T>
176where
177    C: ContractDependencyConfigurator + TransactionTuner + ResponseParser,
178    T: Tokenizable + Parameterize + Debug,
179{
180    #[tracing::instrument(skip_all)]
181    async fn almost_sync_call(
182        self,
183        builder_date: &BuilderData,
184        utxo_manager: &SharedUtxoManager,
185        tx_config: &Option<TransactionConfig>,
186        dry_run_client: Option<&FuelClient>,
187        submit_clients: &[FuelClient],
188    ) -> FuelsResult<SendResult<FuelsResult<CallResponse<T>>>> {
189        let tx_config = tx_config.unwrap_or_default();
190        let consensus_parameters = &builder_date.consensus_parameters;
191        let tb =
192            self.transaction_builder_with_parameters(consensus_parameters, vec![])?;
193
194        let owner = self.account.address();
195        let secret_key = self.account.signer().secret_key();
196        let base_asset_id = *consensus_parameters.base_asset_id();
197
198        let max_fee = builder_date.max_fee();
199
200        let max_fee_coins = tx_config.max_fee_coins.unwrap_or(usize::MAX);
201
202        let input_coins = {
203            let mut utxo_manager = utxo_manager.lock().await;
204            utxo_manager
205                .guaranteed_extract_coins(
206                    owner,
207                    base_asset_id,
208                    max_fee as u128,
209                    max_fee_coins,
210                )
211                .map_err(|e| FuelsError::Other(e.to_string()))
212        }?;
213        let coins_iter = input_coins.iter();
214        let account = self.account.clone();
215
216        let assemble_tx = async move {
217            let witness_limit = crate::wallet_ext::SIGNATURE_MARGIN;
218
219            let mut builder =
220                fuel_core_types::fuel_tx::TransactionBuilder::<Script>::script(
221                    tb.script,
222                    tb.script_data,
223                );
224            builder
225                .with_chain_id(consensus_parameters.chain_id())
226                .max_fee_limit(max_fee)
227                .witness_limit(witness_limit as u64);
228
229            if let Some(expiration_height) = tx_config.expiration_height {
230                builder.expiration(expiration_height);
231            }
232
233            for coin in coins_iter {
234                builder.add_unsigned_coin_input(
235                    secret_key,
236                    coin.utxo_id,
237                    coin.amount,
238                    coin.asset_id,
239                    TxPointer::default(),
240                );
241            }
242
243            builder.add_output(Output::Change {
244                to: owner,
245                amount: 0,
246                asset_id: base_asset_id,
247            });
248
249            for input in tb.inputs {
250                if let fuels::types::input::Input::Contract { contract_id, .. } = input {
251                    let contract_index = builder.inputs().len();
252                    builder.add_input(Input::contract(
253                        Default::default(),
254                        Default::default(),
255                        Default::default(),
256                        Default::default(),
257                        contract_id,
258                    ));
259                    builder.add_output(Output::contract(
260                        contract_index as u16,
261                        Default::default(),
262                        Default::default(),
263                    ));
264                }
265            }
266
267            // Add variable output if policy is Exactly
268            if let VariableOutputPolicy::Exactly(variable_outputs) =
269                tb.variable_output_policy
270            {
271                for _ in 0..variable_outputs {
272                    builder.add_output(Output::Variable {
273                        to: Default::default(),
274                        amount: 0,
275                        asset_id: Default::default(),
276                    });
277                }
278            }
279
280            let dummy_script = builder.clone().finalize();
281            let max_gas = dummy_script.max_gas(
282                consensus_parameters.gas_costs(),
283                consensus_parameters.fee_params(),
284            ) + 1;
285            let available_gas =
286                consensus_parameters.tx_params().max_gas_per_tx() - max_gas;
287
288            let (missing_contracts, used_gas) = if tx_config.estimate_gas_usage {
289                builder.script_gas_limit(available_gas);
290
291                let client =
292                    dry_run_client.unwrap_or_else(|| account.provider().client());
293                let tx_to_dry_run = builder.clone().finalize().into();
294
295                let result = client
296                    .dry_run_opt(
297                        &[tx_to_dry_run],
298                        Some(false),
299                        Some(builder_date.gas_price),
300                        None,
301                    )
302                    .await?
303                    .into_iter()
304                    .next()
305                    .ok_or_else(|| {
306                        FuelsError::Other("Dry run failed to return a result".to_string())
307                    })?;
308
309                result.result.missing_contracts_and_used_gas()
310            } else {
311                (Default::default(), 0)
312            };
313
314            for contract_id in missing_contracts {
315                let contract_index = builder.inputs().len();
316                builder.add_input(Input::contract(
317                    Default::default(),
318                    Default::default(),
319                    Default::default(),
320                    Default::default(),
321                    contract_id,
322                ));
323
324                builder.add_output(Output::contract(
325                    contract_index as u16,
326                    Default::default(),
327                    Default::default(),
328                ));
329            }
330
331            let gas_limit = std::cmp::max(
332                tx_config.min_gas_limit,
333                std::cmp::min(used_gas * 2 + 100_000, available_gas),
334            );
335            builder.script_gas_limit(gas_limit);
336
337            Ok(builder.finalize_as_transaction())
338        };
339
340        let tx = match assemble_tx.await {
341            Ok(tx) => tx,
342            Err(e) => {
343                // Return coins if tx assembly failed
344                let mut utxo_manager = utxo_manager.lock().await;
345                utxo_manager.load_from_coins_vec(input_coins);
346                return Err(e);
347            }
348        };
349
350        let tx_id = tx.id(&consensus_parameters.chain_id());
351
352        let chain_id = consensus_parameters.chain_id();
353        let send_result = match self
354            .account
355            .send_transaction(chain_id, &tx, submit_clients)
356            .await
357        {
358            Ok(result) => result,
359            Err(e) => {
360                let err_msg = e.to_string();
361                if crate::wallet_ext::is_coin_invalid_error(&err_msg) {
362                    // Coins are invalid (spent, missing, mismatched) — do NOT
363                    // return them to the UtxoManager.
364                    tracing::warn!(
365                        %tx_id,
366                        "Transaction rejected due to invalid coins, \
367                         not returning coins to UtxoManager: {err_msg}",
368                    );
369                } else {
370                    // Non-coin error (network, gas, etc.) — coins are still
371                    // valid, return them.
372                    tracing::warn!(
373                        %tx_id,
374                        "Transaction failed, returning coins to UtxoManager: {err_msg}",
375                    );
376                    let mut utxo_manager = utxo_manager.lock().await;
377                    utxo_manager.load_from_coins_vec(input_coins);
378                }
379                return Err(FuelsError::Other(format!(
380                    "Failed to send transaction {tx_id}: {e}"
381                )));
382            }
383        };
384
385        // Transaction was accepted — spawn background task to return coins
386        // if the transaction expires without being confirmed.
387        maybe_return_coins(
388            &self.account,
389            &tx,
390            tx_id,
391            tx_config.expiration_height,
392            utxo_manager,
393        );
394
395        {
396            let mut utxo_manager = utxo_manager.lock().await;
397            utxo_manager.load_from_coins_vec(send_result.known_coins.clone());
398            utxo_manager.load_from_coins_vec(send_result.dynamic_coins.clone());
399        }
400
401        let failure_logs = match &send_result.tx_status {
402            TxStatus::Success(_)
403            | TxStatus::PreconfirmationSuccess(_)
404            | TxStatus::Submitted
405            | TxStatus::SqueezedOut(_) => None,
406            TxStatus::Failure(failure) | TxStatus::PreconfirmationFailure(failure) => {
407                let result = self.log_decoder.decode_logs(&failure.receipts);
408                tracing::error!(tx_id = %&send_result.tx_id, "Failed to process transaction: {result:?}");
409                Some(result)
410            }
411        };
412
413        let tx_status =
414            self.get_response(send_result.tx_status)
415                .map_err(|e: FuelsError| {
416                    if let Some(failure_logs) = &failure_logs {
417                        FuelsError::Other(format!(
418                            "Transaction {tx_id} failed with logs: {failure_logs:?} and error: {e}"
419                        ))
420                    } else {
421                        FuelsError::Other(format!(
422                            "Failed to get transaction status {tx_id}: {e}"
423                        ))
424                    }
425                });
426
427        let result = SendResult {
428            tx_id: send_result.tx_id,
429            tx_status,
430            known_coins: send_result.known_coins,
431            dynamic_coins: send_result.dynamic_coins,
432            preconf_rx_time: send_result.preconf_rx_time,
433        };
434
435        Ok(result)
436    }
437}
438
439pub(crate) fn maybe_return_coins(
440    account: &Wallet,
441    tx: &Transaction,
442    tx_id: TxId,
443    expiration_height: Option<BlockHeight>,
444    utxo_manager: &SharedUtxoManager,
445) {
446    if let Some(expiration_height) = expiration_height {
447        let tx_inputs = tx.inputs().into_owned();
448        let provider = account.provider().clone();
449        let utxo_manager = utxo_manager.clone();
450
451        tokio::spawn(async move {
452            // Wait until we reach expiration_block_height + 1
453            let target_height = expiration_height.succ().expect("shouldn't happen; qed");
454
455            // The client must be unique so the future required block height we
456            // set below is awaited only here, not by the shared client used for
457            // regular requests. Cloning gives exactly that: `ConsistencyPolicy`
458            // clones with its own (non-shared) required-height mutex, while the
459            // pooled connection and TLS config are reused. Building a fresh
460            // `FuelClient` instead re-reads and re-parses the OS trust store
461            // from disk on every call (a hot path under load).
462            let mut client = provider.client().clone();
463            match client
464                .with_required_fuel_block_height(Some(target_height))
465                .transaction(&tx_id)
466                .await
467            {
468                Ok(Some(tx_response)) => {
469                    // Transaction exists, check its status
470                    let status = tx_response.status;
471                    match status {
472                        TransactionStatus::Success { .. }
473                        | TransactionStatus::Failure { .. } => {
474                            // Transaction is confirmed or failed, don't return coins
475                            tracing::debug!(
476                                %tx_id,
477                                "Transaction is confirmed/failed at height {}",
478                                target_height
479                            );
480                        }
481                        _ => {
482                            // Transaction exists but not confirmed/failed, return coins
483                            tracing::warn!(
484                                %tx_id,
485                                "Transaction not confirmed/failed at height {target_height:?}, returning coins",
486                            );
487                            let coins = tx_inputs
488                                .iter()
489                                .filter_map(|input| FuelTxCoin::try_from(input).ok());
490                            let mut utxo_manager = utxo_manager.lock().await;
491                            utxo_manager.load_from_coins_vec(coins.collect());
492                        }
493                    }
494                }
495                Ok(None) => {
496                    // Transaction doesn't exist in fuel-core — coins may have
497                    // been spent or invalidated, so do NOT return them.
498                    tracing::warn!(
499                        %tx_id,
500                        "Transaction not found at height {target_height:?}, \
501                         not returning coins (may be invalid)",
502                    );
503                }
504                Err(err) => {
505                    tracing::error!(
506                        %tx_id,
507                        "Failed to get transaction status: {err:?} to return coins",
508                    );
509                }
510            }
511        });
512    }
513}
514
515pub(crate) trait TransactionStatusExt {
516    fn missing_contracts_and_used_gas(&self) -> (HashSet<ContractId>, u64);
517}
518
519impl TransactionStatusExt for TransactionExecutionResult {
520    fn missing_contracts_and_used_gas(&self) -> (HashSet<ContractId>, u64) {
521        let contracts = find_ids_of_missing_contracts(self.receipts());
522        let used_gas = self
523            .receipts()
524            .iter()
525            .rfind(|r| matches!(r, Receipt::ScriptResult { .. }))
526            .map(|script_result| {
527                script_result
528                    .gas_used()
529                    .expect("could not retrieve gas used from ScriptResult")
530            })
531            .unwrap_or(0);
532
533        (contracts.into_iter().collect(), used_gas)
534    }
535}