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