diff --git a/android/app/src/androidTest/java/com/trustwallet/core/app/blockchains/solana/TestSolanaTransaction.kt b/android/app/src/androidTest/java/com/trustwallet/core/app/blockchains/solana/TestSolanaTransaction.kt index c2f8a3d461c..a7f1ca53348 100644 --- a/android/app/src/androidTest/java/com/trustwallet/core/app/blockchains/solana/TestSolanaTransaction.kt +++ b/android/app/src/androidTest/java/com/trustwallet/core/app/blockchains/solana/TestSolanaTransaction.kt @@ -1,14 +1,11 @@ package com.trustwallet.core.app.blockchains.solana import com.google.protobuf.ByteString -import com.trustwallet.core.app.utils.toHex import com.trustwallet.core.app.utils.toHexByteArray import org.junit.Assert.assertEquals import org.junit.Test -import wallet.core.jni.Base58 import wallet.core.java.AnySigner import wallet.core.jni.Base64 -import wallet.core.jni.CoinType import wallet.core.jni.CoinType.SOLANA import wallet.core.jni.SolanaTransaction import wallet.core.jni.DataVector @@ -18,6 +15,7 @@ import wallet.core.jni.proto.Solana import wallet.core.jni.proto.Solana.DecodingTransactionOutput import wallet.core.jni.proto.Solana.SigningInput import wallet.core.jni.proto.Solana.SigningOutput +import wallet.core.jni.proto.Solana.Encoding class TestSolanaTransaction { @@ -81,4 +79,43 @@ class TestSolanaTransaction { val expectedString = "Ajzc/Tke0CG8Cew5qFa6xZI/7Ya3DN0M8Ige6tKPsGzhg8Bw9DqL18KUrEZZ1F4YqZBo4Rv+FsDT8A7Nss7p4A6BNVZzzGprCJqYQeNg0EVIbmPc6mDitNniHXGeKgPZ6QZbM4FElw9O7IOFTpOBPvQFeqy0vZf/aayncL8EK/UEAgACBssq8Im1alV3N7wXGODL8jLPWwLhTuCqfGZ1Iz9fb5tXlMOJD6jUvASrKmdtLK/qXNyJns2Vqcvlk+nfJYdZaFpIWiT/tAcEYbttfxyLdYxrLckAKdVRtf1OrNgtZeMCII4SAn6SYaaidrX/AN3s/aVn/zrlEKW0cEUIatHVDKtXO0Qss5EhV/E6kz0BNCgtAytf/s0Botvxt3kGCN8ALqcG3fbh12Whk9nL4UbO63msHLSF7V9bN5E6jPWFfv8AqbHiki6ThNH3auuyZPQpJntnN0mA//56nMpK/6HIuu8xAQUEAgQDAQoMoA8AAAAAAAAG" assertEquals(output.encoded, expectedString) } + + @Test + fun testSetPriorityFee() { + val privateKey = ByteString.copyFrom("baf2b2dbbbad7ca96c1fa199c686f3d8fbd2c7b352f307e37e04f33df6741f18".toHexByteArray()) + val originalTx = "AX43+Ir2EDqf2zLEvgzFrCZKRjdr3wCdp8CnvYh6N0G/s86IueX9BbiNUl16iLRGvwREDfi2Srb0hmLNBFw1BwABAAEDODI+iWe7g68B9iwCy8bFkJKvsIEj350oSOpcv4gNnv/st+6qmqipl9lwMK6toB9TiL7LrJVfij+pKwr+pUKxfwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAG6GdPcA92ORzVJe2jfG8KQqqMHr9YTLu30oM4i7MFEoBAgIAAQwCAAAA6AMAAAAAAAA=" + + // Step 1 - Check if there are no price and limit instructions in the original transaction. + assertEquals(SolanaTransaction.getComputeUnitPrice(originalTx), null) + assertEquals(SolanaTransaction.getComputeUnitLimit(originalTx), null) + + // Step 2 - Set price and limit instructions. + val txWithPrice = SolanaTransaction.setComputeUnitPrice(originalTx, "1000") + val updatedTx = SolanaTransaction.setComputeUnitLimit(txWithPrice, "10000") + + assertEquals(updatedTx, "AX43+Ir2EDqf2zLEvgzFrCZKRjdr3wCdp8CnvYh6N0G/s86IueX9BbiNUl16iLRGvwREDfi2Srb0hmLNBFw1BwABAAIEODI+iWe7g68B9iwCy8bFkJKvsIEj350oSOpcv4gNnv/st+6qmqipl9lwMK6toB9TiL7LrJVfij+pKwr+pUKxfwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAwZGb+UhFzL/7K26csOb57yM5bvF9xJrLEObOkAAAAAboZ09wD3Y5HNUl7aN8bwpCqowev1hMu7fSgziLswUSgMDAAUCECcAAAICAAEMAgAAAOgDAAAAAAAAAwAJA+gDAAAAAAAA") + + // Step 3 - Check if price and limit instructions are set successfully. + assertEquals(SolanaTransaction.getComputeUnitPrice(updatedTx), "1000") + assertEquals(SolanaTransaction.getComputeUnitLimit(updatedTx), "10000") + + // Step 4 - Decode transaction into a `RawMessage` Protobuf. + val updatedTxData = Base64.decode(updatedTx) + val decodedData = TransactionDecoder.decode(SOLANA, updatedTxData) + val decodedOutput = DecodingTransactionOutput.parseFrom(decodedData) + + assertEquals(decodedOutput.error, SigningError.OK) + + // Step 5 - Sign the decoded `RawMessage` transaction. + val signingInput = SigningInput.newBuilder() + .setPrivateKey(privateKey) + .setRawMessage(decodedOutput.transaction) + .setTxEncoding(Encoding.Base64) + .build() + val output = AnySigner.sign(signingInput, SOLANA, SigningOutput.parser()) + // Successfully broadcasted tx: + // https://explorer.solana.com/tx/2ho7wZUXbDNz12xGfsXg2kcNMqkBAQjv7YNXNcVcuCmbC4p9FZe9ELeM2gMjq9MKQPpmE3nBW5pbdgwVCfNLr1h8 + val expectedString = "AVUye82Mv+/aWeU2G+B6Nes365mUU2m8iqcGZn/8kFJvw4wY6AgKGG+vJHaknHlCDwE1yi1SIMVUUtNCOm3kHg8BAAIEODI+iWe7g68B9iwCy8bFkJKvsIEj350oSOpcv4gNnv/st+6qmqipl9lwMK6toB9TiL7LrJVfij+pKwr+pUKxfwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAwZGb+UhFzL/7K26csOb57yM5bvF9xJrLEObOkAAAAAboZ09wD3Y5HNUl7aN8bwpCqowev1hMu7fSgziLswUSgMDAAUCECcAAAICAAEMAgAAAOgDAAAAAAAAAwAJA+gDAAAAAAAA" + assertEquals(output.encoded, expectedString) + } } \ No newline at end of file diff --git a/include/TrustWalletCore/TWSolanaTransaction.h b/include/TrustWalletCore/TWSolanaTransaction.h index 681313ff248..871393fc840 100644 --- a/include/TrustWalletCore/TWSolanaTransaction.h +++ b/include/TrustWalletCore/TWSolanaTransaction.h @@ -30,4 +30,38 @@ TWData *_Nonnull TWSolanaTransactionUpdateBlockhashAndSign(TWString *_Nonnull en TWString *_Nonnull recentBlockhash, const struct TWDataVector *_Nonnull privateKeys); +/// Try to find a `ComputeBudgetInstruction::SetComputeUnitPrice` instruction in the given transaction, +/// and returns the specified Unit Price. +/// +/// \param encodedTx base64 encoded Solana transaction. +/// \return nullable Unit Price as a decimal string. Null if no instruction found. +TW_EXPORT_STATIC_METHOD +TWString *_Nullable TWSolanaTransactionGetComputeUnitPrice(TWString *_Nonnull encodedTx); + +/// Try to find a `ComputeBudgetInstruction::SetComputeUnitLimit` instruction in the given transaction, +/// and returns the specified Unit Limit. +/// +/// \param encodedTx base64 encoded Solana transaction. +/// \return nullable Unit Limit as a decimal string. Null if no instruction found. +TW_EXPORT_STATIC_METHOD +TWString *_Nullable TWSolanaTransactionGetComputeUnitLimit(TWString *_Nonnull encodedTx); + +/// Adds or updates a `ComputeBudgetInstruction::SetComputeUnitPrice` instruction of the given transaction, +/// and returns the updated transaction. +/// +/// \param encodedTx base64 encoded Solana transaction. +/// \price Unit Price as a decimal string. +/// \return base64 encoded Solana transaction. Null if an error occurred. +TW_EXPORT_STATIC_METHOD +TWString *_Nullable TWSolanaTransactionSetComputeUnitPrice(TWString *_Nonnull encodedTx, TWString *_Nonnull price); + +/// Adds or updates a `ComputeBudgetInstruction::SetComputeUnitLimit` instruction of the given transaction, +/// and returns the updated transaction. +/// +/// \param encodedTx base64 encoded Solana transaction. +/// \limit Unit Limit as a decimal string. +/// \return base64 encoded Solana transaction. Null if an error occurred. +TW_EXPORT_STATIC_METHOD +TWString *_Nullable TWSolanaTransactionSetComputeUnitLimit(TWString *_Nonnull encodedTx, TWString *_Nonnull limit); + TW_EXTERN_C_END diff --git a/rust/chains/tw_solana/src/modules/compiled_keys.rs b/rust/chains/tw_solana/src/modules/compiled_keys.rs index e138c1d9b29..8f46866f2f7 100644 --- a/rust/chains/tw_solana/src/modules/compiled_keys.rs +++ b/rust/chains/tw_solana/src/modules/compiled_keys.rs @@ -11,6 +11,12 @@ use std::collections::hash_map::Entry; use std::collections::HashMap; use tw_coin_entry::error::prelude::*; +pub fn try_into_u8(num: usize) -> SigningResult { + u8::try_from(num) + .tw_err(|_| SigningErrorType::Error_tx_too_big) + .context("There are too many accounts in the transaction") +} + #[derive(Default, Debug, Clone, Copy, PartialEq, Eq)] struct CompiledKeyMeta { is_signer: bool, @@ -67,10 +73,6 @@ impl CompiledKeys { } pub fn try_into_message_components(self) -> SigningResult<(MessageHeader, Vec)> { - let try_into_u8 = |num: usize| -> SigningResult { - u8::try_from(num).tw_err(|_| SigningErrorType::Error_tx_too_big) - }; - let Self { ordered_keys, key_meta_map, diff --git a/rust/chains/tw_solana/src/modules/insert_instruction.rs b/rust/chains/tw_solana/src/modules/insert_instruction.rs new file mode 100644 index 00000000000..22643e349ce --- /dev/null +++ b/rust/chains/tw_solana/src/modules/insert_instruction.rs @@ -0,0 +1,102 @@ +// SPDX-License-Identifier: Apache-2.0 +// +// Copyright © 2017 Trust Wallet. + +use crate::address::SolanaAddress; +use crate::modules::compiled_keys::try_into_u8; +use crate::transaction::v0::MessageAddressTableLookup; +use crate::transaction::{CompiledInstruction, MessageHeader}; +use std::iter; +use tw_coin_entry::error::prelude::*; +use tw_memory::Data; + +pub trait InsertInstruction { + /// Pushes a simple instruction that doesn't have accounts. + fn push_simple_instruction( + &mut self, + program_id: SolanaAddress, + data: Data, + ) -> SigningResult<()> { + let insert_at = self.instructions_mut().len(); + self.insert_simple_instruction(insert_at, program_id, data) + } + + /// Inserts a simple instruction that doesn't have accounts at the given `insert_at` index. + fn insert_simple_instruction( + &mut self, + insert_at: usize, + program_id: SolanaAddress, + data: Data, + ) -> SigningResult<()> { + if insert_at > self.instructions_mut().len() { + return SigningError::err(SigningErrorType::Error_internal) + .context(format!("Unable to add '{program_id}' instruction at the '{insert_at}' index. Number of existing instructions: {}", self.instructions_mut().len())); + } + + // Step 1 - find or add the `program_id` in the accounts list. + let program_id_index = match self + .account_keys_mut() + .iter() + .position(|acc| *acc == program_id) + { + Some(pos) => try_into_u8(pos)?, + None => self.push_readonly_unsigned_account(program_id)?, + }; + + // Step 2 - Create a `CompiledInstruction` based on the `program_id` index and instruction `data`. + let new_compiled_ix = CompiledInstruction { + program_id_index, + accounts: Vec::default(), + data, + }; + + // Step 3 - Insert the created instruction at the given `insert_at` index. + self.instructions_mut().insert(insert_at, new_compiled_ix); + + Ok(()) + } + + fn push_readonly_unsigned_account(&mut self, account: SolanaAddress) -> SigningResult { + debug_assert!( + !self.account_keys_mut().contains(&account), + "Account must not be in the account list yet" + ); + + self.account_keys_mut().push(account); + self.message_header_mut().num_readonly_unsigned_accounts += 1; + + let account_added_at = try_into_u8(self.account_keys_mut().len() - 1)?; + + // There is no need to update instruction account ids that point to [`Message::account_keys`] list + // as we pushed the account to the end of the list. + // But we must update it in case if there are instruction account ids that point to [`address_table_lookups`]. + + match self.address_table_lookups() { + Some(lookups) if !lookups.is_empty() => (), + // No address table lookups, no need to update the indexes. + _ => return Ok(account_added_at), + } + + self.instructions_mut() + .iter_mut() + .flat_map(|ix| { + ix.accounts + .iter_mut() + .chain(iter::once(&mut ix.program_id_index)) + }) + // Update every instruction account id that points to the address table lookups. + .filter(|ix_account_id| **ix_account_id >= account_added_at) + .for_each(|ix_account_id| *ix_account_id += 1); + + Ok(account_added_at) + } + + /// Returns ALT (Address Lookup Tables) if supported by the message version. + fn address_table_lookups(&self) -> Option<&[MessageAddressTableLookup]>; + + fn account_keys_mut(&mut self) -> &mut Vec; + + fn message_header_mut(&mut self) -> &mut MessageHeader; + + fn instructions_mut(&mut self) -> &mut Vec; +} diff --git a/rust/chains/tw_solana/src/modules/instruction_builder/compute_budget_instruction.rs b/rust/chains/tw_solana/src/modules/instruction_builder/compute_budget_instruction.rs index a95218e4365..2b3ee7fa267 100644 --- a/rust/chains/tw_solana/src/modules/instruction_builder/compute_budget_instruction.rs +++ b/rust/chains/tw_solana/src/modules/instruction_builder/compute_budget_instruction.rs @@ -5,6 +5,7 @@ use crate::defined_addresses::COMPUTE_BUDGET_ADDRESS; use crate::instruction::Instruction; use borsh::{BorshDeserialize, BorshSerialize}; +use tw_encoding::{EncodingError, EncodingResult}; pub type UnitLimit = u32; pub type UnitPrice = u64; @@ -18,6 +19,12 @@ pub enum ComputeBudgetInstruction { SetLoadedAccountsDataSizeLimit(u32), } +impl ComputeBudgetInstruction { + pub fn try_from_borsh(data: &[u8]) -> EncodingResult { + borsh::from_slice(data).map_err(|_| EncodingError::InvalidInput) + } +} + pub struct ComputeBudgetInstructionBuilder; impl ComputeBudgetInstructionBuilder { diff --git a/rust/chains/tw_solana/src/modules/instruction_builder/system_instruction.rs b/rust/chains/tw_solana/src/modules/instruction_builder/system_instruction.rs index ee7e0ab3f59..3897d78b6ea 100644 --- a/rust/chains/tw_solana/src/modules/instruction_builder/system_instruction.rs +++ b/rust/chains/tw_solana/src/modules/instruction_builder/system_instruction.rs @@ -6,6 +6,7 @@ use crate::address::SolanaAddress; use crate::defined_addresses::*; use crate::instruction::{AccountMeta, Instruction}; use serde::{Deserialize, Serialize}; +use tw_encoding::{EncodingError, EncodingResult}; /// An instruction to the system program. #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)] @@ -181,6 +182,12 @@ pub enum SystemInstruction { UpgradeNonceAccount, } +impl SystemInstruction { + pub fn try_from_bincode(data: &[u8]) -> EncodingResult { + bincode::deserialize(data).map_err(|_| EncodingError::InvalidInput) + } +} + pub struct SystemInstructionBuilder; impl SystemInstructionBuilder { diff --git a/rust/chains/tw_solana/src/modules/message_decompiler.rs b/rust/chains/tw_solana/src/modules/message_decompiler.rs new file mode 100644 index 00000000000..d74e6393785 --- /dev/null +++ b/rust/chains/tw_solana/src/modules/message_decompiler.rs @@ -0,0 +1,60 @@ +// SPDX-License-Identifier: Apache-2.0 +// +// Copyright © 2017 Trust Wallet. + +use crate::address::SolanaAddress; +use crate::transaction::versioned::VersionedMessage; +use crate::transaction::CompiledInstruction; +use tw_coin_entry::error::prelude::{OrTWError, ResultContext, SigningErrorType, SigningResult}; +use tw_memory::Data; + +/// [`Instruction`] without `accounts` field. +pub struct InstructionWithoutAccounts { + /// Pubkey of the program that executes this instruction. + pub program_id: SolanaAddress, + /// Opaque data passed to the program for its own interpretation. + pub data: Data, +} + +pub struct MessageDecompiler; + +impl MessageDecompiler { + pub fn decompile_partly( + message: &VersionedMessage, + ) -> SigningResult> { + match message { + VersionedMessage::Legacy(legacy) => { + Self::decompile_partly_impl(&legacy.instructions, &legacy.account_keys) + }, + VersionedMessage::V0(v0) => { + Self::decompile_partly_impl(&v0.instructions, &v0.account_keys) + }, + } + } + + fn decompile_partly_impl( + instructions: &[CompiledInstruction], + account_keys: &[SolanaAddress], + ) -> SigningResult> { + instructions + .iter() + .map(|ix| Self::decompile_instruction_partly(ix, account_keys)) + .collect() + } + + fn decompile_instruction_partly( + ix: &CompiledInstruction, + account_keys: &[SolanaAddress], + ) -> SigningResult { + // Program ID should always be in the transaction's accounts list even if AddressLookupTable is used: + // https://solana.stackexchange.com/questions/16122/using-program-ids-in-address-lookup-tables-missing-documentation-about-luts + let program_id = *account_keys + .get(ix.program_id_index as usize) + .or_tw_err(SigningErrorType::Error_invalid_params) + .context("Program ID not found in the accounts list")?; + Ok(InstructionWithoutAccounts { + program_id, + data: ix.data.clone(), + }) + } +} diff --git a/rust/chains/tw_solana/src/modules/mod.rs b/rust/chains/tw_solana/src/modules/mod.rs index 074e5cbc8a7..8a8d8c61a74 100644 --- a/rust/chains/tw_solana/src/modules/mod.rs +++ b/rust/chains/tw_solana/src/modules/mod.rs @@ -8,8 +8,10 @@ use tw_keypair::ed25519; pub mod compiled_instructions; pub mod compiled_keys; +pub mod insert_instruction; pub mod instruction_builder; pub mod message_builder; +pub mod message_decompiler; pub mod proto_builder; pub mod transaction_decoder; pub mod transaction_util; diff --git a/rust/chains/tw_solana/src/modules/utils.rs b/rust/chains/tw_solana/src/modules/utils.rs index 85b662784b8..da19b9d17d8 100644 --- a/rust/chains/tw_solana/src/modules/utils.rs +++ b/rust/chains/tw_solana/src/modules/utils.rs @@ -2,6 +2,13 @@ // // Copyright © 2017 Trust Wallet. +use crate::defined_addresses::{COMPUTE_BUDGET_ADDRESS, SYSTEM_PROGRAM_ID_ADDRESS}; +use crate::modules::insert_instruction::InsertInstruction; +use crate::modules::instruction_builder::compute_budget_instruction::{ + ComputeBudgetInstruction, ComputeBudgetInstructionBuilder, UnitLimit, UnitPrice, +}; +use crate::modules::instruction_builder::system_instruction::SystemInstruction; +use crate::modules::message_decompiler::{InstructionWithoutAccounts, MessageDecompiler}; use crate::modules::proto_builder::ProtoBuilder; use crate::modules::tx_signer::TxSigner; use crate::modules::PubkeySignatureMap; @@ -75,4 +82,113 @@ impl SolanaTransaction { ..Proto::SigningOutput::default() }) } + + pub fn get_compute_unit_price(encoded_tx: &str) -> SigningResult> { + let tx = VersionedTransaction::from_base64(encoded_tx)?; + let instructions = MessageDecompiler::decompile_partly(&tx.message)?; + Ok(instructions + .iter() + .find_map(try_instruction_as_set_unit_price)) + } + + pub fn get_compute_unit_limit(encoded_tx: &str) -> SigningResult> { + let tx = VersionedTransaction::from_base64(encoded_tx)?; + let instructions = MessageDecompiler::decompile_partly(&tx.message)?; + Ok(instructions + .iter() + .find_map(try_instruction_as_set_unit_limit)) + } + + pub fn set_compute_unit_price(encoded_tx: &str, price: UnitPrice) -> SigningResult { + let tx_bytes = base64::decode(encoded_tx, STANDARD)?; + let mut tx: VersionedTransaction = + bincode::deserialize(&tx_bytes).map_err(|_| SigningErrorType::Error_input_parse)?; + let instructions = MessageDecompiler::decompile_partly(&tx.message)?; + + let set_price_ix = ComputeBudgetInstructionBuilder::set_compute_unit_price(price); + + // First, try to find a `ComputeBudgetInstruction::SetComputeUnitPrice` instruction. + let ix_position = instructions + .iter() + .position(|ix| try_instruction_as_set_unit_price(ix).is_some()); + // If it presents already, it's enough to update the instruction data only. + if let Some(pos) = ix_position { + tx.message.instructions_mut()[pos].data = set_price_ix.data; + return tx.to_base64().tw_err(|_| SigningErrorType::Error_internal); + } + + // `ComputeBudgetInstruction::SetComputeUnitPrice` can be pushed to the end of the instructions list. + tx.message + .push_simple_instruction(set_price_ix.program_id, set_price_ix.data)?; + + tx.to_base64().tw_err(|_| SigningErrorType::Error_internal) + } + + pub fn set_compute_unit_limit(encoded_tx: &str, limit: UnitLimit) -> SigningResult { + let tx_bytes = base64::decode(encoded_tx, STANDARD)?; + let mut tx: VersionedTransaction = + bincode::deserialize(&tx_bytes).map_err(|_| SigningErrorType::Error_input_parse)?; + let instructions = MessageDecompiler::decompile_partly(&tx.message)?; + + let set_limit_ix = ComputeBudgetInstructionBuilder::set_compute_unit_limit(limit); + + // First, try to find a `ComputeBudgetInstruction::SetComputeUnitLimit` instruction. + let ix_position = instructions + .iter() + .position(|ix| try_instruction_as_set_unit_limit(ix).is_some()); + // If it presents already, it's enough to update the instruction data only. + if let Some(pos) = ix_position { + tx.message.instructions_mut()[pos].data = set_limit_ix.data; + return tx.to_base64().tw_err(|_| SigningErrorType::Error_internal); + } + + // `ComputeBudgetInstruction::SetComputeUnitLimit` should be at the beginning of the instructions list. + // However `SystemInstruction::AdvanceNonceAccount` must be the first instruction. + // So in case if the advance nonce instruction presents, we should insert unit limit as the second instruction. + let insert_at = match instructions.first() { + Some(first_ix) if is_instruction_advance_nonce_account(first_ix) => 1, + _ => 0, + }; + + tx.message.insert_simple_instruction( + insert_at, + set_limit_ix.program_id, + set_limit_ix.data, + )?; + + tx.to_base64().tw_err(|_| SigningErrorType::Error_internal) + } +} + +fn try_instruction_as_compute_budget( + ix: &InstructionWithoutAccounts, +) -> Option { + if ix.program_id != *COMPUTE_BUDGET_ADDRESS { + return None; + } + ComputeBudgetInstruction::try_from_borsh(&ix.data).ok() +} + +fn try_instruction_as_set_unit_price(ix: &InstructionWithoutAccounts) -> Option { + match try_instruction_as_compute_budget(ix)? { + ComputeBudgetInstruction::SetComputeUnitPrice(price) => Some(price), + _ => None, + } +} + +fn try_instruction_as_set_unit_limit(ix: &InstructionWithoutAccounts) -> Option { + match try_instruction_as_compute_budget(ix)? { + ComputeBudgetInstruction::SetComputeUnitLimit(limit) => Some(limit), + _ => None, + } +} + +fn is_instruction_advance_nonce_account(ix: &InstructionWithoutAccounts) -> bool { + if ix.program_id != *SYSTEM_PROGRAM_ID_ADDRESS { + return false; + } + let Ok(system_ix) = SystemInstruction::try_from_bincode(&ix.data) else { + return false; + }; + system_ix == SystemInstruction::AdvanceNonceAccount } diff --git a/rust/chains/tw_solana/src/transaction/legacy.rs b/rust/chains/tw_solana/src/transaction/legacy.rs index dae3bf13949..7ed32200058 100644 --- a/rust/chains/tw_solana/src/transaction/legacy.rs +++ b/rust/chains/tw_solana/src/transaction/legacy.rs @@ -3,6 +3,8 @@ // Copyright © 2017 Trust Wallet. use crate::address::SolanaAddress; +use crate::modules::insert_instruction::InsertInstruction; +use crate::transaction::v0::MessageAddressTableLookup; use crate::transaction::{short_vec, CompiledInstruction, MessageHeader, Signature}; use serde::{Deserialize, Serialize}; use tw_hash::{as_byte_sequence, H256}; @@ -28,6 +30,24 @@ pub struct Message { pub instructions: Vec, } +impl InsertInstruction for Message { + fn address_table_lookups(&self) -> Option<&[MessageAddressTableLookup]> { + None + } + + fn account_keys_mut(&mut self) -> &mut Vec { + &mut self.account_keys + } + + fn message_header_mut(&mut self) -> &mut MessageHeader { + &mut self.header + } + + fn instructions_mut(&mut self) -> &mut Vec { + &mut self.instructions + } +} + #[derive(Debug, PartialEq, Default, Eq, Clone, Serialize, Deserialize)] pub struct Transaction { /// A set of signatures of a serialized [`Message`], signed by the first diff --git a/rust/chains/tw_solana/src/transaction/v0.rs b/rust/chains/tw_solana/src/transaction/v0.rs index 8b1a67d0cc9..319be784bb8 100644 --- a/rust/chains/tw_solana/src/transaction/v0.rs +++ b/rust/chains/tw_solana/src/transaction/v0.rs @@ -3,6 +3,7 @@ // Copyright © 2017 Trust Wallet. use crate::address::SolanaAddress; +use crate::modules::insert_instruction::InsertInstruction; use crate::transaction::{short_vec, CompiledInstruction, MessageHeader}; use serde::{Deserialize, Serialize}; use tw_hash::{as_byte_sequence, H256}; @@ -57,3 +58,21 @@ pub struct Message { #[serde(with = "short_vec")] pub address_table_lookups: Vec, } + +impl InsertInstruction for Message { + fn address_table_lookups(&self) -> Option<&[MessageAddressTableLookup]> { + Some(&self.address_table_lookups) + } + + fn account_keys_mut(&mut self) -> &mut Vec { + &mut self.account_keys + } + + fn message_header_mut(&mut self) -> &mut MessageHeader { + &mut self.header + } + + fn instructions_mut(&mut self) -> &mut Vec { + &mut self.instructions + } +} diff --git a/rust/chains/tw_solana/src/transaction/versioned.rs b/rust/chains/tw_solana/src/transaction/versioned.rs index ade097c4b7d..18b81a81576 100644 --- a/rust/chains/tw_solana/src/transaction/versioned.rs +++ b/rust/chains/tw_solana/src/transaction/versioned.rs @@ -6,11 +6,15 @@ use crate::address::SolanaAddress; use crate::blockhash::Blockhash; +use crate::modules::insert_instruction::InsertInstruction; +use crate::transaction::v0::MessageAddressTableLookup; use crate::transaction::{legacy, short_vec, v0, CompiledInstruction, MessageHeader, Signature}; use serde::de::{SeqAccess, Unexpected, Visitor}; use serde::ser::SerializeTuple; use serde::{de, Deserialize, Deserializer, Serialize, Serializer}; use std::fmt; +use tw_encoding::base64::STANDARD; +use tw_encoding::{base64, EncodingError, EncodingResult}; use tw_hash::{as_byte_sequence, H256}; /// Bit mask that indicates whether a serialized message is versioned. @@ -34,6 +38,16 @@ impl VersionedTransaction { message, } } + + pub fn from_base64(s: &str) -> EncodingResult { + let tx_bytes = base64::decode(s, STANDARD)?; + bincode::deserialize(&tx_bytes).map_err(|_| EncodingError::InvalidInput) + } + + pub fn to_base64(&self) -> EncodingResult { + let tx_bytes = bincode::serialize(self).map_err(|_| EncodingError::InvalidInput)?; + Ok(base64::encode(&tx_bytes, STANDARD)) + } } /// Either a legacy message or a v0 message. @@ -133,6 +147,36 @@ impl Serialize for VersionedMessage { } } +impl InsertInstruction for VersionedMessage { + fn address_table_lookups(&self) -> Option<&[MessageAddressTableLookup]> { + match self { + VersionedMessage::Legacy(legacy) => legacy.address_table_lookups(), + VersionedMessage::V0(v0) => v0.address_table_lookups(), + } + } + + fn account_keys_mut(&mut self) -> &mut Vec { + match self { + VersionedMessage::Legacy(legacy) => legacy.account_keys_mut(), + VersionedMessage::V0(v0) => v0.account_keys_mut(), + } + } + + fn message_header_mut(&mut self) -> &mut MessageHeader { + match self { + VersionedMessage::Legacy(legacy) => legacy.message_header_mut(), + VersionedMessage::V0(v0) => v0.message_header_mut(), + } + } + + fn instructions_mut(&mut self) -> &mut Vec { + match self { + VersionedMessage::Legacy(legacy) => legacy.instructions_mut(), + VersionedMessage::V0(v0) => v0.instructions_mut(), + } + } +} + enum MessagePrefix { Legacy(u8), Versioned(u8), diff --git a/rust/tw_tests/tests/chains/solana/solana_transaction_ffi.rs b/rust/tw_tests/tests/chains/solana/solana_transaction_ffi.rs index 20addf94b3f..c9bdb0a5e4b 100644 --- a/rust/tw_tests/tests/chains/solana/solana_transaction_ffi.rs +++ b/rust/tw_tests/tests/chains/solana/solana_transaction_ffi.rs @@ -2,14 +2,24 @@ // // Copyright © 2017 Trust Wallet. -use tw_encoding::base58; +use tw_any_coin::test_utils::address_utils::test_address_derive; +use tw_any_coin::test_utils::sign_utils::{AnySignerHelper, PreImageHelper}; +use tw_any_coin::test_utils::transaction_decode_utils::TransactionDecoderHelper; +use tw_coin_registry::coin_type::CoinType; +use tw_encoding::base64::STANDARD; +use tw_encoding::hex::{DecodeHex, ToHex}; +use tw_encoding::{base58, base64}; use tw_memory::test_utils::tw_data_helper::TWDataHelper; use tw_memory::test_utils::tw_data_vector_helper::TWDataVectorHelper; use tw_memory::test_utils::tw_string_helper::TWStringHelper; use tw_proto::Common::Proto::SigningError; -use tw_proto::Solana::Proto; +use tw_proto::Solana::Proto::{self, mod_SigningInput::OneOftransaction_type as TransactionType}; use tw_solana::SOLANA_ALPHABET; -use wallet_core_rs::ffi::solana::transaction::tw_solana_transaction_update_blockhash_and_sign; +use wallet_core_rs::ffi::solana::transaction::{ + tw_solana_transaction_get_compute_unit_limit, tw_solana_transaction_get_compute_unit_price, + tw_solana_transaction_set_compute_unit_limit, tw_solana_transaction_set_compute_unit_price, + tw_solana_transaction_update_blockhash_and_sign, +}; #[test] fn test_solana_transaction_update_blockhash_and_sign_token_transfer_with_external_fee_payer() { @@ -113,3 +123,164 @@ fn test_solana_transaction_update_blockhash_and_sign_empty_private_keys() { let expected_message = "AgACBssq8Im1alV3N7wXGODL8jLPWwLhTuCqfGZ1Iz9fb5tXlMOJD6jUvASrKmdtLK/qXNyJns2Vqcvlk+nfJYdZaFpIWiT/tAcEYbttfxyLdYxrLckAKdVRtf1OrNgtZeMCII4SAn6SYaaidrX/AN3s/aVn/zrlEKW0cEUIatHVDKtXO0Qss5EhV/E6kz0BNCgtAytf/s0Botvxt3kGCN8ALqcG3fbh12Whk9nL4UbO63msHLSF7V9bN5E6jPWFfv8AqbHiki6ThNH3auuyZPQpJntnN0mA//56nMpK/6HIuu8xAQUEAgQDAQoMoA8AAAAAAAAG"; assert_eq!(output.unsigned_tx, expected_message); } + +struct SetPriorityTestInput { + /// Hex-encoded private key. + private_key: &'static str, + /// Base64-encoded original transaction. + original_tx: &'static str, + /// Priority fee limit if present in the original transaction. + original_limit: Option, + /// Priority fee price if present in the original transaction. + original_price: Option, + /// Base64-encoded transaction after the priority fee and limit being changed. + updated_tx: &'static str, + /// Priority fee limit to be set to. + limit: u32, + /// Priority fee price to be set to. + price: u64, + /// Base64-encoded [`SetPriorityTestInput::updated_tx`] transaction that has been signed. + signed_tx: &'static str, + /// Hex-encoded signature of the [`SetPriorityTestInput::signed_tx`] transaction. + signature: &'static str, +} + +fn test_solana_transaction_set_priority_fee(input: SetPriorityTestInput) { + let original_tx = TWStringHelper::create(input.original_tx); + + // Step 1 - Check if there is are no price and limit instructions. + + let original_price = TWStringHelper::wrap(unsafe { + tw_solana_transaction_get_compute_unit_price(original_tx.ptr()) + }); + let original_limit = TWStringHelper::wrap(unsafe { + tw_solana_transaction_get_compute_unit_limit(original_tx.ptr()) + }); + assert_eq!( + original_price.to_string(), + input.original_price.map(|v| v.to_string()), + "Invalid original price" + ); + assert_eq!( + original_limit.to_string(), + input.original_limit.map(|v| v.to_string()), + "Invalid original limit" + ); + + // Step 2 - Set price and limit instructions. + + let updated_tx = TWStringHelper::wrap(unsafe { + tw_solana_transaction_set_compute_unit_limit( + original_tx.ptr(), + TWStringHelper::create(&input.limit.to_string()).ptr(), + ) + }); + let updated_tx = TWStringHelper::wrap(unsafe { + tw_solana_transaction_set_compute_unit_price( + updated_tx.ptr(), + TWStringHelper::create(&input.price.to_string()).ptr(), + ) + }); + + // Step 3 - Check if price and limit instructions are set successfully. + + let actual_limit = TWStringHelper::wrap(unsafe { + tw_solana_transaction_get_compute_unit_limit(updated_tx.ptr()) + }); + let actual_price = TWStringHelper::wrap(unsafe { + tw_solana_transaction_get_compute_unit_price(updated_tx.ptr()) + }); + assert_eq!( + actual_limit.to_string(), + Some(input.limit.to_string()), + "Invalid updated limit" + ); + assert_eq!( + actual_price.to_string(), + Some(input.price.to_string()), + "Invalid updated price" + ); + + let actual_updated_tx = updated_tx.to_string().unwrap(); + assert_eq!(actual_updated_tx, input.updated_tx); + + // Step 4 - Decode transaction into a `RawMessage` Protobuf. + + let tx_data = base64::decode(&actual_updated_tx, STANDARD).unwrap(); + let mut decoder = TransactionDecoderHelper::::default(); + let decode_output = decoder.decode(CoinType::Solana, tx_data); + + // Step 5 - Sign the decoded `RawMessage` transaction. + + let signing_input = Proto::SigningInput { + private_key: input.private_key.decode_hex().unwrap().into(), + raw_message: decode_output.transaction, + tx_encoding: Proto::Encoding::Base64, + ..Proto::SigningInput::default() + }; + + let mut signer = AnySignerHelper::::default(); + let output = signer.sign(CoinType::Solana, signing_input); + + assert_eq!(output.error, SigningError::OK, "{}", output.error_message); + assert_eq!( + output.encoded, input.signed_tx, + "Invalid signed transaction" + ); + assert_eq!( + output.signatures.first().unwrap().signature, + input.signature, + "Invalid signature" + ); +} + +#[test] +fn test_solana_transaction_set_priority_fee_transfer() { + // Successfully broadcasted tx: + // https://explorer.solana.com/tx/2ho7wZUXbDNz12xGfsXg2kcNMqkBAQjv7YNXNcVcuCmbC4p9FZe9ELeM2gMjq9MKQPpmE3nBW5pbdgwVCfNLr1h8 + test_solana_transaction_set_priority_fee(SetPriorityTestInput { + private_key: "baf2b2dbbbad7ca96c1fa199c686f3d8fbd2c7b352f307e37e04f33df6741f18", + original_tx: "AX43+Ir2EDqf2zLEvgzFrCZKRjdr3wCdp8CnvYh6N0G/s86IueX9BbiNUl16iLRGvwREDfi2Srb0hmLNBFw1BwABAAEDODI+iWe7g68B9iwCy8bFkJKvsIEj350oSOpcv4gNnv/st+6qmqipl9lwMK6toB9TiL7LrJVfij+pKwr+pUKxfwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAG6GdPcA92ORzVJe2jfG8KQqqMHr9YTLu30oM4i7MFEoBAgIAAQwCAAAA6AMAAAAAAAA=", + original_limit: None, + original_price: None, + updated_tx: "AX43+Ir2EDqf2zLEvgzFrCZKRjdr3wCdp8CnvYh6N0G/s86IueX9BbiNUl16iLRGvwREDfi2Srb0hmLNBFw1BwABAAIEODI+iWe7g68B9iwCy8bFkJKvsIEj350oSOpcv4gNnv/st+6qmqipl9lwMK6toB9TiL7LrJVfij+pKwr+pUKxfwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAwZGb+UhFzL/7K26csOb57yM5bvF9xJrLEObOkAAAAAboZ09wD3Y5HNUl7aN8bwpCqowev1hMu7fSgziLswUSgMDAAUCECcAAAICAAEMAgAAAOgDAAAAAAAAAwAJA+gDAAAAAAAA", + limit: 10_000, + price: 1_000, + signed_tx: "AVUye82Mv+/aWeU2G+B6Nes365mUU2m8iqcGZn/8kFJvw4wY6AgKGG+vJHaknHlCDwE1yi1SIMVUUtNCOm3kHg8BAAIEODI+iWe7g68B9iwCy8bFkJKvsIEj350oSOpcv4gNnv/st+6qmqipl9lwMK6toB9TiL7LrJVfij+pKwr+pUKxfwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAwZGb+UhFzL/7K26csOb57yM5bvF9xJrLEObOkAAAAAboZ09wD3Y5HNUl7aN8bwpCqowev1hMu7fSgziLswUSgMDAAUCECcAAAICAAEMAgAAAOgDAAAAAAAAAwAJA+gDAAAAAAAA", + signature: "2ho7wZUXbDNz12xGfsXg2kcNMqkBAQjv7YNXNcVcuCmbC4p9FZe9ELeM2gMjq9MKQPpmE3nBW5pbdgwVCfNLr1h8", + }); +} + +#[test] +fn test_solana_transaction_set_priority_fee_transfer_override() { + // Successfully broadcasted tx: + // https://explorer.solana.com/tx/3dxcZP8Q8WTj2JapeDLymQZvn1wGxPpism59RcaWA28igXo8Lyh1xRbAjipv7hrgo6JKyoAxbZ6ADayZLWqNJZg1 + test_solana_transaction_set_priority_fee(SetPriorityTestInput { + private_key: "baf2b2dbbbad7ca96c1fa199c686f3d8fbd2c7b352f307e37e04f33df6741f18", + original_tx: "Ab8KqzeQeInEQVRNDD1akh2B0EEeb5XJFfyVjqDrocz49NcZ6x5cp76zC9mjZKspsVCWVYIYdvNRYJYVB2OK3g4BAAIEODI+iWe7g68B9iwCy8bFkJKvsIEj350oSOpcv4gNnv/st+6qmqipl9lwMK6toB9TiL7LrJVfij+pKwr+pUKxfwMGRm/lIRcy/+ytunLDm+e8jOW7xfcSayxDmzpAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABwXONiS/AgQzMMUxsafHXszeF2QJdufrIFBZHMZlu8/gMCAAkDLAEAAAAAAAACAAUCiBMAAAMCAAEMAgAAAOgDAAAAAAAA", + original_limit: Some(5_000), + original_price: Some(300), + updated_tx: "Ab8KqzeQeInEQVRNDD1akh2B0EEeb5XJFfyVjqDrocz49NcZ6x5cp76zC9mjZKspsVCWVYIYdvNRYJYVB2OK3g4BAAIEODI+iWe7g68B9iwCy8bFkJKvsIEj350oSOpcv4gNnv/st+6qmqipl9lwMK6toB9TiL7LrJVfij+pKwr+pUKxfwMGRm/lIRcy/+ytunLDm+e8jOW7xfcSayxDmzpAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABwXONiS/AgQzMMUxsafHXszeF2QJdufrIFBZHMZlu8/gMCAAkD6AMAAAAAAAACAAUCECcAAAMCAAEMAgAAAOgDAAAAAAAA", + limit: 10_000, + price: 1_000, + signed_tx: "AYPn6T3Qqfqg/IW+2FL1suZyUDrhFVlEiKBaCAWAtINfuLgWkDw7Q+QaDvGGRa7eMgEnj4hfCA92tssQByeCCw4BAAIEODI+iWe7g68B9iwCy8bFkJKvsIEj350oSOpcv4gNnv/st+6qmqipl9lwMK6toB9TiL7LrJVfij+pKwr+pUKxfwMGRm/lIRcy/+ytunLDm+e8jOW7xfcSayxDmzpAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABwXONiS/AgQzMMUxsafHXszeF2QJdufrIFBZHMZlu8/gMCAAkD6AMAAAAAAAACAAUCECcAAAMCAAEMAgAAAOgDAAAAAAAA", + signature: "3dxcZP8Q8WTj2JapeDLymQZvn1wGxPpism59RcaWA28igXo8Lyh1xRbAjipv7hrgo6JKyoAxbZ6ADayZLWqNJZg1", + }); +} + +#[test] +fn test_solana_transaction_set_priority_fee_transfer_with_address_lookup() { + // Successfully broadcasted tx: + // https://explorer.solana.com/tx/4vkDYvXnAyauDwgQUT9pjhvArCm1jZZFp6xFiT6SYKDHwabPNyNskzzd8YJZR4UJVXakBtRAFku3axVQoA7Apido + test_solana_transaction_set_priority_fee(SetPriorityTestInput { + private_key: "a73613f265ae572772e28bd921a7bcd0f020e188aae4d1ebd07b9b487fde3573", + original_tx: "Acjjw79E1WGC531Oq6lsyg3X+D65Fo37i6A4nKaqDjxWVrF/djOxbyShfg0HwMuCMp2Mj2JtsiGMkJbt/iD8rQ2AAQABAgEFuF07bbuXk4EuSECBjRkLhDeGEZ4Jm4QbrRF/TN0CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAUE/bdT0AlTN8m+IZkrbzaTUxHcnoKW3WZucqu7msImQEBAgACDAIAAAD0AQAAAAAAAAGDq5uNdcMMtPgcMghpRBOza1eEzcNhvr0zid3hRZoV0wEAAA==", + original_limit: None, + original_price: None, + updated_tx: "Acjjw79E1WGC531Oq6lsyg3X+D65Fo37i6A4nKaqDjxWVrF/djOxbyShfg0HwMuCMp2Mj2JtsiGMkJbt/iD8rQ2AAQACAwEFuF07bbuXk4EuSECBjRkLhDeGEZ4Jm4QbrRF/TN0CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADBkZv5SEXMv/srbpyw5vnvIzlu8X3EmssQ5s6QAAAABQT9t1PQCVM3yb4hmStvNpNTEdyegpbdZm5yq7uawiZAwIABQIQJwAAAQIAAwwCAAAA9AEAAAAAAAACAAkD6AMAAAAAAAABg6ubjXXDDLT4HDIIaUQTs2tXhM3DYb69M4nd4UWaFdMBAAA=", + limit: 10_000, + price: 1_000, + signed_tx: "AcRmE7GRYYh3XKjfYpBRvTdXjYMLtowRUxaStETGPTm0qxa7sm11yqGXKiO3SgOdsRL2Y9IQRBMfqwkBZyrK9gKAAQACAwEFuF07bbuXk4EuSECBjRkLhDeGEZ4Jm4QbrRF/TN0CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADBkZv5SEXMv/srbpyw5vnvIzlu8X3EmssQ5s6QAAAABQT9t1PQCVM3yb4hmStvNpNTEdyegpbdZm5yq7uawiZAwIABQIQJwAAAQIAAwwCAAAA9AEAAAAAAAACAAkD6AMAAAAAAAABg6ubjXXDDLT4HDIIaUQTs2tXhM3DYb69M4nd4UWaFdMBAAA=", + signature: "4vkDYvXnAyauDwgQUT9pjhvArCm1jZZFp6xFiT6SYKDHwabPNyNskzzd8YJZR4UJVXakBtRAFku3axVQoA7Apido", + }); +} diff --git a/rust/wallet_core_rs/src/ffi/solana/transaction.rs b/rust/wallet_core_rs/src/ffi/solana/transaction.rs index a4ae7400a05..6f97e5803e3 100644 --- a/rust/wallet_core_rs/src/ffi/solana/transaction.rs +++ b/rust/wallet_core_rs/src/ffi/solana/transaction.rs @@ -49,3 +49,87 @@ pub unsafe extern "C" fn tw_solana_transaction_update_blockhash_and_sign( TWData::from(output_proto).into_ptr() } + +/// Try to find a `ComputeBudgetInstruction::SetComputeUnitPrice` instruction in the given transaction, +/// and returns the specified Unit Price. +/// +/// \param encoded_tx base64 encoded Solana transaction. +/// \return nullable Unit Price as a decimal string. Null if no instruction found. +#[no_mangle] +pub unsafe extern "C" fn tw_solana_transaction_get_compute_unit_price( + encoded_tx: *const TWString, +) -> *mut TWString { + let encoded_tx = try_or_else!(TWString::from_ptr_as_ref(encoded_tx), std::ptr::null_mut); + let encoded_tx = try_or_else!(encoded_tx.as_str(), std::ptr::null_mut); + + match SolanaTransaction::get_compute_unit_price(encoded_tx) { + Ok(Some(price)) => TWString::from(price.to_string()).into_ptr(), + _ => std::ptr::null_mut(), + } +} + +/// Try to find a `ComputeBudgetInstruction::SetComputeUnitLimit` instruction in the given transaction, +/// and returns the specified Unit Limit. +/// +/// \param encoded_tx base64 encoded Solana transaction. +/// \return nullable Unit Limit as a decimal string. Null if no instruction found. +#[no_mangle] +pub unsafe extern "C" fn tw_solana_transaction_get_compute_unit_limit( + encoded_tx: *const TWString, +) -> *mut TWString { + let encoded_tx = try_or_else!(TWString::from_ptr_as_ref(encoded_tx), std::ptr::null_mut); + let encoded_tx = try_or_else!(encoded_tx.as_str(), std::ptr::null_mut); + + match SolanaTransaction::get_compute_unit_limit(encoded_tx) { + Ok(Some(limit)) => TWString::from(limit.to_string()).into_ptr(), + _ => std::ptr::null_mut(), + } +} + +/// Adds or updates a `ComputeBudgetInstruction::SetComputeUnitPrice` instruction of the given transaction, +/// and returns the updated transaction. +/// +/// \param encoded_tx base64 encoded Solana transaction. +/// \price Unit Price as a decimal string. +/// \return base64 encoded Solana transaction. Null if an error occurred. +#[no_mangle] +pub unsafe extern "C" fn tw_solana_transaction_set_compute_unit_price( + encoded_tx: *const TWString, + price: *const TWString, +) -> *mut TWString { + let encoded_tx = try_or_else!(TWString::from_ptr_as_ref(encoded_tx), std::ptr::null_mut); + let encoded_tx = try_or_else!(encoded_tx.as_str(), std::ptr::null_mut); + + let price = try_or_else!(TWString::from_ptr_as_ref(price), std::ptr::null_mut); + let price = try_or_else!(price.as_str(), std::ptr::null_mut); + let price = try_or_else!(price.parse(), std::ptr::null_mut); + + match SolanaTransaction::set_compute_unit_price(encoded_tx, price) { + Ok(updated_tx) => TWString::from(updated_tx).into_ptr(), + _ => std::ptr::null_mut(), + } +} + +/// Adds or updates a `ComputeBudgetInstruction::SetComputeUnitLimit` instruction of the given transaction, +/// and returns the updated transaction. +/// +/// \param encoded_tx base64 encoded Solana transaction. +/// \limit Unit Limit as a decimal string. +/// \return base64 encoded Solana transaction. Null if an error occurred. +#[no_mangle] +pub unsafe extern "C" fn tw_solana_transaction_set_compute_unit_limit( + encoded_tx: *const TWString, + limit: *const TWString, +) -> *mut TWString { + let encoded_tx = try_or_else!(TWString::from_ptr_as_ref(encoded_tx), std::ptr::null_mut); + let encoded_tx = try_or_else!(encoded_tx.as_str(), std::ptr::null_mut); + + let limit = try_or_else!(TWString::from_ptr_as_ref(limit), std::ptr::null_mut); + let limit = try_or_else!(limit.as_str(), std::ptr::null_mut); + let limit = try_or_else!(limit.parse(), std::ptr::null_mut); + + match SolanaTransaction::set_compute_unit_limit(encoded_tx, limit) { + Ok(updated_tx) => TWString::from(updated_tx).into_ptr(), + _ => std::ptr::null_mut(), + } +} diff --git a/src/interface/TWSolanaTransaction.cpp b/src/interface/TWSolanaTransaction.cpp index a585c9f7508..f555f6eb5d7 100644 --- a/src/interface/TWSolanaTransaction.cpp +++ b/src/interface/TWSolanaTransaction.cpp @@ -11,7 +11,6 @@ using namespace TW; TWData *_Nonnull TWSolanaTransactionUpdateBlockhashAndSign(TWString *_Nonnull encodedTx, TWString *_Nonnull recentBlockhash, const struct TWDataVector *_Nonnull privateKeys) { - auto& encodedTxRef = *reinterpret_cast(encodedTx); auto& recentBlockhashRef = *reinterpret_cast(recentBlockhash); @@ -26,3 +25,65 @@ TWData *_Nonnull TWSolanaTransactionUpdateBlockhashAndSign(TWString *_Nonnull en auto outputData = output.toDataOrDefault(); return TWDataCreateWithBytes(outputData.data(), outputData.size()); } + +TWString *_Nullable TWSolanaTransactionGetComputeUnitPrice(TWString *_Nonnull encodedTx) { + auto& encodedTxRef = *reinterpret_cast(encodedTx); + + Rust::TWStringWrapper encodedTxStr = encodedTxRef; + Rust::TWStringWrapper maybePrice = Rust::tw_solana_transaction_get_compute_unit_price(encodedTxStr.get()); + + if (!maybePrice) { + return nullptr; + } + + auto priceStr = maybePrice.toStringOrDefault(); + return TWStringCreateWithUTF8Bytes(priceStr.c_str()); +} + +TWString *_Nullable TWSolanaTransactionGetComputeUnitLimit(TWString *_Nonnull encodedTx) { + auto& encodedTxRef = *reinterpret_cast(encodedTx); + + Rust::TWStringWrapper encodedTxStr = encodedTxRef; + Rust::TWStringWrapper maybeLimit = Rust::tw_solana_transaction_get_compute_unit_limit(encodedTxStr.get()); + + if (!maybeLimit) { + return nullptr; + } + + auto limitStr = maybeLimit.toStringOrDefault(); + return TWStringCreateWithUTF8Bytes(limitStr.c_str()); +} + +TWString *_Nullable TWSolanaTransactionSetComputeUnitPrice(TWString *_Nonnull encodedTx, TWString *_Nonnull price) { + auto& encodedTxRef = *reinterpret_cast(encodedTx); + auto& priceRef = *reinterpret_cast(price); + + Rust::TWStringWrapper encodedTxStr = encodedTxRef; + Rust::TWStringWrapper priceStr = priceRef; + + Rust::TWStringWrapper updatedTxStr = Rust::tw_solana_transaction_set_compute_unit_price(encodedTxStr.get(), priceStr.get()); + + if (!updatedTxStr) { + return nullptr; + } + + auto updatedTx = updatedTxStr.toStringOrDefault(); + return TWStringCreateWithUTF8Bytes(updatedTx.c_str()); +} + +TWString *_Nullable TWSolanaTransactionSetComputeUnitLimit(TWString *_Nonnull encodedTx, TWString *_Nonnull limit) { + auto& encodedTxRef = *reinterpret_cast(encodedTx); + auto& limitRef = *reinterpret_cast(limit); + + Rust::TWStringWrapper encodedTxStr = encodedTxRef; + Rust::TWStringWrapper limitStr = limitRef; + + Rust::TWStringWrapper updatedTxStr = Rust::tw_solana_transaction_set_compute_unit_limit(encodedTxStr.get(), limitStr.get()); + + if (!updatedTxStr) { + return nullptr; + } + + auto updatedTx = updatedTxStr.toStringOrDefault(); + return TWStringCreateWithUTF8Bytes(updatedTx.c_str()); +} diff --git a/swift/Tests/Blockchains/SolanaTests.swift b/swift/Tests/Blockchains/SolanaTests.swift index e813fe6c245..9c6287ddef3 100644 --- a/swift/Tests/Blockchains/SolanaTests.swift +++ b/swift/Tests/Blockchains/SolanaTests.swift @@ -290,4 +290,43 @@ class SolanaTests: XCTestCase { let expected = "AQPWaOi7dMdmQpXi8HyQQKwiqIftrg1igGQxGtZeT50ksn4wAnyH4DtDrkkuE0fqgx80LTp4LwNN9a440SrmoA8BAAEDZsL1CMnFVcrMn7JtiOiN1U4hC7WovOVof2DX51xM0H/GizyJTHgrBanCf8bGbrFNTn0x3pCGq30hKbywSTr6AgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABAgIAAQwCAAAAKgAAAAAAAAA=" XCTAssertEqual(output.encoded, expected) } + + func testSetPriorityFee() throws { + let privateKey = Data(hexString: "baf2b2dbbbad7ca96c1fa199c686f3d8fbd2c7b352f307e37e04f33df6741f18")! + let originalTx = "AX43+Ir2EDqf2zLEvgzFrCZKRjdr3wCdp8CnvYh6N0G/s86IueX9BbiNUl16iLRGvwREDfi2Srb0hmLNBFw1BwABAAEDODI+iWe7g68B9iwCy8bFkJKvsIEj350oSOpcv4gNnv/st+6qmqipl9lwMK6toB9TiL7LrJVfij+pKwr+pUKxfwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAG6GdPcA92ORzVJe2jfG8KQqqMHr9YTLu30oM4i7MFEoBAgIAAQwCAAAA6AMAAAAAAAA=" + + // Step 1 - Check if there are no price and limit instructions in the original transaction. + XCTAssertEqual(SolanaTransaction.getComputeUnitPrice(encodedTx: originalTx), nil) + XCTAssertEqual(SolanaTransaction.getComputeUnitLimit(encodedTx: originalTx), nil) + + // Step 2 - Set price and limit instructions. + let txWithPrice = SolanaTransaction.setComputeUnitPrice(encodedTx: originalTx, price: "1000")! + let updatedTx = SolanaTransaction.setComputeUnitLimit(encodedTx: txWithPrice, limit: "10000")! + + XCTAssertEqual(updatedTx, "AX43+Ir2EDqf2zLEvgzFrCZKRjdr3wCdp8CnvYh6N0G/s86IueX9BbiNUl16iLRGvwREDfi2Srb0hmLNBFw1BwABAAIEODI+iWe7g68B9iwCy8bFkJKvsIEj350oSOpcv4gNnv/st+6qmqipl9lwMK6toB9TiL7LrJVfij+pKwr+pUKxfwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAwZGb+UhFzL/7K26csOb57yM5bvF9xJrLEObOkAAAAAboZ09wD3Y5HNUl7aN8bwpCqowev1hMu7fSgziLswUSgMDAAUCECcAAAICAAEMAgAAAOgDAAAAAAAAAwAJA+gDAAAAAAAA") + + // Step 3 - Check if price and limit instructions are set successfully. + XCTAssertEqual(SolanaTransaction.getComputeUnitPrice(encodedTx: updatedTx), "1000") + XCTAssertEqual(SolanaTransaction.getComputeUnitLimit(encodedTx: updatedTx), "10000") + + // Step 4 - Decode transaction into a `RawMessage` Protobuf. + let updatedTxData = Base64.decode(string: updatedTx)! + let decodeOutputData = TransactionDecoder.decode(coinType: .solana, encodedTx: updatedTxData) + var decodeOutput = try SolanaDecodingTransactionOutput(serializedData: decodeOutputData) + + XCTAssertEqual(decodeOutput.error, .ok) + + // Step 5 - Sign the decoded `RawMessage` transaction. + let signingInput = SolanaSigningInput.with { + $0.privateKey = privateKey + $0.rawMessage = decodeOutput.transaction + $0.txEncoding = .base64 + } + + let output: SolanaSigningOutput = AnySigner.sign(input: signingInput, coin: .solana) + XCTAssertEqual(output.error, .ok) + // Successfully broadcasted tx: + // https://explorer.solana.com/tx/2ho7wZUXbDNz12xGfsXg2kcNMqkBAQjv7YNXNcVcuCmbC4p9FZe9ELeM2gMjq9MKQPpmE3nBW5pbdgwVCfNLr1h8 + XCTAssertEqual(output.encoded, "AVUye82Mv+/aWeU2G+B6Nes365mUU2m8iqcGZn/8kFJvw4wY6AgKGG+vJHaknHlCDwE1yi1SIMVUUtNCOm3kHg8BAAIEODI+iWe7g68B9iwCy8bFkJKvsIEj350oSOpcv4gNnv/st+6qmqipl9lwMK6toB9TiL7LrJVfij+pKwr+pUKxfwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAwZGb+UhFzL/7K26csOb57yM5bvF9xJrLEObOkAAAAAboZ09wD3Y5HNUl7aN8bwpCqowev1hMu7fSgziLswUSgMDAAUCECcAAAICAAEMAgAAAOgDAAAAAAAAAwAJA+gDAAAAAAAA") + } } diff --git a/tests/chains/Solana/TWSolanaTransaction.cpp b/tests/chains/Solana/TWSolanaTransaction.cpp index 6850ce721c5..14613f3c344 100644 --- a/tests/chains/Solana/TWSolanaTransaction.cpp +++ b/tests/chains/Solana/TWSolanaTransaction.cpp @@ -9,6 +9,7 @@ #include "TestUtilities.h" #include "Base64.h" #include "Base58.h" +#include "HexCoding.h" #include @@ -86,4 +87,54 @@ TEST(TWSolanaTransaction, DecodeUpdateBlockhashAndSign) { EXPECT_EQ(output.encoded(), "Ajzc/Tke0CG8Cew5qFa6xZI/7Ya3DN0M8Ige6tKPsGzhg8Bw9DqL18KUrEZZ1F4YqZBo4Rv+FsDT8A7Nss7p4A6BNVZzzGprCJqYQeNg0EVIbmPc6mDitNniHXGeKgPZ6QZbM4FElw9O7IOFTpOBPvQFeqy0vZf/aayncL8EK/UEAgACBssq8Im1alV3N7wXGODL8jLPWwLhTuCqfGZ1Iz9fb5tXlMOJD6jUvASrKmdtLK/qXNyJns2Vqcvlk+nfJYdZaFpIWiT/tAcEYbttfxyLdYxrLckAKdVRtf1OrNgtZeMCII4SAn6SYaaidrX/AN3s/aVn/zrlEKW0cEUIatHVDKtXO0Qss5EhV/E6kz0BNCgtAytf/s0Botvxt3kGCN8ALqcG3fbh12Whk9nL4UbO63msHLSF7V9bN5E6jPWFfv8AqbHiki6ThNH3auuyZPQpJntnN0mA//56nMpK/6HIuu8xAQUEAgQDAQoMoA8AAAAAAAAG"); } +TEST(TWSolanaTransaction, SetPriorityFee) { + // base64 encoded + const auto privateKey = parse_hex("baf2b2dbbbad7ca96c1fa199c686f3d8fbd2c7b352f307e37e04f33df6741f18"); + const auto originalTx = STRING("AX43+Ir2EDqf2zLEvgzFrCZKRjdr3wCdp8CnvYh6N0G/s86IueX9BbiNUl16iLRGvwREDfi2Srb0hmLNBFw1BwABAAEDODI+iWe7g68B9iwCy8bFkJKvsIEj350oSOpcv4gNnv/st+6qmqipl9lwMK6toB9TiL7LrJVfij+pKwr+pUKxfwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAG6GdPcA92ORzVJe2jfG8KQqqMHr9YTLu30oM4i7MFEoBAgIAAQwCAAAA6AMAAAAAAAA="); + + // Step 1 - Check if there are no price and limit instructions in the original transaction. + + EXPECT_EQ(TWSolanaTransactionGetComputeUnitPrice(originalTx.get()), nullptr); + EXPECT_EQ(TWSolanaTransactionGetComputeUnitLimit(originalTx.get()), nullptr); + + // Step 2 - Set price and limit instructions. + + const auto txWithPrice = WRAPS(TWSolanaTransactionSetComputeUnitPrice(originalTx.get(), STRING("1000").get())); + const auto updatedTx = WRAPS(TWSolanaTransactionSetComputeUnitLimit(txWithPrice.get(), STRING("10000").get())); + + assertStringsEqual(updatedTx, "AX43+Ir2EDqf2zLEvgzFrCZKRjdr3wCdp8CnvYh6N0G/s86IueX9BbiNUl16iLRGvwREDfi2Srb0hmLNBFw1BwABAAIEODI+iWe7g68B9iwCy8bFkJKvsIEj350oSOpcv4gNnv/st+6qmqipl9lwMK6toB9TiL7LrJVfij+pKwr+pUKxfwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAwZGb+UhFzL/7K26csOb57yM5bvF9xJrLEObOkAAAAAboZ09wD3Y5HNUl7aN8bwpCqowev1hMu7fSgziLswUSgMDAAUCECcAAAICAAEMAgAAAOgDAAAAAAAAAwAJA+gDAAAAAAAA"); + + // Step 3 - Check if price and limit instructions are set successfully. + + assertStringsEqual(WRAPS(TWSolanaTransactionGetComputeUnitPrice(updatedTx.get())), "1000"); + assertStringsEqual(WRAPS(TWSolanaTransactionGetComputeUnitLimit(updatedTx.get())), "10000"); + + // Step 4 - Decode transaction into a `RawMessage` Protobuf. + + const std::string updateTxDataB64 {TWStringUTF8Bytes(updatedTx.get()) }; + const auto updatedTxData = Base64::decode(updateTxDataB64); + const auto updatedTxRef = WRAPD(TWDataCreateWithBytes(updatedTxData.data(), updatedTxData.size())); + + const auto decodeOutputData = WRAPD(TWTransactionDecoderDecode(TWCoinTypeSolana, updatedTxRef.get())); + Proto::DecodingTransactionOutput decodeOutput; + decodeOutput.ParseFromArray(TWDataBytes(decodeOutputData.get()), static_cast(TWDataSize(decodeOutputData.get()))); + EXPECT_EQ(decodeOutput.error(), Common::Proto::SigningError::OK); + + // Step 5 - Sign the decoded `RawMessage` transaction. + + Proto::SigningInput input; + input.set_private_key(privateKey.data(), privateKey.size()); + *input.mutable_raw_message() = decodeOutput.transaction(); + input.set_tx_encoding(Proto::Encoding::Base64); + + Proto::SigningOutput output; + ANY_SIGN(input, TWCoinTypeSolana); + + EXPECT_EQ(output.error(), Common::Proto::SigningError::OK); + + // Successfully broadcasted tx: + // https://explorer.solana.com/tx/2ho7wZUXbDNz12xGfsXg2kcNMqkBAQjv7YNXNcVcuCmbC4p9FZe9ELeM2gMjq9MKQPpmE3nBW5pbdgwVCfNLr1h8 + EXPECT_EQ(output.encoded(), "AVUye82Mv+/aWeU2G+B6Nes365mUU2m8iqcGZn/8kFJvw4wY6AgKGG+vJHaknHlCDwE1yi1SIMVUUtNCOm3kHg8BAAIEODI+iWe7g68B9iwCy8bFkJKvsIEj350oSOpcv4gNnv/st+6qmqipl9lwMK6toB9TiL7LrJVfij+pKwr+pUKxfwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAwZGb+UhFzL/7K26csOb57yM5bvF9xJrLEObOkAAAAAboZ09wD3Y5HNUl7aN8bwpCqowev1hMu7fSgziLswUSgMDAAUCECcAAAICAAEMAgAAAOgDAAAAAAAAAwAJA+gDAAAAAAAA"); +} + } // TW::Solana::tests