From ce5ead50ff89b99f646e5e35b125fbba0a921786 Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Mon, 19 Aug 2024 09:07:30 -0500 Subject: [PATCH] rest: introduce transaction resolve endpoint --- Cargo.lock | 2 + crates/sui-e2e-tests/Cargo.toml | 1 + crates/sui-e2e-tests/tests/rest.rs | 311 ++++++++++ crates/sui-rest-api/Cargo.toml | 1 + crates/sui-rest-api/openapi/openapi.json | 59 ++ crates/sui-rest-api/src/client/mod.rs | 4 + crates/sui-rest-api/src/client/sdk.rs | 19 + crates/sui-rest-api/src/lib.rs | 1 + crates/sui-rest-api/src/transactions/mod.rs | 4 + .../sui-rest-api/src/transactions/resolve.rs | 564 ++++++++++++++++++ 10 files changed, 966 insertions(+) create mode 100644 crates/sui-rest-api/src/transactions/resolve.rs diff --git a/Cargo.lock b/Cargo.lock index 423e16e677663e..25b1e4e05099b1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -13310,6 +13310,7 @@ dependencies = [ "sui-node", "sui-protocol-config", "sui-rest-api", + "sui-sdk 0.0.0", "sui-sdk 1.35.0", "sui-simulator", "sui-storage", @@ -14460,6 +14461,7 @@ dependencies = [ "fastcrypto", "itertools 0.13.0", "mime", + "move-binary-format", "mysten-network", "openapiv3", "prometheus", diff --git a/crates/sui-e2e-tests/Cargo.toml b/crates/sui-e2e-tests/Cargo.toml index 970458f3a9e94a..46d390f63f2236 100644 --- a/crates/sui-e2e-tests/Cargo.toml +++ b/crates/sui-e2e-tests/Cargo.toml @@ -64,6 +64,7 @@ sui-sdk.workspace = true sui-keys.workspace = true sui-rest-api.workspace = true shared-crypto.workspace = true +sui-sdk2.workspace = true passkey-types.workspace = true passkey-client.workspace = true diff --git a/crates/sui-e2e-tests/tests/rest.rs b/crates/sui-e2e-tests/tests/rest.rs index ff408edee65492..fcf571e1876d13 100644 --- a/crates/sui-e2e-tests/tests/rest.rs +++ b/crates/sui-e2e-tests/tests/rest.rs @@ -1,10 +1,21 @@ // Copyright (c) Mysten Labs, Inc. // SPDX-License-Identifier: Apache-2.0 +use shared_crypto::intent::Intent; +use sui_keys::keystore::AccountKeystore; use sui_macros::sim_test; +use sui_rest_api::client::reqwest::StatusCode; use sui_rest_api::client::BalanceChange; use sui_rest_api::Client; use sui_rest_api::ExecuteTransactionQueryParameters; +use sui_sdk2::types::Argument; +use sui_sdk2::types::Command; +use sui_sdk2::types::TransactionExpiration; +use sui_sdk2::types::UnresolvedGasPayment; +use sui_sdk2::types::UnresolvedInputArgument; +use sui_sdk2::types::UnresolvedObjectReference; +use sui_sdk2::types::UnresolvedProgrammableTransaction; +use sui_sdk2::types::UnresolvedTransaction; use sui_test_transaction_builder::make_transfer_sui_transaction; use sui_types::base_types::SuiAddress; use sui_types::effects::TransactionEffectsAPI; @@ -53,3 +64,303 @@ async fn execute_transaction_transfer() { assert_eq!(actual, expected); } + +#[sim_test] +async fn resolve_transaction_simple_transfer() { + let test_cluster = TestClusterBuilder::new().build().await; + + let client = Client::new(test_cluster.rpc_url()); + let recipient = SuiAddress::random_for_testing_only(); + + let (sender, mut gas) = test_cluster.wallet.get_one_account().await.unwrap(); + gas.sort_by_key(|object_ref| object_ref.0); + let obj_to_send = gas.first().unwrap().0; + + let unresolved_transaction = UnresolvedTransaction { + ptb: UnresolvedProgrammableTransaction { + inputs: vec![ + UnresolvedInputArgument::ImmutableOrOwned(UnresolvedObjectReference { + object_id: obj_to_send.into(), + version: None, + digest: None, + }), + UnresolvedInputArgument::Pure { + value: bcs::to_bytes(&recipient).unwrap(), + }, + ], + commands: vec![Command::TransferObjects(sui_sdk2::types::TransferObjects { + objects: vec![Argument::Input(0)], + address: Argument::Input(1), + })], + }, + sender: sender.into(), + gas_payment: None, + expiration: TransactionExpiration::None, + }; + + let resolved = client + .inner() + .resolve_transaction(&unresolved_transaction) + .await + .unwrap() + .into_inner(); + + let signed_transaction = test_cluster + .wallet + .sign_transaction(&resolved.transaction.into()); + let effects = client + .execute_transaction( + &ExecuteTransactionQueryParameters::default(), + &signed_transaction, + ) + .await + .unwrap() + .effects; + + assert!(effects.status().is_ok()); + assert_eq!(resolved.effects, effects.into()); +} + +#[sim_test] +async fn resolve_transaction_transfer_with_sponsor() { + let test_cluster = TestClusterBuilder::new().build().await; + + let client = Client::new(test_cluster.rpc_url()); + let recipient = SuiAddress::random_for_testing_only(); + + let (sender, gas) = test_cluster.wallet.get_one_account().await.unwrap(); + let obj_to_send = gas.first().unwrap().0; + let sponsor = test_cluster.wallet.get_addresses()[1]; + + let unresolved_transaction = UnresolvedTransaction { + ptb: UnresolvedProgrammableTransaction { + inputs: vec![ + UnresolvedInputArgument::ImmutableOrOwned(UnresolvedObjectReference { + object_id: obj_to_send.into(), + version: None, + digest: None, + }), + UnresolvedInputArgument::Pure { + value: bcs::to_bytes(&recipient).unwrap(), + }, + ], + commands: vec![Command::TransferObjects(sui_sdk2::types::TransferObjects { + objects: vec![Argument::Input(0)], + address: Argument::Input(1), + })], + }, + sender: sender.into(), + gas_payment: Some(UnresolvedGasPayment { + objects: vec![], + owner: sponsor.into(), + price: None, + budget: None, + }), + expiration: TransactionExpiration::None, + }; + + let resolved = client + .inner() + .resolve_transaction(&unresolved_transaction) + .await + .unwrap() + .into_inner(); + + let transaction_data = resolved.transaction.clone().into(); + let sender_sig = test_cluster + .wallet + .config + .keystore + .sign_secure(&sender, &transaction_data, Intent::sui_transaction()) + .unwrap(); + let sponsor_sig = test_cluster + .wallet + .config + .keystore + .sign_secure(&sponsor, &transaction_data, Intent::sui_transaction()) + .unwrap(); + + let signed_transaction = sui_types::transaction::Transaction::from_data( + transaction_data, + vec![sender_sig, sponsor_sig], + ); + let effects = client + .execute_transaction( + &ExecuteTransactionQueryParameters::default(), + &signed_transaction, + ) + .await + .unwrap() + .effects; + + assert!(effects.status().is_ok()); + assert_eq!(resolved.effects, effects.into()); +} + +#[sim_test] +async fn resolve_transaction_borrowed_shared_object() { + let test_cluster = TestClusterBuilder::new().build().await; + + let client = Client::new(test_cluster.rpc_url()); + + let sender = test_cluster.wallet.get_addresses()[0]; + + let unresolved_transaction = UnresolvedTransaction { + ptb: UnresolvedProgrammableTransaction { + inputs: vec![UnresolvedInputArgument::Shared { + object_id: "0x6".parse().unwrap(), + initial_shared_version: None, + mutable: None, + }], + commands: vec![Command::MoveCall(sui_sdk2::types::MoveCall { + package: "0x2".parse().unwrap(), + module: "clock".parse().unwrap(), + function: "timestamp_ms".parse().unwrap(), + type_arguments: vec![], + arguments: vec![Argument::Input(0)], + })], + }, + sender: sender.into(), + gas_payment: None, + expiration: TransactionExpiration::None, + }; + + let resolved = client + .inner() + .resolve_transaction(&unresolved_transaction) + .await + .unwrap() + .into_inner(); + + let signed_transaction = test_cluster + .wallet + .sign_transaction(&resolved.transaction.into()); + let effects = client + .execute_transaction( + &ExecuteTransactionQueryParameters::default(), + &signed_transaction, + ) + .await + .unwrap() + .effects; + + assert!(effects.status().is_ok()); +} + +#[sim_test] +async fn resolve_transaction_mutable_shared_object() { + let test_cluster = TestClusterBuilder::new().build().await; + + let client = Client::new(test_cluster.rpc_url()); + + let (sender, mut gas) = test_cluster.wallet.get_one_account().await.unwrap(); + gas.sort_by_key(|object_ref| object_ref.0); + let obj_to_stake = gas.first().unwrap().0; + let validator_address = client + .inner() + .get_system_state_summary() + .await + .unwrap() + .inner() + .active_validators + .first() + .unwrap() + .address; + + let unresolved_transaction = UnresolvedTransaction { + ptb: UnresolvedProgrammableTransaction { + inputs: vec![ + UnresolvedInputArgument::Shared { + object_id: "0x5".parse().unwrap(), + initial_shared_version: None, + mutable: None, + }, + UnresolvedInputArgument::ImmutableOrOwned(UnresolvedObjectReference { + object_id: obj_to_stake.into(), + version: None, + digest: None, + }), + UnresolvedInputArgument::Pure { + value: bcs::to_bytes(&validator_address).unwrap(), + }, + ], + commands: vec![Command::MoveCall(sui_sdk2::types::MoveCall { + package: "0x3".parse().unwrap(), + module: "sui_system".parse().unwrap(), + function: "request_add_stake".parse().unwrap(), + type_arguments: vec![], + arguments: vec![Argument::Input(0), Argument::Input(1), Argument::Input(2)], + })], + }, + sender: sender.into(), + gas_payment: None, + expiration: TransactionExpiration::None, + }; + + let resolved = client + .inner() + .resolve_transaction(&unresolved_transaction) + .await + .unwrap() + .into_inner(); + + let signed_transaction = test_cluster + .wallet + .sign_transaction(&resolved.transaction.into()); + let effects = client + .execute_transaction( + &ExecuteTransactionQueryParameters::default(), + &signed_transaction, + ) + .await + .unwrap() + .effects; + + assert!(effects.status().is_ok()); + assert_eq!(resolved.effects, effects.into()); +} + +#[sim_test] +async fn resolve_transaction_insufficient_gas() { + let test_cluster = TestClusterBuilder::new().build().await; + let client = Client::new(test_cluster.rpc_url()); + + // Test the case where we don't have enough coins/gas for the required budget + let unresolved_transaction = UnresolvedTransaction { + ptb: UnresolvedProgrammableTransaction { + inputs: vec![UnresolvedInputArgument::Shared { + object_id: "0x6".parse().unwrap(), + initial_shared_version: None, + mutable: None, + }], + commands: vec![Command::MoveCall(sui_sdk2::types::MoveCall { + package: "0x2".parse().unwrap(), + module: "clock".parse().unwrap(), + function: "timestamp_ms".parse().unwrap(), + type_arguments: vec![], + arguments: vec![Argument::Input(0)], + })], + }, + sender: SuiAddress::random_for_testing_only().into(), // random account with no gas + gas_payment: None, + expiration: TransactionExpiration::None, + }; + + let error = client + .inner() + .resolve_transaction(&unresolved_transaction) + .await + .unwrap_err(); + + assert_eq!(error.status(), Some(StatusCode::BAD_REQUEST)); + assert_contains( + error.message().unwrap_or_default(), + "unable to select sufficient gas", + ); +} + +fn assert_contains(haystack: &str, needle: &str) { + if !haystack.contains(needle) { + panic!("'{haystack}' does not contain '{needle}'"); + } +} diff --git a/crates/sui-rest-api/Cargo.toml b/crates/sui-rest-api/Cargo.toml index aa046b7c70b2a7..49953d2fd0602f 100644 --- a/crates/sui-rest-api/Cargo.toml +++ b/crates/sui-rest-api/Cargo.toml @@ -31,6 +31,7 @@ fastcrypto.workspace = true sui-types.workspace = true mysten-network.workspace = true sui-protocol-config.workspace = true +move-binary-format.workspace = true [dev-dependencies] diffy = "0.3" diff --git a/crates/sui-rest-api/openapi/openapi.json b/crates/sui-rest-api/openapi/openapi.json index 980ea66b8f13f3..5c46fe87c9d620 100644 --- a/crates/sui-rest-api/openapi/openapi.json +++ b/crates/sui-rest-api/openapi/openapi.json @@ -751,6 +751,28 @@ } } }, + "/transactions/resolve": { + "post": { + "tags": [ + "Transactions" + ], + "operationId": "ResolveTransaction", + "requestBody": {}, + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ResolveTransactionResponse" + } + }, + "application/bcs": {} + } + } + } + } + }, "/coins/{coin_type}": { "get": { "tags": [ @@ -3814,6 +3836,43 @@ } } }, + "ResolveTransactionResponse": { + "description": "Response type for the execute transaction endpoint", + "type": "object", + "required": [ + "effects", + "transaction" + ], + "properties": { + "balance_changes": { + "type": "array", + "items": { + "$ref": "#/components/schemas/BalanceChange" + } + }, + "effects": { + "$ref": "#/components/schemas/TransactionEffects" + }, + "events": { + "$ref": "#/components/schemas/TransactionEvents" + }, + "input_objects": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Object" + } + }, + "output_objects": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Object" + } + }, + "transaction": { + "$ref": "#/components/schemas/Transaction" + } + } + }, "Secp256k1PublicKey": { "description": "Base64 encoded data", "type": "string", diff --git a/crates/sui-rest-api/src/client/mod.rs b/crates/sui-rest-api/src/client/mod.rs index 9619747510f73f..a5582a0928662e 100644 --- a/crates/sui-rest-api/src/client/mod.rs +++ b/crates/sui-rest-api/src/client/mod.rs @@ -30,6 +30,10 @@ impl Client { } } + pub fn inner(&self) -> &sdk::Client { + &self.inner + } + pub async fn get_latest_checkpoint(&self) -> Result { self.inner .get_latest_checkpoint() diff --git a/crates/sui-rest-api/src/client/sdk.rs b/crates/sui-rest-api/src/client/sdk.rs index a2dd6b3e3bdf67..379e54b3ff6956 100644 --- a/crates/sui-rest-api/src/client/sdk.rs +++ b/crates/sui-rest-api/src/client/sdk.rs @@ -16,6 +16,7 @@ use sui_sdk2::types::SignedTransaction; use sui_sdk2::types::StructTag; use sui_sdk2::types::Transaction; use sui_sdk2::types::TransactionDigest; +use sui_sdk2::types::UnresolvedTransaction; use sui_sdk2::types::ValidatorCommittee; use sui_sdk2::types::Version; use tap::Pipe; @@ -34,6 +35,7 @@ use crate::system::SystemStateSummary; use crate::system::X_SUI_MAX_SUPPORTED_PROTOCOL_VERSION; use crate::system::X_SUI_MIN_SUPPORTED_PROTOCOL_VERSION; use crate::transactions::ListTransactionsQueryParameters; +use crate::transactions::ResolveTransactionResponse; use crate::transactions::TransactionExecutionResponse; use crate::transactions::TransactionResponse; use crate::transactions::TransactionSimulationResponse; @@ -422,6 +424,23 @@ impl Client { self.bcs(response).await } + pub async fn resolve_transaction( + &self, + unresolved_transaction: &UnresolvedTransaction, + ) -> Result> { + let url = self.url.join("transactions/resolve")?; + + let response = self + .inner + .post(url) + .header(reqwest::header::ACCEPT, crate::APPLICATION_BCS) + .json(unresolved_transaction) + .send() + .await?; + + self.bcs(response).await + } + async fn check_response( &self, response: reqwest::Response, diff --git a/crates/sui-rest-api/src/lib.rs b/crates/sui-rest-api/src/lib.rs index c0b0b16cbfc254..e7ff7d011cb95f 100644 --- a/crates/sui-rest-api/src/lib.rs +++ b/crates/sui-rest-api/src/lib.rs @@ -85,6 +85,7 @@ const ENDPOINTS: &[&dyn ApiEndpoint] = &[ &system::GetGasInfo, &transactions::ExecuteTransaction, &transactions::SimulateTransaction, + &transactions::ResolveTransaction, &coins::GetCoinInfo, ]; diff --git a/crates/sui-rest-api/src/transactions/mod.rs b/crates/sui-rest-api/src/transactions/mod.rs index 381d0de5d4248a..237143a9d78911 100644 --- a/crates/sui-rest-api/src/transactions/mod.rs +++ b/crates/sui-rest-api/src/transactions/mod.rs @@ -9,6 +9,10 @@ pub use execution::SimulateTransaction; pub use execution::TransactionExecutionResponse; pub use execution::TransactionSimulationResponse; +mod resolve; +pub use resolve::ResolveTransaction; +pub use resolve::ResolveTransactionResponse; + use axum::extract::{Path, Query, State}; use axum::http::StatusCode; use sui_sdk2::types::CheckpointSequenceNumber; diff --git a/crates/sui-rest-api/src/transactions/resolve.rs b/crates/sui-rest-api/src/transactions/resolve.rs new file mode 100644 index 00000000000000..2fc4685da8d6dc --- /dev/null +++ b/crates/sui-rest-api/src/transactions/resolve.rs @@ -0,0 +1,564 @@ +// Copyright (c) Mysten Labs, Inc. +// SPDX-License-Identifier: Apache-2.0 + +use std::collections::{BTreeMap, HashMap}; + +use super::execution::derive_balance_changes; +use crate::objects::ObjectNotFoundError; +use crate::openapi::{ + ApiEndpoint, OperationBuilder, RequestBodyBuilder, ResponseBuilder, RouteHandler, +}; +use crate::reader::StateReader; +use crate::{accept::AcceptFormat, response::ResponseContent}; +use crate::{RestError, RestService, Result}; +use axum::extract::State; +use axum::Json; +use itertools::Itertools; +use move_binary_format::normalized; +use schemars::JsonSchema; +use sui_protocol_config::ProtocolConfig; +use sui_sdk2::types::Argument; +use sui_sdk2::types::BalanceChange; +use sui_sdk2::types::Command; +use sui_sdk2::types::Object; +use sui_sdk2::types::ObjectId; +use sui_sdk2::types::Transaction; +use sui_sdk2::types::TransactionEffects; +use sui_sdk2::types::TransactionEvents; +use sui_sdk2::types::UnresolvedInputArgument; +use sui_sdk2::types::UnresolvedObjectReference; +use sui_sdk2::types::UnresolvedProgrammableTransaction; +use sui_sdk2::types::UnresolvedTransaction; +use sui_types::base_types::{ObjectID, ObjectRef, SuiAddress}; +use sui_types::effects::TransactionEffectsAPI; +use sui_types::gas::GasCostSummary; +use sui_types::gas_coin::GasCoin; +use sui_types::move_package::MovePackage; +use sui_types::transaction::{ + CallArg, GasData, ObjectArg, ProgrammableTransaction, TransactionData, TransactionDataAPI, +}; +use sui_types::transaction_executor::SimulateTransactionResult; +use tap::Pipe; + +pub struct ResolveTransaction; + +impl ApiEndpoint for ResolveTransaction { + fn method(&self) -> axum::http::Method { + axum::http::Method::POST + } + + fn path(&self) -> &'static str { + "/transactions/resolve" + } + + fn operation( + &self, + generator: &mut schemars::gen::SchemaGenerator, + ) -> openapiv3::v3_1::Operation { + OperationBuilder::new() + .tag("Transactions") + .operation_id("ResolveTransaction") + .request_body( + RequestBodyBuilder::new() + // .json_content::(generator) + .build(), + ) + .response( + 200, + ResponseBuilder::new() + .json_content::(generator) + .bcs_content() + .build(), + ) + .build() + } + + fn handler(&self) -> RouteHandler { + RouteHandler::new(self.method(), resolve_transaction) + } +} + +async fn resolve_transaction( + State(state): State, + accept: AcceptFormat, + Json(unresolved_transaction): Json, +) -> Result> { + let executor = state + .executor + .as_ref() + .ok_or_else(|| anyhow::anyhow!("No Transaction Executor"))?; + let (reference_gas_price, protocol_config) = { + let system_state = state.reader.get_system_state_summary()?; + + let current_protocol_version = state.reader.get_system_state_summary()?.protocol_version; + + let protocol_config = ProtocolConfig::get_for_version_if_supported( + current_protocol_version.into(), + state.reader.inner().get_chain_identifier()?.chain(), + ) + .ok_or_else(|| { + RestError::new( + axum::http::StatusCode::INTERNAL_SERVER_ERROR, + "unable to get current protocol config", + ) + })?; + + (system_state.reference_gas_price, protocol_config) + }; + let called_packages = + called_packages(&state.reader, &protocol_config, &unresolved_transaction)?; + let user_provided_budget = unresolved_transaction + .gas_payment + .as_ref() + .and_then(|payment| payment.budget); + let mut resolved_transaction = resolve_object_references( + &state.reader, + &called_packages, + reference_gas_price, + protocol_config.max_tx_gas(), + unresolved_transaction, + )?; + + let simulation_result = executor + .simulate_transaction(resolved_transaction.clone()) + .map_err(anyhow::Error::from)?; + + let budget = if let Some(user_provided_budget) = user_provided_budget { + user_provided_budget + } else { + let estimate = estimate_gas_budget_from_gas_cost( + simulation_result.effects.gas_cost_summary(), + reference_gas_price, + ); + resolved_transaction.gas_data_mut().budget = estimate; + estimate + }; + + // If we ended up using a mock gas coin we need to do gas selection now + if simulation_result.mock_gas_id.is_some() { + let input_objects = resolved_transaction + .input_objects() + .map_err(anyhow::Error::from)? + .iter() + .flat_map(|obj| match obj { + sui_types::transaction::InputObjectKind::ImmOrOwnedMoveObject((id, _, _)) => { + Some(*id) + } + _ => None, + }) + .collect_vec(); + let gas_coins = select_gas( + &state.reader, + resolved_transaction.gas_data().owner, + budget, + protocol_config.max_gas_payment_objects(), + &input_objects, + )?; + resolved_transaction.gas_data_mut().payment = gas_coins; + } + + // Do one last dry-run + let SimulateTransactionResult { + input_objects, + output_objects, + events, + effects, + .. + } = executor + .simulate_transaction(resolved_transaction.clone()) + .map_err(anyhow::Error::from)?; + + let events = events.map(Into::into); + let effects = effects.into(); + + let input_objects = input_objects + .into_values() + .map(Into::into) + .collect::>(); + let output_objects = output_objects + .into_values() + .map(Into::into) + .collect::>(); + let balance_changes = derive_balance_changes(&effects, &input_objects, &output_objects); + + ResolveTransactionResponse { + transaction: resolved_transaction.into(), + events, + effects, + balance_changes: Some(balance_changes), + input_objects: Some(input_objects), + output_objects: Some(output_objects), + } + .pipe(|response| match accept { + AcceptFormat::Json => ResponseContent::Json(response), + AcceptFormat::Bcs => ResponseContent::Bcs(response), + }) + .pipe(Ok) +} + +struct NormalizedPackage { + #[allow(unused)] + package: MovePackage, + normalized_modules: BTreeMap, +} + +fn called_packages( + reader: &StateReader, + protocol_config: &ProtocolConfig, + unresolved_transaction: &UnresolvedTransaction, +) -> Result> { + let binary_config = sui_types::execution_config_utils::to_binary_config(protocol_config); + let mut packages = HashMap::new(); + + for move_call in unresolved_transaction + .ptb + .commands + .iter() + .filter_map(|command| { + if let Command::MoveCall(move_call) = command { + Some(move_call) + } else { + None + } + }) + { + let package = reader + .inner() + .get_object(&(move_call.package.into()))? + .ok_or_else(|| ObjectNotFoundError::new(move_call.package))? + .data + .try_as_package() + .ok_or_else(|| { + RestError::new( + axum::http::StatusCode::BAD_REQUEST, + format!("object {} is not a package", move_call.package), + ) + })? + .to_owned(); + + let normalized_modules = package.normalize(&binary_config).map_err(|e| { + RestError::new( + axum::http::StatusCode::INTERNAL_SERVER_ERROR, + format!("unable to normalize package {}: {e}", move_call.package), + ) + })?; + let package = NormalizedPackage { + package, + normalized_modules, + }; + + packages.insert(move_call.package, package); + } + + Ok(packages) +} + +fn resolve_object_references( + reader: &StateReader, + called_packages: &HashMap, + reference_gas_price: u64, + max_gas_budget: u64, + unresolved_transaction: UnresolvedTransaction, +) -> Result { + let sender = unresolved_transaction.sender.into(); + let gas_data = if let Some(unresolved_gas_payment) = unresolved_transaction.gas_payment { + let payment = unresolved_gas_payment + .objects + .into_iter() + .map(|unresolved| resolve_object_reference(reader, unresolved)) + .collect::>>()?; + GasData { + payment, + owner: unresolved_gas_payment.owner.into(), + price: unresolved_gas_payment.price.unwrap_or(reference_gas_price), + budget: unresolved_gas_payment.budget.unwrap_or(max_gas_budget), + } + } else { + GasData { + payment: vec![], + owner: sender, + price: reference_gas_price, + budget: max_gas_budget, + } + }; + let expiration = unresolved_transaction.expiration.into(); + let ptb = resolve_ptb(reader, called_packages, unresolved_transaction.ptb)?; + Ok(TransactionData::V1( + sui_types::transaction::TransactionDataV1 { + kind: sui_types::transaction::TransactionKind::ProgrammableTransaction(ptb), + sender, + gas_data, + expiration, + }, + )) +} + +/// Response type for the execute transaction endpoint +#[derive(Debug, serde::Serialize, serde::Deserialize, JsonSchema)] +pub struct ResolveTransactionResponse { + pub transaction: Transaction, + pub effects: TransactionEffects, + + pub events: Option, + pub balance_changes: Option>, + pub input_objects: Option>, + pub output_objects: Option>, +} + +fn resolve_object_reference( + reader: &StateReader, + unresolved_object_reference: UnresolvedObjectReference, +) -> Result { + let UnresolvedObjectReference { + object_id, + version, + digest, + } = unresolved_object_reference; + + let id = object_id.into(); + let (v, d) = if let Some(version) = version { + let object = reader + .inner() + .get_object_by_key(&id, version.into())? + .ok_or_else(|| ObjectNotFoundError::new_with_version(object_id, version))?; + (object.version(), object.digest()) + } else { + let object = reader + .inner() + .get_object(&id)? + .ok_or_else(|| ObjectNotFoundError::new(object_id))?; + (object.version(), object.digest()) + }; + + if digest.is_some_and(|digest| digest.inner() != d.inner()) { + return Err(RestError::new( + axum::http::StatusCode::BAD_REQUEST, + format!("provided digest doesn't match, provided: {digest:?} actual: {d}"), + )); + } + + Ok((id, v, d)) +} + +fn resolve_ptb( + reader: &StateReader, + called_packages: &HashMap, + unresolved_ptb: UnresolvedProgrammableTransaction, +) -> Result { + let inputs = unresolved_ptb + .inputs + .into_iter() + .enumerate() + .map(|(arg_idx, arg)| { + resolve_arg( + reader, + called_packages, + &unresolved_ptb.commands, + arg, + arg_idx, + ) + }) + .collect::>()?; + + ProgrammableTransaction { + inputs, + commands: unresolved_ptb + .commands + .into_iter() + .map(Into::into) + .collect(), + } + .pipe(Ok) +} + +fn resolve_arg( + reader: &StateReader, + called_packages: &HashMap, + commands: &[Command], + arg: UnresolvedInputArgument, + arg_idx: usize, +) -> Result { + match arg { + UnresolvedInputArgument::Pure { value } => CallArg::Pure(value), + UnresolvedInputArgument::ImmutableOrOwned(obj_ref) => CallArg::Object( + ObjectArg::ImmOrOwnedObject(resolve_object_reference(reader, obj_ref)?), + ), + UnresolvedInputArgument::Shared { + object_id, + initial_shared_version: _, + mutable: _, + } => { + let id = object_id.into(); + let object = reader + .inner() + .get_object(&id)? + .ok_or_else(|| ObjectNotFoundError::new(object_id))?; + + let initial_shared_version = if let sui_types::object::Owner::Shared { + initial_shared_version, + } = object.owner() + { + *initial_shared_version + } else { + return Err(RestError::new( + axum::http::StatusCode::BAD_REQUEST, + format!("object {object_id} is not a shared object"), + )); + }; + + let mut mutable = false; + + for (move_call, idx) in find_arg_uses(arg_idx, commands).filter_map(|(command, idx)| { + if let Command::MoveCall(move_call) = command { + idx.map(|idx| (move_call, idx)) + } else { + None + } + }) { + let function = called_packages + // Find the package + .get(&move_call.package) + // Find the module + .and_then(|package| package.normalized_modules.get(move_call.module.as_str())) + // Find the function + .and_then(|module| module.functions.get(move_call.function.as_str())) + .ok_or_else(|| { + RestError::new( + axum::http::StatusCode::BAD_REQUEST, + "unable to find function", + ) + })?; + + let arg_type = function.parameters.get(idx).ok_or_else(|| { + RestError::new( + axum::http::StatusCode::BAD_REQUEST, + "invalid input parameter", + ) + })?; + + if matches!( + arg_type, + move_binary_format::normalized::Type::MutableReference(_) + ) { + mutable = true; + } + } + + CallArg::Object(ObjectArg::SharedObject { + id, + initial_shared_version, + mutable, + }) + } + UnresolvedInputArgument::Receiving(obj_ref) => CallArg::Object(ObjectArg::Receiving( + resolve_object_reference(reader, obj_ref)?, + )), + } + .pipe(Ok) +} + +fn find_arg_uses( + arg_idx: usize, + commands: &[Command], +) -> impl Iterator)> { + commands.iter().filter_map(move |command| { + match command { + Command::MoveCall(move_call) => move_call + .arguments + .iter() + .position(|elem| matches_input_arg(*elem, arg_idx)) + .map(Some), + Command::TransferObjects(transfer_objects) => transfer_objects + .objects + .iter() + .position(|elem| matches_input_arg(*elem, arg_idx)) + .map(Some), + Command::SplitCoins(split_coins) => { + matches_input_arg(split_coins.coin, arg_idx).then_some(None) + } + Command::MergeCoins(merge_coins) => { + if matches_input_arg(merge_coins.coin, arg_idx) { + Some(None) + } else { + merge_coins + .coins_to_merge + .iter() + .position(|elem| matches_input_arg(*elem, arg_idx)) + .map(Some) + } + } + Command::Publish(_) => None, + Command::MakeMoveVector(make_move_vector) => make_move_vector + .elements + .iter() + .position(|elem| matches_input_arg(*elem, arg_idx)) + .map(Some), + Command::Upgrade(upgrade) => matches_input_arg(upgrade.ticket, arg_idx).then_some(None), + } + .map(|x| (command, x)) + }) +} + +fn matches_input_arg(arg: Argument, arg_idx: usize) -> bool { + matches!(arg, Argument::Input(idx) if idx as usize == arg_idx) +} + +/// Estimate the gas budget using the gas_cost_summary from a previous DryRun +/// +/// The estimated gas budget is computed as following: +/// * the maximum between A and B, where: +/// A = computation cost + GAS_SAFE_OVERHEAD * reference gas price +/// B = computation cost + storage cost - storage rebate + GAS_SAFE_OVERHEAD * reference gas price +/// overhead +/// +/// This gas estimate is computed similarly as in the TypeScript SDK +fn estimate_gas_budget_from_gas_cost( + gas_cost_summary: &GasCostSummary, + reference_gas_price: u64, +) -> u64 { + const GAS_SAFE_OVERHEAD: u64 = 1000; + + let safe_overhead = GAS_SAFE_OVERHEAD * reference_gas_price; + let computation_cost_with_overhead = gas_cost_summary.computation_cost + safe_overhead; + + let gas_usage = gas_cost_summary.net_gas_usage() + safe_overhead as i64; + computation_cost_with_overhead.max(if gas_usage < 0 { 0 } else { gas_usage as u64 }) +} + +fn select_gas( + reader: &StateReader, + owner: SuiAddress, + budget: u64, + max_gas_payment_objects: u32, + input_objects: &[ObjectID], +) -> Result> { + let gas_coins = reader + .inner() + .account_owned_objects_info_iter(owner, None)? + .filter(|info| info.type_.is_gas_coin()) + .filter(|info| !input_objects.contains(&info.object_id)) + .filter_map(|info| reader.inner().get_object(&info.object_id).ok().flatten()) + .filter_map(|object| { + GasCoin::try_from(&object) + .ok() + .map(|coin| (object.compute_object_reference(), coin.value())) + }) + .take(max_gas_payment_objects as usize); + + let mut selected_gas = vec![]; + let mut selected_gas_value = 0; + + for (object_ref, value) in gas_coins { + selected_gas.push(object_ref); + selected_gas_value += value; + + if selected_gas_value >= budget { + return Ok(selected_gas); + } + } + + Err(RestError::new( + axum::http::StatusCode::BAD_REQUEST, + format!("unable to select sufficient gas coins from account {owner} to satisfy required budget {budget}"), + )) +}