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