diff --git a/crates/sui-adapter/src/temporary_store.rs b/crates/sui-adapter/src/temporary_store.rs index 3230c51e4be31..69d66d9d90211 100644 --- a/crates/sui-adapter/src/temporary_store.rs +++ b/crates/sui-adapter/src/temporary_store.rs @@ -28,6 +28,43 @@ pub struct InnerTemporaryStore { pub deleted: BTreeMap, } +impl InnerTemporaryStore { + /// Return the written object value with the given ID (if any) + pub fn get_written_object(&self, id: &ObjectID) -> Option<&Object> { + self.written.get(id).map(|o| &o.1) + } + + /// Return the set of object ID's created during the current tx + pub fn created(&self) -> Vec { + self.written + .values() + .filter_map(|(obj_ref, _, w)| { + if *w == WriteKind::Create { + Some(obj_ref.0) + } else { + None + } + }) + .collect() + } + + /// Get the written objects owned by `address` + pub fn get_written_objects_owned_by(&self, address: &SuiAddress) -> Vec { + self.written + .values() + .filter_map(|(_, o, _)| { + if o.get_single_owner() + .map_or(false, |owner| &owner == address) + { + Some(o.id()) + } else { + None + } + }) + .collect() + } +} + pub struct TemporaryStore { // The backing store for retrieving Move packages onchain. // When executing a Move call, the dependent packages are not going to be @@ -299,7 +336,7 @@ impl TemporaryStore { pub fn write_object(&mut self, mut object: Object, kind: WriteKind) { // there should be no write after delete - debug_assert!(self.deleted.get(&object.id()) == None); + debug_assert!(self.deleted.get(&object.id()).is_none()); // Check it is not read-only #[cfg(test)] // Movevm should ensure this if let Some(existing_object) = self.read_object(&object.id()) { @@ -318,7 +355,7 @@ impl TemporaryStore { pub fn delete_object(&mut self, id: &ObjectID, version: SequenceNumber, kind: DeleteKind) { // there should be no deletion after write - debug_assert!(self._written.get(id) == None); + debug_assert!(self._written.get(id).is_none()); // Check it is not read-only #[cfg(test)] // Movevm should ensure this if let Some(object) = self.read_object(id) { @@ -345,7 +382,7 @@ impl Storage for TemporaryStore { fn read_object(&self, id: &ObjectID) -> Option<&Object> { // there should be no read after delete - debug_assert!(self.deleted.get(id) == None); + debug_assert!(self.deleted.get(id).is_none()); self._written .get(id) .map(|(obj, _kind)| obj) @@ -435,3 +472,19 @@ impl ParentSync for TemporaryStore { self.store.get_latest_parent_entry_ref(object_id) } } + +/// Create an empty `TemporaryStore` with no backing storage for module resolution. +/// For testing purposes only. +pub fn empty_for_testing() -> TemporaryStore<()> { + TemporaryStore::new( + (), + InputObjects::new(Vec::new()), + TransactionDigest::genesis(), + ) +} + +/// Create a `TemporaryStore` with the given inputs and no backing storage for module resolution. +/// For testing purposes only. +pub fn with_input_objects_for_testing(input_objects: InputObjects) -> TemporaryStore<()> { + TemporaryStore::new((), input_objects, TransactionDigest::genesis()) +} diff --git a/crates/sui-core/src/execution_engine.rs b/crates/sui-core/src/execution_engine.rs index 8432f7271317a..27e20bbba143b 100644 --- a/crates/sui-core/src/execution_engine.rs +++ b/crates/sui-core/src/execution_engine.rs @@ -4,8 +4,11 @@ use move_core_types::ident_str; use move_core_types::identifier::Identifier; use std::{collections::BTreeSet, sync::Arc}; +#[cfg(test)] +use sui_adapter::temporary_store; use sui_adapter::temporary_store::InnerTemporaryStore; -use sui_types::storage::{ParentSync, WriteKind}; +use sui_types::id::UID; +use sui_types::storage::{DeleteKind, ParentSync, WriteKind}; use crate::authority::TemporaryStore; use move_core_types::language_storage::ModuleId; @@ -13,11 +16,15 @@ use move_vm_runtime::{move_vm::MoveVM, native_functions::NativeFunctionTable}; use sui_adapter::adapter; use sui_types::coin::Coin; use sui_types::committee::EpochId; -use sui_types::error::ExecutionError; +use sui_types::error::{ExecutionError, ExecutionErrorKind}; use sui_types::gas::GasCostSummary; use sui_types::gas_coin::GasCoin; -use sui_types::messages::ObjectArg; -use sui_types::object::{MoveObject, Owner, OBJECT_START_VERSION}; +#[cfg(test)] +use sui_types::messages::ExecutionFailureStatus; +#[cfg(test)] +use sui_types::messages::InputObjects; +use sui_types::messages::{ObjectArg, Pay}; +use sui_types::object::{Data, MoveObject, Owner, OBJECT_START_VERSION}; use sui_types::{ base_types::{ObjectID, ObjectRef, SuiAddress, TransactionDigest, TxContext}, event::{Event, TransferType}, @@ -128,7 +135,7 @@ fn execute_transaction( recipient, object_ref, }) => { - // unwrap is is safe because we built the object map from the transactions + // unwrap is safe because we built the object map from the transactions let object = temporary_store .objects() .get(&object_ref.0) @@ -163,6 +170,21 @@ fn execute_transaction( tx_ctx, ) } + SingleTransactionKind::Pay(Pay { + coins, + recipients, + amounts, + }) => { + let coin_objects = // unwrap is is safe because we built the object map from the transaction + coins.iter().map(|c| + temporary_store + .objects() + .get(&c.0) + .unwrap() + .clone() + ).collect(); + pay(temporary_store, coin_objects, recipients, amounts, tx_ctx) + } SingleTransactionKind::Publish(MoveModulePublish { modules }) => adapter::publish( temporary_store, native_functions.clone(), @@ -277,6 +299,157 @@ fn transfer_object( Ok(()) } +/// Debit `coins` to pay amount[i] to recipient[i]. The coins are debited from left to right. +/// A new coin object is created for each recipient. +fn pay( + temporary_store: &mut TemporaryStore, + coin_objects: Vec, + recipients: Vec, + amounts: Vec, + tx_ctx: &mut TxContext, +) -> Result<(), ExecutionError> { + if coin_objects.is_empty() { + return Err(ExecutionError::new_with_source( + ExecutionErrorKind::EmptyInputCoins, + "Pay transaction requires a non-empty list of input coins".to_string(), + )); + } + if recipients.is_empty() { + return Err(ExecutionError::new_with_source( + ExecutionErrorKind::EmptyRecipients, + "Pay transaction requires a non-empty list of recipient addresses".to_string(), + )); + } + if recipients.len() != amounts.len() { + return Err(ExecutionError::new_with_source( + ExecutionErrorKind::RecipientsAmountsArityMismatch, + format!( + "Found {:?} recipient addresses, but {:?} recipient amounts", + recipients.len(), + amounts.len() + ), + )); + } + + // ensure all input objects are coins of the same type + let mut coins = Vec::new(); + let mut coin_type = None; + for coin_obj in &coin_objects { + match &coin_obj.data { + Data::Move(move_obj) => { + if !Coin::is_coin(&move_obj.type_) { + return Err(ExecutionError::new_with_source( + ExecutionErrorKind::InvalidCoinObject, + "Provided non-Coin object as input to pay transaction".to_string(), + )); + } + if let Some(typ) = &coin_type { + if typ != &move_obj.type_ { + return Err(ExecutionError::new_with_source( + ExecutionErrorKind::InvalidCoinObject, + format!("Expected all Coin objects passed as input to pay() to be the same type, but found mismatch: {:?} vs {:}", typ, move_obj.type_), + )); + } + } else { + // first iteration of the loop, establish the coin type + coin_type = Some(move_obj.type_.clone()) + } + + let coin = Coin::from_bcs_bytes(move_obj.contents()) + .expect("Deserializing coin object should not fail"); + coins.push(coin) + } + _ => { + return Err(ExecutionError::new_with_source( + ExecutionErrorKind::InvalidCoinObject, + "Provided non-Coin object as input to pay transaction".to_string(), + )) + } + } + } + // safe because coin_objects must be non-empty, and coin_type must be set in loop above + let coin_type = coin_type.unwrap(); + + // make sure the total value of the coins can cover all of the amounts + let total_amount: u64 = amounts.iter().sum(); + let total_coins = coins.iter().fold(0, |acc, c| acc + c.value()); + if total_amount > total_coins { + return Err(ExecutionError::new_with_source( + ExecutionErrorKind::InsufficientBalance, + format!("Attempting to pay a total amount {:?} that is greater than the sum of input coin values {:?}", total_amount, total_coins), + )); + } + + // walk through the coins from left to right, debiting as needed to cover each amount to send + let mut cur_coin_idx = 0; + for (recipient, amount) in recipients.iter().zip(amounts) { + let mut remaining_amount = amount; + loop { + // while remaining_amount != 0 + // guaranteed to be in-bounds because of the total > total_coins check above + let coin = &mut coins[cur_coin_idx]; + let coin_value = coin.value(); + if coin_value == 0 { + // can't extract any more value from this coin, go to the next one + cur_coin_idx += 1; + continue; + } + if coin_value >= remaining_amount { + // can get everything we need from this coin + coin.balance.withdraw(remaining_amount).unwrap(); + // create a new coin for the recipient with the original amount + let new_coin = Object::new_move( + MoveObject::new_coin( + coin_type.clone(), + OBJECT_START_VERSION, + bcs::to_bytes(&Coin::new(UID::new(tx_ctx.fresh_id()), amount)) + .expect("Serializing coin value cannot fail"), + ), + Owner::AddressOwner(*recipient), + tx_ctx.digest(), + ); + temporary_store.write_object(new_coin, WriteKind::Create); + break; // done paying this recipieint, on to the next one + } else { + // need to take all of this coin and some from a subsequent coin + coin.balance.withdraw(coin_value).unwrap(); + remaining_amount -= coin_value; + } + } + } + + #[cfg(debug_assertions)] + { + // double check that we didn't create or destroy money + let new_total_coins = coins.iter().fold(0, |acc, c| acc + c.value()); + assert_eq!(total_coins - new_total_coins, total_amount) + } + + // update the input coins to reflect the decrease in value. + // if the input coin has value 0, delete it + for (coin_idx, mut coin_object) in coin_objects.into_iter().enumerate() { + let coin = &coins[coin_idx]; + if coin.value() == 0 { + temporary_store.delete_object( + &coin_object.id(), + coin_object.version(), + DeleteKind::Normal, + ) + } else { + // unwrapped unsafe because we checked that it was a coin object above + coin_object + .data + .try_as_move_mut() + .unwrap() + .update_contents_and_increment_version( + bcs::to_bytes(&coin).expect("Coin serialization should not fail"), + ); + temporary_store.write_object(coin_object, WriteKind::Mutate); + } + } + Ok(()) +} + /// Transfer the gas object (which is a SUI coin object) with an optional `amount`. /// If `amount` is specified, the gas object remains in the original owner, but a new SUI coin /// is created with `amount` balance and is transferred to `recipient`; @@ -345,3 +518,214 @@ fn transfer_sui( Ok(()) } + +#[test] +fn test_pay_empty_coins() { + let coin_objects = Vec::new(); + let recipients = vec![SuiAddress::random_for_testing_only()]; + let amounts = vec![10]; + let mut store: TemporaryStore<()> = temporary_store::empty_for_testing(); + let mut ctx = TxContext::random_for_testing_only(); + + assert_eq!( + pay(&mut store, coin_objects, recipients, amounts, &mut ctx) + .unwrap_err() + .to_execution_status(), + ExecutionFailureStatus::EmptyInputCoins + ); +} + +#[test] +fn test_pay_empty_recipients() { + let coin_objects = vec![Object::new_gas_coin_for_testing( + 10, + SuiAddress::random_for_testing_only(), + )]; + let recipients = Vec::new(); + let amounts = vec![10]; + let mut store: TemporaryStore<()> = temporary_store::empty_for_testing(); + let mut ctx = TxContext::random_for_testing_only(); + + assert_eq!( + pay(&mut store, coin_objects, recipients, amounts, &mut ctx) + .unwrap_err() + .to_execution_status(), + ExecutionFailureStatus::EmptyRecipients + ); +} + +#[test] +fn test_pay_empty_amounts() { + let coin_objects = vec![Object::new_gas_coin_for_testing( + 10, + SuiAddress::random_for_testing_only(), + )]; + let recipients = vec![SuiAddress::random_for_testing_only()]; + let amounts = Vec::new(); + let mut store: TemporaryStore<()> = temporary_store::empty_for_testing(); + let mut ctx = TxContext::random_for_testing_only(); + + assert_eq!( + pay(&mut store, coin_objects, recipients, amounts, &mut ctx) + .unwrap_err() + .to_execution_status(), + ExecutionFailureStatus::RecipientsAmountsArityMismatch + ); +} + +#[test] +fn test_pay_arity_mismatch() { + // different number of recipients and amounts + let owner = SuiAddress::random_for_testing_only(); + let coin_objects = vec![Object::new_gas_coin_for_testing(10, owner)]; + let recipients = vec![ + SuiAddress::random_for_testing_only(), + SuiAddress::random_for_testing_only(), + ]; + let amounts = vec![5]; + let mut store: TemporaryStore<()> = temporary_store::empty_for_testing(); + let mut ctx = TxContext::random_for_testing_only(); + + assert_eq!( + pay(&mut store, coin_objects, recipients, amounts, &mut ctx) + .unwrap_err() + .to_execution_status(), + ExecutionFailureStatus::RecipientsAmountsArityMismatch + ); +} + +#[test] +fn test_pay_insufficient_balance() { + let coin_objects = vec![ + Object::new_gas_coin_for_testing(10, SuiAddress::random_for_testing_only()), + Object::new_gas_coin_for_testing(5, SuiAddress::random_for_testing_only()), + ]; + let recipients = vec![ + SuiAddress::random_for_testing_only(), + SuiAddress::random_for_testing_only(), + ]; + let amounts = vec![10, 6]; + let mut store: TemporaryStore<()> = temporary_store::empty_for_testing(); + let mut ctx = TxContext::random_for_testing_only(); + + assert_eq!( + pay(&mut store, coin_objects, recipients, amounts, &mut ctx) + .unwrap_err() + .to_execution_status(), + ExecutionFailureStatus::InsufficientBalance + ); +} + +#[cfg(test)] +fn get_coin_balance(store: &InnerTemporaryStore, id: &ObjectID) -> u64 { + Coin::extract_balance_if_coin(store.get_written_object(id).unwrap()) + .unwrap() + .unwrap() +} + +#[test] +fn test_pay_success_without_delete() { + // supplied one coin and only needed to use part of it. should + // mutate 1 object, create 1 object, and delete no objects + let sender = SuiAddress::random_for_testing_only(); + let coin1 = Object::new_gas_coin_for_testing(10, sender); + let coin2 = Object::new_gas_coin_for_testing(5, sender); + let coin_objects = vec![coin1, coin2]; + let recipient1 = SuiAddress::random_for_testing_only(); + let recipient2 = SuiAddress::random_for_testing_only(); + let recipients = vec![recipient1, recipient2]; + let amounts = vec![6, 3]; + let mut store: TemporaryStore<()> = + temporary_store::with_input_objects_for_testing(InputObjects::from(coin_objects.clone())); + let mut ctx = TxContext::with_sender_for_testing_only(&sender); + + assert!(pay(&mut store, coin_objects, recipients, amounts, &mut ctx).is_ok()); + let (store, _events) = store.into_inner(); + + assert!(store.deleted.is_empty()); + assert_eq!(store.created().len(), 2); + let recipient1_objs = store.get_written_objects_owned_by(&recipient1); + let recipient2_objs = store.get_written_objects_owned_by(&recipient2); + assert_eq!(recipient1_objs.len(), 1); + assert_eq!(recipient2_objs.len(), 1); + assert_eq!(get_coin_balance(&store, &recipient1_objs[0]), 6); + assert_eq!(get_coin_balance(&store, &recipient2_objs[0]), 3); + + let owner_objs = store.get_written_objects_owned_by(&sender); + assert_eq!(owner_objs.len(), 2); + assert_eq!( + get_coin_balance(&store, &owner_objs[0]) + get_coin_balance(&store, &owner_objs[1]), + 6 + ); +} + +#[test] +fn test_pay_success_delete_one() { + // supplied two coins, spent all of the first one and some of the second one + let sender = SuiAddress::random_for_testing_only(); + let coin1 = Object::new_gas_coin_for_testing(10, sender); + let coin2 = Object::new_gas_coin_for_testing(5, sender); + let input_coin_id1 = coin1.id(); + let input_coin_id2 = coin2.id(); + let coin_objects = vec![coin1, coin2]; + let recipient = SuiAddress::random_for_testing_only(); + let recipients = vec![recipient]; + let amounts = vec![11]; + let mut store: TemporaryStore<()> = + temporary_store::with_input_objects_for_testing(InputObjects::from(coin_objects.clone())); + let mut ctx = TxContext::random_for_testing_only(); + + assert!(pay(&mut store, coin_objects, recipients, amounts, &mut ctx).is_ok()); + let (store, _events) = store.into_inner(); + + assert_eq!(store.deleted.len(), 1); + assert!(store.deleted.contains_key(&input_coin_id1)); + + assert_eq!(store.written.len(), 2); + assert_eq!(store.created().len(), 1); + let recipient_objs = store.get_written_objects_owned_by(&recipient); + assert_eq!(recipient_objs.len(), 1); + assert_eq!(get_coin_balance(&store, &recipient_objs[0]), 11); + + let owner_objs = store.get_written_objects_owned_by(&sender); + assert_eq!(owner_objs.len(), 1); + assert_eq!(owner_objs[0], input_coin_id2); + assert_eq!(get_coin_balance(&store, &owner_objs[0]), 4); +} + +#[test] +fn test_pay_success_delete_all() { + // supplied two coins, spent both of them + let sender = SuiAddress::random_for_testing_only(); + let coin1 = Object::new_gas_coin_for_testing(10, sender); + let coin2 = Object::new_gas_coin_for_testing(5, sender); + let input_coin_id1 = coin1.id(); + let input_coin_id2 = coin2.id(); + let coin_objects = vec![coin1, coin2]; + let recipient1 = SuiAddress::random_for_testing_only(); + let recipient2 = SuiAddress::random_for_testing_only(); + let recipients = vec![recipient1, recipient2]; + let amounts = vec![4, 11]; + let mut store: TemporaryStore<()> = + temporary_store::with_input_objects_for_testing(InputObjects::from(coin_objects.clone())); + let mut ctx = TxContext::with_sender_for_testing_only(&sender); + + assert!(pay(&mut store, coin_objects, recipients, amounts, &mut ctx).is_ok()); + let (store, _events) = store.into_inner(); + + assert_eq!(store.deleted.len(), 2); + assert!(store.deleted.contains_key(&input_coin_id1)); + assert!(store.deleted.contains_key(&input_coin_id2)); + + assert_eq!(store.written.len(), 2); + assert_eq!(store.created().len(), 2); + let recipient1_objs = store.get_written_objects_owned_by(&recipient1); + let recipient2_objs = store.get_written_objects_owned_by(&recipient2); + assert_eq!(recipient1_objs.len(), 1); + assert_eq!(recipient2_objs.len(), 1); + assert_eq!(get_coin_balance(&store, &recipient1_objs[0]), 4); + assert_eq!(get_coin_balance(&store, &recipient2_objs[0]), 11); + + let owner_objs = store.get_written_objects_owned_by(&sender); + assert!(owner_objs.is_empty()); +} diff --git a/crates/sui-core/tests/staged/sui.yaml b/crates/sui-core/tests/staged/sui.yaml index 44a42035477f8..13557f17f1366 100644 --- a/crates/sui-core/tests/staged/sui.yaml +++ b/crates/sui-core/tests/staged/sui.yaml @@ -148,35 +148,43 @@ ExecutionFailureStatus: 9: InvalidCoinObject: UNIT 10: - NonEntryFunctionInvoked: UNIT + EmptyInputCoins: UNIT 11: - EntryTypeArityMismatch: UNIT + EmptyRecipients: UNIT 12: + RecipientsAmountsArityMismatch: UNIT + 13: + InsufficientBalance: UNIT + 14: + NonEntryFunctionInvoked: UNIT + 15: + EntryTypeArityMismatch: UNIT + 16: EntryArgumentError: NEWTYPE: TYPENAME: EntryArgumentError - 13: + 17: CircularObjectOwnership: NEWTYPE: TYPENAME: CircularObjectOwnership - 14: + 18: MissingObjectOwner: NEWTYPE: TYPENAME: MissingObjectOwner - 15: + 19: InvalidSharedChildUse: NEWTYPE: TYPENAME: InvalidSharedChildUse - 16: + 20: InvalidSharedByValue: NEWTYPE: TYPENAME: InvalidSharedByValue - 17: + 21: TooManyChildObjects: STRUCT: - object: TYPENAME: ObjectID - 18: + 22: InvalidParentDeletion: STRUCT: - parent: @@ -184,29 +192,29 @@ ExecutionFailureStatus: - kind: OPTION: TYPENAME: DeleteKind - 19: + 23: InvalidParentFreezing: STRUCT: - parent: TYPENAME: ObjectID - 20: + 24: PublishErrorEmptyPackage: UNIT - 21: + 25: PublishErrorNonZeroAddress: UNIT - 22: + 26: PublishErrorDuplicateModule: UNIT - 23: + 27: SuiMoveVerificationError: UNIT - 24: + 28: MovePrimitiveRuntimeError: UNIT - 25: + 29: MoveAbort: TUPLE: - TYPENAME: ModuleId - U64 - 26: + 30: VMVerificationOrDeserializationError: UNIT - 27: + 31: VMInvariantViolation: UNIT ExecutionStatus: ENUM: @@ -380,6 +388,19 @@ Owner: Shared: UNIT 3: Immutable: UNIT +Pay: + STRUCT: + - coins: + SEQ: + TUPLE: + - TYPENAME: ObjectID + - TYPENAME: SequenceNumber + - TYPENAME: ObjectDigest + - recipients: + SEQ: + TYPENAME: SuiAddress + - amounts: + SEQ: U64 SequenceNumber: NEWTYPESTRUCT: U64 SingleTransactionKind: @@ -401,6 +422,10 @@ SingleTransactionKind: NEWTYPE: TYPENAME: TransferSui 4: + Pay: + NEWTYPE: + TYPENAME: Pay + 5: ChangeEpoch: NEWTYPE: TYPENAME: ChangeEpoch diff --git a/crates/sui-cost/src/estimator.rs b/crates/sui-cost/src/estimator.rs index adde740a6606e..479ac9935e5d1 100644 --- a/crates/sui-cost/src/estimator.rs +++ b/crates/sui-cost/src/estimator.rs @@ -42,8 +42,8 @@ pub fn estimate_computational_costs_for_transaction( } .unwrap() .clone()), - SingleTransactionKind::TransferObject(_) => unsupported_tx_kind, + SingleTransactionKind::Pay(_) => unsupported_tx_kind, SingleTransactionKind::Publish(_) => unsupported_tx_kind, SingleTransactionKind::Call(_) => unsupported_tx_kind, SingleTransactionKind::ChangeEpoch(_) => unsupported_tx_kind, @@ -54,7 +54,7 @@ pub fn estimate_computational_costs_for_transaction( pub fn read_estimate_file( ) -> Result, anyhow::Error> { - let json_str = fs::read_to_string(&ESTIMATE_FILE).unwrap(); + let json_str = fs::read_to_string(ESTIMATE_FILE).unwrap(); // Remove the metadata: first 4 lines form snapshot tests let json_str = json_str diff --git a/crates/sui-json-rpc-types/src/lib.rs b/crates/sui-json-rpc-types/src/lib.rs index bc7123691da85..754d7d2845eef 100644 --- a/crates/sui-json-rpc-types/src/lib.rs +++ b/crates/sui-json-rpc-types/src/lib.rs @@ -44,7 +44,7 @@ use sui_types::gas::GasCostSummary; use sui_types::gas_coin::GasCoin; use sui_types::messages::{ CallArg, CertifiedTransaction, CertifiedTransactionEffects, ExecuteTransactionResponse, - ExecutionStatus, InputObjectKind, MoveModulePublish, ObjectArg, SingleTransactionKind, + ExecutionStatus, InputObjectKind, MoveModulePublish, ObjectArg, Pay, SingleTransactionKind, TransactionData, TransactionEffects, TransactionKind, }; use sui_types::messages_checkpoint::CheckpointSequenceNumber; @@ -1367,6 +1367,29 @@ impl TryFrom for SuiMovePackage { } } +#[derive(Debug, Deserialize, Serialize, JsonSchema, Clone, Eq, PartialEq)] +#[serde(rename = "Pay")] +pub struct SuiPay { + /// The coins to be used for payment + pub coins: Vec, + /// The addresses that will receive payment + pub recipients: Vec, + /// The amounts each recipient will receive. + /// Must be the same length as amounts + pub amounts: Vec, +} + +impl From for SuiPay { + fn from(p: Pay) -> Self { + let coins = p.coins.into_iter().map(|c| c.into()).collect(); + SuiPay { + coins, + recipients: p.recipients, + amounts: p.amounts, + } + } +} + #[derive(Debug, Deserialize, Serialize, JsonSchema, Clone)] #[serde(rename = "TransactionData", rename_all = "camelCase")] pub struct SuiTransactionData { @@ -1419,6 +1442,8 @@ impl TryFrom for SuiTransactionData { pub enum SuiTransactionKind { /// Initiate an object transfer between addresses TransferObject(SuiTransferObject), + /// Pay one or more recipients from a set of input coins + Pay(SuiPay), /// Publish a new Move module Publish(SuiMovePackage), /// Call a function in a published Move module @@ -1454,6 +1479,21 @@ impl Display for SuiTransactionKind { writeln!(writer, "Amount: Full Balance")?; } } + Self::Pay(p) => { + writeln!(writer, "Transaction Kind : Pay")?; + writeln!(writer, "Coins:")?; + for obj_ref in &p.coins { + writeln!(writer, "Object ID : {}", obj_ref.object_id)?; + } + writeln!(writer, "Recipients:")?; + for recipient in &p.recipients { + writeln!(writer, "{}", recipient)?; + } + writeln!(writer, "Amounts:")?; + for amount in &p.amounts { + writeln!(writer, "{}", amount)? + } + } Self::Publish(_p) => { write!(writer, "Transaction Kind : Publish")?; } @@ -1493,6 +1533,7 @@ impl TryFrom for SuiTransactionKind { recipient: t.recipient, amount: t.amount, }), + SingleTransactionKind::Pay(p) => Self::Pay(p.into()), SingleTransactionKind::Publish(p) => Self::Publish(p.try_into()?), SingleTransactionKind::Call(c) => Self::Call(SuiMoveCall { package: c.package.into(), diff --git a/crates/sui-open-rpc/spec/openrpc.json b/crates/sui-open-rpc/spec/openrpc.json index 76e795ba57cb5..7cea9cc8c877c 100644 --- a/crates/sui-open-rpc/spec/openrpc.json +++ b/crates/sui-open-rpc/spec/openrpc.json @@ -3922,6 +3922,39 @@ } ] }, + "Pay": { + "type": "object", + "required": [ + "amounts", + "coins", + "recipients" + ], + "properties": { + "amounts": { + "description": "The amounts each recipient will receive. Must be the same length as amounts", + "type": "array", + "items": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + } + }, + "coins": { + "description": "The coins to be used for payment", + "type": "array", + "items": { + "$ref": "#/components/schemas/ObjectRef" + } + }, + "recipients": { + "description": "The addresses that will receive payment", + "type": "array", + "items": { + "$ref": "#/components/schemas/SuiAddress" + } + } + } + }, "RPCTransactionRequestParams": { "oneOf": [ { @@ -4624,6 +4657,19 @@ }, "additionalProperties": false }, + { + "description": "Pay one or more recipients from a set of input coins", + "type": "object", + "required": [ + "Pay" + ], + "properties": { + "Pay": { + "$ref": "#/components/schemas/Pay" + } + }, + "additionalProperties": false + }, { "description": "Publish a new Move module", "type": "object", diff --git a/crates/sui-storage/src/indexes.rs b/crates/sui-storage/src/indexes.rs index 4d6bbacdc48c4..226cc49ed240e 100644 --- a/crates/sui-storage/src/indexes.rs +++ b/crates/sui-storage/src/indexes.rs @@ -186,8 +186,8 @@ impl IndexStore { .iter() .skip_to(&( package, - module.clone().unwrap_or_else(|| "".to_string()), - function.clone().unwrap_or_else(|| "".to_string()), + module.clone().unwrap_or_default(), + function.clone().unwrap_or_default(), TxSequenceNumber::MIN, ))? .take_while(|((id, m, f, _), _)| { diff --git a/crates/sui-telemetry/src/lib.rs b/crates/sui-telemetry/src/lib.rs index 1b0d7c225cc09..52e0ad1713d3e 100644 --- a/crates/sui-telemetry/src/lib.rs +++ b/crates/sui-telemetry/src/lib.rs @@ -73,7 +73,7 @@ pub async fn send_telemetry_event(is_validator: bool) { fn get_git_rev() -> String { let output_res = Command::new("git") - .args(&["describe", "--always", "--dirty"]) + .args(["describe", "--always", "--dirty"]) .output(); if let Ok(output) = output_res { if output.status.success() { diff --git a/crates/sui-types/src/base_types.rs b/crates/sui-types/src/base_types.rs index c6a26135be5e2..19f2f6543a28e 100644 --- a/crates/sui-types/src/base_types.rs +++ b/crates/sui-types/src/base_types.rs @@ -146,10 +146,7 @@ impl SuiAddress { where S: serde::ser::Serializer, { - serializer.serialize_str( - &key.map(|addr| encode_bytes_hex(&addr)) - .unwrap_or_else(|| "".to_string()), - ) + serializer.serialize_str(&key.map(encode_bytes_hex).unwrap_or_default()) } pub fn optional_address_from_hex<'de, D>( @@ -193,7 +190,7 @@ impl TryFrom> for SuiAddress { impl From<&AuthorityPublicKeyBytes> for SuiAddress { fn from(pkb: &AuthorityPublicKeyBytes) -> Self { let mut hasher = Sha3_256::default(); - hasher.update(&[AuthorityPublicKey::SIGNATURE_SCHEME.flag()]); + hasher.update([AuthorityPublicKey::SIGNATURE_SCHEME.flag()]); hasher.update(pkb); let g_arr = hasher.finalize(); @@ -206,7 +203,7 @@ impl From<&AuthorityPublicKeyBytes> for SuiAddress { impl From<&T> for SuiAddress { fn from(pk: &T) -> Self { let mut hasher = Sha3_256::default(); - hasher.update(&[T::SIGNATURE_SCHEME.flag()]); + hasher.update([T::SIGNATURE_SCHEME.flag()]); hasher.update(pk); let g_arr = hasher.finalize(); @@ -219,7 +216,7 @@ impl From<&T> for SuiAddress { impl From<&PublicKey> for SuiAddress { fn from(pk: &PublicKey) -> Self { let mut hasher = Sha3_256::default(); - hasher.update(&[pk.flag()]); + hasher.update([pk.flag()]); hasher.update(pk); let g_arr = hasher.finalize(); @@ -401,6 +398,11 @@ impl TxContext { ) } + // for testing + pub fn with_sender_for_testing_only(sender: &SuiAddress) -> Self { + Self::new(sender, &TransactionDigest::random(), 0) + } + /// A function that lists all IDs created by this TXContext pub fn recreate_all_ids(&self) -> BTreeSet { (0..self.ids_created) @@ -585,7 +587,7 @@ pub fn dbg_object_id(name: u8) -> ObjectID { impl std::fmt::Debug for ObjectDigest { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::result::Result<(), std::fmt::Error> { - let s = hex::encode(&self.0); + let s = hex::encode(self.0); write!(f, "o#{}", s)?; Ok(()) } diff --git a/crates/sui-types/src/messages.rs b/crates/sui-types/src/messages.rs index f4e6f9c9fb99f..6b52b27674ce6 100644 --- a/crates/sui-types/src/messages.rs +++ b/crates/sui-types/src/messages.rs @@ -96,6 +96,18 @@ pub struct TransferSui { pub amount: Option, } +/// Pay each recipient the corresponding amount using the input coins +#[derive(Debug, PartialEq, Eq, Hash, Clone, Serialize, Deserialize)] +pub struct Pay { + /// The coins to be used for payment + pub coins: Vec, + /// The addresses that will receive payment + pub recipients: Vec, + /// The amounts each recipient will receive. + /// Must be the same length as recipients + pub amounts: Vec, +} + #[derive(Debug, PartialEq, Eq, Hash, Clone, Serialize, Deserialize)] pub struct ChangeEpoch { /// The next (to become) epoch ID. @@ -116,6 +128,8 @@ pub enum SingleTransactionKind { Call(MoveCall), /// Initiate a SUI coin transfer between addresses TransferSui(TransferSui), + /// Pay multiple recipients using multiple input coins + Pay(Pay), /// A system transaction that will update epoch information on-chain. /// It will only ever be executed once in an epoch. /// The argument is the next epoch number, which is critical @@ -224,6 +238,10 @@ impl SingleTransactionKind { Self::TransferSui(_) => { vec![] } + Self::Pay(Pay { coins, .. }) => coins + .iter() + .map(|o| InputObjectKind::ImmOrOwnedMoveObject(*o)) + .collect(), Self::ChangeEpoch(_) => { vec![InputObjectKind::SharedMoveObject( SUI_SYSTEM_STATE_OBJECT_ID, @@ -255,7 +273,7 @@ impl Display for SingleTransactionKind { let (object_id, seq, digest) = t.object_ref; writeln!(writer, "Object ID : {}", &object_id)?; writeln!(writer, "Sequence Number : {:?}", seq)?; - writeln!(writer, "Object Digest : {}", encode_bytes_hex(&digest.0))?; + writeln!(writer, "Object Digest : {}", encode_bytes_hex(digest.0))?; } Self::TransferSui(t) => { writeln!(writer, "Transaction Kind : Transfer SUI")?; @@ -266,6 +284,23 @@ impl Display for SingleTransactionKind { writeln!(writer, "Amount: Full Balance")?; } } + Self::Pay(p) => { + writeln!(writer, "Transaction Kind : Pay")?; + writeln!(writer, "Coins:")?; + for (object_id, seq, digest) in &p.coins { + writeln!(writer, "Object ID : {}", &object_id)?; + writeln!(writer, "Sequence Number : {:?}", seq)?; + writeln!(writer, "Object Digest : {}", encode_bytes_hex(digest.0))?; + } + writeln!(writer, "Recipients:")?; + for recipient in &p.recipients { + writeln!(writer, "{}", recipient)?; + } + writeln!(writer, "Amounts:")?; + for amount in &p.amounts { + writeln!(writer, "{}", amount)? + } + } Self::Publish(_p) => { writeln!(writer, "Transaction Kind : Publish")?; } @@ -346,11 +381,12 @@ impl TransactionKind { ); // Check that all transaction kinds can be in a batch. let valid = self.single_transactions().all(|s| match s { - SingleTransactionKind::Call(_) => true, - SingleTransactionKind::TransferObject(_) => true, - SingleTransactionKind::TransferSui(_) => false, - SingleTransactionKind::ChangeEpoch(_) => false, - SingleTransactionKind::Publish(_) => false, + SingleTransactionKind::Call(_) + | SingleTransactionKind::TransferObject(_) + | SingleTransactionKind::Pay(_) => true, + SingleTransactionKind::TransferSui(_) + | SingleTransactionKind::ChangeEpoch(_) + | SingleTransactionKind::Publish(_) => false, }); fp_ensure!( valid, @@ -359,7 +395,14 @@ impl TransactionKind { } ); } - Self::Single(_) => (), + Self::Single(s) => match s { + SingleTransactionKind::Pay(_) + | SingleTransactionKind::Call(_) + | SingleTransactionKind::Publish(_) + | SingleTransactionKind::TransferObject(_) + | SingleTransactionKind::TransferSui(_) + | SingleTransactionKind::ChangeEpoch(_) => (), + }, } Ok(()) } @@ -1028,6 +1071,18 @@ pub enum ExecutionFailureStatus { InvalidTransferSuiInsufficientBalance, InvalidCoinObject, + // + // Pay errors + // + /// Supplied 0 input coins + EmptyInputCoins, + /// Supplied an empty list of recipient addresses for the payment + EmptyRecipients, + /// Supplied a different number of recipient addresses and recipient amounts + RecipientsAmountsArityMismatch, + /// Not enough funds to perform the requested payment + InsufficientBalance, + // // MoveCall errors // @@ -1132,6 +1187,16 @@ impl ExecutionFailureStatus { impl std::fmt::Display for ExecutionFailureStatus { fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { match self { + ExecutionFailureStatus::EmptyInputCoins => { + write!(f, "Expected a non-empty list of input Coin objects") + } + ExecutionFailureStatus::EmptyRecipients => { + write!(f, "Expected a non-empty list of recipient addresses") + } + ExecutionFailureStatus::InsufficientBalance => write!( + f, + "Value of input coins is insufficient to cover outgoing amounts" + ), ExecutionFailureStatus::InsufficientGas => write!(f, "Insufficient Gas."), ExecutionFailureStatus::InvalidGasObject => { write!( @@ -1184,6 +1249,10 @@ impl std::fmt::Display for ExecutionFailureStatus { ExecutionFailureStatus::InvalidSharedByValue(data) => { write!(f, "Invalid Shared Object By-Value Usage. {data}.") } + ExecutionFailureStatus::RecipientsAmountsArityMismatch => write!( + f, + "Expected recipient and amounts lists to be the same length" + ), ExecutionFailureStatus::TooManyChildObjects { object } => { write!( f, @@ -1719,6 +1788,17 @@ impl InputObjects { } } +impl From> for InputObjects { + fn from(objects: Vec) -> Self { + Self::new( + objects + .into_iter() + .map(|o| (o.input_object_kind(), o)) + .collect(), + ) + } +} + pub struct SignatureAggregator<'a> { committee: &'a Committee, weight: StakeUnit, diff --git a/crates/sui-types/src/object.rs b/crates/sui-types/src/object.rs index 2e47ea4af7d19..0857a6259c1ec 100644 --- a/crates/sui-types/src/object.rs +++ b/crates/sui-types/src/object.rs @@ -19,6 +19,7 @@ use serde_with::Bytes; use crate::crypto::sha3_hash; use crate::error::{ExecutionError, ExecutionErrorKind}; use crate::error::{SuiError, SuiResult}; +use crate::messages::InputObjectKind; use crate::move_package::MovePackage; use crate::{ base_types::{ @@ -96,6 +97,10 @@ impl MoveObject { unsafe { Self::new_from_execution(GasCoin::type_(), true, version, None, contents) } } + pub fn new_coin(coin_type: StructTag, version: SequenceNumber, contents: Vec) -> Self { + unsafe { Self::new_from_execution(coin_type, true, version, None, contents) } + } + pub fn has_public_transfer(&self) -> bool { self.has_public_transfer } @@ -478,6 +483,15 @@ impl Object { ObjectDigest::new(sha3_hash(self)) } + pub fn input_object_kind(&self) -> InputObjectKind { + match &self.owner { + Owner::Shared => InputObjectKind::SharedMoveObject(self.id()), + Owner::ObjectOwner(_) | Owner::AddressOwner(_) | Owner::Immutable => { + InputObjectKind::ImmOrOwnedMoveObject(self.compute_object_reference()) + } + } + } + /// Approximate size of the object in bytes. This is used for gas metering. /// This will be slgihtly different from the serialized size, but /// we also don't want to serialize the object just to get the size. @@ -586,6 +600,18 @@ impl Object { Self::with_id_owner_for_testing(ObjectID::random(), owner) } + /// Generate a new gas coin worth `value` wiht a random object ID and owner + /// For testing purposes only + pub fn new_gas_coin_for_testing(value: u64, owner: SuiAddress) -> Self { + let content = GasCoin::new(ObjectID::random(), value); + let obj = MoveObject::new_gas_coin(OBJECT_START_VERSION, content.to_bcs_bytes()); + Object::new_move( + obj, + Owner::AddressOwner(owner), + TransactionDigest::genesis(), + ) + } + /// Get a `MoveStructLayout` for `self`. /// The `resolver` value must contain the module that declares `self.type_` and the (transitive) /// dependencies of `self.type_` in order for this to succeed. Failure will result in an `ObjectSerializationError` diff --git a/crates/sui/src/genesis_ceremony.rs b/crates/sui/src/genesis_ceremony.rs index 18aa6826be0e8..43cbb0b9ac124 100644 --- a/crates/sui/src/genesis_ceremony.rs +++ b/crates/sui/src/genesis_ceremony.rs @@ -195,7 +195,7 @@ pub fn run(cmd: Ceremony) -> Result<()> { let signature_dir = dir.join(GENESIS_BUILDER_SIGNATURE_DIR); std::fs::create_dir_all(&signature_dir)?; - let hex_name = encode_bytes_hex(&AuthorityPublicKeyBytes::from(keypair.public())); + let hex_name = encode_bytes_hex(AuthorityPublicKeyBytes::from(keypair.public())); fs::write(signature_dir.join(hex_name), signature)?; println!("Successfully verified {SUI_GENESIS_FILENAME}");