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