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