1use 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
41pub 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 .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 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 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 ¶meters.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#[derive(Debug, Default, serde::Serialize, serde::Deserialize, JsonSchema)]
190pub struct ResolveTransactionQueryParameters {
191 #[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 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#[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 .get(&move_call.package)
420 .and_then(|package| {
422 package.normalized_modules.get(move_call.module.as_str())
423 })
424 .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 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
486fn 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
536fn 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}