iota_rest_api/transactions/
resolve.rs

1// Copyright (c) Mysten Labs, Inc.
2// Modifications Copyright (c) 2025 IOTA Stiftung
3// SPDX-License-Identifier: Apache-2.0
4
5use std::collections::{BTreeMap, HashMap};
6
7use axum::{
8    Json,
9    extract::{Query, State},
10};
11use iota_protocol_config::ProtocolConfig;
12use iota_sdk2::types::{
13    Argument, Command, ObjectId, Transaction, UnresolvedInputArgument, UnresolvedObjectReference,
14    UnresolvedProgrammableTransaction, UnresolvedTransaction,
15};
16use iota_types::{
17    base_types::{IotaAddress, ObjectID, ObjectRef},
18    effects::TransactionEffectsAPI,
19    gas::GasCostSummary,
20    gas_coin::GasCoin,
21    move_package::MovePackage,
22    transaction::{
23        CallArg, GasData, ObjectArg, ProgrammableTransaction, TransactionData, TransactionDataAPI,
24    },
25    transaction_executor::VmChecks,
26};
27use itertools::Itertools;
28use move_binary_format::normalized;
29use schemars::JsonSchema;
30use tap::Pipe;
31
32use super::{TransactionSimulationResponse, execution::SimulateTransactionQueryParameters};
33use crate::{
34    RestError, RestService, Result,
35    accept::AcceptFormat,
36    objects::ObjectNotFoundError,
37    openapi::{ApiEndpoint, OperationBuilder, RequestBodyBuilder, ResponseBuilder, RouteHandler},
38    reader::StateReader,
39    response::ResponseContent,
40};
41
42// TODO
43// - Updating the UnresolvedTransaction format to provide less information about
44//   inputs
45// - handle basic type inference and BCS serialization of pure args
46pub struct ResolveTransaction;
47
48impl ApiEndpoint<RestService> for ResolveTransaction {
49    fn method(&self) -> axum::http::Method {
50        axum::http::Method::POST
51    }
52
53    fn path(&self) -> &'static str {
54        "/transactions/resolve"
55    }
56
57    fn operation(
58        &self,
59        generator: &mut schemars::gen::SchemaGenerator,
60    ) -> openapiv3::v3_1::Operation {
61        OperationBuilder::new()
62            .tag("Transactions")
63            .operation_id("ResolveTransaction")
64            .query_parameters::<ResolveTransactionQueryParameters>(generator)
65            .request_body(
66                RequestBodyBuilder::new()
67                    // .json_content::<UnresolvedTransaction>(generator)
68                    .build(),
69            )
70            .response(
71                200,
72                ResponseBuilder::new()
73                    .json_content::<ResolveTransactionResponse>(generator)
74                    .bcs_content()
75                    .build(),
76            )
77            .build()
78    }
79
80    fn handler(&self) -> RouteHandler<RestService> {
81        RouteHandler::new(self.method(), resolve_transaction)
82    }
83}
84
85async fn resolve_transaction(
86    State(state): State<RestService>,
87    Query(parameters): Query<ResolveTransactionQueryParameters>,
88    accept: AcceptFormat,
89    Json(unresolved_transaction): Json<UnresolvedTransaction>,
90) -> Result<ResponseContent<ResolveTransactionResponse>> {
91    let executor = state
92        .executor
93        .as_ref()
94        .ok_or_else(|| anyhow::anyhow!("No Transaction Executor"))?;
95    let (reference_gas_price, protocol_config) = {
96        let system_state = state.reader.get_system_state_summary()?;
97
98        let current_protocol_version = state.reader.get_system_state_summary()?.protocol_version;
99
100        let protocol_config = ProtocolConfig::get_for_version_if_supported(
101            current_protocol_version.into(),
102            state.reader.inner().get_chain_identifier()?.chain(),
103        )
104        .ok_or_else(|| {
105            RestError::new(
106                axum::http::StatusCode::INTERNAL_SERVER_ERROR,
107                "unable to get current protocol config",
108            )
109        })?;
110
111        (system_state.reference_gas_price, protocol_config)
112    };
113    let mut called_packages =
114        called_packages(&state.reader, &protocol_config, &unresolved_transaction)?;
115    let user_provided_budget = unresolved_transaction
116        .gas_payment
117        .as_ref()
118        .and_then(|payment| payment.budget);
119    let mut resolved_transaction = resolve_unresolved_transaction(
120        &state.reader,
121        &mut called_packages,
122        reference_gas_price,
123        protocol_config.max_tx_gas(),
124        unresolved_transaction,
125    )?;
126
127    // If the user didn't provide a budget we need to run a quick simulation in
128    // order to calculate a good estimated budget to use
129    let budget = if let Some(user_provided_budget) = user_provided_budget {
130        user_provided_budget
131    } else {
132        // Hardcoded dry run simulation
133        let dry_run_checks = VmChecks::Enabled;
134        let simulation_result = executor
135            .simulate_transaction(resolved_transaction.clone(), dry_run_checks)
136            .map_err(anyhow::Error::from)?;
137
138        let estimate = estimate_gas_budget_from_gas_cost(
139            simulation_result.effects.gas_cost_summary(),
140            reference_gas_price,
141        );
142        resolved_transaction.gas_data_mut().budget = estimate;
143        estimate
144    };
145
146    // If the user didn't provide any gas payment we need to do gas selection now
147    if resolved_transaction.gas_data().payment.is_empty() {
148        let input_objects = resolved_transaction
149            .input_objects()
150            .map_err(anyhow::Error::from)?
151            .iter()
152            .flat_map(|obj| match obj {
153                iota_types::transaction::InputObjectKind::ImmOrOwnedMoveObject((id, _, _)) => {
154                    Some(*id)
155                }
156                _ => None,
157            })
158            .collect_vec();
159        let gas_coins = select_gas(
160            &state.reader,
161            resolved_transaction.gas_data().owner,
162            budget,
163            protocol_config.max_gas_payment_objects(),
164            &input_objects,
165        )?;
166        resolved_transaction.gas_data_mut().payment = gas_coins;
167    }
168
169    let simulation = if parameters.simulate {
170        super::execution::simulate_transaction_impl(
171            executor,
172            &parameters.simulate_transaction_parameters,
173            resolved_transaction.clone().try_into()?,
174        )?
175        .pipe(Some)
176    } else {
177        None
178    };
179
180    ResolveTransactionResponse {
181        transaction: resolved_transaction.try_into()?,
182        simulation,
183    }
184    .pipe(|response| match accept {
185        AcceptFormat::Json => ResponseContent::Json(response),
186        AcceptFormat::Bcs => ResponseContent::Bcs(response),
187    })
188    .pipe(Ok)
189}
190
191/// Query parameters for the resolve transaction endpoint
192#[derive(Debug, Default, serde::Serialize, serde::Deserialize, JsonSchema)]
193pub struct ResolveTransactionQueryParameters {
194    /// Request that the fully resolved transaction be simulated and have its
195    /// results sent back in the response.
196    #[serde(default)]
197    pub simulate: bool,
198    #[serde(flatten)]
199    pub simulate_transaction_parameters: SimulateTransactionQueryParameters,
200}
201
202struct NormalizedPackages {
203    packages: HashMap<ObjectId, NormalizedPackage>,
204}
205
206struct NormalizedPackage {
207    #[allow(unused)]
208    package: MovePackage,
209    normalized_modules: BTreeMap<String, normalized::Module<normalized::RcIdentifier>>,
210}
211
212fn called_packages(
213    reader: &StateReader,
214    protocol_config: &ProtocolConfig,
215    unresolved_transaction: &UnresolvedTransaction,
216) -> Result<NormalizedPackages> {
217    let binary_config = iota_types::execution_config_utils::to_binary_config(protocol_config);
218    let mut pool = normalized::RcPool::new();
219    let mut packages = HashMap::new();
220
221    for move_call in unresolved_transaction
222        .ptb
223        .commands
224        .iter()
225        .filter_map(|command| {
226            if let Command::MoveCall(move_call) = command {
227                Some(move_call)
228            } else {
229                None
230            }
231        })
232    {
233        let package = reader
234            .inner()
235            .try_get_object(&(move_call.package.into()))?
236            .ok_or_else(|| ObjectNotFoundError::new(move_call.package))?
237            .data
238            .try_as_package()
239            .ok_or_else(|| {
240                RestError::new(
241                    axum::http::StatusCode::BAD_REQUEST,
242                    format!("object {} is not a package", move_call.package),
243                )
244            })?
245            .to_owned();
246
247        // Normalization doesn't take the linkage or type origin tables into account,
248        // which means that if you have an upgraded package that introduces a
249        // new type, then that type's package ID is going to appear incorrectly
250        // if you fetch it from its normalized module.
251        //
252        // Despite the above this is safe given we are only using the signature
253        // information (and in particular the reference kind) from the
254        // normalized package.
255        let normalized_modules = package
256            .normalize(&mut pool, &binary_config, /* include code */ true)
257            .map_err(|e| {
258                RestError::new(
259                    axum::http::StatusCode::INTERNAL_SERVER_ERROR,
260                    format!("unable to normalize package {}: {e}", move_call.package),
261                )
262            })?;
263        let package = NormalizedPackage {
264            package,
265            normalized_modules,
266        };
267
268        packages.insert(move_call.package, package);
269    }
270
271    Ok(NormalizedPackages { packages })
272}
273
274fn resolve_unresolved_transaction(
275    reader: &StateReader,
276    called_packages: &mut NormalizedPackages,
277    reference_gas_price: u64,
278    max_gas_budget: u64,
279    unresolved_transaction: UnresolvedTransaction,
280) -> Result<TransactionData> {
281    let sender = unresolved_transaction.sender.into();
282    let gas_data = if let Some(unresolved_gas_payment) = unresolved_transaction.gas_payment {
283        let payment = unresolved_gas_payment
284            .objects
285            .into_iter()
286            .map(|unresolved| resolve_object_reference(reader, unresolved))
287            .collect::<Result<Vec<_>>>()?;
288        GasData {
289            payment,
290            owner: unresolved_gas_payment.owner.into(),
291            price: unresolved_gas_payment.price.unwrap_or(reference_gas_price),
292            budget: unresolved_gas_payment.budget.unwrap_or(max_gas_budget),
293        }
294    } else {
295        GasData {
296            payment: vec![],
297            owner: sender,
298            price: reference_gas_price,
299            budget: max_gas_budget,
300        }
301    };
302    let expiration = unresolved_transaction.expiration.into();
303    let ptb = resolve_ptb(reader, called_packages, unresolved_transaction.ptb)?;
304    Ok(TransactionData::V1(
305        iota_types::transaction::TransactionDataV1 {
306            kind: iota_types::transaction::TransactionKind::ProgrammableTransaction(ptb),
307            sender,
308            gas_data,
309            expiration,
310        },
311    ))
312}
313
314/// Response type for the execute transaction endpoint
315#[derive(Debug, serde::Serialize, serde::Deserialize, JsonSchema)]
316pub struct ResolveTransactionResponse {
317    pub transaction: Transaction,
318    pub simulation: Option<TransactionSimulationResponse>,
319}
320
321fn resolve_object_reference(
322    reader: &StateReader,
323    unresolved_object_reference: UnresolvedObjectReference,
324) -> Result<ObjectRef> {
325    let UnresolvedObjectReference {
326        object_id,
327        version,
328        digest,
329    } = unresolved_object_reference;
330
331    let id = object_id.into();
332    let (v, d) = if let Some(version) = version {
333        let object = reader
334            .inner()
335            .try_get_object_by_key(&id, version.into())?
336            .ok_or_else(|| ObjectNotFoundError::new_with_version(object_id, version))?;
337        (object.version(), object.digest())
338    } else {
339        let object = reader
340            .inner()
341            .try_get_object(&id)?
342            .ok_or_else(|| ObjectNotFoundError::new(object_id))?;
343        (object.version(), object.digest())
344    };
345
346    if digest.is_some_and(|digest| digest.inner() != d.inner()) {
347        return Err(RestError::new(
348            axum::http::StatusCode::BAD_REQUEST,
349            format!("provided digest doesn't match, provided: {digest:?} actual: {d}"),
350        ));
351    }
352
353    Ok((id, v, d))
354}
355
356fn resolve_ptb(
357    reader: &StateReader,
358    called_packages: &mut NormalizedPackages,
359    unresolved_ptb: UnresolvedProgrammableTransaction,
360) -> Result<ProgrammableTransaction> {
361    let inputs = unresolved_ptb
362        .inputs
363        .into_iter()
364        .enumerate()
365        .map(|(arg_idx, arg)| {
366            resolve_arg(
367                reader,
368                called_packages,
369                &unresolved_ptb.commands,
370                arg,
371                arg_idx,
372            )
373        })
374        .collect::<Result<_>>()?;
375
376    ProgrammableTransaction {
377        inputs,
378        commands: unresolved_ptb
379            .commands
380            .into_iter()
381            .map(TryInto::try_into)
382            .collect::<Result<_, _>>()?,
383    }
384    .pipe(Ok)
385}
386
387fn resolve_arg(
388    reader: &StateReader,
389    called_packages: &mut NormalizedPackages,
390    commands: &[Command],
391    arg: UnresolvedInputArgument,
392    arg_idx: usize,
393) -> Result<CallArg> {
394    match arg {
395        UnresolvedInputArgument::Pure { value } => CallArg::Pure(value),
396        UnresolvedInputArgument::ImmutableOrOwned(obj_ref) => CallArg::Object(
397            ObjectArg::ImmOrOwnedObject(resolve_object_reference(reader, obj_ref)?),
398        ),
399        UnresolvedInputArgument::Shared {
400            object_id,
401            initial_shared_version: _,
402            mutable: _,
403        } => {
404            let id = object_id.into();
405            let object = reader
406                .inner()
407                .try_get_object(&id)?
408                .ok_or_else(|| ObjectNotFoundError::new(object_id))?;
409
410            let initial_shared_version = if let iota_types::object::Owner::Shared {
411                initial_shared_version,
412            } = object.owner()
413            {
414                *initial_shared_version
415            } else {
416                return Err(RestError::new(
417                    axum::http::StatusCode::BAD_REQUEST,
418                    format!("object {object_id} is not a shared object"),
419                ));
420            };
421
422            let mut mutable = false;
423
424            for (command, idx) in find_arg_uses(arg_idx, commands) {
425                match (command, idx) {
426                    (Command::MoveCall(move_call), Some(idx)) => {
427                        let function = called_packages
428                            .packages
429                            // Find the package
430                            .get(&move_call.package)
431                            // Find the module
432                            .and_then(|package| {
433                                package.normalized_modules.get(move_call.module.as_str())
434                            })
435                            // Find the function
436                            .and_then(|module| module.functions.get(move_call.function.as_str()))
437                            .ok_or_else(|| {
438                                RestError::new(
439                                    axum::http::StatusCode::BAD_REQUEST,
440                                    format!(
441                                        "unable to find function {package}::{module}::{function}",
442                                        package = move_call.package,
443                                        module = move_call.module,
444                                        function = move_call.function
445                                    ),
446                                )
447                            })?;
448
449                        let arg_type = function.parameters.get(idx).ok_or_else(|| {
450                            RestError::new(
451                                axum::http::StatusCode::BAD_REQUEST,
452                                "invalid input parameter",
453                            )
454                        })?;
455
456                        if matches!(
457                            &**arg_type,
458                            normalized::Type::Reference(/* mut */ true, _)
459                                | normalized::Type::Datatype(_)
460                        ) {
461                            mutable = true;
462                        }
463                    }
464
465                    (
466                        Command::SplitCoins(_)
467                        | Command::MergeCoins(_)
468                        | Command::MakeMoveVector(_),
469                        _,
470                    ) => {
471                        mutable = true;
472                    }
473
474                    _ => {}
475                }
476
477                // Early break out of the loop if we've already determined that the shared
478                // object is needed to be mutable
479                if mutable {
480                    break;
481                }
482            }
483
484            CallArg::Object(ObjectArg::SharedObject {
485                id,
486                initial_shared_version,
487                mutable,
488            })
489        }
490        UnresolvedInputArgument::Receiving(obj_ref) => CallArg::Object(ObjectArg::Receiving(
491            resolve_object_reference(reader, obj_ref)?,
492        )),
493    }
494    .pipe(Ok)
495}
496
497/// Given an particular input argument, find all of its uses.
498///
499/// The returned iterator contains all commands where the argument is used and
500/// an optional index for where the argument is used in that command.
501fn find_arg_uses(
502    arg_idx: usize,
503    commands: &[Command],
504) -> impl Iterator<Item = (&Command, Option<usize>)> {
505    commands.iter().filter_map(move |command| {
506        match command {
507            Command::MoveCall(move_call) => move_call
508                .arguments
509                .iter()
510                .position(|elem| matches_input_arg(*elem, arg_idx))
511                .map(Some),
512            Command::TransferObjects(transfer_objects) => transfer_objects
513                .objects
514                .iter()
515                .position(|elem| matches_input_arg(*elem, arg_idx))
516                .map(Some),
517            Command::SplitCoins(split_coins) => {
518                matches_input_arg(split_coins.coin, arg_idx).then_some(None)
519            }
520            Command::MergeCoins(merge_coins) => {
521                if matches_input_arg(merge_coins.coin, arg_idx) {
522                    Some(None)
523                } else {
524                    merge_coins
525                        .coins_to_merge
526                        .iter()
527                        .position(|elem| matches_input_arg(*elem, arg_idx))
528                        .map(Some)
529                }
530            }
531            Command::Publish(_) => None,
532            Command::MakeMoveVector(make_move_vector) => make_move_vector
533                .elements
534                .iter()
535                .position(|elem| matches_input_arg(*elem, arg_idx))
536                .map(Some),
537            Command::Upgrade(upgrade) => matches_input_arg(upgrade.ticket, arg_idx).then_some(None),
538        }
539        .map(|x| (command, x))
540    })
541}
542
543fn matches_input_arg(arg: Argument, arg_idx: usize) -> bool {
544    matches!(arg, Argument::Input(idx) if idx as usize == arg_idx)
545}
546
547/// Estimate the gas budget using the gas_cost_summary from a previous DryRun
548///
549/// The estimated gas budget is computed as following:
550/// * the maximum between A and B, where: A = computation cost +
551///   GAS_SAFE_OVERHEAD * reference gas price B = computation cost + storage
552///   cost - storage rebate + GAS_SAFE_OVERHEAD * reference gas price overhead
553///
554/// This gas estimate is computed similarly as in the TypeScript SDK
555fn estimate_gas_budget_from_gas_cost(
556    gas_cost_summary: &GasCostSummary,
557    reference_gas_price: u64,
558) -> u64 {
559    const GAS_SAFE_OVERHEAD: u64 = 1000;
560
561    let safe_overhead = GAS_SAFE_OVERHEAD * reference_gas_price;
562    let computation_cost_with_overhead = gas_cost_summary.computation_cost + safe_overhead;
563
564    let gas_usage = gas_cost_summary.net_gas_usage() + safe_overhead as i64;
565    computation_cost_with_overhead.max(if gas_usage < 0 { 0 } else { gas_usage as u64 })
566}
567
568fn select_gas(
569    reader: &StateReader,
570    owner: IotaAddress,
571    budget: u64,
572    max_gas_payment_objects: u32,
573    input_objects: &[ObjectID],
574) -> Result<Vec<ObjectRef>> {
575    let gas_coins = reader
576        .inner()
577        .indexes()
578        .ok_or_else(RestError::not_found)?
579        .account_owned_objects_info_iter(owner, None)?
580        .filter(|info| info.type_.is_gas_coin())
581        .filter(|info| !input_objects.contains(&info.object_id))
582        .filter_map(|info| {
583            reader
584                .inner()
585                .try_get_object(&info.object_id)
586                .ok()
587                .flatten()
588        })
589        .filter_map(|object| {
590            GasCoin::try_from(&object)
591                .ok()
592                .map(|coin| (object.compute_object_reference(), coin.value()))
593        })
594        .sorted_by(|object1, object2| object2.1.cmp(&object1.1))
595        .take(max_gas_payment_objects as usize);
596
597    let mut selected_gas = vec![];
598    let mut selected_gas_value = 0;
599
600    for (object_ref, value) in gas_coins {
601        selected_gas.push(object_ref);
602        selected_gas_value += value;
603        if selected_gas_value >= budget {
604            return Ok(selected_gas);
605        }
606    }
607
608    Err(RestError::new(
609        axum::http::StatusCode::BAD_REQUEST,
610        format!(
611            "unable to select sufficient gas coins from account {owner} \
612                to satisfy required budget {budget}"
613        ),
614    ))
615}