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