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}
98
99impl TransactionConfig {
100    pub fn builder() -> TransactionConfigBuilder {
101        TransactionConfigBuilder::new()
102    }
103}
104
105#[derive(Debug, Clone, Copy, Default)]
106pub struct TransactionConfigBuilder {
107    min_gas_limit: Option<u64>,
108    estimate_gas_usage: Option<bool>,
109    expiration_height: Option<BlockHeight>,
110}
111
112impl TransactionConfigBuilder {
113    pub fn new() -> Self {
114        TransactionConfigBuilder {
115            min_gas_limit: None,
116            estimate_gas_usage: None,
117            expiration_height: None,
118        }
119    }
120
121    pub fn with_min_gas_limit(&mut self, min_gas_limit: u64) -> &mut Self {
122        self.min_gas_limit = Some(min_gas_limit);
123        self
124    }
125
126    pub fn with_estimate_gas_usage(&mut self, estimate_gas_usage: bool) -> &mut Self {
127        self.estimate_gas_usage = Some(estimate_gas_usage);
128        self
129    }
130
131    pub fn min_gas_limit(&self) -> Option<u64> {
132        self.min_gas_limit
133    }
134
135    pub fn estimate_gas_usage(&self) -> Option<bool> {
136        self.estimate_gas_usage
137    }
138
139    pub fn with_expiration_height(
140        &mut self,
141        expiration_height: BlockHeight,
142    ) -> &mut Self {
143        self.expiration_height = Some(expiration_height);
144        self
145    }
146
147    pub fn expiration_height(&self) -> Option<BlockHeight> {
148        self.expiration_height
149    }
150
151    pub fn build(self) -> TransactionConfig {
152        TransactionConfig {
153            min_gas_limit: self.min_gas_limit.unwrap_or(DEFAULT_METHOD_GAS),
154            estimate_gas_usage: self.estimate_gas_usage.unwrap_or(true),
155            expiration_height: self.expiration_height,
156        }
157    }
158}
159
160impl<C, T> CallHandlerExt<T> for CallHandler<Wallet, C, T>
161where
162    C: ContractDependencyConfigurator + TransactionTuner + ResponseParser,
163    T: Tokenizable + Parameterize + Debug,
164{
165    #[tracing::instrument(skip_all)]
166    async fn almost_sync_call(
167        self,
168        builder_date: &BuilderData,
169        utxo_manager: &SharedUtxoManager,
170        tx_config: &Option<TransactionConfig>,
171        dry_run_client: Option<&FuelClient>,
172        submit_clients: &[FuelClient],
173    ) -> FuelsResult<SendResult<FuelsResult<CallResponse<T>>>> {
174        let tx_config = tx_config.unwrap_or_default();
175        let consensus_parameters = &builder_date.consensus_parameters;
176        let tb =
177            self.transaction_builder_with_parameters(consensus_parameters, vec![])?;
178
179        let owner = self.account.address();
180        let secret_key = self.account.signer().secret_key();
181        let base_asset_id = *consensus_parameters.base_asset_id();
182
183        let max_fee = builder_date.max_fee();
184
185        let input_coins = {
186            let mut utxo_manager = utxo_manager.lock().await;
187            utxo_manager
188                .guaranteed_extract_coins(owner, base_asset_id, max_fee as u128)
189                .map_err(|e| FuelsError::Other(e.to_string()))
190        }?;
191        let coins_iter = input_coins.iter();
192        let account = self.account.clone();
193
194        let assemble_tx = async move {
195            let witness_limit = crate::wallet_ext::SIGNATURE_MARGIN;
196
197            let mut builder =
198                fuel_core_types::fuel_tx::TransactionBuilder::<Script>::script(
199                    tb.script,
200                    tb.script_data,
201                );
202            builder
203                .with_chain_id(consensus_parameters.chain_id())
204                .max_fee_limit(max_fee)
205                .witness_limit(witness_limit as u64);
206
207            if let Some(expiration_height) = tx_config.expiration_height {
208                builder.expiration(expiration_height);
209            }
210
211            for coin in coins_iter {
212                builder.add_unsigned_coin_input(
213                    secret_key,
214                    coin.utxo_id,
215                    coin.amount,
216                    coin.asset_id,
217                    TxPointer::default(),
218                );
219            }
220
221            builder.add_output(Output::Change {
222                to: owner,
223                amount: 0,
224                asset_id: base_asset_id,
225            });
226
227            for input in tb.inputs {
228                if let fuels::types::input::Input::Contract { contract_id, .. } = input {
229                    let contract_index = builder.inputs().len();
230                    builder.add_input(Input::contract(
231                        Default::default(),
232                        Default::default(),
233                        Default::default(),
234                        Default::default(),
235                        contract_id,
236                    ));
237                    builder.add_output(Output::contract(
238                        contract_index as u16,
239                        Default::default(),
240                        Default::default(),
241                    ));
242                }
243            }
244
245            // Add variable output if policy is Exactly
246            if let VariableOutputPolicy::Exactly(variable_outputs) =
247                tb.variable_output_policy
248            {
249                for _ in 0..variable_outputs {
250                    builder.add_output(Output::Variable {
251                        to: Default::default(),
252                        amount: 0,
253                        asset_id: Default::default(),
254                    });
255                }
256            }
257
258            let dummy_script = builder.clone().finalize();
259            let max_gas = dummy_script.max_gas(
260                consensus_parameters.gas_costs(),
261                consensus_parameters.fee_params(),
262            ) + 1;
263            let available_gas =
264                consensus_parameters.tx_params().max_gas_per_tx() - max_gas;
265
266            let (missing_contracts, used_gas) = if tx_config.estimate_gas_usage {
267                builder.script_gas_limit(available_gas);
268
269                let client =
270                    dry_run_client.unwrap_or_else(|| account.provider().client());
271                let tx_to_dry_run = builder.clone().finalize().into();
272
273                let result = client
274                    .dry_run_opt(
275                        &[tx_to_dry_run],
276                        Some(false),
277                        Some(builder_date.gas_price),
278                        None,
279                    )
280                    .await?
281                    .into_iter()
282                    .next()
283                    .ok_or_else(|| {
284                        FuelsError::Other("Dry run failed to return a result".to_string())
285                    })?;
286
287                result.result.missing_contracts_and_used_gas()
288            } else {
289                (Default::default(), 0)
290            };
291
292            for contract_id in missing_contracts {
293                let contract_index = builder.inputs().len();
294                builder.add_input(Input::contract(
295                    Default::default(),
296                    Default::default(),
297                    Default::default(),
298                    Default::default(),
299                    contract_id,
300                ));
301
302                builder.add_output(Output::contract(
303                    contract_index as u16,
304                    Default::default(),
305                    Default::default(),
306                ));
307            }
308
309            let gas_limit = std::cmp::max(
310                tx_config.min_gas_limit,
311                std::cmp::min(used_gas * 2 + 100_000, available_gas),
312            );
313            builder.script_gas_limit(gas_limit);
314
315            Ok(builder.finalize_as_transaction())
316        };
317
318        let tx = match assemble_tx.await {
319            Ok(tx) => tx,
320            Err(e) => {
321                // Return coins if tx assembly failed
322                let mut utxo_manager = utxo_manager.lock().await;
323                utxo_manager.load_from_coins_vec(input_coins);
324                return Err(e);
325            }
326        };
327
328        let tx_id = tx.id(&consensus_parameters.chain_id());
329
330        let chain_id = consensus_parameters.chain_id();
331        let send_result = match self
332            .account
333            .send_transaction(chain_id, &tx, submit_clients)
334            .await
335        {
336            Ok(result) => result,
337            Err(e) => {
338                let err_msg = e.to_string();
339                if crate::wallet_ext::is_coin_invalid_error(&err_msg) {
340                    // Coins are invalid (spent, missing, mismatched) — do NOT
341                    // return them to the UtxoManager.
342                    tracing::warn!(
343                        %tx_id,
344                        "Transaction rejected due to invalid coins, \
345                         not returning coins to UtxoManager: {err_msg}",
346                    );
347                } else {
348                    // Non-coin error (network, gas, etc.) — coins are still
349                    // valid, return them.
350                    tracing::warn!(
351                        %tx_id,
352                        "Transaction failed, returning coins to UtxoManager: {err_msg}",
353                    );
354                    let mut utxo_manager = utxo_manager.lock().await;
355                    utxo_manager.load_from_coins_vec(input_coins);
356                }
357                return Err(FuelsError::Other(format!(
358                    "Failed to send transaction {tx_id}: {e}"
359                )));
360            }
361        };
362
363        // Transaction was accepted — spawn background task to return coins
364        // if the transaction expires without being confirmed.
365        maybe_return_coins(
366            &self.account,
367            &tx,
368            tx_id,
369            tx_config.expiration_height,
370            utxo_manager,
371        );
372
373        {
374            let mut utxo_manager = utxo_manager.lock().await;
375            utxo_manager.load_from_coins_vec(send_result.known_coins.clone());
376            utxo_manager.load_from_coins_vec(send_result.dynamic_coins.clone());
377        }
378
379        let failure_logs = match &send_result.tx_status {
380            TxStatus::Success(_)
381            | TxStatus::PreconfirmationSuccess(_)
382            | TxStatus::Submitted
383            | TxStatus::SqueezedOut(_) => None,
384            TxStatus::Failure(failure) | TxStatus::PreconfirmationFailure(failure) => {
385                let result = self.log_decoder.decode_logs(&failure.receipts);
386                tracing::error!(tx_id = %&send_result.tx_id, "Failed to process transaction: {result:?}");
387                Some(result)
388            }
389        };
390
391        let tx_status =
392            self.get_response(send_result.tx_status)
393                .map_err(|e: FuelsError| {
394                    if let Some(failure_logs) = &failure_logs {
395                        FuelsError::Other(format!(
396                            "Transaction {tx_id} failed with logs: {failure_logs:?} and error: {e}"
397                        ))
398                    } else {
399                        FuelsError::Other(format!(
400                            "Failed to get transaction status {tx_id}: {e}"
401                        ))
402                    }
403                });
404
405        let result = SendResult {
406            tx_id: send_result.tx_id,
407            tx_status,
408            known_coins: send_result.known_coins,
409            dynamic_coins: send_result.dynamic_coins,
410            preconf_rx_time: send_result.preconf_rx_time,
411        };
412
413        Ok(result)
414    }
415}
416
417pub(crate) fn maybe_return_coins(
418    account: &Wallet,
419    tx: &Transaction,
420    tx_id: TxId,
421    expiration_height: Option<BlockHeight>,
422    utxo_manager: &SharedUtxoManager,
423) {
424    if let Some(expiration_height) = expiration_height {
425        let tx_inputs = tx.inputs().into_owned();
426        let provider = account.provider().clone();
427        let utxo_manager = utxo_manager.clone();
428
429        tokio::spawn(async move {
430            // Wait until we reach expiration_block_height + 1
431            let target_height = expiration_height.succ().expect("shouldn't happen; qed");
432
433            // The client should be unique to avoid required height for regular use
434            let mut client = FuelClient::new(provider.url())
435                .expect("The URL is correct because we send transactions before; qed");
436            match client
437                .with_required_fuel_block_height(Some(target_height))
438                .transaction(&tx_id)
439                .await
440            {
441                Ok(Some(tx_response)) => {
442                    // Transaction exists, check its status
443                    let status = tx_response.status;
444                    match status {
445                        TransactionStatus::Success { .. }
446                        | TransactionStatus::Failure { .. } => {
447                            // Transaction is confirmed or failed, don't return coins
448                            tracing::debug!(
449                                %tx_id,
450                                "Transaction is confirmed/failed at height {}",
451                                target_height
452                            );
453                        }
454                        _ => {
455                            // Transaction exists but not confirmed/failed, return coins
456                            tracing::warn!(
457                                %tx_id,
458                                "Transaction not confirmed/failed at height {target_height:?}, returning coins",
459                            );
460                            let coins = tx_inputs
461                                .iter()
462                                .filter_map(|input| FuelTxCoin::try_from(input).ok());
463                            let mut utxo_manager = utxo_manager.lock().await;
464                            utxo_manager.load_from_coins_vec(coins.collect());
465                        }
466                    }
467                }
468                Ok(None) => {
469                    // Transaction doesn't exist in fuel-core — coins may have
470                    // been spent or invalidated, so do NOT return them.
471                    tracing::warn!(
472                        %tx_id,
473                        "Transaction not found at height {target_height:?}, \
474                         not returning coins (may be invalid)",
475                    );
476                }
477                Err(err) => {
478                    tracing::error!(
479                        %tx_id,
480                        "Failed to get transaction status: {err:?} to return coins",
481                    );
482                }
483            }
484        });
485    }
486}
487
488pub(crate) trait TransactionStatusExt {
489    fn missing_contracts_and_used_gas(&self) -> (HashSet<ContractId>, u64);
490}
491
492impl TransactionStatusExt for TransactionExecutionResult {
493    fn missing_contracts_and_used_gas(&self) -> (HashSet<ContractId>, u64) {
494        let contracts = find_ids_of_missing_contracts(self.receipts());
495        let used_gas = self
496            .receipts()
497            .iter()
498            .rfind(|r| matches!(r, Receipt::ScriptResult { .. }))
499            .map(|script_result| {
500                script_result
501                    .gas_used()
502                    .expect("could not retrieve gas used from ScriptResult")
503            })
504            .unwrap_or(0);
505
506        (contracts.into_iter().collect(), used_gas)
507    }
508}