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