From 5eddb82f5c2f01ee271ff35d9b57f959bd22d749 Mon Sep 17 00:00:00 2001 From: 0xh3rman <119309671+0xh3rman@users.noreply.github.com> Date: Fri, 18 Sep 2026 11:44:49 +0900 Subject: [PATCH 1/2] refactor and add solana v1 tx format support --- .../src/builder/transaction/accounts.rs | 4 +- .../src/builder/transaction/builder.rs | 89 ++-- .../src/builder/transaction/lookup.rs | 25 +- .../src/instructions/associated_token.rs | 14 +- .../src/instructions/compute_budget.rs | 58 ++- .../src/instructions/program_ids.rs | 1 - .../gem_solana/src/instructions/system.rs | 9 + core/crates/gem_solana/src/lib.rs | 3 +- .../gem_solana/src/signer/chain_signer.rs | 145 +++--- core/crates/gem_solana/src/signer/swap.rs | 116 +++-- core/crates/gem_solana/src/signer/testkit.rs | 12 +- .../gem_solana/src/signer/transaction.rs | 58 ++- core/crates/gem_solana/src/testkit.rs | 184 ------- .../src/testkit/account_data_mock.rs | 13 + .../src/testkit/block_transaction_mock.rs | 24 + .../gem_solana/src/testkit/epoch_info_mock.rs | 11 + .../src/testkit/instruction_mock.rs | 17 + .../src/testkit/lookup_table_mock.rs | 21 + .../gem_solana/src/testkit/message_mock.rs | 36 ++ core/crates/gem_solana/src/testkit/mod.rs | 19 + .../gem_solana/src/testkit/pubkey_mock.rs | 15 + .../gem_solana/src/testkit/siws_mock.rs | 13 + .../src/testkit/token_account_info_mock.rs | 29 ++ .../src/testkit/token_balance_mock.rs | 14 + .../src/testkit/transaction_mock.rs | 56 +++ core/crates/gem_solana/src/transaction.rs | 88 ++-- core/crates/gem_solana/src/types/message.rs | 226 ++++++++- core/crates/gem_solana/src/types/mod.rs | 15 +- core/crates/gem_solana/src/types/pda.rs | 2 +- .../gem_solana/src/types/transaction.rs | 267 ---------- .../src/types/transaction/decoder.rs | 143 +++++- .../gem_solana/src/types/transaction/mod.rs | 464 ++++++++++++++++++ .../testdata/transaction_v1_mainnet.json | 1 + .../payment/src/solana_pay/transaction.rs | 3 +- .../testkit/transaction_load_input_mock.rs | 10 +- .../swapper/src/chainflip/tx_builder.rs | 17 +- .../swapper/src/mayan/tx_builder/solana.rs | 6 +- core/crates/swapper/src/relay/solana.rs | 4 +- 38 files changed, 1479 insertions(+), 753 deletions(-) delete mode 100644 core/crates/gem_solana/src/testkit.rs create mode 100644 core/crates/gem_solana/src/testkit/account_data_mock.rs create mode 100644 core/crates/gem_solana/src/testkit/block_transaction_mock.rs create mode 100644 core/crates/gem_solana/src/testkit/epoch_info_mock.rs create mode 100644 core/crates/gem_solana/src/testkit/instruction_mock.rs create mode 100644 core/crates/gem_solana/src/testkit/lookup_table_mock.rs create mode 100644 core/crates/gem_solana/src/testkit/message_mock.rs create mode 100644 core/crates/gem_solana/src/testkit/mod.rs create mode 100644 core/crates/gem_solana/src/testkit/pubkey_mock.rs create mode 100644 core/crates/gem_solana/src/testkit/siws_mock.rs create mode 100644 core/crates/gem_solana/src/testkit/token_account_info_mock.rs create mode 100644 core/crates/gem_solana/src/testkit/token_balance_mock.rs create mode 100644 core/crates/gem_solana/src/testkit/transaction_mock.rs delete mode 100644 core/crates/gem_solana/src/types/transaction.rs create mode 100644 core/crates/gem_solana/src/types/transaction/mod.rs create mode 100644 core/crates/gem_solana/testdata/transaction_v1_mainnet.json diff --git a/core/crates/gem_solana/src/builder/transaction/accounts.rs b/core/crates/gem_solana/src/builder/transaction/accounts.rs index dfe174582c..03c3366eef 100644 --- a/core/crates/gem_solana/src/builder/transaction/accounts.rs +++ b/core/crates/gem_solana/src/builder/transaction/accounts.rs @@ -1,8 +1,6 @@ use std::collections::HashMap; -use crate::{AccountMeta, CompiledInstruction, Instruction, MessageHeader, Pubkey, Result, SolanaError}; - -pub(super) const MAX_ACCOUNT_KEYS: usize = u8::MAX as usize + 1; +use crate::{AccountMeta, CompiledInstruction, Instruction, MessageHeader, Pubkey, Result, SolanaError, types::MAX_ACCOUNT_KEYS}; #[derive(Default)] pub(crate) struct AccountBuckets { diff --git a/core/crates/gem_solana/src/builder/transaction/builder.rs b/core/crates/gem_solana/src/builder/transaction/builder.rs index 0f79772af5..22914c9ffa 100644 --- a/core/crates/gem_solana/src/builder/transaction/builder.rs +++ b/core/crates/gem_solana/src/builder/transaction/builder.rs @@ -1,9 +1,9 @@ use std::{collections::HashSet, iter::once}; -use crate::{AccountMeta, AddressLookupTableAccount, Instruction, Message, Pubkey, Result, SignatureBytes, SolanaError, VersionedMessageV0, VersionedTransaction}; +use crate::{AccountMeta, AddressLookupTableAccount, Instruction, Message, Pubkey, Result, SignatureBytes, SolanaError, VersionedMessageV0, VersionedTransaction, instructions::system::is_advance_nonce_account, types::MAX_ACCOUNT_KEYS}; use super::{ - accounts::{AccountBuckets, MAX_ACCOUNT_KEYS, collect_accounts, compile_instructions, index_accounts}, + accounts::{AccountBuckets, collect_accounts, compile_instructions, index_accounts}, lookup::{LoadedAccounts, lookup_locations}, }; @@ -42,13 +42,14 @@ impl TransactionBuilder { pub fn build_v0(self, address_lookup_tables: &[AddressLookupTableAccount]) -> Result { let lookup_locations = lookup_locations(address_lookup_tables); let program_ids = self.instructions.iter().map(|instruction| instruction.program_id).collect::>(); + let nonce_account = durable_nonce_account(&self.instructions); let accounts = collect_accounts(self.fee_payer, program_first_accounts(&self.instructions)); let mut static_accounts = AccountBuckets::default(); let mut loaded_accounts = LoadedAccounts::new(address_lookup_tables.len()); for account in accounts { match lookup_locations.get(&account.pubkey).copied() { - Some(location) if !account.is_signer && !program_ids.contains(&account.pubkey) => loaded_accounts.push(account, location), + Some(location) if !account.is_signer && !program_ids.contains(&account.pubkey) && Some(account.pubkey) != nonce_account => loaded_accounts.push(account, location), _ => static_accounts.push(account), } } @@ -90,6 +91,11 @@ impl TransactionBuilder { } } +fn durable_nonce_account(instructions: &[Instruction]) -> Option { + let instruction = instructions.first().filter(|instruction| is_advance_nonce_account(instruction))?; + instruction.accounts.first().map(|account| account.pubkey) +} + fn program_first_accounts(instructions: &[Instruction]) -> impl Iterator + '_ { instructions.iter().flat_map(|instruction| once(AccountMeta::new_readonly(instruction.program_id)).chain(instruction.accounts.iter().cloned())) } @@ -119,14 +125,14 @@ mod tests { use super::TransactionBuilder; use crate::{ - AccountMeta, AddressLookupTableAccount, CompiledInstruction, Instruction, Message, MessageAddressTableLookup, MessageHeader, Pubkey, SignatureBytes, SolanaError, VersionedMessageV0, VersionedTransaction, + AccountMeta, AddressLookupTableAccount, CompiledInstruction, Instruction, MessageAddressTableLookup, Pubkey, SignatureBytes, SolanaError, VersionedTransaction, builder::InstructionBuilder, instructions::{ program_ids::{system_program, token_program}, - system::transfer, + system::{ADVANCE_NONCE_ACCOUNT_DISCRIMINANT, transfer}, token::transfer_checked, }, - testkit::TEST_BLOCKHASH, + testkit::{TEST_BLOCKHASH, mock_v0_transaction}, }; #[test] @@ -188,28 +194,32 @@ mod tests { let mut data = 2u32.to_le_bytes().to_vec(); data.extend_from_slice(&123u64.to_le_bytes()); + assert_eq!(parsed, mock_v0_transaction(vec![fee_payer, recipient, system_program()], vec![CompiledInstruction::mock(2, vec![0, 1], data)], vec![],)); + } + + #[test] + fn test_versioned_transaction_builder_keeps_the_nonce_account_static() { + let fee_payer = Pubkey::mock(1); + let nonce_account = Pubkey::mock(2); + let looked_up_account = Pubkey::mock(3); + let advance_nonce = InstructionBuilder::new(system_program()) + .account(nonce_account, false, true) + .account(looked_up_account, false, false) + .data(ADVANCE_NONCE_ACCOUNT_DISCRIMINANT.to_vec()) + .build(); + let lookup_table = AddressLookupTableAccount::new(Pubkey::mock(4), vec![nonce_account, looked_up_account]); + + let mut builder = TransactionBuilder::new(fee_payer, TEST_BLOCKHASH); + builder.add_instruction(advance_nonce); + let transaction = builder.build_v0(&[lookup_table]).unwrap(); + assert_eq!( - parsed, - VersionedTransaction::V0 { - signatures: vec![SignatureBytes::default()], - message: VersionedMessageV0 { - message: Message { - header: MessageHeader { - num_required_signatures: 1, - num_readonly_signed_accounts: 0, - num_readonly_unsigned_accounts: 1 - }, - account_keys: vec![fee_payer, recipient, system_program()], - recent_blockhash: TEST_BLOCKHASH, - instructions: vec![CompiledInstruction { - program_id_index: 2, - accounts: vec![0, 1], - data - }], - }, - address_table_lookups: vec![], - }, - } + transaction, + mock_v0_transaction( + vec![fee_payer, nonce_account, system_program()], + vec![CompiledInstruction::mock(2, vec![1, 3], ADVANCE_NONCE_ACCOUNT_DISCRIMINANT.to_vec())], + vec![MessageAddressTableLookup::new(Pubkey::mock(4), vec![], vec![1])], + ) ); } @@ -232,26 +242,11 @@ mod tests { assert_eq!( parsed, - VersionedTransaction::V0 { - signatures: vec![SignatureBytes::default()], - message: VersionedMessageV0 { - message: Message { - header: MessageHeader { - num_required_signatures: 1, - num_readonly_signed_accounts: 0, - num_readonly_unsigned_accounts: 1 - }, - account_keys: vec![fee_payer, program_id], - recent_blockhash: TEST_BLOCKHASH, - instructions: vec![CompiledInstruction { - program_id_index: 1, - accounts: vec![0, 2], - data: vec![1, 2, 3] - }], - }, - address_table_lookups: vec![MessageAddressTableLookup::new(Pubkey::mock(4), vec![0], vec![])], - }, - } + mock_v0_transaction( + vec![fee_payer, program_id], + vec![CompiledInstruction::mock(1, vec![0, 2], vec![1, 2, 3])], + vec![MessageAddressTableLookup::new(Pubkey::mock(4), vec![0], vec![])], + ) ); } diff --git a/core/crates/gem_solana/src/builder/transaction/lookup.rs b/core/crates/gem_solana/src/builder/transaction/lookup.rs index 8ea747472f..7e73d48386 100644 --- a/core/crates/gem_solana/src/builder/transaction/lookup.rs +++ b/core/crates/gem_solana/src/builder/transaction/lookup.rs @@ -2,7 +2,7 @@ use std::collections::HashMap; use crate::{AccountMeta, AddressLookupTableAccount, MessageAddressTableLookup, Pubkey}; -use super::accounts::MAX_ACCOUNT_KEYS; +use crate::types::MAX_ACCOUNT_KEYS; pub(super) struct LoadedAccounts { writable: Vec>, @@ -45,10 +45,29 @@ impl LoadedAccounts { pub(super) fn lookup_locations(address_lookup_tables: &[AddressLookupTableAccount]) -> HashMap { let mut locations = HashMap::new(); - for (table_index, table) in address_lookup_tables.iter().enumerate().rev() { + for (table_index, table) in address_lookup_tables.iter().enumerate() { for (entry_index, address) in table.addresses.iter().take(MAX_ACCOUNT_KEYS).enumerate() { - locations.insert(*address, (table_index, entry_index as u8)); + locations.entry(*address).or_insert((table_index, entry_index as u8)); } } locations } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_lookup_locations() { + let duplicated = Pubkey::mock(1); + let shared = Pubkey::mock(2); + let first_table = AddressLookupTableAccount::new(Pubkey::mock(10), vec![duplicated, shared, duplicated]); + let second_table = AddressLookupTableAccount::new(Pubkey::mock(11), vec![Pubkey::mock(3), shared]); + + let locations = lookup_locations(&[first_table, second_table]); + + assert_eq!(locations.get(&duplicated), Some(&(0, 0))); + assert_eq!(locations.get(&shared), Some(&(0, 1))); + assert_eq!(locations.get(&Pubkey::mock(3)), Some(&(1, 0))); + } +} diff --git a/core/crates/gem_solana/src/instructions/associated_token.rs b/core/crates/gem_solana/src/instructions/associated_token.rs index 9c9dd9bccb..c5e44b221a 100644 --- a/core/crates/gem_solana/src/instructions/associated_token.rs +++ b/core/crates/gem_solana/src/instructions/associated_token.rs @@ -1,9 +1,10 @@ use crate::{ Result, - instructions::program_ids::{associated_token_program, rent_sysvar, system_program}, + instructions::program_ids::{associated_token_program, system_program}, types::{AccountMeta, Instruction, Pubkey, find_program_address}, }; +const CREATE_DISCRIMINANT: u8 = 0; const CREATE_IDEMPOTENT_DISCRIMINANT: u8 = 1; pub fn create_associated_token_account_idempotent(payer: &Pubkey, wallet: &Pubkey, mint: &Pubkey, token_program: &Pubkey) -> Result { @@ -21,12 +22,15 @@ pub fn create_associated_token_account_idempotent_with_address(payer: &Pubkey, a AccountMeta::new_readonly(*mint), AccountMeta::new_readonly(system_program()), AccountMeta::new_readonly(*token_program), - AccountMeta::new_readonly(rent_sysvar()), ], data: vec![CREATE_IDEMPOTENT_DISCRIMINANT], } } +pub fn is_create_account_data(data: &[u8]) -> bool { + data.is_empty() || data == [CREATE_DISCRIMINANT] || data == [CREATE_IDEMPOTENT_DISCRIMINANT] +} + pub fn get_associated_token_address_with_program_id(wallet: &Pubkey, mint: &Pubkey, token_program: &Pubkey) -> Result { let seeds = [wallet.as_bytes().as_slice(), token_program.as_bytes().as_slice(), mint.as_bytes().as_slice()]; find_program_address(&associated_token_program(), &seeds).map(|(address, _)| address) @@ -49,8 +53,10 @@ mod tests { let instruction = create_associated_token_account_idempotent(&payer, &wallet, &mint, &token_program()).unwrap(); assert_eq!(instruction.program_id, associated_token_program()); - assert_eq!(instruction.accounts[1].pubkey, token_address); - assert_eq!(instruction.accounts[5].pubkey, token_program()); + assert_eq!( + instruction.accounts.iter().map(|account| account.pubkey).collect::>(), + vec![payer, token_address, wallet, mint, system_program(), token_program()] + ); assert_eq!(instruction.data, [CREATE_IDEMPOTENT_DISCRIMINANT]); } } diff --git a/core/crates/gem_solana/src/instructions/compute_budget.rs b/core/crates/gem_solana/src/instructions/compute_budget.rs index e4775207f5..bd84cbcbca 100644 --- a/core/crates/gem_solana/src/instructions/compute_budget.rs +++ b/core/crates/gem_solana/src/instructions/compute_budget.rs @@ -1,13 +1,10 @@ -use hex_lit::hex; - use crate::{ - instructions::program_ids::{compute_budget_program, system_program}, + instructions::{program_ids::compute_budget_program, system::is_advance_nonce_account}, types::Instruction, }; pub const SET_COMPUTE_UNIT_LIMIT_DISCRIMINANT: u8 = 2; pub const SET_COMPUTE_UNIT_PRICE_DISCRIMINANT: u8 = 3; -const ADVANCE_NONCE_ACCOUNT_DISCRIMINANT: [u8; 4] = hex!("04000000"); pub fn set_compute_unit_price(micro_lamports: u64) -> Instruction { let mut data = Vec::with_capacity(9); @@ -33,18 +30,28 @@ pub fn set_compute_unit_limit(units: u32) -> Instruction { pub fn parse_compute_unit_limit_data(data: &[u8]) -> Option { let bytes = data.strip_prefix(&[SET_COMPUTE_UNIT_LIMIT_DISCRIMINANT])?; - u32::from_le_bytes(bytes.try_into().ok()?).into() + Some(u32::from_le_bytes(<[u8; 4]>::try_from(bytes.get(..4)?).ok()?)) } pub fn parse_compute_unit_price_data(data: &[u8]) -> Option { let bytes = data.strip_prefix(&[SET_COMPUTE_UNIT_PRICE_DISCRIMINANT])?; - u64::from_le_bytes(bytes.try_into().ok()?).into() + Some(u64::from_le_bytes(<[u8; 8]>::try_from(bytes.get(..8)?).ok()?)) +} + +pub(crate) fn find_unique_compute_unit_limit<'a>(instruction_data: impl Iterator) -> Option { + let mut limits = instruction_data.filter_map(parse_compute_unit_limit_data); + let limit = limits.next()?; + limits.next().is_none().then_some(limit) +} + +pub(crate) fn find_unique_compute_unit_price<'a>(instruction_data: impl Iterator) -> Option { + let mut prices = instruction_data.filter_map(parse_compute_unit_price_data); + let price = prices.next()?; + prices.next().is_none().then_some(price) } pub fn get_compute_unit_limit(instructions: &[Instruction]) -> Option { - instructions - .iter() - .find_map(|instruction| (instruction.program_id == compute_budget_program()).then(|| parse_compute_unit_limit_data(&instruction.data)).flatten()) + find_unique_compute_unit_limit(instructions.iter().filter(|instruction| instruction.program_id == compute_budget_program()).map(|instruction| instruction.data.as_slice())) } pub fn ensure_compute_unit_price(instructions: &mut Vec, micro_lamports: u64) -> bool { @@ -55,32 +62,51 @@ pub fn ensure_compute_unit_price(instructions: &mut Vec, micro_lamp return false; } - let insertion_index = usize::from( - instructions - .first() - .is_some_and(|instruction| instruction.program_id == system_program() && instruction.data.get(0..4) == Some(&ADVANCE_NONCE_ACCOUNT_DISCRIMINANT)), - ); + let insertion_index = usize::from(instructions.first().is_some_and(is_advance_nonce_account)); instructions.insert(insertion_index, set_compute_unit_price(micro_lamports)); true } #[cfg(test)] mod tests { + use hex_lit::hex; + use super::*; use crate::Pubkey; - use crate::instructions::system::transfer; + use crate::instructions::program_ids::system_program; + use crate::instructions::system::{ADVANCE_NONCE_ACCOUNT_DISCRIMINANT, transfer}; #[test] fn test_compute_budget_wire_formats_and_parsing() { let limit = set_compute_unit_limit(200_000); assert_eq!(limit.data, hex!("02400d0300")); assert_eq!(parse_compute_unit_limit_data(&limit.data), Some(200_000)); + assert_eq!(parse_compute_unit_limit_data(&hex!("02400d0300ff")), Some(200_000)); assert_eq!(parse_compute_unit_limit_data(&[2, 1]), None); let price = set_compute_unit_price(1_000); assert_eq!(price.data, hex!("03e803000000000000")); assert_eq!(parse_compute_unit_price_data(&price.data), Some(1_000)); - assert_eq!(get_compute_unit_limit(&[price, limit]), Some(200_000)); + assert_eq!(parse_compute_unit_price_data(&hex!("03e803000000000000ff")), Some(1_000)); + assert_eq!(parse_compute_unit_price_data(&[3, 1]), None); + } + + #[test] + fn test_get_compute_unit_limit() { + let limit = set_compute_unit_limit(200_000); + let price = set_compute_unit_price(1_000); + + assert_eq!(get_compute_unit_limit(&[price.clone(), limit.clone()]), Some(200_000)); + assert_eq!(get_compute_unit_limit(&[limit.clone(), set_compute_unit_limit(300_000)]), None); + assert_eq!(get_compute_unit_limit(&[price]), None); + assert_eq!( + get_compute_unit_limit(&[Instruction { + program_id: system_program(), + accounts: vec![], + data: limit.data, + }]), + None + ); } #[test] diff --git a/core/crates/gem_solana/src/instructions/program_ids.rs b/core/crates/gem_solana/src/instructions/program_ids.rs index 599eeb18f4..044c13179e 100644 --- a/core/crates/gem_solana/src/instructions/program_ids.rs +++ b/core/crates/gem_solana/src/instructions/program_ids.rs @@ -15,4 +15,3 @@ program_id!(token_2022_program, "06ddf6e1ee758fde18425dbce46ccddab61afc4d83b90d2 program_id!(associated_token_program, "8c97258f4e2489f1bb3d1029148e0d830b5a1399daff1084048e7bd8dbe9f859"); program_id!(memo_program, "054a535a992921064d24e87160da387c7c35b5ddbc92bb81e41fa8404105448d"); program_id!(compute_budget_program, "0306466fe5211732ffecadba72c39be7bc8ce5bbc5f7126b2c439b3a40000000"); -program_id!(rent_sysvar, "06a7d517192c5c51218cc94c3d4af17f58daee089ba1fd44e3dbd98a00000000"); diff --git a/core/crates/gem_solana/src/instructions/system.rs b/core/crates/gem_solana/src/instructions/system.rs index 7dc01606c9..fc06328316 100644 --- a/core/crates/gem_solana/src/instructions/system.rs +++ b/core/crates/gem_solana/src/instructions/system.rs @@ -6,6 +6,15 @@ use crate::{ }; const TRANSFER_DISCRIMINANT: [u8; 4] = hex!("02000000"); +pub const ADVANCE_NONCE_ACCOUNT_DISCRIMINANT: [u8; 4] = hex!("04000000"); + +pub fn is_advance_nonce_account_data(data: &[u8]) -> bool { + data.get(..4) == Some(ADVANCE_NONCE_ACCOUNT_DISCRIMINANT.as_slice()) +} + +pub fn is_advance_nonce_account(instruction: &Instruction) -> bool { + instruction.program_id == system_program() && is_advance_nonce_account_data(&instruction.data) +} pub fn transfer(from: &Pubkey, to: &Pubkey, lamports: u64) -> Instruction { let mut data = Vec::with_capacity(12); diff --git a/core/crates/gem_solana/src/lib.rs b/core/crates/gem_solana/src/lib.rs index eaca0496fa..155c4e2213 100644 --- a/core/crates/gem_solana/src/lib.rs +++ b/core/crates/gem_solana/src/lib.rs @@ -38,7 +38,8 @@ pub use transaction::{ try_decode_transaction, }; pub use types::{ - AccountMeta, AddressLookupTableAccount, CompiledInstruction, Instruction, MAX_TRANSACTION_SIZE, Message, MessageAddressTableLookup, MessageHeader, Pubkey, SignatureBytes, VersionedMessageV0, VersionedTransaction, find_program_address, + AccountMeta, AddressLookupTableAccount, CompiledInstruction, Instruction, MAX_TRANSACTION_SIZE, Message, MessageAddressTableLookup, MessageHeader, Pubkey, SignatureBytes, TransactionConfig, VersionedMessageV0, VersionedMessageV1, + VersionedTransaction, find_program_address, }; pub(crate) use error::Result; diff --git a/core/crates/gem_solana/src/signer/chain_signer.rs b/core/crates/gem_solana/src/signer/chain_signer.rs index 6b44d1f7be..f9647de3cf 100644 --- a/core/crates/gem_solana/src/signer/chain_signer.rs +++ b/core/crates/gem_solana/src/signer/chain_signer.rs @@ -61,22 +61,15 @@ impl ChainSigner for SolanaChainSigner { let data = extra.data_as_str().map_err(SignerError::invalid_input)?; let mut transaction = decode_transaction(data).map_err(SignerError::invalid_input)?; - if transaction.signatures().first().is_none_or(|signature| signature.as_bytes() != &[0u8; 64]) { - return Err(SignerError::invalid_input("user signature should be first")); - } - - if metadata.source == ApplicationMetadataSource::Payment { + if metadata.source == ApplicationMetadataSource::Payment && !transaction.uses_durable_nonce() { *transaction.recent_blockhash_mut() = transaction::block_hash(input)?; } - let message_bytes = transaction.serialize_message().map_err(|e| SignerError::signing_error(format!("serialize message: {e}")))?; - let signature = sign_solana_message(private_key, &message_bytes).map_err(|e| SignerError::signing_error(format!("sign: {e}")))?; + let signature = transaction::sign_transaction(&mut transaction, private_key)?; match extra.output_type { TransferDataOutputType::Signature => Ok(bs58::encode(signature.as_bytes()).into_string()), TransferDataOutputType::EncodedTransaction => { - let signature_slot = transaction.signatures_mut().first_mut().ok_or_else(|| SignerError::signing_error("missing Solana signature slot"))?; - *signature_slot = signature; let bytes = transaction.serialize().map_err(|e| SignerError::signing_error(format!("serialize transaction: {e}")))?; Ok(encode_base64(&bytes)) } @@ -87,95 +80,95 @@ impl ChainSigner for SolanaChainSigner { #[cfg(test)] mod tests { use super::*; - use crate::signer::testkit::{DOUBLE_SIG_TX, EXPECTED_MESSAGE_HEX, SINGLE_SIG_TX}; + use crate::instructions::{program_ids::system_program, system::ADVANCE_NONCE_ACCOUNT_DISCRIMINANT}; + use crate::signer::testkit::SINGLE_SIG_TX; use crate::testkit::mock_legacy_transaction; - use crate::{SignatureBytes, VersionedTransaction}; + use crate::testkit::mock_v1_transaction; + use crate::{CompiledInstruction, SignatureBytes, VersionedTransaction}; use gem_encoding::decode_base64; use primitives::testkit::signer_mock::TEST_PRIVATE_KEY; - use primitives::{ApplicationMetadataSource, Chain, ChainSigner, SignerInput, TransactionInputType, TransactionLoadInput, TransactionLoadMetadata, TransferDataOutputType}; + use primitives::{ApplicationMetadataSource, Chain, ChainSigner, SignerInput, TransactionLoadInput, TransactionLoadMetadata, TransferDataOutputType}; - #[test] - fn test_deserialize_single_signature_transaction() { - let bytes = decode_base64(SINGLE_SIG_TX).unwrap(); - let transaction = VersionedTransaction::deserialize_with_version(&bytes).unwrap(); + fn signed_blockhash(transaction: VersionedTransaction, source: ApplicationMetadataSource) -> [u8; 32] { + let encoded = encode_base64(&transaction.serialize().unwrap()); + let mut input = TransactionLoadInput::mock_sign_data_with_source(Chain::Solana, &encoded, TransferDataOutputType::EncodedTransaction, source); + input.metadata = TransactionLoadMetadata::mock_solana(&bs58::encode([4; 32]).into_string()); + let fee = input.default_fee(); - assert_eq!(transaction.signatures().len(), 1); + let result = SolanaChainSigner.sign_data(&SignerInput::new(input, fee), &TEST_PRIVATE_KEY).unwrap(); - let message_bytes = transaction.serialize_message().unwrap(); - let message_hex: String = message_bytes.iter().map(|b| format!("{b:02x}")).collect(); - assert_eq!(message_hex, EXPECTED_MESSAGE_HEX); + *VersionedTransaction::deserialize_with_version(&decode_base64(&result).unwrap()).unwrap().recent_blockhash() } #[test] - fn test_deserialize_double_signature_transaction() { - let bytes = decode_base64(DOUBLE_SIG_TX).unwrap(); - let transaction = VersionedTransaction::deserialize_with_version(&bytes).unwrap(); - - assert_eq!(transaction.signatures().len(), 2); - } + fn test_sign_data_blockhash() { + let mut latest_blockhash = mock_legacy_transaction(); + *latest_blockhash.recent_blockhash_mut() = [7; 32]; + latest_blockhash.add_signature(SignatureBytes::new([0; 64])); - #[test] - fn test_sign_data_encoded_transaction() { - let signer = SolanaChainSigner; - let input = TransactionLoadInput::mock_sign_data(Chain::Solana, SINGLE_SIG_TX, TransferDataOutputType::EncodedTransaction); - let fee = input.default_fee(); - let input = SignerInput::new(input, fee); + let mut durable_nonce = mock_legacy_transaction(); + *durable_nonce.recent_blockhash_mut() = [7; 32]; + let message = durable_nonce.message_mut(); + message.account_keys.push(system_program()); + message.header.num_readonly_unsigned_accounts += 1; + message.instructions.insert(0, CompiledInstruction::mock(2, vec![1], ADVANCE_NONCE_ACCOUNT_DISCRIMINANT.to_vec())); + durable_nonce.add_signature(SignatureBytes::new([0; 64])); - let result = signer.sign_data(&input, &TEST_PRIVATE_KEY).unwrap(); + let mut wallet_connect = mock_legacy_transaction(); + *wallet_connect.recent_blockhash_mut() = [0; 32]; + wallet_connect.add_signature(SignatureBytes::new([0; 64])); - let signed_bytes = decode_base64(&result).unwrap(); - let signed_transaction = VersionedTransaction::deserialize_with_version(&signed_bytes).unwrap(); - assert_eq!(signed_transaction.signatures().len(), 1); - assert_ne!(signed_transaction.signatures()[0].as_bytes(), &[0u8; 64]); + assert_eq!(signed_blockhash(latest_blockhash, ApplicationMetadataSource::Payment), [4; 32]); + assert_eq!(signed_blockhash(durable_nonce, ApplicationMetadataSource::Payment), [7; 32]); + assert_eq!(signed_blockhash(wallet_connect, ApplicationMetadataSource::WalletConnect), [0; 32]); } #[test] - fn test_sign_data_uses_latest_blockhash_for_payment() { - let mut transaction = mock_legacy_transaction(); - *transaction.recent_blockhash_mut() = [7; 32]; - transaction.add_signature(SignatureBytes::new([0; 64])); + fn test_sign_data_fills_the_wallet_signer_slot() { + let transaction = mock_v1_transaction(2, 1); + let message_bytes = transaction.serialize_message().unwrap(); + let transaction_config = *transaction.transaction_config().unwrap(); + let fee_payer_signature = transaction.signatures()[0]; let encoded = encode_base64(&transaction.serialize().unwrap()); - let blockhash = bs58::encode([4; 32]).into_string(); - let mut input = TransactionLoadInput::mock_sign_data(Chain::Solana, &encoded, TransferDataOutputType::EncodedTransaction); - let TransactionInputType::Generic { metadata, .. } = &mut input.input_type else { - panic!("expected generic transaction input"); - }; - metadata.source = ApplicationMetadataSource::Payment; - input.metadata = TransactionLoadMetadata::mock_solana(&blockhash); + let input = TransactionLoadInput::mock_sign_data(Chain::Solana, &encoded, TransferDataOutputType::EncodedTransaction); let fee = input.default_fee(); - let input = SignerInput::new(input, fee); - let result = SolanaChainSigner.sign_data(&input, &TEST_PRIVATE_KEY).unwrap(); + let result = SolanaChainSigner.sign_data(&SignerInput::new(input, fee), &TEST_PRIVATE_KEY).unwrap(); + let signed = VersionedTransaction::deserialize_with_version(&decode_base64(&result).unwrap()).unwrap(); - assert_eq!(signed.recent_blockhash(), &[4; 32]); - } + let expected_signature = sign_solana_message(&TEST_PRIVATE_KEY, &message_bytes).unwrap(); + assert_eq!(signed.serialize_message().unwrap(), message_bytes); + assert_eq!(*signed.transaction_config().unwrap(), transaction_config); + assert_eq!(signed.signatures(), &[fee_payer_signature, expected_signature]); - #[test] - fn test_sign_data_preserves_wallet_connect_blockhash() { - let mut transaction = mock_legacy_transaction(); - *transaction.recent_blockhash_mut() = [0; 32]; - transaction.add_signature(SignatureBytes::new([0; 64])); - let encoded = encode_base64(&transaction.serialize().unwrap()); - let mut input = TransactionLoadInput::mock_sign_data(Chain::Solana, &encoded, TransferDataOutputType::EncodedTransaction); - input.metadata = TransactionLoadMetadata::mock_solana(&bs58::encode([4; 32]).into_string()); - let fee = input.default_fee(); + let signature_input = TransactionLoadInput::mock_sign_data(Chain::Solana, &encoded, TransferDataOutputType::Signature); + let fee = signature_input.default_fee(); - let result = SolanaChainSigner.sign_data(&SignerInput::new(input, fee), &TEST_PRIVATE_KEY).unwrap(); - let signed = VersionedTransaction::deserialize_with_version(&decode_base64(&result).unwrap()).unwrap(); - assert_eq!(signed.recent_blockhash(), &[0; 32]); + let signature = SolanaChainSigner.sign_data(&SignerInput::new(signature_input, fee), &TEST_PRIVATE_KEY).unwrap(); + + assert_eq!(signature, bs58::encode(expected_signature.as_bytes()).into_string()); } #[test] - fn test_sign_data_signature_output() { - let signer = SolanaChainSigner; - let input = TransactionLoadInput::mock_sign_data(Chain::Solana, SINGLE_SIG_TX, TransferDataOutputType::Signature); + fn test_sign_data_rejects_transactions_the_wallet_must_not_sign() { + let input = TransactionLoadInput::mock_sign_data(Chain::Solana, SINGLE_SIG_TX, TransferDataOutputType::EncodedTransaction); let fee = input.default_fee(); - let input = SignerInput::new(input, fee); - let result = signer.sign_data(&input, &TEST_PRIVATE_KEY).unwrap(); + assert_eq!( + SolanaChainSigner.sign_data(&SignerInput::new(input, fee), &TEST_PRIVATE_KEY).unwrap_err().to_string(), + "Invalid input: wallet account is not a required signer of the Solana transaction" + ); + + let mut transaction = mock_v1_transaction(1, 0); + transaction.signatures_mut()[0] = SignatureBytes::new([9; 64]); + let encoded = encode_base64(&transaction.serialize().unwrap()); + let input = TransactionLoadInput::mock_sign_data(Chain::Solana, &encoded, TransferDataOutputType::EncodedTransaction); + let fee = input.default_fee(); - let sig_bytes = bs58::decode(&result).into_vec().unwrap(); - assert_eq!(sig_bytes.len(), 64); + assert_eq!( + SolanaChainSigner.sign_data(&SignerInput::new(input, fee), &TEST_PRIVATE_KEY).unwrap_err().to_string(), + "Invalid input: Solana transaction already contains the wallet signature" + ); } #[test] @@ -216,5 +209,15 @@ mod tests { let result = SolanaChainSigner.sign_message(&message, &TEST_PRIVATE_KEY); assert_eq!(result.unwrap_err().to_string(), format!("Invalid input: {SIGN_MESSAGE_PAYLOAD_REJECTION}")); + + let transaction = mock_v1_transaction(1, 0); + let full_transaction = transaction.serialize().unwrap(); + let message = transaction.serialize_message().unwrap(); + + assert_eq!( + SolanaChainSigner.sign_message(&full_transaction, &TEST_PRIVATE_KEY).unwrap_err().to_string(), + format!("Invalid input: {SIGN_MESSAGE_PAYLOAD_REJECTION}") + ); + assert_eq!(SolanaChainSigner.sign_message(&message, &TEST_PRIVATE_KEY).unwrap_err().to_string(), format!("Invalid input: {SIGN_MESSAGE_PAYLOAD_REJECTION}")); } } diff --git a/core/crates/gem_solana/src/signer/swap.rs b/core/crates/gem_solana/src/signer/swap.rs index bd5435125f..d570edb174 100644 --- a/core/crates/gem_solana/src/signer/swap.rs +++ b/core/crates/gem_solana/src/signer/swap.rs @@ -2,20 +2,19 @@ use gem_encoding::encode_base64; use num_traits::ToPrimitive; use primitives::{SignerError, SignerInput, TransactionFee}; -use super::sign_message as sign_solana_message; +use super::transaction::sign_transaction; use crate::decode_transaction; pub(crate) fn sign(input: &SignerInput, private_key: &[u8]) -> Result, SignerError> { let swap_data = input.input_type.get_swap_data()?; let transaction_base64 = &swap_data.data.data; - let unit_price = input.fee.unit_price_u64()?; let quote_gas_limit = swap_data.data.gas_limit.as_ref().map(|_| swap_data.data.gas_limit_as_u32()).transpose().map_err(SignerError::invalid_input)?; - Ok(vec![sign_transaction(transaction_base64, private_key, unit_price, quote_gas_limit, &input.fee)?]) + Ok(vec![sign_swap_transaction(transaction_base64, private_key, quote_gas_limit, &input.fee)?]) } -fn sign_transaction(transaction_base64: &str, private_key: &[u8], unit_price: u64, quote_gas_limit: Option, fee: &TransactionFee) -> Result { +fn sign_swap_transaction(transaction_base64: &str, private_key: &[u8], quote_gas_limit: Option, fee: &TransactionFee) -> Result { let mut transaction = decode_transaction(transaction_base64).map_err(SignerError::invalid_input)?; if transaction.signatures().len() <= 1 { @@ -26,22 +25,20 @@ fn sign_transaction(transaction_base64: &str, private_key: &[u8], unit_price: u6 (gas_limit > 0).then_some(gas_limit) } }; - if unit_price > 0 { - transaction.set_compute_unit_price(unit_price); + if let Some(config) = transaction.transaction_config_mut() { + config.priority_fee = Some(fee.priority_fee_u64()?); + } else { + let unit_price = fee.unit_price_u64()?; + if unit_price > 0 && !transaction.set_compute_unit_price(unit_price) { + return Err(SignerError::invalid_input("Solana swap transaction has no compute unit price instruction to replace")); + } } if let Some(gas_limit) = gas_limit.filter(|gas_limit| *gas_limit > 0) { transaction.set_compute_unit_limit(gas_limit); } } - let message_bytes = transaction.serialize_message().map_err(|e| SignerError::signing_error(format!("serialize message: {e}")))?; - let sig = sign_solana_message(private_key, &message_bytes).map_err(|e| SignerError::signing_error(format!("sign: {e}")))?; - - let sigs = transaction.signatures_mut(); - match sigs.first_mut() { - Some(signature) => *signature = sig, - None => sigs.push(sig), - } + sign_transaction(&mut transaction, private_key)?; let bytes = transaction.serialize().map_err(|e| SignerError::signing_error(format!("serialize transaction: {e}")))?; Ok(encode_base64(&bytes)) @@ -49,26 +46,33 @@ fn sign_transaction(transaction_base64: &str, private_key: &[u8], unit_price: u6 #[cfg(test)] mod tests { - use crate::signer::{SolanaChainSigner, testkit::SINGLE_SIG_TX}; + use crate::SignatureBytes; + use crate::signer::{ + SolanaChainSigner, + testkit::{SINGLE_SIG_TX, transaction_with_wallet_fee_payer}, + }; + use crate::testkit::{mock_legacy_transaction, mock_v1_transaction}; + use gem_encoding::encode_base64; use primitives::swap::SwapData; use primitives::testkit::signer_mock::TEST_PRIVATE_KEY; use primitives::{Asset, AssetId, Chain, ChainSigner, GasPriceType, SignerInput, SwapProvider, TransactionFee, TransactionInputType, TransactionLoadInput}; - #[test] - fn test_sign_swap_without_quote_gas_limit_uses_embedded_limit() { - let signer = SolanaChainSigner; - let original_limit = crate::decode_transaction(SINGLE_SIG_TX).unwrap().get_compute_unit_limit(); - let swap_data = SwapData::mock_with_provider_data(SwapProvider::Jupiter, SINGLE_SIG_TX, None); + fn swap_input(encoded: &str, gas_limit: Option<&str>, priority_fee: u64, unit_price: u64) -> SignerInput { let input_type = TransactionInputType::Swap { from_asset: Asset::mock_sol(), to_asset: Asset::mock_spl_token(), - swap_data, + swap_data: SwapData::mock_with_provider_data(SwapProvider::Jupiter, encoded, gas_limit), }; - let input = TransactionLoadInput::mock_with_input_type(input_type); - let fee = TransactionFee::new_gas_price_type(GasPriceType::solana(5_000u64, 0u64, 0u64), 5_000u64.into(), 1u64.into(), Default::default(), AssetId::from_chain(Chain::Solana)); - let input = SignerInput::new(input, fee); + let fee = TransactionFee::new_gas_price_type(GasPriceType::solana(5_000u64, priority_fee, unit_price), 5_000u64.into(), 1u64.into(), Default::default(), AssetId::from_chain(Chain::Solana)); + SignerInput::new(TransactionLoadInput::mock_with_input_type(input_type), fee) + } + + #[test] + fn test_sign_swap_without_quote_gas_limit_uses_embedded_limit() { + let encoded = transaction_with_wallet_fee_payer(SINGLE_SIG_TX); + let original_limit = crate::decode_transaction(&encoded).unwrap().get_compute_unit_limit(); - let result = signer.sign_swap(&input, &TEST_PRIVATE_KEY).unwrap(); + let result = SolanaChainSigner.sign_swap(&swap_input(&encoded, None, 0, 0), &TEST_PRIVATE_KEY).unwrap(); let signed_transaction = crate::decode_transaction(&result[0]).unwrap(); assert_eq!(signed_transaction.get_compute_unit_limit(), original_limit); @@ -77,21 +81,61 @@ mod tests { #[test] fn test_sign_swap_prefers_quote_gas_limit() { - let signer = SolanaChainSigner; let gas_limit = crate::DEFAULT_SWAP_GAS_LIMIT.to_string(); - let swap_data = SwapData::mock_with_provider_data(SwapProvider::Jupiter, SINGLE_SIG_TX, Some(&gas_limit)); - let input_type = TransactionInputType::Swap { - from_asset: Asset::mock_sol(), - to_asset: Asset::mock_spl_token(), - swap_data, - }; - let input = TransactionLoadInput::mock_with_input_type(input_type); - let fee = TransactionFee::new_gas_price_type(GasPriceType::solana(5_000u64, 0u64, 0u64), 5_000u64.into(), 1u64.into(), Default::default(), AssetId::from_chain(Chain::Solana)); - let input = SignerInput::new(input, fee); + let input = swap_input(&transaction_with_wallet_fee_payer(SINGLE_SIG_TX), Some(&gas_limit), 0, 0); - let result = signer.sign_swap(&input, &TEST_PRIVATE_KEY).unwrap(); + let result = SolanaChainSigner.sign_swap(&input, &TEST_PRIVATE_KEY).unwrap(); let signed_transaction = crate::decode_transaction(&result[0]).unwrap(); assert_eq!(signed_transaction.get_compute_unit_limit(), Some(crate::DEFAULT_SWAP_GAS_LIMIT)); } + + #[test] + fn test_sign_swap_v1_uses_absolute_priority_fee_and_quote_gas_limit() { + let transaction = mock_v1_transaction(1, 0); + let original_config = *transaction.transaction_config().unwrap(); + let encoded = encode_base64(&transaction.serialize().unwrap()); + let gas_limit = crate::DEFAULT_SWAP_GAS_LIMIT.to_string(); + + let result = SolanaChainSigner.sign_swap(&swap_input(&encoded, Some(&gas_limit), 12_345, 999_999), &TEST_PRIVATE_KEY).unwrap(); + let signed = crate::decode_transaction(&result[0]).unwrap(); + + assert_eq!(signed.get_priority_fee(), Some(12_345)); + assert_eq!(signed.get_compute_unit_limit(), Some(crate::DEFAULT_SWAP_GAS_LIMIT)); + assert_eq!(signed.get_compute_unit_price(), None); + assert_eq!(signed.transaction_config().unwrap().loaded_accounts_data_size_limit, original_config.loaded_accounts_data_size_limit); + assert_eq!(signed.transaction_config().unwrap().heap_size, original_config.heap_size); + + let result = SolanaChainSigner.sign_swap(&swap_input(&encoded, Some(&gas_limit), 0, 999_999), &TEST_PRIVATE_KEY).unwrap(); + let signed = crate::decode_transaction(&result[0]).unwrap(); + assert_eq!(signed.get_priority_fee(), Some(0)); + } + + #[test] + fn test_sign_swap_fills_the_wallet_signer_slot() { + let transaction = mock_v1_transaction(2, 1); + let original_config = *transaction.transaction_config().unwrap(); + let message_bytes = transaction.serialize_message().unwrap(); + let fee_payer_signature = transaction.signatures()[0]; + let encoded = encode_base64(&transaction.serialize().unwrap()); + let gas_limit = crate::DEFAULT_SWAP_GAS_LIMIT.to_string(); + + let result = SolanaChainSigner.sign_swap(&swap_input(&encoded, Some(&gas_limit), 12_345, 999_999), &TEST_PRIVATE_KEY).unwrap(); + let signed = crate::decode_transaction(&result[0]).unwrap(); + let expected_signature = crate::signer::sign_message(&TEST_PRIVATE_KEY, &message_bytes).unwrap(); + + assert_eq!(*signed.transaction_config().unwrap(), original_config); + assert_eq!(signed.signatures(), &[fee_payer_signature, expected_signature]); + } + + #[test] + fn test_sign_swap_rejects_a_missing_compute_unit_price() { + let mut transaction = mock_legacy_transaction(); + transaction.add_signature(SignatureBytes::new([0; 64])); + let encoded = encode_base64(&transaction.serialize().unwrap()); + + let result = SolanaChainSigner.sign_swap(&swap_input(&encoded, None, 12_345, 999_999), &TEST_PRIVATE_KEY); + + assert_eq!(result.unwrap_err().to_string(), "Invalid input: Solana swap transaction has no compute unit price instruction to replace"); + } } diff --git a/core/crates/gem_solana/src/signer/testkit.rs b/core/crates/gem_solana/src/signer/testkit.rs index 5b5b542f9b..9b085a9815 100644 --- a/core/crates/gem_solana/src/signer/testkit.rs +++ b/core/crates/gem_solana/src/signer/testkit.rs @@ -1,11 +1,17 @@ -use gem_encoding::decode_base64; +use gem_encoding::{decode_base64, encode_base64}; use signer::Ed25519KeyPair; -use crate::{Pubkey, VersionedTransaction}; +use crate::{Pubkey, VersionedTransaction, VersionedTransactionExt, decode_transaction, testkit::test_wallet_pubkey}; pub const TEST_RECIPIENT: &str = "EN2sCsJ1WDV8UFqsiTXHcUPUxQ4juE71eCknHYYMifkd"; pub const TEST_SENDER_TOKEN_ADDRESS: &str = "HEeranxp3y7kVQKVSLdZW1rUmnbs7bAtUTMu8o88Jash"; +pub fn transaction_with_wallet_fee_payer(encoded_base64: &str) -> String { + let mut transaction = decode_transaction(encoded_base64).unwrap(); + transaction.account_keys_mut()[0] = test_wallet_pubkey(); + encode_base64(&transaction.serialize().unwrap()) +} + pub fn sender_address_for_key(private_key: &[u8]) -> String { let key_pair = Ed25519KeyPair::from_private_key(private_key).unwrap(); bs58::encode(key_pair.public_key_bytes).into_string() @@ -30,5 +36,3 @@ pub fn account_key(transaction: &VersionedTransaction, instruction_index: usize, } pub const SINGLE_SIG_TX: &str = "AQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACAAQAIE4X7qT7inGBPqFijUWiMASkIQer7GcY6cKR108e8O++fF6bw89YHC7acs3m1YUIhVlGt8Lsh5HSIZozEPbcak9lEkZ+TiEMZdELfvcljcnBtv3Cf2z6oSYOBlVK0qYEJiVPvd6ryg3kqPlsEMcw3Fwx5sgoudurpYDKruhuxfayEdR478uf/smdZykpYhZIZWgIVX3IpDQW2WlWxw1AX3C53BHo4HDkVOPejukK6/oQdRT8m1S5xpmRD9q8e3XSK/Xu3TqNZNjLp6OSRg8r3sOv1e+QztSj31QG3tKRlT5zLqo0WQ1mJ0Hxkrw69L1dQmgMqgZd20xmPPvg53n23dsfNG6CPj/KUTsrekiJQc2Hvabji48RBleABXKq2lBKpj89u0FRUiC4li6CDgDgrZl6r6UV4hTXUW6fWb5yNguwg6dRIiwf+OZsakVXlghtpfUMBbAo8Tzu8oq+0HQFjMFcAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAMGRm/lIRcy/+ytunLDm+e8jOW7xfcSayxDmzpAAAAABHnVW/IxwG7udMVuzmgVB/2xst6j9I5RArHNola8E48G3fbh12Whk9nL4UbO63msHLSF7V9bN5E6jPWFfv8AqYyXJY9OJInxuz0QKRSODYMLWhOZ2v8QhASOe9jb6fhZrBrj0IfykjcGJUj3DEwErsKplWlJhufLtGdSBiHThjC0P/on9df2SnTAmx8pWHneSwmrNt/J3VFLMhqns4zl6Mb6evO+2606PWXzaqvJdDGxu+TC0vbg5HymAgNFL11hhfF5aGC8uN+dIBDrxV3vHwZYs2RmFKm87C1HtO3e+NIHDAAFAsBcFQAPBgAJACwLDgEBCwIACQwCAAAAgJaYAAAAAAAOAQkBEQ8GAAYAEgsOAQENQg4QAAkFCgYsEgcNEQ0qKRYZCgUXGBArDg0nDi0uEAUhIgQfICYOHiUdGhsoHB4eHh4eHgQDECMOEBQDFQoTCAIBJDLBIJszQdacgQUEAAAAEgA8AAQDKAACB2QCAxEBZAMEgJaYAAAAAACBqQkAAAAAADIAMg4DCQAAAQkEB4tUBJUod+xdJHClaAbwfY0KcsPyS6puvinwAKI7S1cD9e7vBh7xJzZ/XBUMq/0+5x74cLXUfrm4RcW7GMg1vW5lr144gPYI6KKjBMO8u74DwL/CbfplXQ5y4uTDJAQIjShVPQLz8v+me8U9jJd68IPKjLkFvL08uLoAzNqZz4glI8qy5jvrpHYo8Vzh6SmqRgs5a0H2AAOFaZwE6uzp5wNl7es="; -pub const DOUBLE_SIG_TX: &str = "AgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADg8M2FRx269K+zS8zLnv1jrOc5UgDry1oYxecVoCE+FxaIlIE3LTCK5GF5CzCCSkyPQPR14YZsIa38Vu8zmewBgAIAAwuF+6k+4pxgT6hYo1FojAEpCEHq+xnGOnCkddPHvDvvnwoEJW7RK+RvTyYjTaEmmeJJGx7FDytUlV3phwhnLk/r+o7AIiuGV76I/RQwJmovrxVIVynZIDhgTTNAHNxKXKQMOlp0lJiwd9U+29PnQtf+c3R43jQldu6Ve4l4MJzRLHu3TqNZNjLp6OSRg8r3sOv1e+QztSj31QG3tKRlT5zLCWp06QMZwgS1uI/TJ/gwLpboVWOHBIESfT2odVamEF8olyekhrnZjIZzm9FeP8AkdfjUBFdj1PeKOYjOQQ5yIVmPLxjpjiM7L1AxMxqh2a/yjPk5ti2H8FK09PE9u6wRAwZGb+UhFzL/7K26csOb57yM5bvF9xJrLEObOkAAAACMlyWPTiSJ8bs9ECkUjg2DC1oTmdr/EIQEjnvY2+n4WQbd9uHXZaGT2cvhRs7reawctIXtX1s3kTqM9YV+/wCp0P5e0Steyi4OtRMALbRonjhrWQUnM3/sbCIGRwipaicFCAAJA4AaBgAAAAAACQcAAgMLDQoOAQEKAwQCAAkDgJaYAAAAAAAFBQADAgsNvAGP6/zC01qGTam9BJP5vR95KkrtwfmdVFNadaRsOP1WqPLGt8jXWBehgJaYAAAAAAAAAAAAAAAAAJF5EQAAAAAAAAAAAAAAAAAVAIk/g7omh1PrQHSTjRmxQaSVbGwIsUkg7qwrN0tYmJrlA5JYGB9c6sjb/7cDCJAkPK7WmpWZ0ohtlXqct2Vq872zKDwhDQAAAAAhh39oAAAAAJ1rmLGP0mte/uxo0CDc8b56lMLDFTU3ebxrOu1EGI3fMgUTAwIABQsGDxAREgcTDAEUFRYKDQiNNiXP7dL61wHZww37lhGNrgM1pfXVtOEebA/y8LN1Fi1I9ekUZZPYqwIREAopJw4LGgwPAwQg"; -pub const EXPECTED_MESSAGE_HEX: &str = "800100081385fba93ee29c604fa858a351688c01290841eafb19c63a70a475d3c7bc3bef9f17a6f0f3d6070bb69cb379b56142215651adf0bb21e47488668cc43db71a93d944919f938843197442dfbdc96372706dbf709fdb3ea84983819552b4a981098953ef77aaf283792a3e5b0431cc37170c79b20a2e76eae96032abba1bb17dac84751e3bf2e7ffb26759ca4a588592195a02155f72290d05b65a55b1c35017dc2e77047a381c391538f7a3ba42bafe841d453f26d52e71a66443f6af1edd748afd7bb74ea3593632e9e8e49183caf7b0ebf57be433b528f7d501b7b4a4654f9ccbaa8d16435989d07c64af0ebd2f57509a032a819776d3198f3ef839de7db776c7cd1ba08f8ff2944ecade9222507361ef69b8e2e3c44195e0015caab69412a98fcf6ed05454882e258ba08380382b665eabe945788535d45ba7d66f9c8d82ec20e9d4488b07fe399b1a9155e5821b697d43016c0a3c4f3bbca2afb41d0163305700000000000000000000000000000000000000000000000000000000000000000306466fe5211732ffecadba72c39be7bc8ce5bbc5f7126b2c439b3a400000000479d55bf231c06eee74c56ece681507fdb1b2dea3f48e5102b1cda256bc138f06ddf6e1d765a193d9cbe146ceeb79ac1cb485ed5f5b37913a8cf5857eff00a98c97258f4e2489f1bb3d1029148e0d830b5a1399daff1084048e7bd8dbe9f859ac1ae3d087f29237062548f70c4c04aec2a995694986e7cbb467520621d38630b43ffa27f5d7f64a74c09b1f295879de4b09ab36dfc9dd514b321aa7b38ce5e8c6fa7af3bedbad3a3d65f36aabc97431b1bbe4c2d2f6e0e47ca60203452f5d6185f1796860bcb8df9d2010ebc55def1f0658b3646614a9bcec2d47b4eddef8d2070c000502c05c15000f060009002c0b0e01010b0200090c0200000080969800000000000e010901110f06000600120b0e01010d420e100009050a062c12070d110d2a2916190a051718102b0e0d270e2d2e10052122041f20260e1e251d1a1b281c1e1e1e1e1e1e040310230e101403150a130802012432c1209b3341d69c81050400000012003c000403280002076402031101640304809698000000000081a90900000000003200320e03090000010904078b5404952877ec5d2470a56806f07d8d0a72c3f24baa6ebe29f000a23b4b5703f5eeef061ef127367f5c150cabfd3ee71ef870b5d47eb9b845c5bb18c835bd6e65af5e3880f608e8a2a304c3bcbbbe03c0bfc26dfa655d0e72e2e4c32404088d28553d02f3f2ffa67bc53d8c977af083ca8cb905bcbd3cb8ba00ccda99cf882523cab2e63beba47628f15ce1e929aa460b396b41f6000385699c04eaece9e70365edeb"; diff --git a/core/crates/gem_solana/src/signer/transaction.rs b/core/crates/gem_solana/src/signer/transaction.rs index 443f5b76b1..8e4298295d 100644 --- a/core/crates/gem_solana/src/signer/transaction.rs +++ b/core/crates/gem_solana/src/signer/transaction.rs @@ -1,14 +1,42 @@ +use ::signer::Ed25519KeyPair; use gem_encoding::encode_base64; use num_traits::ToPrimitive; use primitives::{SignerError, SignerInput, TransactionFee}; use super::sign_message; use crate::{ - AccountMeta, Instruction, Pubkey, VersionedTransaction, + AccountMeta, Instruction, Pubkey, SignatureBytes, VersionedTransaction, builder::{AccountBuckets, collect_accounts, compile_legacy}, instructions::compute_budget::{set_compute_unit_limit, set_compute_unit_price}, }; +pub(crate) fn sign_transaction(transaction: &mut VersionedTransaction, private_key: &[u8]) -> Result { + let wallet = Pubkey::new(Ed25519KeyPair::from_private_key(private_key)?.public_key_bytes); + let required_signers = transaction.num_required_signatures() as usize; + let wallet_slots = transaction + .account_keys() + .iter() + .take(required_signers) + .enumerate() + .filter_map(|(index, account)| (*account == wallet).then_some(index)) + .collect::>(); + + if wallet_slots.is_empty() { + return Err(SignerError::invalid_input("wallet account is not a required signer of the Solana transaction")); + } + if wallet_slots.iter().any(|index| transaction.signatures().get(*index) != Some(&SignatureBytes::default())) { + return Err(SignerError::invalid_input("Solana transaction already contains the wallet signature")); + } + + let signature = sign_message(private_key, &transaction.serialize_message()?)?; + for (index, slot) in transaction.signatures_mut().iter_mut().enumerate() { + if wallet_slots.contains(&index) { + *slot = signature; + } + } + Ok(signature) +} + pub(crate) fn compute_budget_instructions(fee: &TransactionFee) -> Result, SignerError> { let unit_price = fee.unit_price_u64()?; let gas_limit = fee.gas_limit.to_u32().ok_or_else(|| SignerError::invalid_input("invalid gas limit"))?; @@ -51,14 +79,7 @@ pub(super) fn block_hash(input: &SignerInput) -> Result<[u8; 32], SignerError> { #[cfg(test)] mod tests { use super::*; - use crate::{CompiledInstruction, Message, MessageHeader, SignatureBytes, decode_transaction, signer::testkit::SINGLE_SIG_TX}; - - #[test] - fn test_decode_transaction_compute_unit_limit() { - let transaction = decode_transaction(SINGLE_SIG_TX).unwrap(); - - assert_eq!(transaction.get_compute_unit_limit(), Some(1_400_000)); - } + use crate::{CompiledInstruction, Message, MessageHeader, SignatureBytes}; #[test] fn test_build_legacy_transaction_preserves_account_order_by_bucket() { @@ -108,25 +129,10 @@ mod tests { VersionedTransaction::Legacy { signatures: vec![SignatureBytes::default()], message: Message { - header: MessageHeader { - num_required_signatures: 1, - num_readonly_signed_accounts: 0, - num_readonly_unsigned_accounts: 4, - }, + header: MessageHeader::mock(1, 4), account_keys: vec![fee_payer, writable, readonly_first, readonly_second, program_first, program_second], recent_blockhash: [0; 32], - instructions: vec![ - CompiledInstruction { - program_id_index: 4, - accounts: vec![0, 2, 1], - data: vec![1], - }, - CompiledInstruction { - program_id_index: 5, - accounts: vec![3], - data: vec![2], - }, - ], + instructions: vec![CompiledInstruction::mock(4, vec![0, 2, 1], vec![1]), CompiledInstruction::mock(5, vec![3], vec![2])], }, } ); diff --git a/core/crates/gem_solana/src/testkit.rs b/core/crates/gem_solana/src/testkit.rs deleted file mode 100644 index 17d7dbd23e..0000000000 --- a/core/crates/gem_solana/src/testkit.rs +++ /dev/null @@ -1,184 +0,0 @@ -use primitives::testkit::signer_mock::TEST_PRIVATE_KEY_SOLANA_ADDRESS; - -use num_bigint::BigUint; - -use crate::{ - AccountMeta, AddressLookupTableAccount, CompiledInstruction, Instruction, Message, MessageHeader, Pubkey, SYSTEM_PROGRAM_ID, VersionedTransaction, - models::{AccountData, BlockTransaction, EpochInfo, Info, Meta, Parsed, StakeDelegation, StakeInfo, TokenAccountData, TokenAccountInfo, TokenAccountInfoData, TokenAmount, TokenBalance, Transaction, TransactionMessage, ValueData}, - siws::SiwsMessage, -}; - -pub(crate) const TEST_BLOCKHASH: [u8; 32] = [1; 32]; - -impl Pubkey { - pub(crate) fn mock(value: u32) -> Self { - let mut bytes = [0; 32]; - bytes[..4].copy_from_slice(&value.to_le_bytes()); - Self::new(bytes) - } -} - -impl AddressLookupTableAccount { - pub(crate) fn mock(table_key: &str, entries: &[(u8, &str)]) -> Self { - let max_index = entries.iter().map(|(index, _)| *index).max().unwrap_or(0) as usize; - let mut addresses: Vec = (0..=max_index) - .map(|entry_index| { - let mut bytes = [0; 32]; - bytes[0] = 0xFE; - bytes[1..3].copy_from_slice(&(entry_index as u16).to_le_bytes()); - Pubkey::new(bytes) - }) - .collect(); - - for (index, value) in entries { - addresses[*index as usize] = Pubkey::from_base58(value).unwrap(); - } - - Self::new(Pubkey::from_base58(table_key).unwrap(), addresses) - } -} - -impl Instruction { - pub(crate) fn mock(program_id_index: usize, account_indexes: &[u8], data_base58: &str, combined_accounts: &[AccountMeta]) -> Self { - Self { - program_id: combined_accounts[program_id_index].pubkey, - accounts: account_indexes.iter().map(|index| combined_accounts[*index as usize].clone()).collect(), - data: bs58::decode(data_base58).into_vec().unwrap(), - } - } -} - -pub(crate) fn mock_transaction(programs: &[(&str, Vec)]) -> VersionedTransaction { - let mut account_keys = vec![Pubkey::new([1; 32])]; - account_keys.extend(programs.iter().map(|(program, _)| Pubkey::from_base58(program).unwrap())); - let instructions = programs - .iter() - .enumerate() - .map(|(index, (_, data))| CompiledInstruction { - program_id_index: (index + 1) as u8, - accounts: vec![], - data: data.clone(), - }) - .collect(); - - mock_transaction_with_accounts(account_keys, instructions) -} - -#[cfg(feature = "signer")] -pub(crate) fn mock_legacy_transaction() -> VersionedTransaction { - mock_transaction_with_accounts( - vec![Pubkey::new([1; 32]), Pubkey::new([2; 32])], - vec![CompiledInstruction { - program_id_index: 1, - accounts: vec![0], - data: vec![], - }], - ) -} - -pub(crate) fn mock_transaction_with_accounts(account_keys: Vec, instructions: Vec) -> VersionedTransaction { - VersionedTransaction::Legacy { - signatures: vec![], - message: Message { - header: MessageHeader { - num_required_signatures: 1, - num_readonly_signed_accounts: 0, - num_readonly_unsigned_accounts: (account_keys.len() - 1) as u8, - }, - account_keys, - recent_blockhash: [0; 32], - instructions, - }, - } -} - -impl SiwsMessage { - pub(crate) fn mock_complete() -> Self { - Self::parse(include_str!("../testdata/siws_complete.txt")).unwrap().unwrap() - } -} - -pub(crate) fn mock_siws_message(body: &str) -> String { - format!("example.com wants you to sign in with your Solana account:\n{TEST_PRIVATE_KEY_SOLANA_ADDRESS}{body}") -} - -impl AccountData { - pub(crate) fn mock() -> Self { - ValueData { - data: vec![], - owner: SYSTEM_PROGRAM_ID.to_string(), - } - } -} - -impl EpochInfo { - pub fn mock(slot_index: u64) -> Self { - EpochInfo { - epoch: 200, - slot_index, - slots_in_epoch: 432000, - } - } -} - -impl TokenBalance { - pub fn mock(mint: &str, owner: &str, amount: u64) -> Self { - Self { - account_index: 0, - mint: mint.to_string(), - owner: owner.to_string(), - ui_token_amount: TokenAmount { amount: BigUint::from(amount), decimals: 6 }, - } - } -} - -impl TokenAccountInfo { - pub fn mock_stake(activation_epoch: u64, deactivation_epoch: u64) -> Self { - Self { - pubkey: "stake1".to_string(), - account: TokenAccountData { - data: Parsed { - parsed: Info { - info: TokenAccountInfoData { - mint: None, - token_amount: None, - stake: Some(StakeInfo { - delegation: StakeDelegation { - activation_epoch, - deactivation_epoch, - stake: "1000000".to_string(), - voter: "validator1".to_string(), - }, - }), - }, - }, - }, - owner: "owner1".to_string(), - lamports: 1000000, - }, - } - } -} - -impl BlockTransaction { - pub fn mock(account_keys: &[&str], pre_balances: Vec, post_balances: Vec) -> Self { - Self { - meta: Meta { - err: None, - fee: 5000, - pre_balances, - post_balances, - pre_token_balances: vec![], - post_token_balances: vec![], - loaded_addresses: None, - }, - transaction: Transaction { - message: TransactionMessage { - account_keys: account_keys.iter().map(|key| key.to_string()).collect(), - instructions: vec![], - }, - signatures: vec![], - }, - } - } -} diff --git a/core/crates/gem_solana/src/testkit/account_data_mock.rs b/core/crates/gem_solana/src/testkit/account_data_mock.rs new file mode 100644 index 0000000000..093074b4f0 --- /dev/null +++ b/core/crates/gem_solana/src/testkit/account_data_mock.rs @@ -0,0 +1,13 @@ +use crate::{ + SYSTEM_PROGRAM_ID, + models::{AccountData, ValueData}, +}; + +impl AccountData { + pub(crate) fn mock() -> Self { + ValueData { + data: vec![], + owner: SYSTEM_PROGRAM_ID.to_string(), + } + } +} diff --git a/core/crates/gem_solana/src/testkit/block_transaction_mock.rs b/core/crates/gem_solana/src/testkit/block_transaction_mock.rs new file mode 100644 index 0000000000..c26fb85e50 --- /dev/null +++ b/core/crates/gem_solana/src/testkit/block_transaction_mock.rs @@ -0,0 +1,24 @@ +use crate::models::{BlockTransaction, Meta, Transaction, TransactionMessage}; + +impl BlockTransaction { + pub fn mock(account_keys: &[&str], pre_balances: Vec, post_balances: Vec) -> Self { + Self { + meta: Meta { + err: None, + fee: 5000, + pre_balances, + post_balances, + pre_token_balances: vec![], + post_token_balances: vec![], + loaded_addresses: None, + }, + transaction: Transaction { + message: TransactionMessage { + account_keys: account_keys.iter().map(|key| key.to_string()).collect(), + instructions: vec![], + }, + signatures: vec![], + }, + } + } +} diff --git a/core/crates/gem_solana/src/testkit/epoch_info_mock.rs b/core/crates/gem_solana/src/testkit/epoch_info_mock.rs new file mode 100644 index 0000000000..bea6188935 --- /dev/null +++ b/core/crates/gem_solana/src/testkit/epoch_info_mock.rs @@ -0,0 +1,11 @@ +use crate::models::EpochInfo; + +impl EpochInfo { + pub fn mock(slot_index: u64) -> Self { + EpochInfo { + epoch: 200, + slot_index, + slots_in_epoch: 432000, + } + } +} diff --git a/core/crates/gem_solana/src/testkit/instruction_mock.rs b/core/crates/gem_solana/src/testkit/instruction_mock.rs new file mode 100644 index 0000000000..4e4608de03 --- /dev/null +++ b/core/crates/gem_solana/src/testkit/instruction_mock.rs @@ -0,0 +1,17 @@ +use crate::{AccountMeta, CompiledInstruction, Instruction}; + +impl Instruction { + pub(crate) fn mock(program_id_index: usize, account_indexes: &[u8], data_base58: &str, combined_accounts: &[AccountMeta]) -> Self { + Self { + program_id: combined_accounts[program_id_index].pubkey, + accounts: account_indexes.iter().map(|index| combined_accounts[*index as usize].clone()).collect(), + data: bs58::decode(data_base58).into_vec().unwrap(), + } + } +} + +impl CompiledInstruction { + pub(crate) fn mock(program_id_index: u8, accounts: Vec, data: Vec) -> Self { + Self { program_id_index, accounts, data } + } +} diff --git a/core/crates/gem_solana/src/testkit/lookup_table_mock.rs b/core/crates/gem_solana/src/testkit/lookup_table_mock.rs new file mode 100644 index 0000000000..e5884de67f --- /dev/null +++ b/core/crates/gem_solana/src/testkit/lookup_table_mock.rs @@ -0,0 +1,21 @@ +use crate::{AddressLookupTableAccount, Pubkey}; + +impl AddressLookupTableAccount { + pub(crate) fn mock(table_key: &str, entries: &[(u8, &str)]) -> Self { + let max_index = entries.iter().map(|(index, _)| *index).max().unwrap_or(0) as usize; + let mut addresses: Vec = (0..=max_index) + .map(|entry_index| { + let mut bytes = [0; 32]; + bytes[0] = 0xFE; + bytes[1..3].copy_from_slice(&(entry_index as u16).to_le_bytes()); + Pubkey::new(bytes) + }) + .collect(); + + for (index, value) in entries { + addresses[*index as usize] = Pubkey::from_base58(value).unwrap(); + } + + Self::new(Pubkey::from_base58(table_key).unwrap(), addresses) + } +} diff --git a/core/crates/gem_solana/src/testkit/message_mock.rs b/core/crates/gem_solana/src/testkit/message_mock.rs new file mode 100644 index 0000000000..972479928d --- /dev/null +++ b/core/crates/gem_solana/src/testkit/message_mock.rs @@ -0,0 +1,36 @@ +use crate::{CompiledInstruction, Message, MessageHeader, Pubkey, TransactionConfig, VersionedMessageV1, testkit::TEST_BLOCKHASH}; + +impl MessageHeader { + pub(crate) fn mock(num_required_signatures: u8, num_readonly_unsigned_accounts: u8) -> Self { + Self { + num_required_signatures, + num_readonly_signed_accounts: 0, + num_readonly_unsigned_accounts, + } + } +} + +impl TransactionConfig { + pub(crate) fn mock() -> Self { + Self { + priority_fee: Some(10_000), + compute_unit_limit: Some(200_000), + loaded_accounts_data_size_limit: Some(8192), + heap_size: Some(32 * 1024), + } + } +} + +impl VersionedMessageV1 { + pub(crate) fn mock(num_required_signatures: u8, account_keys: Vec, instructions: Vec, config: TransactionConfig) -> Self { + Self { + message: Message { + header: MessageHeader::mock(num_required_signatures, account_keys.len() as u8 - num_required_signatures), + account_keys, + recent_blockhash: TEST_BLOCKHASH, + instructions, + }, + config, + } + } +} diff --git a/core/crates/gem_solana/src/testkit/mod.rs b/core/crates/gem_solana/src/testkit/mod.rs new file mode 100644 index 0000000000..df834ec6ce --- /dev/null +++ b/core/crates/gem_solana/src/testkit/mod.rs @@ -0,0 +1,19 @@ +mod account_data_mock; +mod block_transaction_mock; +mod epoch_info_mock; +mod instruction_mock; +mod lookup_table_mock; +mod message_mock; +mod pubkey_mock; +mod siws_mock; +mod token_account_info_mock; +mod token_balance_mock; +mod transaction_mock; + +pub(crate) use pubkey_mock::test_wallet_pubkey; +pub(crate) use siws_mock::mock_siws_message; +#[cfg(feature = "signer")] +pub(crate) use transaction_mock::mock_legacy_transaction; +pub(crate) use transaction_mock::{mock_transaction, mock_transaction_with_accounts, mock_v0_transaction, mock_v1_transaction}; + +pub(crate) const TEST_BLOCKHASH: [u8; 32] = [1; 32]; diff --git a/core/crates/gem_solana/src/testkit/pubkey_mock.rs b/core/crates/gem_solana/src/testkit/pubkey_mock.rs new file mode 100644 index 0000000000..714a3ad437 --- /dev/null +++ b/core/crates/gem_solana/src/testkit/pubkey_mock.rs @@ -0,0 +1,15 @@ +use primitives::testkit::signer_mock::TEST_PRIVATE_KEY_SOLANA_ADDRESS; + +use crate::Pubkey; + +impl Pubkey { + pub(crate) fn mock(value: u32) -> Self { + let mut bytes = [0; 32]; + bytes[..4].copy_from_slice(&value.to_le_bytes()); + Self::new(bytes) + } +} + +pub(crate) fn test_wallet_pubkey() -> Pubkey { + Pubkey::from_base58(TEST_PRIVATE_KEY_SOLANA_ADDRESS).unwrap() +} diff --git a/core/crates/gem_solana/src/testkit/siws_mock.rs b/core/crates/gem_solana/src/testkit/siws_mock.rs new file mode 100644 index 0000000000..11170c99e5 --- /dev/null +++ b/core/crates/gem_solana/src/testkit/siws_mock.rs @@ -0,0 +1,13 @@ +use primitives::testkit::signer_mock::TEST_PRIVATE_KEY_SOLANA_ADDRESS; + +use crate::siws::SiwsMessage; + +impl SiwsMessage { + pub(crate) fn mock_complete() -> Self { + Self::parse(include_str!("../../testdata/siws_complete.txt")).unwrap().unwrap() + } +} + +pub(crate) fn mock_siws_message(body: &str) -> String { + format!("example.com wants you to sign in with your Solana account:\n{TEST_PRIVATE_KEY_SOLANA_ADDRESS}{body}") +} diff --git a/core/crates/gem_solana/src/testkit/token_account_info_mock.rs b/core/crates/gem_solana/src/testkit/token_account_info_mock.rs new file mode 100644 index 0000000000..5b886fe6d5 --- /dev/null +++ b/core/crates/gem_solana/src/testkit/token_account_info_mock.rs @@ -0,0 +1,29 @@ +use crate::models::{Info, Parsed, StakeDelegation, StakeInfo, TokenAccountData, TokenAccountInfo, TokenAccountInfoData}; + +impl TokenAccountInfo { + pub fn mock_stake(activation_epoch: u64, deactivation_epoch: u64) -> Self { + Self { + pubkey: "stake1".to_string(), + account: TokenAccountData { + data: Parsed { + parsed: Info { + info: TokenAccountInfoData { + mint: None, + token_amount: None, + stake: Some(StakeInfo { + delegation: StakeDelegation { + activation_epoch, + deactivation_epoch, + stake: "1000000".to_string(), + voter: "validator1".to_string(), + }, + }), + }, + }, + }, + owner: "owner1".to_string(), + lamports: 1000000, + }, + } + } +} diff --git a/core/crates/gem_solana/src/testkit/token_balance_mock.rs b/core/crates/gem_solana/src/testkit/token_balance_mock.rs new file mode 100644 index 0000000000..cc32eb4769 --- /dev/null +++ b/core/crates/gem_solana/src/testkit/token_balance_mock.rs @@ -0,0 +1,14 @@ +use num_bigint::BigUint; + +use crate::models::{TokenAmount, TokenBalance}; + +impl TokenBalance { + pub fn mock(mint: &str, owner: &str, amount: u64) -> Self { + Self { + account_index: 0, + mint: mint.to_string(), + owner: owner.to_string(), + ui_token_amount: TokenAmount { amount: BigUint::from(amount), decimals: 6 }, + } + } +} diff --git a/core/crates/gem_solana/src/testkit/transaction_mock.rs b/core/crates/gem_solana/src/testkit/transaction_mock.rs new file mode 100644 index 0000000000..79c69e1a1b --- /dev/null +++ b/core/crates/gem_solana/src/testkit/transaction_mock.rs @@ -0,0 +1,56 @@ +use crate::{ + CompiledInstruction, Message, MessageAddressTableLookup, MessageHeader, Pubkey, SignatureBytes, TransactionConfig, VersionedMessageV0, VersionedMessageV1, VersionedTransaction, + testkit::{TEST_BLOCKHASH, test_wallet_pubkey}, +}; + +pub(crate) fn mock_transaction(programs: &[(&str, Vec)]) -> VersionedTransaction { + let mut account_keys = vec![Pubkey::new([1; 32])]; + account_keys.extend(programs.iter().map(|(program, _)| Pubkey::from_base58(program).unwrap())); + let instructions = programs.iter().enumerate().map(|(index, (_, data))| CompiledInstruction::mock((index + 1) as u8, vec![], data.clone())).collect(); + + mock_transaction_with_accounts(account_keys, instructions) +} + +#[cfg(feature = "signer")] +pub(crate) fn mock_legacy_transaction() -> VersionedTransaction { + mock_transaction_with_accounts(vec![test_wallet_pubkey(), Pubkey::new([2; 32])], vec![CompiledInstruction::mock(1, vec![0], vec![])]) +} + +pub(crate) fn mock_transaction_with_accounts(account_keys: Vec, instructions: Vec) -> VersionedTransaction { + VersionedTransaction::Legacy { + signatures: vec![], + message: Message { + header: MessageHeader::mock(1, (account_keys.len() - 1) as u8), + account_keys, + recent_blockhash: [0; 32], + instructions, + }, + } +} + +pub(crate) fn mock_v0_transaction(account_keys: Vec, instructions: Vec, address_table_lookups: Vec) -> VersionedTransaction { + VersionedTransaction::V0 { + signatures: vec![SignatureBytes::default()], + message: VersionedMessageV0 { + message: Message { + header: MessageHeader::mock(1, 1), + account_keys, + recent_blockhash: TEST_BLOCKHASH, + instructions, + }, + address_table_lookups, + }, + } +} + +pub(crate) fn mock_v1_transaction(signature_count: u8, wallet_index: u8) -> VersionedTransaction { + let mut account_keys = (1..=signature_count).map(|value| Pubkey::new([value; 32])).collect::>(); + if let Some(account) = account_keys.get_mut(wallet_index as usize) { + *account = test_wallet_pubkey(); + } + account_keys.push(Pubkey::new([100; 32])); + VersionedTransaction::V1 { + signatures: (0..signature_count).map(|value| if value == wallet_index { SignatureBytes::default() } else { SignatureBytes::new([value + 1; 64]) }).collect(), + message: VersionedMessageV1::mock(signature_count, account_keys, vec![CompiledInstruction::mock(signature_count, vec![0], vec![0xde, 0xad])], TransactionConfig::mock()), + } +} diff --git a/core/crates/gem_solana/src/transaction.rs b/core/crates/gem_solana/src/transaction.rs index 9be479fcbc..52ca5d622d 100644 --- a/core/crates/gem_solana/src/transaction.rs +++ b/core/crates/gem_solana/src/transaction.rs @@ -4,7 +4,17 @@ use primitives::{AssetId, Chain, SolanaInstruction, TransactionType}; use crate::{ AccountMeta, AddressLookupTableAccount, CompiledInstruction, Instruction, Pubkey, TransactionBuilder, VersionedTransaction, - instructions::program_ids::{SOLANA_ASSOCIATED_TOKEN_ACCOUNT_PROGRAM_ID, SOLANA_COMPUTE_BUDGET_PROGRAM_ID, SOLANA_MEMO_PROGRAM_ID, SOLANA_SYSTEM_PROGRAM_ID, SOLANA_TOKEN_2022_PROGRAM_ID, SOLANA_TOKEN_PROGRAM_ID}, + instructions::{ + associated_token::is_create_account_data, + program_ids::{SOLANA_ASSOCIATED_TOKEN_ACCOUNT_PROGRAM_ID, SOLANA_COMPUTE_BUDGET_PROGRAM_ID, SOLANA_MEMO_PROGRAM_ID, SOLANA_SYSTEM_PROGRAM_ID, SOLANA_TOKEN_2022_PROGRAM_ID, SOLANA_TOKEN_PROGRAM_ID, system_program}, + system::is_advance_nonce_account_data, + }, +}; + +#[cfg(feature = "signer")] +use crate::{ + encode_length_to_compact_u16_bytes, + types::{MESSAGE_VERSION_PREFIX, OFFCHAIN_MESSAGE_PREFIX}, }; pub trait VersionedTransactionExt { @@ -17,6 +27,8 @@ pub trait VersionedTransactionExt { fn simple_transfer(&self, signer: &Pubkey) -> Option; fn transaction_type(&self) -> TransactionType; + + fn uses_durable_nonce(&self) -> bool; } #[derive(Debug, Clone, PartialEq, Eq)] @@ -56,6 +68,13 @@ impl VersionedTransactionExt for VersionedTransaction { None => TransactionType::SmartContractCall, } } + + fn uses_durable_nonce(&self) -> bool { + let account_keys = self.account_keys(); + self.instructions() + .first() + .is_some_and(|instruction| account_keys.get(instruction.program_id_index as usize) == Some(&system_program()) && is_advance_nonce_account_data(&instruction.data)) + } } fn decode_transfer(transaction: &VersionedTransaction, signer: Option<&Pubkey>) -> Option { @@ -117,8 +136,7 @@ fn associated_token_owner<'a>(transaction: &'a VersionedTransaction, token_accou let account_keys = transaction.account_keys(); transaction.instructions().iter().find_map(|instruction| { let program = account_keys.get(instruction.program_id_index as usize)?; - let creates_account = instruction.data.is_empty() || instruction.data.as_slice() == [1]; - if program.to_base58() != SOLANA_ASSOCIATED_TOKEN_ACCOUNT_PROGRAM_ID || !creates_account || instruction_account(instruction, account_keys, 1)? != token_account { + if program.to_base58() != SOLANA_ASSOCIATED_TOKEN_ACCOUNT_PROGRAM_ID || !is_create_account_data(&instruction.data) || instruction_account(instruction, account_keys, 1)? != token_account { return None; } instruction_account(instruction, account_keys, 2) @@ -141,13 +159,21 @@ pub(crate) fn try_decode_transaction_bytes(transaction: &[u8]) -> Option bool { - try_decode_transaction_bytes(transaction).is_some() || try_decode_transaction_message(transaction).is_some() + is_versioned_message(transaction) || try_decode_transaction_bytes(transaction).is_some() || try_decode_legacy_message(transaction).is_some() } #[cfg(feature = "signer")] -fn try_decode_transaction_message(message: &[u8]) -> Option { - let mut transaction = Vec::with_capacity(message.len() + 1); - transaction.push(0); +fn is_versioned_message(payload: &[u8]) -> bool { + payload.first().is_some_and(|prefix| (MESSAGE_VERSION_PREFIX..OFFCHAIN_MESSAGE_PREFIX).contains(prefix)) +} + +#[cfg(feature = "signer")] +fn try_decode_legacy_message(message: &[u8]) -> Option { + const SIGNATURE_LENGTH: usize = 64; + + let signature_count = *message.first()? as usize; + let mut transaction = encode_length_to_compact_u16_bytes(signature_count).ok()?; + transaction.resize(transaction.len() + signature_count * SIGNATURE_LENGTH, 0); transaction.extend_from_slice(message); let decoded = VersionedTransaction::deserialize_with_version(&transaction).ok()?; @@ -239,26 +265,11 @@ mod tests { transfer_data.push(6); let mut legacy_transfer_data = vec![3]; legacy_transfer_data.extend_from_slice(&19_000_000u64.to_le_bytes()); - let transfer_instruction = CompiledInstruction { - program_id_index: 4, - accounts: vec![1, 2, 3, 0], - data: transfer_data, - }; - let memo_instruction = CompiledInstruction { - program_id_index: 5, - accounts: vec![], - data: b"payment memo".to_vec(), - }; + let transfer_instruction = CompiledInstruction::mock(4, vec![1, 2, 3, 0], transfer_data); + let memo_instruction = CompiledInstruction::mock(5, vec![], b"payment memo".to_vec()); let transfer = mock_transaction_with_accounts(account_keys.clone(), vec![transfer_instruction.clone(), memo_instruction]); let truncated_transfer = mock_transaction(&[(SOLANA_TOKEN_PROGRAM_ID, vec![12])]); - let legacy_transfer = mock_transaction_with_accounts( - account_keys.clone(), - vec![CompiledInstruction { - program_id_index: 4, - accounts: vec![1, 3, 0], - data: legacy_transfer_data, - }], - ); + let legacy_transfer = mock_transaction_with_accounts(account_keys.clone(), vec![CompiledInstruction::mock(4, vec![1, 3, 0], legacy_transfer_data)]); let ambiguous = mock_transaction_with_accounts(account_keys, vec![transfer_instruction.clone(), transfer_instruction]); let contract_call = mock_transaction(&[("BPFLoaderUpgradeab1e11111111111111111111111", vec![1])]); @@ -289,20 +300,13 @@ mod tests { let mut transfer_data = vec![12]; transfer_data.extend_from_slice(&19_000_000u64.to_le_bytes()); transfer_data.push(6); - let instructions = vec![ - CompiledInstruction { - program_id_index: 5, - accounts: vec![0, 3, 4, 2], - data: vec![1], - }, - CompiledInstruction { - program_id_index: 6, - accounts: vec![1, 2, 3, 0], - data: transfer_data, - }, - ]; + let instructions = vec![CompiledInstruction::mock(5, vec![0, 3, 4, 2], vec![1]), CompiledInstruction::mock(6, vec![1, 2, 3, 0], transfer_data)]; let transaction = mock_transaction_with_accounts(account_keys.clone(), instructions.clone()); - let ambiguous = mock_transaction_with_accounts(account_keys, vec![instructions[1].clone(), instructions[1].clone()]); + let ambiguous = mock_transaction_with_accounts(account_keys.clone(), vec![instructions[1].clone(), instructions[1].clone()]); + let create_instructions = [vec![], vec![0], vec![1]].map(|data| { + let instructions = vec![CompiledInstruction::mock(instructions[0].program_id_index, instructions[0].accounts.clone(), data), instructions[1].clone()]; + mock_transaction_with_accounts(account_keys.clone(), instructions) + }); let transfer = transaction.simple_transfer(&payer).unwrap(); assert_eq!(transfer.asset_id, AssetId::from_token(Chain::Solana, &mint.to_base58())); @@ -310,6 +314,9 @@ mod tests { assert_eq!(transfer.value, BigUint::from(19_000_000u64)); assert_eq!(ambiguous.simple_transfer(&payer), None); assert_eq!(transaction.simple_transfer(&Pubkey::new([9; 32])), None); + for transaction in create_instructions { + assert_eq!(transaction.simple_transfer(&payer).unwrap().recipient, recipient.to_base58()); + } } #[cfg(feature = "signer")] @@ -327,7 +334,10 @@ mod tests { transaction_with_trailing_byte.push(0); v0_message.push(0); assert!(!is_transaction_bytes(&transaction_with_trailing_byte)); - assert!(!is_transaction_bytes(&v0_message)); + assert!(is_transaction_bytes(&v0_message)); + assert!(is_transaction_bytes(&[MESSAGE_VERSION_PREFIX | 2, 1, 2, 3])); + assert!(is_transaction_bytes(&[OFFCHAIN_MESSAGE_PREFIX - 1])); + assert!(!is_transaction_bytes(&[OFFCHAIN_MESSAGE_PREFIX, 1, 2, 3])); assert!(!is_transaction_bytes(b"hello")); } } diff --git a/core/crates/gem_solana/src/types/message.rs b/core/crates/gem_solana/src/types/message.rs index e93dee7b5f..e1a0302fff 100644 --- a/core/crates/gem_solana/src/types/message.rs +++ b/core/crates/gem_solana/src/types/message.rs @@ -1,9 +1,25 @@ +use std::collections::HashSet; + use crate::{ Result, encode_length_to_compact_u16_bytes, - types::{CompiledInstruction, MessageAddressTableLookup, Pubkey}, + types::{CompiledInstruction, MessageAddressTableLookup, Pubkey, invalid_transaction}, }; pub(crate) const MESSAGE_VERSION_PREFIX: u8 = 0x80; +pub(crate) const MESSAGE_V1_PREFIX: u8 = MESSAGE_VERSION_PREFIX | 1; +#[cfg(feature = "signer")] +pub(crate) const OFFCHAIN_MESSAGE_PREFIX: u8 = 0xFF; + +const MAX_V1_SIGNATURES: usize = 12; +const MAX_V1_ADDRESSES: usize = 64; +const MAX_V1_INSTRUCTIONS: usize = 64; +const MIN_V1_HEAP_SIZE: u32 = 32 * 1024; +const MAX_V1_HEAP_SIZE: u32 = 256 * 1024; +const PRIORITY_FEE_MASK: u32 = 0b11; +const COMPUTE_UNIT_LIMIT_MASK: u32 = 0b100; +const LOADED_ACCOUNTS_DATA_SIZE_LIMIT_MASK: u32 = 0b1000; +const HEAP_SIZE_MASK: u32 = 0b10000; +pub(crate) const V1_CONFIG_MASK: u32 = PRIORITY_FEE_MASK | COMPUTE_UNIT_LIMIT_MASK | LOADED_ACCOUNTS_DATA_SIZE_LIMIT_MASK | HEAP_SIZE_MASK; #[derive(Debug, Clone, PartialEq, Eq)] pub struct MessageHeader { @@ -12,6 +28,35 @@ pub struct MessageHeader { pub num_readonly_unsigned_accounts: u8, } +impl MessageHeader { + pub(crate) fn validate(&self, account_keys_len: usize) -> Result<()> { + let num_required_signatures = self.num_required_signatures as usize; + if num_required_signatures == 0 || num_required_signatures > account_keys_len { + return Err(invalid_transaction("required-signature count must be between one and the account count")); + } + if self.num_readonly_signed_accounts as usize >= num_required_signatures { + return Err(invalid_transaction("read-only signer count leaves no writable fee payer")); + } + if self.num_readonly_unsigned_accounts as usize > account_keys_len - num_required_signatures { + return Err(invalid_transaction("read-only account count exceeds unsigned-account count")); + } + Ok(()) + } +} + +pub(crate) fn validate_instructions(instructions: &[CompiledInstruction], num_static_keys: usize, num_account_keys: usize) -> Result<()> { + for instruction in instructions { + let program_id_index = instruction.program_id_index as usize; + if program_id_index == 0 || program_id_index >= num_static_keys { + return Err(invalid_transaction("instruction program index is invalid")); + } + if instruction.accounts.iter().any(|index| *index as usize >= num_account_keys) { + return Err(invalid_transaction("instruction account index is invalid")); + } + } + Ok(()) +} + #[derive(Debug, Clone, PartialEq, Eq)] pub struct Message { pub header: MessageHeader, @@ -64,6 +109,133 @@ impl VersionedMessageV0 { } } +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub struct TransactionConfig { + pub priority_fee: Option, + pub compute_unit_limit: Option, + pub loaded_accounts_data_size_limit: Option, + pub heap_size: Option, +} + +impl TransactionConfig { + pub(crate) fn mask(&self) -> u32 { + let mut mask = 0; + if self.priority_fee.is_some() { + mask |= PRIORITY_FEE_MASK; + } + if self.compute_unit_limit.is_some() { + mask |= COMPUTE_UNIT_LIMIT_MASK; + } + if self.loaded_accounts_data_size_limit.is_some() { + mask |= LOADED_ACCOUNTS_DATA_SIZE_LIMIT_MASK; + } + if self.heap_size.is_some() { + mask |= HEAP_SIZE_MASK; + } + mask + } + + pub(crate) fn has_priority_fee(mask: u32) -> bool { + mask & PRIORITY_FEE_MASK == PRIORITY_FEE_MASK + } + + pub(crate) fn has_invalid_priority_fee(mask: u32) -> bool { + let priority_fee = mask & PRIORITY_FEE_MASK; + priority_fee != 0 && priority_fee != PRIORITY_FEE_MASK + } + + pub(crate) fn has_compute_unit_limit(mask: u32) -> bool { + mask & COMPUTE_UNIT_LIMIT_MASK != 0 + } + + pub(crate) fn has_loaded_accounts_data_size_limit(mask: u32) -> bool { + mask & LOADED_ACCOUNTS_DATA_SIZE_LIMIT_MASK != 0 + } + + pub(crate) fn has_heap_size(mask: u32) -> bool { + mask & HEAP_SIZE_MASK != 0 + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct VersionedMessageV1 { + pub message: Message, + pub config: TransactionConfig, +} + +impl VersionedMessageV1 { + pub fn serialize_for_signing(&self) -> Result> { + self.validate()?; + + let mut bytes = vec![ + MESSAGE_V1_PREFIX, + self.message.header.num_required_signatures, + self.message.header.num_readonly_signed_accounts, + self.message.header.num_readonly_unsigned_accounts, + ]; + bytes.extend_from_slice(&self.config.mask().to_le_bytes()); + bytes.extend_from_slice(&self.message.recent_blockhash); + bytes.push(self.message.instructions.len() as u8); + bytes.push(self.message.account_keys.len() as u8); + for pubkey in &self.message.account_keys { + bytes.extend_from_slice(pubkey.as_bytes()); + } + if let Some(priority_fee) = self.config.priority_fee { + bytes.extend_from_slice(&priority_fee.to_le_bytes()); + } + if let Some(compute_unit_limit) = self.config.compute_unit_limit { + bytes.extend_from_slice(&compute_unit_limit.to_le_bytes()); + } + if let Some(loaded_accounts_data_size_limit) = self.config.loaded_accounts_data_size_limit { + bytes.extend_from_slice(&loaded_accounts_data_size_limit.to_le_bytes()); + } + if let Some(heap_size) = self.config.heap_size { + bytes.extend_from_slice(&heap_size.to_le_bytes()); + } + for instruction in &self.message.instructions { + bytes.push(instruction.program_id_index); + bytes.push(instruction.accounts.len() as u8); + bytes.extend_from_slice(&(instruction.data.len() as u16).to_le_bytes()); + } + for instruction in &self.message.instructions { + bytes.extend_from_slice(&instruction.accounts); + bytes.extend_from_slice(&instruction.data); + } + Ok(bytes) + } + + pub(crate) fn validate(&self) -> Result<()> { + let account_keys_len = self.message.account_keys.len(); + if self.message.header.num_required_signatures as usize > MAX_V1_SIGNATURES { + return Err(invalid_transaction("V1 required-signature count exceeds 12")); + } + if account_keys_len > MAX_V1_ADDRESSES { + return Err(invalid_transaction("V1 account count exceeds 64")); + } + self.message.header.validate(account_keys_len)?; + if self.message.account_keys.iter().collect::>().len() != account_keys_len { + return Err(invalid_transaction("V1 contains duplicate accounts")); + } + if self.message.instructions.len() > MAX_V1_INSTRUCTIONS { + return Err(invalid_transaction("V1 instruction count exceeds 64")); + } + if let Some(heap_size) = self.config.heap_size + && (!(MIN_V1_HEAP_SIZE..=MAX_V1_HEAP_SIZE).contains(&heap_size) || !heap_size.is_multiple_of(1024)) + { + return Err(invalid_transaction("V1 heap size is invalid")); + } + for instruction in &self.message.instructions { + if instruction.accounts.len() > u8::MAX as usize { + return Err(invalid_transaction("V1 instruction account count exceeds 255")); + } + if instruction.data.len() > u16::MAX as usize { + return Err(invalid_transaction("V1 instruction data length exceeds 65535")); + } + } + validate_instructions(&self.message.instructions, account_keys_len, account_keys_len) + } +} + fn push_compact_slice(bytes: &mut Vec, slice: &[u8]) -> Result<()> { bytes.extend(encode_length_to_compact_u16_bytes(slice.len())?); bytes.extend_from_slice(slice); @@ -75,23 +247,16 @@ mod tests { use hex_lit::hex; use super::*; + use crate::testkit::TEST_BLOCKHASH; #[test] fn test_versioned_message() { let v0_message = VersionedMessageV0 { message: Message { - header: MessageHeader { - num_required_signatures: 1, - num_readonly_signed_accounts: 0, - num_readonly_unsigned_accounts: 1, - }, + header: MessageHeader::mock(1, 1), account_keys: vec![Pubkey::new([0; 32]), Pubkey::new([1; 32])], recent_blockhash: [0u8; 32], - instructions: vec![CompiledInstruction { - program_id_index: 1, - accounts: vec![0], - data: vec![], - }], + instructions: vec![CompiledInstruction::mock(1, vec![0], vec![])], }, address_table_lookups: vec![MessageAddressTableLookup::new(Pubkey::new([2; 32]), vec![0, 1], vec![2])], }; @@ -106,4 +271,43 @@ mod tests { assert_eq!(v0_message.serialize_for_signing().unwrap(), expected); } + + #[test] + fn test_v1_serialize_for_signing() { + let message = VersionedMessageV1::mock( + 1, + vec![Pubkey::new([1; 32]), Pubkey::new([2; 32])], + vec![CompiledInstruction::mock(1, vec![], vec![])], + TransactionConfig { + priority_fee: Some(0x0102030405060708), + compute_unit_limit: Some(0x11223344), + ..TransactionConfig::default() + }, + ); + + let mut expected = hex!("8101000107000000").to_vec(); + expected.extend_from_slice(&TEST_BLOCKHASH); + expected.extend_from_slice(&hex!("0102")); + expected.extend_from_slice(&[1; 32]); + expected.extend_from_slice(&[2; 32]); + expected.extend_from_slice(&hex!("08070605040302014433221101000000")); + + let grouped_message = VersionedMessageV1::mock( + 1, + vec![Pubkey::new([1; 32]), Pubkey::new([2; 32]), Pubkey::new([3; 32])], + vec![CompiledInstruction::mock(1, vec![0], vec![0xaa, 0xbb]), CompiledInstruction::mock(2, vec![0, 1], vec![0xcc])], + TransactionConfig::default(), + ); + + let mut grouped_expected = hex!("8101000200000000").to_vec(); + grouped_expected.extend_from_slice(&TEST_BLOCKHASH); + grouped_expected.extend_from_slice(&hex!("0203")); + grouped_expected.extend_from_slice(&[1; 32]); + grouped_expected.extend_from_slice(&[2; 32]); + grouped_expected.extend_from_slice(&[3; 32]); + grouped_expected.extend_from_slice(&hex!("010102000202010000aabb0001cc")); + + assert_eq!(message.serialize_for_signing().unwrap(), expected); + assert_eq!(grouped_message.serialize_for_signing().unwrap(), grouped_expected); + } } diff --git a/core/crates/gem_solana/src/types/mod.rs b/core/crates/gem_solana/src/types/mod.rs index 33764a61f5..472a546540 100644 --- a/core/crates/gem_solana/src/types/mod.rs +++ b/core/crates/gem_solana/src/types/mod.rs @@ -1,3 +1,7 @@ +use std::fmt::Display; + +use crate::SolanaError; + mod account; mod instruction; mod message; @@ -8,10 +12,19 @@ mod transaction; pub use account::{AddressLookupTableAccount, MessageAddressTableLookup}; pub use instruction::{AccountMeta, CompiledInstruction, Instruction}; -pub use message::{Message, MessageHeader, VersionedMessageV0}; +pub use message::{Message, MessageHeader, TransactionConfig, VersionedMessageV0, VersionedMessageV1}; pub use pda::find_program_address; pub use pubkey::Pubkey; pub use signature::SignatureBytes; pub use transaction::VersionedTransaction; +#[cfg(feature = "signer")] +pub(crate) use message::{MESSAGE_VERSION_PREFIX, OFFCHAIN_MESSAGE_PREFIX}; + pub const MAX_TRANSACTION_SIZE: usize = 1232; +pub(crate) const MAX_V1_TRANSACTION_SIZE: usize = 4096; +pub(crate) const MAX_ACCOUNT_KEYS: usize = u8::MAX as usize + 1; + +pub(crate) fn invalid_transaction(reason: impl Display) -> SolanaError { + SolanaError::invalid_input(format!("Invalid Solana transaction: {reason}")) +} diff --git a/core/crates/gem_solana/src/types/pda.rs b/core/crates/gem_solana/src/types/pda.rs index 11c1f88ceb..685294951d 100644 --- a/core/crates/gem_solana/src/types/pda.rs +++ b/core/crates/gem_solana/src/types/pda.rs @@ -13,7 +13,7 @@ const PROGRAM_DERIVED_ADDRESS_MARKER: &[u8] = b"ProgramDerivedAddress"; pub fn find_program_address(program_id: &Pubkey, seeds: &[&[u8]]) -> Result<(Pubkey, u8)> { validate_seeds(seeds, true)?; - for bump in (0..=u8::MAX).rev() { + for bump in (1..=u8::MAX).rev() { let bump_seed = [bump]; let mut seeds_with_bump = Vec::with_capacity(seeds.len().saturating_add(1)); seeds_with_bump.extend_from_slice(seeds); diff --git a/core/crates/gem_solana/src/types/transaction.rs b/core/crates/gem_solana/src/types/transaction.rs deleted file mode 100644 index e30cd75b67..0000000000 --- a/core/crates/gem_solana/src/types/transaction.rs +++ /dev/null @@ -1,267 +0,0 @@ -use crate::{ - Result, encode_length_to_compact_u16_bytes, - instructions::{ - compute_budget::{parse_compute_unit_limit_data, parse_compute_unit_price_data, set_compute_unit_limit, set_compute_unit_price}, - program_ids::compute_budget_program, - }, - types::{CompiledInstruction, Message, Pubkey, SignatureBytes, VersionedMessageV0}, -}; - -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum VersionedTransaction { - Legacy { signatures: Vec, message: Message }, - V0 { signatures: Vec, message: VersionedMessageV0 }, -} - -impl VersionedTransaction { - pub fn message(&self) -> &Message { - match self { - Self::Legacy { message, .. } => message, - Self::V0 { message, .. } => &message.message, - } - } - - pub fn message_mut(&mut self) -> &mut Message { - match self { - Self::Legacy { message, .. } => message, - Self::V0 { message, .. } => &mut message.message, - } - } - - pub fn num_required_signatures(&self) -> u8 { - self.message().header.num_required_signatures - } - - pub fn account_keys(&self) -> &[Pubkey] { - &self.message().account_keys - } - - pub fn recent_blockhash(&self) -> &[u8; 32] { - &self.message().recent_blockhash - } - - pub fn instructions(&self) -> &[CompiledInstruction] { - &self.message().instructions - } - - pub fn signatures(&self) -> &[SignatureBytes] { - match self { - Self::Legacy { signatures, .. } => signatures, - Self::V0 { signatures, .. } => signatures, - } - } - - pub fn signatures_mut(&mut self) -> &mut Vec { - match self { - Self::Legacy { signatures, .. } => signatures, - Self::V0 { signatures, .. } => signatures, - } - } - - pub fn add_signature(&mut self, signature: SignatureBytes) { - self.signatures_mut().push(signature); - } - - pub fn get_compute_unit_price(&self) -> Option { - self.compute_budget_data().find_map(parse_compute_unit_price_data) - } - - pub fn set_compute_unit_price(&mut self, micro_lamports: u64) -> bool { - self.replace_compute_budget_data(|data| parse_compute_unit_price_data(data).is_some(), set_compute_unit_price(micro_lamports).data) - } - - pub fn get_compute_unit_limit(&self) -> Option { - self.compute_budget_data().find_map(parse_compute_unit_limit_data) - } - - pub fn set_compute_unit_limit(&mut self, units: u32) -> bool { - self.replace_compute_budget_data(|data| parse_compute_unit_limit_data(data).is_some(), set_compute_unit_limit(units).data) - } - - pub fn serialize_message(&self) -> Result> { - match self { - Self::Legacy { message, .. } => message.serialize_for_signing(), - Self::V0 { message, .. } => message.serialize_for_signing(), - } - } - - pub fn serialize(&self) -> Result> { - let mut bytes = encode_length_to_compact_u16_bytes(self.signatures().len())?; - for signature in self.signatures() { - bytes.extend_from_slice(signature.as_bytes()); - } - bytes.extend(self.serialize_message()?); - Ok(bytes) - } - - fn compute_budget_program_index(&self) -> Option { - self.account_keys().iter().position(|key| *key == compute_budget_program()).and_then(|index| u8::try_from(index).ok()) - } - - fn compute_budget_data(&self) -> impl Iterator { - let program_id_index = self.compute_budget_program_index(); - self.instructions() - .iter() - .filter(move |instruction| Some(instruction.program_id_index) == program_id_index) - .map(|instruction| instruction.data.as_slice()) - } - - fn replace_compute_budget_data(&mut self, matches: impl Fn(&[u8]) -> bool, data: Vec) -> bool { - let Some(program_id_index) = self.compute_budget_program_index() else { - return false; - }; - let instruction = self.message_mut().instructions.iter_mut().find(|instruction| instruction.program_id_index == program_id_index && matches(&instruction.data)); - match instruction { - Some(instruction) => { - instruction.data = data; - true - } - None => false, - } - } -} - -mod decoder; - -#[cfg(test)] -mod tests { - use std::iter::repeat_n; - - use super::*; - use crate::{testkit::mock_transaction_with_accounts, types::message::MESSAGE_VERSION_PREFIX}; - use gem_encoding::decode_base64; - - const LEGACY_TX: &str = "AQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABAAgWAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEbrtjJdvWJAv9GZTGL8LaZtMvDe4j2ery4z7rOkRbioxZflXLFqWqlAt1REFSiam0ljvfB1tbBruEpGRTcUQIyQ+ddH9NRneQZQXje5U/3c4cZ2f1JESi76CvBvRoQ6I1LeNzfZ4ZONkowCnqCyeo5+D6Q21gn3U7HVw/KD3HyUW5gVpu5F8ZojWkXLg/+3N6q3ojiaqYyBIbz7VP7jS5Yktrxv5b22C/EFSDs5jUPA7Gz3GLdBNs0iwBHlqUqNEeyNpDX0HWNHV2LiVDOx6m018ea6P+1xroNvWKhmDeTW7oqHXAEK1ih5IO68BBiiKqWNR5VZdBgBsnR+rZKfpfuyE3yQziYO+SoWzCXuvQLyVcRCNKJrACzaN8XXUR1z3rOt8T1lYUIIAQS7tqgcLRsn18N4vVQgXQyv3bQWjh3JtpQT3Bgy9N9myGC4PDjGuVnx2Y7mF4eqlysb0rgrdrB2+FMK6YBPXtlXF4QPTY6rEe+hxkBpCoGK7UJu5BHUK4gJhAewgMolkoyq6sTbFQFuR86447k9ky2veh5uGg40gAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAjJclj04kifG7PRApFI4NgwtaE5na/xCEBI572Nvp+FkDBkZv5SEXMv/srbpyw5vnvIzlu8X3EmssQ5s6QAAAAMb6evO+2606PWXzaqvJdDGxu+TC0vbg5HymAgNFL11hBUpTWpkpIQZNJOhxYNo4fHw1td28kruB5B+oQEEFRI0Gm4hX/quBhPtof2NGGMA12sQ53BrrO1WYoPAAAAAAAQbd9uHXZaGT2cvhRs7reawctIXtX1s3kTqM9YV+/wCpDgNoX46QkFPkWBIcZvWnau3HcGqhHIL4qpUqjyt4ealuCa42Moiy1mB8REcWJlkis4eCMyKfY2HMRfldn8r2XwcQAAUCoGgGABAACQNwEQEAAAAAAA8GAAYAEw4UAQAVERQUEgAHExEGCQoCBAULDAgBMSsE7QsayR5iC50OAAAAAAA8XqkAAAAAAAAAAAAAAAAAAAAAAAAAAAABAAEBAAAABgIUAwYAAAEJFAMKAwAJA8wSAAAAAAAADgIADQwCAAAAODEAAAAAAAA="; - - const MAYAN_V0_TX: &str = "ATzYOiofQZSWsNe3SxxEPip+Xp9A2Fji+h0xfs7FkmvQxNNgwjeEbTlMr7+e42q9vcvExw2CX4PgNBRuY77O+waAAQAEDPlBHYJN7SVAqQdmNtdFQsCIDVJuEnf59VTtTCOGI7yLh4jpmImexNtJSORTO+sbJ63Aysdx88si41jIW1Wf65qHxwlVbaZ8xI24o/VzmleK1NqPB2lMTcy78ZFbqJ6agIqQAqWC7XmuIVDA/VxhSMZPxFOazPZMJbWyD+TYtXxA3sS/qzC61MydFxPOY3xt62Ug5Tp3r/hC0NimkXNfrMH0UmoX+WTY7c2jVeACjg8EqVgtZZSXgaQRvotGaelPhCySBd5s0S8tvrZZSGGBUknE3Jjh4aGsgXpNY0QHkFnJayU0QDsmAQ7sF/E5yI6Oq1k8w8tnKB6wJR28JzZwp3KVGAf9PgfpG6VoBYOYtT4QWhLzz8wJo5Da/9f9tVVfo7Qj5Z1paZLqq3kUJ1PAm9bYE1qpQE9jUkcSHEnSn0OVAwZGb+UhFzL/7K26csOb57yM5bvF9xJrLEObOkAAAAAGTCSuZOXkbU4/LKndRkF4gm16E7to0DdpTPoefoS0rYF08m4FFLws+yIpIkWYIyALDIz0sekCn1BgZGSqLNo5CwsAF0FkUEJ2ZE5kVGxlWmNsc25JeDVkeUExCgAFAhxCBwAKAAkDBBcBAAAAAAAJBxUABgUWGRQACQcVAAEAGxkUAQEJAxkAAQwCAAAAC/UHPQYAAAAJAhQBAREJBxUAAwAWGRQBAQgoHAABAxsWFBQGHRwhACITAQMPERIUBBACBxweAA4gHw0MAQMbFhQUGjIBLQAAALtk+swxxK8UC/UHPQYAAAD8nvqKAAAAAGQAAAAAAAIAAAAaQAYAAl8A0CAAAgkEFAEAAAEJCQoYAAAFBgMWFxQZxgEgTCkMJ6KE2yRjS4oAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAhOLnPgTBemY6Iqc117gEL72WnXMeAAAAAAAAAAAAAAAAAIM1ifzW7bbgj0x8MtT3G1S9oCkT/ySLiQAAAAAAAAAAAAAAAG4DAAAAAAAA8dcFAAAAAAA7a4ppAAAAAAAAAAAAAAAAAAAAAN3bmpXkQ6IE64ZQ1epXjtcH/iEjAAMC0SsrhG32PclzqA5blk8lqOrlLpR5OoOt60ksQpGLgw4D2cMN+5YRja4DNaX11bThHmwP8vCzdRYtSPXpFGWT2KsACgUGESAhKSowMTQme3jXiWKuyj4qkRn+CZK3WspZpXBM+tnHyaYm4WA/BAMICgsDBgkMIbxt+8RM8X78HZP9nB+0Ah2xfOX9io4UH0AdkLgPT00Fdnd6fH4CdHU="; - - fn decode_legacy_transaction() -> VersionedTransaction { - let data = decode_base64(LEGACY_TX).unwrap(); - VersionedTransaction::deserialize_with_version(&data).unwrap() - } - - fn decode_mayan_transaction() -> VersionedTransaction { - let data = decode_base64(MAYAN_V0_TX).unwrap(); - VersionedTransaction::deserialize_with_version(&data).unwrap() - } - - #[test] - fn test_decode_legacy() { - let transaction = decode_legacy_transaction(); - assert_eq!(transaction.serialize_message().unwrap()[0], 1); - assert_eq!(transaction.signatures().len(), 1); - assert_eq!(transaction.account_keys().len(), 22); - assert_eq!(transaction.instructions().len(), 7); - } - - #[test] - fn test_get_compute_unit_price() { - assert_eq!(decode_legacy_transaction().get_compute_unit_price(), Some(70_000)); - assert_eq!(decode_mayan_transaction().get_compute_unit_price(), Some(71_428)); - } - - #[test] - fn test_set_compute_unit_price() { - let mut transaction = decode_legacy_transaction(); - assert!(transaction.set_compute_unit_price(999_999)); - assert_eq!(transaction.get_compute_unit_price(), Some(999_999)); - - let mut without_budget = mock_transaction_with_accounts(vec![Pubkey::new([1; 32])], vec![]); - assert!(!without_budget.set_compute_unit_price(1)); - assert_eq!(without_budget.get_compute_unit_price(), None); - } - - #[test] - fn test_get_compute_unit_limit() { - assert_eq!(decode_legacy_transaction().get_compute_unit_limit(), Some(420_000)); - assert_eq!(decode_mayan_transaction().get_compute_unit_limit(), Some(475_676)); - } - - #[test] - fn test_set_compute_unit_limit() { - let mut transaction = decode_legacy_transaction(); - assert!(transaction.set_compute_unit_limit(500_000)); - assert_eq!(transaction.get_compute_unit_limit(), Some(500_000)); - } - - fn legacy_transaction_bytes(header: [u8; 3], num_accounts: u8) -> Vec { - let mut bytes = vec![0]; - bytes.extend_from_slice(&header); - bytes.push(num_accounts); - bytes.extend(repeat_n(0u8, 32 * num_accounts as usize)); - bytes.extend_from_slice(&[0u8; 32]); - bytes - } - - #[test] - fn test_deserialize_rejects_huge_instruction_count() { - let mut bytes = legacy_transaction_bytes([1, 0, 0], 1); - bytes.extend(encode_length_to_compact_u16_bytes(60_000).unwrap()); - - assert!(VersionedTransaction::deserialize_with_version(&bytes).is_err()); - } - - #[test] - fn test_deserialize_rejects_inconsistent_header() { - for header in [[1, 0, 5], [1, 0, 2], [1, 2, 0], [1, 1, 0], [0, 0, 0], [3, 0, 0]] { - let mut bytes = legacy_transaction_bytes(header, 2); - bytes.push(0); - assert!(VersionedTransaction::deserialize_with_version(&bytes).is_err(), "header {header:?} must be rejected"); - } - - let mut bytes = legacy_transaction_bytes([1, 0, 2], 2); - bytes.push(0); - bytes.push(0); - bytes[1] |= MESSAGE_VERSION_PREFIX; - assert!(VersionedTransaction::deserialize_with_version(&bytes).is_err()); - } - - #[test] - fn test_deserialize_rejects_trailing_bytes_and_missing_lookup_count() { - let mut legacy = legacy_transaction_bytes([1, 0, 0], 1); - legacy.push(0); - assert!(VersionedTransaction::deserialize_with_version(&legacy).is_ok()); - legacy.push(0); - assert!(VersionedTransaction::deserialize_with_version(&legacy).is_err()); - - let mut v0 = legacy_transaction_bytes([1, 0, 0], 1); - v0.insert(1, MESSAGE_VERSION_PREFIX); - v0.push(0); - assert!(VersionedTransaction::deserialize_with_version(&v0).is_err()); - v0.push(0); - assert!(VersionedTransaction::deserialize_with_version(&v0).is_ok()); - } - - #[test] - fn test_deserialize_rejects_unsupported_version() { - let mut bytes = legacy_transaction_bytes([1, 0, 0], 1); - bytes.insert(1, MESSAGE_VERSION_PREFIX | 1); - bytes.push(0); - bytes.push(0); - assert!(VersionedTransaction::deserialize_with_version(&bytes).is_err()); - } - - #[test] - fn test_serialize_roundtrip_legacy() { - let data = decode_base64(LEGACY_TX).unwrap(); - let transaction = VersionedTransaction::deserialize_with_version(&data).unwrap(); - - let reserialized = transaction.serialize().unwrap(); - assert_eq!(reserialized, data, "byte-exact roundtrip failed"); - - let decoded = VersionedTransaction::deserialize_with_version(&reserialized).unwrap(); - assert_eq!(decoded, transaction); - } - - #[test] - fn test_serialize_roundtrip_v0() { - let data = decode_base64(MAYAN_V0_TX).unwrap(); - let transaction = VersionedTransaction::deserialize_with_version(&data).unwrap(); - - let reserialized = transaction.serialize().unwrap(); - assert_eq!(reserialized, data, "byte-exact roundtrip failed"); - - let decoded = VersionedTransaction::deserialize_with_version(&reserialized).unwrap(); - assert_eq!(decoded, transaction); - } -} diff --git a/core/crates/gem_solana/src/types/transaction/decoder.rs b/core/crates/gem_solana/src/types/transaction/decoder.rs index 5008a5851a..578e453f8d 100644 --- a/core/crates/gem_solana/src/types/transaction/decoder.rs +++ b/core/crates/gem_solana/src/types/transaction/decoder.rs @@ -1,9 +1,11 @@ -use std::fmt::Display; - use super::VersionedTransaction; use crate::{ - Result, SolanaError, decode_compact_u16_len, - types::{CompiledInstruction, Message, MessageAddressTableLookup, MessageHeader, Pubkey, SignatureBytes, VersionedMessageV0, message::MESSAGE_VERSION_PREFIX}, + Result, decode_compact_u16_len, + types::{ + CompiledInstruction, MAX_ACCOUNT_KEYS, MAX_TRANSACTION_SIZE, MAX_V1_TRANSACTION_SIZE, Message, MessageAddressTableLookup, MessageHeader, Pubkey, SignatureBytes, TransactionConfig, VersionedMessageV0, VersionedMessageV1, + invalid_transaction, + message::{MESSAGE_V1_PREFIX, MESSAGE_VERSION_PREFIX, V1_CONFIG_MASK, validate_instructions}, + }, }; const PUBKEY_LENGTH: usize = 32; @@ -12,8 +14,18 @@ const SIGNATURE_LENGTH: usize = 64; impl VersionedTransaction { pub fn deserialize_with_version(bytes: &[u8]) -> Result { let mut decoder = Decoder::new(bytes); - let signatures = decode_signatures(&mut decoder)?; - let transaction = decode_message(&mut decoder, signatures)?; + let transaction = if decoder.peek_u8("transaction is empty")? == MESSAGE_V1_PREFIX { + if bytes.len() > MAX_V1_TRANSACTION_SIZE { + return Err(invalid_transaction("V1 transaction size exceeds 4096 bytes")); + } + decode_v1_transaction(&mut decoder)? + } else { + if bytes.len() > MAX_TRANSACTION_SIZE { + return Err(invalid_transaction("transaction size exceeds 1232 bytes")); + } + let signatures = decode_signatures(&mut decoder)?; + decode_message(&mut decoder, signatures)? + }; if !decoder.is_empty() { return Err(invalid_transaction("trailing bytes after the message")); } @@ -60,6 +72,18 @@ impl<'a> Decoder<'a> { self.read_bytes(N, missing)?.try_into().map_err(|_| invalid_transaction(missing)) } + fn read_u16(&mut self, missing: &'static str) -> Result { + Ok(u16::from_le_bytes(self.read_array(missing)?)) + } + + fn read_u32(&mut self, missing: &'static str) -> Result { + Ok(u32::from_le_bytes(self.read_array(missing)?)) + } + + fn read_u64(&mut self, missing: &'static str) -> Result { + Ok(u64::from_le_bytes(self.read_array(missing)?)) + } + fn read_arrays(&mut self, count: usize, missing: &'static str) -> Result> { self.read_bytes(count.saturating_mul(N), missing)? .chunks_exact(N) @@ -74,10 +98,6 @@ impl<'a> Decoder<'a> { } } -fn invalid_transaction(reason: impl Display) -> SolanaError { - SolanaError::invalid_input(format!("Invalid Solana transaction: {reason}")) -} - fn decode_signatures(decoder: &mut Decoder<'_>) -> Result> { let signature_count = decoder.read_length()?; let signatures = decoder.read_arrays::(signature_count, "not enough bytes for signatures")?; @@ -88,6 +108,7 @@ fn decode_message(decoder: &mut Decoder<'_>, signatures: Vec) -> let version = decoder.peek_u8("message is empty")?; if version & MESSAGE_VERSION_PREFIX == 0 { let message = decode_message_body(decoder)?; + validate_message(&message, &[], signatures.len())?; return Ok(VersionedTransaction::Legacy { signatures, message }); } @@ -96,6 +117,7 @@ fn decode_message(decoder: &mut Decoder<'_>, signatures: Vec) -> 0 => { let message = decode_message_body(decoder)?; let address_table_lookups = decode_address_table_lookups(decoder)?; + validate_message(&message, &address_table_lookups, signatures.len())?; Ok(VersionedTransaction::V0 { signatures, message: VersionedMessageV0 { message, address_table_lookups }, @@ -105,6 +127,91 @@ fn decode_message(decoder: &mut Decoder<'_>, signatures: Vec) -> } } +fn validate_message(message: &Message, address_table_lookups: &[MessageAddressTableLookup], signature_count: usize) -> Result<()> { + if signature_count != message.header.num_required_signatures as usize { + return Err(invalid_transaction("signature count does not match the required-signature count")); + } + if address_table_lookups.iter().any(|lookup| lookup.writable_indexes.is_empty() && lookup.readonly_indexes.is_empty()) { + return Err(invalid_transaction("address lookup table loads no account")); + } + let loaded_key_count: usize = address_table_lookups.iter().map(|lookup| lookup.writable_indexes.len().saturating_add(lookup.readonly_indexes.len())).sum(); + let total_key_count = message.account_keys.len().saturating_add(loaded_key_count); + if total_key_count > MAX_ACCOUNT_KEYS { + return Err(invalid_transaction("account count exceeds 256")); + } + validate_instructions(&message.instructions, message.account_keys.len(), total_key_count) +} + +fn decode_v1_transaction(decoder: &mut Decoder<'_>) -> Result { + let message = decode_v1_message(decoder)?; + let signature_count = message.message.header.num_required_signatures as usize; + let signatures = decoder.read_arrays::(signature_count, "not enough bytes for V1 signatures")?; + Ok(VersionedTransaction::V1 { + signatures: signatures.into_iter().map(SignatureBytes::new).collect(), + message, + }) +} + +fn decode_v1_message(decoder: &mut Decoder<'_>) -> Result { + let prefix = decoder.read_u8("message is empty")?; + if prefix != MESSAGE_V1_PREFIX { + return Err(invalid_transaction("invalid V1 message prefix")); + } + let header = MessageHeader { + num_required_signatures: decoder.read_u8("missing V1 required-signature count")?, + num_readonly_signed_accounts: decoder.read_u8("missing V1 read-only signer count")?, + num_readonly_unsigned_accounts: decoder.read_u8("missing V1 read-only account count")?, + }; + let mask = decoder.read_u32("missing V1 transaction config mask")?; + if mask & !V1_CONFIG_MASK != 0 || TransactionConfig::has_invalid_priority_fee(mask) { + return Err(invalid_transaction("invalid V1 transaction config mask")); + } + let recent_blockhash = decoder.read_array("missing V1 lifetime specifier")?; + let instruction_count = decoder.read_u8("missing V1 instruction count")? as usize; + let account_count = decoder.read_u8("missing V1 account count")? as usize; + let account_keys = decoder.read_arrays::(account_count, "not enough bytes for V1 accounts")?; + let account_keys = account_keys.into_iter().map(Pubkey::new).collect::>(); + let config = TransactionConfig { + priority_fee: TransactionConfig::has_priority_fee(mask).then(|| decoder.read_u64("missing V1 priority fee")).transpose()?, + compute_unit_limit: TransactionConfig::has_compute_unit_limit(mask).then(|| decoder.read_u32("missing V1 compute unit limit")).transpose()?, + loaded_accounts_data_size_limit: TransactionConfig::has_loaded_accounts_data_size_limit(mask) + .then(|| decoder.read_u32("missing V1 loaded-accounts data-size limit")) + .transpose()?, + heap_size: TransactionConfig::has_heap_size(mask).then(|| decoder.read_u32("missing V1 heap size")).transpose()?, + }; + if instruction_count.saturating_mul(4) > decoder.remaining() { + return Err(invalid_transaction("V1 instruction count exceeds remaining bytes")); + } + let instruction_headers = (0..instruction_count) + .map(|_| { + Ok(( + decoder.read_u8("missing V1 instruction program ID index")?, + decoder.read_u8("missing V1 instruction account count")? as usize, + decoder.read_u16("missing V1 instruction data length")? as usize, + )) + }) + .collect::>>()?; + let instructions = instruction_headers + .into_iter() + .map(|(program_id_index, account_count, data_length)| { + let accounts = decoder.read_bytes(account_count, "not enough V1 instruction account indexes")?.to_vec(); + let data = decoder.read_bytes(data_length, "not enough V1 instruction data")?.to_vec(); + Ok(CompiledInstruction { program_id_index, accounts, data }) + }) + .collect::>>()?; + let message = VersionedMessageV1 { + message: Message { + header, + account_keys, + recent_blockhash, + instructions, + }, + config, + }; + message.validate()?; + Ok(message) +} + fn decode_message_body(decoder: &mut Decoder<'_>) -> Result { let header = MessageHeader { num_required_signatures: decoder.read_u8("missing required-signature count")?, @@ -115,7 +222,7 @@ fn decode_message_body(decoder: &mut Decoder<'_>) -> Result { let account_count = decoder.read_length()?; let account_keys = decoder.read_arrays::(account_count, "not enough bytes for accounts")?; let account_keys = account_keys.into_iter().map(Pubkey::new).collect::>(); - validate_header_counts(&header, account_keys.len())?; + header.validate(account_keys.len())?; let recent_blockhash = decoder.read_array("missing recent blockhash")?; let instruction_count = decoder.read_length()?; @@ -132,20 +239,6 @@ fn decode_message_body(decoder: &mut Decoder<'_>) -> Result { }) } -fn validate_header_counts(header: &MessageHeader, account_keys_len: usize) -> Result<()> { - let num_required_signatures = header.num_required_signatures as usize; - if num_required_signatures == 0 || num_required_signatures > account_keys_len { - return Err(invalid_transaction("required-signature count must be between one and the account count")); - } - if header.num_readonly_signed_accounts as usize >= num_required_signatures { - return Err(invalid_transaction("read-only signer count leaves no writable fee payer")); - } - if header.num_readonly_unsigned_accounts as usize > account_keys_len - num_required_signatures { - return Err(invalid_transaction("read-only account count exceeds unsigned-account count")); - } - Ok(()) -} - fn decode_instruction(decoder: &mut Decoder<'_>) -> Result { let program_id_index = decoder.read_u8("missing instruction program ID index")?; let account_count = decoder.read_length()?; diff --git a/core/crates/gem_solana/src/types/transaction/mod.rs b/core/crates/gem_solana/src/types/transaction/mod.rs new file mode 100644 index 0000000000..0efe4338cd --- /dev/null +++ b/core/crates/gem_solana/src/types/transaction/mod.rs @@ -0,0 +1,464 @@ +use crate::{ + Result, encode_length_to_compact_u16_bytes, + instructions::{ + compute_budget::{find_unique_compute_unit_limit, find_unique_compute_unit_price, parse_compute_unit_limit_data, parse_compute_unit_price_data, set_compute_unit_limit, set_compute_unit_price}, + program_ids::compute_budget_program, + }, + types::{CompiledInstruction, MAX_V1_TRANSACTION_SIZE, Message, Pubkey, SignatureBytes, TransactionConfig, VersionedMessageV0, VersionedMessageV1, invalid_transaction}, +}; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum VersionedTransaction { + Legacy { signatures: Vec, message: Message }, + V0 { signatures: Vec, message: VersionedMessageV0 }, + V1 { signatures: Vec, message: VersionedMessageV1 }, +} + +impl VersionedTransaction { + pub fn message(&self) -> &Message { + match self { + Self::Legacy { message, .. } => message, + Self::V0 { message, .. } => &message.message, + Self::V1 { message, .. } => &message.message, + } + } + + pub fn message_mut(&mut self) -> &mut Message { + match self { + Self::Legacy { message, .. } => message, + Self::V0 { message, .. } => &mut message.message, + Self::V1 { message, .. } => &mut message.message, + } + } + + pub fn num_required_signatures(&self) -> u8 { + self.message().header.num_required_signatures + } + + pub fn account_keys(&self) -> &[Pubkey] { + &self.message().account_keys + } + + pub fn recent_blockhash(&self) -> &[u8; 32] { + &self.message().recent_blockhash + } + + pub fn instructions(&self) -> &[CompiledInstruction] { + &self.message().instructions + } + + pub fn signatures(&self) -> &[SignatureBytes] { + match self { + Self::Legacy { signatures, .. } => signatures, + Self::V0 { signatures, .. } => signatures, + Self::V1 { signatures, .. } => signatures, + } + } + + pub fn signatures_mut(&mut self) -> &mut Vec { + match self { + Self::Legacy { signatures, .. } => signatures, + Self::V0 { signatures, .. } => signatures, + Self::V1 { signatures, .. } => signatures, + } + } + + pub fn add_signature(&mut self, signature: SignatureBytes) { + self.signatures_mut().push(signature); + } + + pub fn get_compute_unit_price(&self) -> Option { + match self { + Self::Legacy { .. } | Self::V0 { .. } => find_unique_compute_unit_price(self.compute_budget_data()), + Self::V1 { .. } => None, + } + } + + pub fn set_compute_unit_price(&mut self, micro_lamports: u64) -> bool { + match self { + Self::Legacy { .. } | Self::V0 { .. } => self.replace_compute_budget_data(|data| parse_compute_unit_price_data(data).is_some(), set_compute_unit_price(micro_lamports).data), + Self::V1 { .. } => false, + } + } + + pub fn get_priority_fee(&self) -> Option { + self.transaction_config().and_then(|config| config.priority_fee) + } + + pub fn transaction_config(&self) -> Option<&TransactionConfig> { + match self { + Self::Legacy { .. } | Self::V0 { .. } => None, + Self::V1 { message, .. } => Some(&message.config), + } + } + + pub fn transaction_config_mut(&mut self) -> Option<&mut TransactionConfig> { + match self { + Self::Legacy { .. } | Self::V0 { .. } => None, + Self::V1 { message, .. } => Some(&mut message.config), + } + } + + pub fn get_compute_unit_limit(&self) -> Option { + match self { + Self::V1 { message, .. } => message.config.compute_unit_limit, + Self::Legacy { .. } | Self::V0 { .. } => find_unique_compute_unit_limit(self.compute_budget_data()), + } + } + + pub fn set_compute_unit_limit(&mut self, units: u32) -> bool { + match self { + Self::V1 { message, .. } => { + message.config.compute_unit_limit = Some(units); + true + } + Self::Legacy { .. } | Self::V0 { .. } => self.replace_compute_budget_data(|data| parse_compute_unit_limit_data(data).is_some(), set_compute_unit_limit(units).data), + } + } + + pub fn serialize_message(&self) -> Result> { + match self { + Self::Legacy { message, .. } => message.serialize_for_signing(), + Self::V0 { message, .. } => message.serialize_for_signing(), + Self::V1 { message, .. } => message.serialize_for_signing(), + } + } + + pub fn serialize(&self) -> Result> { + match self { + Self::Legacy { .. } | Self::V0 { .. } => { + let mut bytes = encode_length_to_compact_u16_bytes(self.signatures().len())?; + for signature in self.signatures() { + bytes.extend_from_slice(signature.as_bytes()); + } + bytes.extend(self.serialize_message()?); + Ok(bytes) + } + Self::V1 { signatures, message } => { + if signatures.len() != message.message.header.num_required_signatures as usize { + return Err(invalid_transaction("V1 signature count does not match the message header")); + } + let mut bytes = self.serialize_message()?; + for signature in signatures { + bytes.extend_from_slice(signature.as_bytes()); + } + if bytes.len() > MAX_V1_TRANSACTION_SIZE { + return Err(invalid_transaction("V1 transaction size exceeds 4096 bytes")); + } + Ok(bytes) + } + } + } + + fn compute_budget_program_index(&self) -> Option { + self.account_keys().iter().position(|key| *key == compute_budget_program()).and_then(|index| u8::try_from(index).ok()) + } + + fn compute_budget_data(&self) -> impl Iterator { + let program_id_index = self.compute_budget_program_index(); + self.instructions() + .iter() + .filter(move |instruction| Some(instruction.program_id_index) == program_id_index) + .map(|instruction| instruction.data.as_slice()) + } + + fn replace_compute_budget_data(&mut self, matches: impl Fn(&[u8]) -> bool, data: Vec) -> bool { + let Some(program_id_index) = self.compute_budget_program_index() else { + return false; + }; + let instruction = self.message_mut().instructions.iter_mut().find(|instruction| instruction.program_id_index == program_id_index && matches(&instruction.data)); + match instruction { + Some(instruction) => { + instruction.data = data; + true + } + None => false, + } + } +} + +mod decoder; + +#[cfg(test)] +mod tests { + use std::iter::repeat_n; + + use super::*; + use crate::testkit::{mock_transaction_with_accounts, mock_v1_transaction}; + use crate::types::message::MESSAGE_VERSION_PREFIX; + use gem_encoding::decode_base64; + use serde_json::Value; + + const LEGACY_TX: &str = "AQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABAAgWAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEbrtjJdvWJAv9GZTGL8LaZtMvDe4j2ery4z7rOkRbioxZflXLFqWqlAt1REFSiam0ljvfB1tbBruEpGRTcUQIyQ+ddH9NRneQZQXje5U/3c4cZ2f1JESi76CvBvRoQ6I1LeNzfZ4ZONkowCnqCyeo5+D6Q21gn3U7HVw/KD3HyUW5gVpu5F8ZojWkXLg/+3N6q3ojiaqYyBIbz7VP7jS5Yktrxv5b22C/EFSDs5jUPA7Gz3GLdBNs0iwBHlqUqNEeyNpDX0HWNHV2LiVDOx6m018ea6P+1xroNvWKhmDeTW7oqHXAEK1ih5IO68BBiiKqWNR5VZdBgBsnR+rZKfpfuyE3yQziYO+SoWzCXuvQLyVcRCNKJrACzaN8XXUR1z3rOt8T1lYUIIAQS7tqgcLRsn18N4vVQgXQyv3bQWjh3JtpQT3Bgy9N9myGC4PDjGuVnx2Y7mF4eqlysb0rgrdrB2+FMK6YBPXtlXF4QPTY6rEe+hxkBpCoGK7UJu5BHUK4gJhAewgMolkoyq6sTbFQFuR86447k9ky2veh5uGg40gAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAjJclj04kifG7PRApFI4NgwtaE5na/xCEBI572Nvp+FkDBkZv5SEXMv/srbpyw5vnvIzlu8X3EmssQ5s6QAAAAMb6evO+2606PWXzaqvJdDGxu+TC0vbg5HymAgNFL11hBUpTWpkpIQZNJOhxYNo4fHw1td28kruB5B+oQEEFRI0Gm4hX/quBhPtof2NGGMA12sQ53BrrO1WYoPAAAAAAAQbd9uHXZaGT2cvhRs7reawctIXtX1s3kTqM9YV+/wCpDgNoX46QkFPkWBIcZvWnau3HcGqhHIL4qpUqjyt4ealuCa42Moiy1mB8REcWJlkis4eCMyKfY2HMRfldn8r2XwcQAAUCoGgGABAACQNwEQEAAAAAAA8GAAYAEw4UAQAVERQUEgAHExEGCQoCBAULDAgBMSsE7QsayR5iC50OAAAAAAA8XqkAAAAAAAAAAAAAAAAAAAAAAAAAAAABAAEBAAAABgIUAwYAAAEJFAMKAwAJA8wSAAAAAAAADgIADQwCAAAAODEAAAAAAAA="; + + const MAYAN_V0_TX: &str = "ATzYOiofQZSWsNe3SxxEPip+Xp9A2Fji+h0xfs7FkmvQxNNgwjeEbTlMr7+e42q9vcvExw2CX4PgNBRuY77O+waAAQAEDPlBHYJN7SVAqQdmNtdFQsCIDVJuEnf59VTtTCOGI7yLh4jpmImexNtJSORTO+sbJ63Aysdx88si41jIW1Wf65qHxwlVbaZ8xI24o/VzmleK1NqPB2lMTcy78ZFbqJ6agIqQAqWC7XmuIVDA/VxhSMZPxFOazPZMJbWyD+TYtXxA3sS/qzC61MydFxPOY3xt62Ug5Tp3r/hC0NimkXNfrMH0UmoX+WTY7c2jVeACjg8EqVgtZZSXgaQRvotGaelPhCySBd5s0S8tvrZZSGGBUknE3Jjh4aGsgXpNY0QHkFnJayU0QDsmAQ7sF/E5yI6Oq1k8w8tnKB6wJR28JzZwp3KVGAf9PgfpG6VoBYOYtT4QWhLzz8wJo5Da/9f9tVVfo7Qj5Z1paZLqq3kUJ1PAm9bYE1qpQE9jUkcSHEnSn0OVAwZGb+UhFzL/7K26csOb57yM5bvF9xJrLEObOkAAAAAGTCSuZOXkbU4/LKndRkF4gm16E7to0DdpTPoefoS0rYF08m4FFLws+yIpIkWYIyALDIz0sekCn1BgZGSqLNo5CwsAF0FkUEJ2ZE5kVGxlWmNsc25JeDVkeUExCgAFAhxCBwAKAAkDBBcBAAAAAAAJBxUABgUWGRQACQcVAAEAGxkUAQEJAxkAAQwCAAAAC/UHPQYAAAAJAhQBAREJBxUAAwAWGRQBAQgoHAABAxsWFBQGHRwhACITAQMPERIUBBACBxweAA4gHw0MAQMbFhQUGjIBLQAAALtk+swxxK8UC/UHPQYAAAD8nvqKAAAAAGQAAAAAAAIAAAAaQAYAAl8A0CAAAgkEFAEAAAEJCQoYAAAFBgMWFxQZxgEgTCkMJ6KE2yRjS4oAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAhOLnPgTBemY6Iqc117gEL72WnXMeAAAAAAAAAAAAAAAAAIM1ifzW7bbgj0x8MtT3G1S9oCkT/ySLiQAAAAAAAAAAAAAAAG4DAAAAAAAA8dcFAAAAAAA7a4ppAAAAAAAAAAAAAAAAAAAAAN3bmpXkQ6IE64ZQ1epXjtcH/iEjAAMC0SsrhG32PclzqA5blk8lqOrlLpR5OoOt60ksQpGLgw4D2cMN+5YRja4DNaX11bThHmwP8vCzdRYtSPXpFGWT2KsACgUGESAhKSowMTQme3jXiWKuyj4qkRn+CZK3WspZpXBM+tnHyaYm4WA/BAMICgsDBgkMIbxt+8RM8X78HZP9nB+0Ah2xfOX9io4UH0AdkLgPT00Fdnd6fH4CdHU="; + + fn decode_legacy_transaction() -> VersionedTransaction { + let data = decode_base64(LEGACY_TX).unwrap(); + VersionedTransaction::deserialize_with_version(&data).unwrap() + } + + fn decode_mayan_transaction() -> VersionedTransaction { + let data = decode_base64(MAYAN_V0_TX).unwrap(); + VersionedTransaction::deserialize_with_version(&data).unwrap() + } + + fn v1_mainnet_bytes() -> Vec { + let fixture: Value = serde_json::from_str(include_str!("../../../testdata/transaction_v1_mainnet.json")).unwrap(); + decode_base64(fixture["transaction"][0].as_str().unwrap()).unwrap() + } + + #[test] + fn test_get_compute_unit_price() { + assert_eq!(decode_legacy_transaction().get_compute_unit_price(), Some(70_000)); + assert_eq!(decode_mayan_transaction().get_compute_unit_price(), Some(71_428)); + } + + #[test] + fn test_set_compute_unit_price() { + let mut transaction = decode_legacy_transaction(); + assert!(transaction.set_compute_unit_price(999_999)); + assert_eq!(transaction.get_compute_unit_price(), Some(999_999)); + + let mut without_budget = mock_transaction_with_accounts(vec![Pubkey::new([1; 32])], vec![]); + assert!(!without_budget.set_compute_unit_price(1)); + assert_eq!(without_budget.get_compute_unit_price(), None); + } + + #[test] + fn test_get_compute_unit_limit() { + assert_eq!(decode_legacy_transaction().get_compute_unit_limit(), Some(420_000)); + assert_eq!(decode_mayan_transaction().get_compute_unit_limit(), Some(475_676)); + } + + #[test] + fn test_set_compute_unit_limit() { + let mut transaction = decode_legacy_transaction(); + assert!(transaction.set_compute_unit_limit(500_000)); + assert_eq!(transaction.get_compute_unit_limit(), Some(500_000)); + } + + fn legacy_message_bytes(header: [u8; 3], num_accounts: u8) -> Vec { + let mut bytes = header.to_vec(); + bytes.push(num_accounts); + bytes.extend(repeat_n(0u8, 32 * num_accounts as usize)); + bytes.extend_from_slice(&[0u8; 32]); + bytes + } + + fn transaction_bytes(message: &[u8], signature_count: u8) -> Vec { + let mut bytes = vec![signature_count]; + bytes.extend(repeat_n(0u8, 64 * signature_count as usize)); + bytes.extend_from_slice(message); + bytes + } + + #[test] + fn test_deserialize_rejects_huge_instruction_count() { + let mut message = legacy_message_bytes([1, 0, 0], 1); + message.extend(encode_length_to_compact_u16_bytes(60_000).unwrap()); + + assert!(VersionedTransaction::deserialize_with_version(&transaction_bytes(&message, 1)).is_err()); + } + + #[test] + fn test_deserialize_rejects_inconsistent_header() { + for header in [[1, 0, 5], [1, 0, 2], [1, 2, 0], [1, 1, 0], [0, 0, 0], [3, 0, 0]] { + let mut message = legacy_message_bytes(header, 2); + message.push(0); + let bytes = transaction_bytes(&message, header[0]); + assert!(VersionedTransaction::deserialize_with_version(&bytes).is_err(), "header {header:?} must be rejected"); + } + + let mut message = legacy_message_bytes([1, 0, 2], 2); + message.push(0); + message.push(0); + message[0] |= MESSAGE_VERSION_PREFIX; + assert!(VersionedTransaction::deserialize_with_version(&transaction_bytes(&message, 1)).is_err()); + } + + #[test] + fn test_deserialize_rejects_signature_count_mismatch() { + let mut message = legacy_message_bytes([2, 0, 0], 2); + message.push(0); + + assert!(VersionedTransaction::deserialize_with_version(&transaction_bytes(&message, 2)).is_ok()); + assert!(VersionedTransaction::deserialize_with_version(&transaction_bytes(&message, 1)).is_err()); + assert!(VersionedTransaction::deserialize_with_version(&transaction_bytes(&message, 3)).is_err()); + } + + #[test] + fn test_deserialize_rejects_trailing_bytes_and_missing_lookup_count() { + let mut message = legacy_message_bytes([1, 0, 0], 1); + message.push(0); + let mut legacy = transaction_bytes(&message, 1); + assert!(VersionedTransaction::deserialize_with_version(&legacy).is_ok()); + legacy.push(0); + assert!(VersionedTransaction::deserialize_with_version(&legacy).is_err()); + + let mut message = legacy_message_bytes([1, 0, 0], 1); + message.insert(0, MESSAGE_VERSION_PREFIX); + message.push(0); + let mut v0 = transaction_bytes(&message, 1); + assert!(VersionedTransaction::deserialize_with_version(&v0).is_err()); + v0.push(0); + assert!(VersionedTransaction::deserialize_with_version(&v0).is_ok()); + } + + #[test] + fn test_deserialize_rejects_too_many_account_keys() { + let mut message = legacy_message_bytes([1, 0, 0], 1); + message.insert(0, MESSAGE_VERSION_PREFIX); + message.push(0); + message.push(1); + message.extend_from_slice(&[7u8; 32]); + message.extend(encode_length_to_compact_u16_bytes(255).unwrap()); + message.extend(0..=254u8); + let mut full = message.clone(); + message.push(0); + assert!(VersionedTransaction::deserialize_with_version(&transaction_bytes(&message, 1)).is_ok()); + + full.push(1); + full.push(255); + assert!(VersionedTransaction::deserialize_with_version(&transaction_bytes(&full, 1)).is_err()); + } + + #[test] + fn test_deserialize_rejects_unsupported_version() { + let mut message = legacy_message_bytes([1, 0, 0], 1); + message.insert(0, MESSAGE_VERSION_PREFIX | 1); + message.push(0); + message.push(0); + + assert!(VersionedTransaction::deserialize_with_version(&transaction_bytes(&message, 1)).is_err()); + } + + #[test] + fn test_serialize_roundtrip_legacy() { + let data = decode_base64(LEGACY_TX).unwrap(); + let transaction = VersionedTransaction::deserialize_with_version(&data).unwrap(); + + assert_eq!(transaction.serialize_message().unwrap()[0], 1); + assert_eq!(transaction.signatures().len(), 1); + assert_eq!(transaction.account_keys().len(), 22); + assert_eq!(transaction.instructions().len(), 7); + + let reserialized = transaction.serialize().unwrap(); + assert_eq!(reserialized, data, "byte-exact roundtrip failed"); + + let decoded = VersionedTransaction::deserialize_with_version(&reserialized).unwrap(); + assert_eq!(decoded, transaction); + } + + #[test] + fn test_serialize_roundtrip_v0() { + let data = decode_base64(MAYAN_V0_TX).unwrap(); + let transaction = VersionedTransaction::deserialize_with_version(&data).unwrap(); + + let reserialized = transaction.serialize().unwrap(); + assert_eq!(reserialized, data, "byte-exact roundtrip failed"); + + let decoded = VersionedTransaction::deserialize_with_version(&reserialized).unwrap(); + assert_eq!(decoded, transaction); + } + + #[test] + fn test_serialize_roundtrip_v1_mainnet_transaction() { + let data = v1_mainnet_bytes(); + let transaction = VersionedTransaction::deserialize_with_version(&data).unwrap(); + + assert_eq!(data.len(), MAX_V1_TRANSACTION_SIZE); + assert_eq!(transaction.signatures().len(), 1); + assert_eq!(transaction.account_keys().len(), 2); + assert_eq!(transaction.instructions().len(), 1); + assert_eq!(transaction.instructions()[0].data.len(), 3905); + assert_eq!(transaction.get_priority_fee(), Some(10_000)); + assert_eq!(transaction.get_compute_unit_limit(), Some(10_000)); + assert_eq!(transaction.transaction_config().unwrap().loaded_accounts_data_size_limit, Some(8192)); + assert_eq!(transaction.transaction_config().unwrap().heap_size, None); + assert_eq!(transaction.serialize_message().unwrap().len(), 4032); + assert_eq!(transaction.serialize().unwrap(), data); + } + + #[test] + fn test_v1_preserves_omitted_and_explicit_zero_config() { + let mut omitted = mock_v1_transaction(1, 0); + omitted.transaction_config_mut().unwrap().priority_fee = None; + let omitted_bytes = omitted.serialize().unwrap(); + let decoded_omitted = VersionedTransaction::deserialize_with_version(&omitted_bytes).unwrap(); + + let mut explicit_zero = omitted; + explicit_zero.transaction_config_mut().unwrap().priority_fee = Some(0); + let explicit_zero_bytes = explicit_zero.serialize().unwrap(); + let decoded_explicit_zero = VersionedTransaction::deserialize_with_version(&explicit_zero_bytes).unwrap(); + + assert_eq!(decoded_omitted.get_priority_fee(), None); + assert_eq!(decoded_explicit_zero.get_priority_fee(), Some(0)); + assert_eq!(explicit_zero_bytes.len(), omitted_bytes.len() + 8); + assert_eq!(decoded_omitted.serialize().unwrap(), omitted_bytes); + assert_eq!(decoded_explicit_zero.serialize().unwrap(), explicit_zero_bytes); + } + + #[test] + fn test_v1_rejects_malformed_wire_data() { + let transaction = mock_v1_transaction(1, 0); + let bytes = transaction.serialize().unwrap(); + + let mut unknown_mask = bytes.clone(); + unknown_mask[4] |= 0b100000; + assert!(VersionedTransaction::deserialize_with_version(&unknown_mask).is_err()); + + let mut partial_priority_fee = bytes.clone(); + partial_priority_fee[4] &= !0b10; + assert!(VersionedTransaction::deserialize_with_version(&partial_priority_fee).is_err()); + + let mut duplicate_account = bytes.clone(); + duplicate_account.copy_within(42..74, 74); + assert!(VersionedTransaction::deserialize_with_version(&duplicate_account).is_err()); + + let mut invalid_program_index = bytes.clone(); + invalid_program_index[126] = 2; + assert!(VersionedTransaction::deserialize_with_version(&invalid_program_index).is_err()); + + let mut truncated = bytes.clone(); + truncated.pop(); + assert!(VersionedTransaction::deserialize_with_version(&truncated).is_err()); + + let mut trailing = bytes; + trailing.push(0); + assert!(VersionedTransaction::deserialize_with_version(&trailing).is_err()); + + let mut oversized_wire = v1_mainnet_bytes(); + oversized_wire[124..126].copy_from_slice(&3906u16.to_le_bytes()); + oversized_wire.insert(4032, 0); + assert_eq!(oversized_wire.len(), MAX_V1_TRANSACTION_SIZE + 1); + assert!(VersionedTransaction::deserialize_with_version(&oversized_wire).is_err()); + } + + #[test] + fn test_v1_serialize_rejects_invalid_transactions() { + let mut missing_signature = mock_v1_transaction(1, 0); + missing_signature.signatures_mut().clear(); + assert!(missing_signature.serialize().is_err()); + + let mut oversized_transaction = VersionedTransaction::deserialize_with_version(&v1_mainnet_bytes()).unwrap(); + oversized_transaction.message_mut().instructions[0].data.push(0); + assert!(oversized_transaction.serialize().is_err()); + + assert!(mock_v1_transaction(13, 0).serialize().is_err()); + + let mut too_many_accounts = mock_v1_transaction(1, 0); + too_many_accounts.message_mut().account_keys.extend((2..=64).map(|value| Pubkey::new([value; 32]))); + assert_eq!(too_many_accounts.account_keys().len(), 65); + assert!(too_many_accounts.serialize().is_err()); + + let mut too_many_instructions = mock_v1_transaction(1, 0); + let instruction = too_many_instructions.instructions()[0].clone(); + too_many_instructions.message_mut().instructions.resize(65, instruction); + assert!(too_many_instructions.serialize().is_err()); + + let mut invalid_heap_size = mock_v1_transaction(1, 0); + invalid_heap_size.transaction_config_mut().unwrap().heap_size = Some(32 * 1024 + 1); + assert!(invalid_heap_size.serialize().is_err()); + } +} diff --git a/core/crates/gem_solana/testdata/transaction_v1_mainnet.json b/core/crates/gem_solana/testdata/transaction_v1_mainnet.json new file mode 100644 index 0000000000..7e29e05750 --- /dev/null +++ b/core/crates/gem_solana/testdata/transaction_v1_mainnet.json @@ -0,0 +1 @@ +{"meta":{"computeUnitsConsumed":4344,"costUnits":6348,"err":null,"fee":15000,"innerInstructions":[],"loadedAddresses":{"readonly":[],"writable":[]},"logMessages":["Program Memo4c2pN8afCj432Lb7RMVKi9PbQnnW7ewFFaV3oAH invoke [1]","Program log: Signed by:","Program log: tv1A4VVMdjmD3Jfk7vj7fvvL3oNUEYogCokHZ2CPwhf","Program log: Memo (len 3905)","Program log: v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1 v1","Program Memo4c2pN8afCj432Lb7RMVKi9PbQnnW7ewFFaV3oAH consumed 4344 of 10000 compute units","Program Memo4c2pN8afCj432Lb7RMVKi9PbQnnW7ewFFaV3oAH success"],"postBalances":[9985000,1141440],"postTokenBalances":[],"preBalances":[10000000,1141440],"preTokenBalances":[],"rewards":null,"status":{"Ok":null}},"transaction":["gQEAAQ8AAADT4SQAdvbj/EceRAam8dp9NOYlK9LlhB137s8R4fCkIwECDUyNNZqUXP9FoDz9JHU+0XcGOgBbIZmAbp4TqxQZfo4FSlNSKFTN0QPMLkYxo/UXkqxyfPHlCryXgorFmU88whAnAAAAAAAAECcAAAAgAAABAUEPAHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYxIHYx2Bntqx2AZhEXCfH1CvutApyKD8t1C1302pkP4g8d6ZIVEIeD+rYzERmGrUZiOtc78Sw/sMm9HXR1G+rYflr3AQ==","base64"],"version":1} diff --git a/core/crates/payment/src/solana_pay/transaction.rs b/core/crates/payment/src/solana_pay/transaction.rs index 3f27173363..b336621e98 100644 --- a/core/crates/payment/src/solana_pay/transaction.rs +++ b/core/crates/payment/src/solana_pay/transaction.rs @@ -1,5 +1,5 @@ use gem_encoding::encode_base64; -use gem_solana::{Pubkey, SignatureBytes, VersionedTransactionExt, decode_transaction}; +use gem_solana::{Pubkey, VersionedTransactionExt, decode_transaction}; use primitives::{PaymentAmount, PaymentRequest, TransactionType}; pub(super) struct PreparedTransaction { @@ -25,7 +25,6 @@ pub(super) fn prepare(transaction: &str, signer: &str) -> Result transaction.add_signature(SignatureBytes::new([0u8; 64])), [signature] if signature.as_bytes() == &[0u8; 64] => {} [_] => return Err("Solana Pay transaction already contains the wallet signature".to_string()), _ => return Err("Solana Pay transaction has an invalid signature count".to_string()), diff --git a/core/crates/primitives/src/testkit/transaction_load_input_mock.rs b/core/crates/primitives/src/testkit/transaction_load_input_mock.rs index 68cc79344b..a891a21de3 100644 --- a/core/crates/primitives/src/testkit/transaction_load_input_mock.rs +++ b/core/crates/primitives/src/testkit/transaction_load_input_mock.rs @@ -1,7 +1,7 @@ use super::signer_mock::{TEST_EVM_RECIPIENT, TEST_EVM_SENDER, TEST_OSMOSIS_SENDER}; use crate::{ - ApplicationMetadata, Asset, AssetId, AssetType, Chain, GasPriceType, SignerInput, TransactionFee, TransactionInputType, TransactionLoadInput, TransactionLoadMetadata, TransferDataExtra, TransferDataOutputAction, TransferDataOutputType, - UTXO, asset_constants::NEAR_USDT_ASSET_ID, + ApplicationMetadata, ApplicationMetadataSource, Asset, AssetId, AssetType, Chain, GasPriceType, SignerInput, TransactionFee, TransactionInputType, TransactionLoadInput, TransactionLoadMetadata, TransferDataExtra, + TransferDataOutputAction, TransferDataOutputType, UTXO, asset_constants::NEAR_USDT_ASSET_ID, }; use num_bigint::BigInt; use num_bigint::BigUint; @@ -235,10 +235,14 @@ impl TransactionLoadInput { } pub fn mock_sign_data(chain: Chain, data: &str, output_type: TransferDataOutputType) -> Self { + Self::mock_sign_data_with_source(chain, data, output_type, ApplicationMetadataSource::WalletConnect) + } + + pub fn mock_sign_data_with_source(chain: Chain, data: &str, output_type: TransferDataOutputType, source: ApplicationMetadataSource) -> Self { TransactionLoadInput { input_type: TransactionInputType::Generic { asset: Asset::from_chain(chain), - metadata: ApplicationMetadata::mock(), + metadata: ApplicationMetadata { source, ..ApplicationMetadata::mock() }, extra: TransferDataExtra { data: Some(data.as_bytes().to_vec()), output_type, diff --git a/core/crates/swapper/src/chainflip/tx_builder.rs b/core/crates/swapper/src/chainflip/tx_builder.rs index 323cf618bf..6f526e9a47 100644 --- a/core/crates/swapper/src/chainflip/tx_builder.rs +++ b/core/crates/swapper/src/chainflip/tx_builder.rs @@ -1,7 +1,11 @@ use std::{str::FromStr, sync::Arc}; use gem_encoding::encode_base64; -use gem_solana::{AccountMeta, DEFAULT_SWAP_GAS_LIMIT, InstructionBuilder, Pubkey, SolanaClient, TransactionBuilder, compute_budget::set_compute_unit_limit, try_decode_blockhash}; +use gem_solana::{ + AccountMeta, DEFAULT_SWAP_GAS_LIMIT, InstructionBuilder, Pubkey, SolanaClient, TransactionBuilder, + compute_budget::{set_compute_unit_limit, set_compute_unit_price}, + try_decode_blockhash, +}; use gem_tron::address::TronAddress; use num_bigint::BigUint; use primitives::{ @@ -51,10 +55,8 @@ pub(super) fn build_solana_transaction(fee_payer: &str, response: &SolanaVaultSw }) .collect::>()?; let instruction = InstructionBuilder::new(program_id).accounts(accounts).data(data).build(); - let mut transaction_builder = TransactionBuilder::new(fee_payer, blockhash); - transaction_builder.add_instruction(set_compute_unit_limit(DEFAULT_SWAP_GAS_LIMIT)); - transaction_builder.add_instruction(instruction); + transaction_builder.add_instructions(vec![set_compute_unit_price(0), set_compute_unit_limit(DEFAULT_SWAP_GAS_LIMIT), instruction]); let transaction = transaction_builder.build().map_err(SwapperError::transaction_error)?; let bytes = transaction.serialize().map_err(SwapperError::transaction_error)?; @@ -67,6 +69,7 @@ mod tests { use super::*; use crate::chainflip::broker::{SolanaVaultSwapResponse, TronVaultSwapResponse}; use gem_jsonrpc::types::JsonRpcResponse; + use gem_solana::decode_transaction; use num_bigint::BigUint; #[test] @@ -137,9 +140,13 @@ mod tests { assert_eq!( tx_b64, - "AQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABAAQIhfupPuKcYE+oWKNRaIwBKQhB6vsZxjpwpHXTx7w7758q21EdC4D4NruUv9F26xeVqhYm0WXVWkSIjeQIxD3II9tUC6aOjrGBy017zEItREWS3QDEQI/vMhwSVTo/1e2664X/uFi6gx6sRwFnSAPu1ODmcAsu2sf8IuwYArWOf4gAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAMGRm/lIRcy/+ytunLDm+e8jOW7xfcSayxDmzpAAAAAiKB2TmOdpVByNvc2jO/SqWcRJnwnp6i4PhwcXOdR2sf+adsEMvxMdgZ9RYJ0BKLVq++GfFFu+oFIYBJkEkLMJpzwID++OVGHruXrGUzSEC5Cyny69vOfFr8T0fbCq+HOAgUABQKgaAYABwYGAQADAgS2AaMmXOLzaY3EgB0sBAAAAAAEAAAAFAAAAFFLyx+aq7kE5hBr0QUrZtJwbbu3BwAAAABsAAAAAAoAAACF+6k+4pxgT6hYo1FojAEpCEHq+xnGOnCkddPHvDvvn8qhRbbz/dR46Sb6cwLQdTEAAAAAAAAAAAAAAAAAAAAAAAAeg9KXLT3KOjMNYMJ3fuW40laDxj+jWRFphWCYMPQgVAUABAAtEQAAADiSTMM0VhiQ46gZQHNTcQ4J" + "AQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABAAQIhfupPuKcYE+oWKNRaIwBKQhB6vsZxjpwpHXTx7w7758q21EdC4D4NruUv9F26xeVqhYm0WXVWkSIjeQIxD3II9tUC6aOjrGBy017zEItREWS3QDEQI/vMhwSVTo/1e2664X/uFi6gx6sRwFnSAPu1ODmcAsu2sf8IuwYArWOf4gAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAMGRm/lIRcy/+ytunLDm+e8jOW7xfcSayxDmzpAAAAAiKB2TmOdpVByNvc2jO/SqWcRJnwnp6i4PhwcXOdR2sf+adsEMvxMdgZ9RYJ0BKLVq++GfFFu+oFIYBJkEkLMJpzwID++OVGHruXrGUzSEC5Cyny69vOfFr8T0fbCq+HOAwUACQMAAAAAAAAAAAUABQKgaAYABwYGAQADAgS2AaMmXOLzaY3EgB0sBAAAAAAEAAAAFAAAAFFLyx+aq7kE5hBr0QUrZtJwbbu3BwAAAABsAAAAAAoAAACF+6k+4pxgT6hYo1FojAEpCEHq+xnGOnCkddPHvDvvn8qhRbbz/dR46Sb6cwLQdTEAAAAAAAAAAAAAAAAAAAAAAAAeg9KXLT3KOjMNYMJ3fuW40laDxj+jWRFphWCYMPQgVAUABAAtEQAAADiSTMM0VhiQ46gZQHNTcQ4J" ); + let transaction = decode_transaction(&tx_b64).unwrap(); + assert_eq!(transaction.get_compute_unit_price(), Some(0)); + assert_eq!(transaction.get_compute_unit_limit(), Some(DEFAULT_SWAP_GAS_LIMIT)); + Ok(()) } } diff --git a/core/crates/swapper/src/mayan/tx_builder/solana.rs b/core/crates/swapper/src/mayan/tx_builder/solana.rs index 11887179fa..35a8365337 100644 --- a/core/crates/swapper/src/mayan/tx_builder/solana.rs +++ b/core/crates/swapper/src/mayan/tx_builder/solana.rs @@ -38,8 +38,10 @@ pub(in crate::mayan::tx_builder) async fn build_quote_data(quote: &Quote, transa let blockhash = async { rpc_client.get_latest_blockhash().await.map(|response| response.value.blockhash).map_err(SwapperError::from) }; let (lookup_tables, blockhash) = try_join!(lookup_tables, blockhash)?; let fee_payer = SolanaAddress::parse("e.request.wallet_address).map_err(solana_error)?.into(); - let data = encode_v0_transaction(fee_payer, &blockhash, &transaction.instructions, &lookup_tables).map_err(solana_error)?; - let gas_limit = compute_budget::get_compute_unit_limit(&transaction.instructions).map(|limit| limit.to_string()); + let mut instructions = transaction.instructions; + compute_budget::ensure_compute_unit_price(&mut instructions, 0); + let data = encode_v0_transaction(fee_payer, &blockhash, &instructions, &lookup_tables).map_err(solana_error)?; + let gas_limit = compute_budget::get_compute_unit_limit(&instructions).map(|limit| limit.to_string()); Ok(SwapperQuoteData::new_contract(String::new(), BigUint::from(0u64), data, None, gas_limit)) } diff --git a/core/crates/swapper/src/relay/solana.rs b/core/crates/swapper/src/relay/solana.rs index 17f7f721bd..3acd1a430a 100644 --- a/core/crates/swapper/src/relay/solana.rs +++ b/core/crates/swapper/src/relay/solana.rs @@ -17,7 +17,8 @@ pub async fn build_quote_data(wallet_address: &str, step: &SolanaStepData, rpc_p } fn build_transaction(wallet_address: &str, step: &SolanaStepData, blockhash: &str, lookup_tables: &[AddressLookupTableAccount]) -> Result { - let instructions = instructions_from_primitives::(step.instructions.clone()).map_err(SwapperError::transaction_error)?; + let mut instructions = instructions_from_primitives::(step.instructions.clone()).map_err(SwapperError::transaction_error)?; + compute_budget::ensure_compute_unit_price(&mut instructions, 0); let fee_payer = SolanaAddress::parse(wallet_address).map_err(SwapperError::transaction_error)?.into(); let data = encode_v0_transaction(fee_payer, blockhash, &instructions, lookup_tables).map_err(SwapperError::transaction_error)?; let gas_limit = compute_budget::get_compute_unit_limit(&instructions).map(|limit| limit.to_string()); @@ -40,6 +41,7 @@ mod tests { let transaction = decode_transaction("e_data.data).unwrap(); assert_eq!(transaction.num_required_signatures(), 1); + assert_eq!(transaction.get_compute_unit_price(), Some(0)); assert!(quote_data.to.is_empty()); assert_eq!(quote_data.value, BigUint::ZERO); assert!(quote_data.approval.is_none()); From 440b5f0b6d2ec3dc56e1240c8eea4a5a3b79f6ca Mon Sep 17 00:00:00 2001 From: 0xh3rman <119309671+0xh3rman@users.noreply.github.com> Date: Tue, 22 Sep 2026 20:37:41 +0900 Subject: [PATCH 2/2] split and make transaction/mod.rs thin entrance --- core/crates/gem_solana/src/testkit/mod.rs | 2 +- .../src/testkit/transaction_mock.rs | 12 + .../src/types/transaction/decoder.rs | 140 ++++++ .../gem_solana/src/types/transaction/mod.rs | 464 +----------------- .../src/types/transaction/versioned.rs | 312 ++++++++++++ 5 files changed, 467 insertions(+), 463 deletions(-) create mode 100644 core/crates/gem_solana/src/types/transaction/versioned.rs diff --git a/core/crates/gem_solana/src/testkit/mod.rs b/core/crates/gem_solana/src/testkit/mod.rs index df834ec6ce..16a31d650c 100644 --- a/core/crates/gem_solana/src/testkit/mod.rs +++ b/core/crates/gem_solana/src/testkit/mod.rs @@ -14,6 +14,6 @@ pub(crate) use pubkey_mock::test_wallet_pubkey; pub(crate) use siws_mock::mock_siws_message; #[cfg(feature = "signer")] pub(crate) use transaction_mock::mock_legacy_transaction; -pub(crate) use transaction_mock::{mock_transaction, mock_transaction_with_accounts, mock_v0_transaction, mock_v1_transaction}; +pub(crate) use transaction_mock::{TEST_LEGACY_TX, TEST_MAYAN_V0_TX, mock_transaction, mock_transaction_with_accounts, mock_v0_transaction, mock_v1_mainnet_transaction_bytes, mock_v1_transaction}; pub(crate) const TEST_BLOCKHASH: [u8; 32] = [1; 32]; diff --git a/core/crates/gem_solana/src/testkit/transaction_mock.rs b/core/crates/gem_solana/src/testkit/transaction_mock.rs index 79c69e1a1b..a011a08590 100644 --- a/core/crates/gem_solana/src/testkit/transaction_mock.rs +++ b/core/crates/gem_solana/src/testkit/transaction_mock.rs @@ -1,3 +1,6 @@ +use gem_encoding::decode_base64; +use serde_json::Value; + use crate::{ CompiledInstruction, Message, MessageAddressTableLookup, MessageHeader, Pubkey, SignatureBytes, TransactionConfig, VersionedMessageV0, VersionedMessageV1, VersionedTransaction, testkit::{TEST_BLOCKHASH, test_wallet_pubkey}, @@ -54,3 +57,12 @@ pub(crate) fn mock_v1_transaction(signature_count: u8, wallet_index: u8) -> Vers message: VersionedMessageV1::mock(signature_count, account_keys, vec![CompiledInstruction::mock(signature_count, vec![0], vec![0xde, 0xad])], TransactionConfig::mock()), } } + +pub(crate) const TEST_LEGACY_TX: &str = "AQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABAAgWAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEbrtjJdvWJAv9GZTGL8LaZtMvDe4j2ery4z7rOkRbioxZflXLFqWqlAt1REFSiam0ljvfB1tbBruEpGRTcUQIyQ+ddH9NRneQZQXje5U/3c4cZ2f1JESi76CvBvRoQ6I1LeNzfZ4ZONkowCnqCyeo5+D6Q21gn3U7HVw/KD3HyUW5gVpu5F8ZojWkXLg/+3N6q3ojiaqYyBIbz7VP7jS5Yktrxv5b22C/EFSDs5jUPA7Gz3GLdBNs0iwBHlqUqNEeyNpDX0HWNHV2LiVDOx6m018ea6P+1xroNvWKhmDeTW7oqHXAEK1ih5IO68BBiiKqWNR5VZdBgBsnR+rZKfpfuyE3yQziYO+SoWzCXuvQLyVcRCNKJrACzaN8XXUR1z3rOt8T1lYUIIAQS7tqgcLRsn18N4vVQgXQyv3bQWjh3JtpQT3Bgy9N9myGC4PDjGuVnx2Y7mF4eqlysb0rgrdrB2+FMK6YBPXtlXF4QPTY6rEe+hxkBpCoGK7UJu5BHUK4gJhAewgMolkoyq6sTbFQFuR86447k9ky2veh5uGg40gAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAjJclj04kifG7PRApFI4NgwtaE5na/xCEBI572Nvp+FkDBkZv5SEXMv/srbpyw5vnvIzlu8X3EmssQ5s6QAAAAMb6evO+2606PWXzaqvJdDGxu+TC0vbg5HymAgNFL11hBUpTWpkpIQZNJOhxYNo4fHw1td28kruB5B+oQEEFRI0Gm4hX/quBhPtof2NGGMA12sQ53BrrO1WYoPAAAAAAAQbd9uHXZaGT2cvhRs7reawctIXtX1s3kTqM9YV+/wCpDgNoX46QkFPkWBIcZvWnau3HcGqhHIL4qpUqjyt4ealuCa42Moiy1mB8REcWJlkis4eCMyKfY2HMRfldn8r2XwcQAAUCoGgGABAACQNwEQEAAAAAAA8GAAYAEw4UAQAVERQUEgAHExEGCQoCBAULDAgBMSsE7QsayR5iC50OAAAAAAA8XqkAAAAAAAAAAAAAAAAAAAAAAAAAAAABAAEBAAAABgIUAwYAAAEJFAMKAwAJA8wSAAAAAAAADgIADQwCAAAAODEAAAAAAAA="; + +pub(crate) const TEST_MAYAN_V0_TX: &str = "ATzYOiofQZSWsNe3SxxEPip+Xp9A2Fji+h0xfs7FkmvQxNNgwjeEbTlMr7+e42q9vcvExw2CX4PgNBRuY77O+waAAQAEDPlBHYJN7SVAqQdmNtdFQsCIDVJuEnf59VTtTCOGI7yLh4jpmImexNtJSORTO+sbJ63Aysdx88si41jIW1Wf65qHxwlVbaZ8xI24o/VzmleK1NqPB2lMTcy78ZFbqJ6agIqQAqWC7XmuIVDA/VxhSMZPxFOazPZMJbWyD+TYtXxA3sS/qzC61MydFxPOY3xt62Ug5Tp3r/hC0NimkXNfrMH0UmoX+WTY7c2jVeACjg8EqVgtZZSXgaQRvotGaelPhCySBd5s0S8tvrZZSGGBUknE3Jjh4aGsgXpNY0QHkFnJayU0QDsmAQ7sF/E5yI6Oq1k8w8tnKB6wJR28JzZwp3KVGAf9PgfpG6VoBYOYtT4QWhLzz8wJo5Da/9f9tVVfo7Qj5Z1paZLqq3kUJ1PAm9bYE1qpQE9jUkcSHEnSn0OVAwZGb+UhFzL/7K26csOb57yM5bvF9xJrLEObOkAAAAAGTCSuZOXkbU4/LKndRkF4gm16E7to0DdpTPoefoS0rYF08m4FFLws+yIpIkWYIyALDIz0sekCn1BgZGSqLNo5CwsAF0FkUEJ2ZE5kVGxlWmNsc25JeDVkeUExCgAFAhxCBwAKAAkDBBcBAAAAAAAJBxUABgUWGRQACQcVAAEAGxkUAQEJAxkAAQwCAAAAC/UHPQYAAAAJAhQBAREJBxUAAwAWGRQBAQgoHAABAxsWFBQGHRwhACITAQMPERIUBBACBxweAA4gHw0MAQMbFhQUGjIBLQAAALtk+swxxK8UC/UHPQYAAAD8nvqKAAAAAGQAAAAAAAIAAAAaQAYAAl8A0CAAAgkEFAEAAAEJCQoYAAAFBgMWFxQZxgEgTCkMJ6KE2yRjS4oAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAhOLnPgTBemY6Iqc117gEL72WnXMeAAAAAAAAAAAAAAAAAIM1ifzW7bbgj0x8MtT3G1S9oCkT/ySLiQAAAAAAAAAAAAAAAG4DAAAAAAAA8dcFAAAAAAA7a4ppAAAAAAAAAAAAAAAAAAAAAN3bmpXkQ6IE64ZQ1epXjtcH/iEjAAMC0SsrhG32PclzqA5blk8lqOrlLpR5OoOt60ksQpGLgw4D2cMN+5YRja4DNaX11bThHmwP8vCzdRYtSPXpFGWT2KsACgUGESAhKSowMTQme3jXiWKuyj4qkRn+CZK3WspZpXBM+tnHyaYm4WA/BAMICgsDBgkMIbxt+8RM8X78HZP9nB+0Ah2xfOX9io4UH0AdkLgPT00Fdnd6fH4CdHU="; + +pub(crate) fn mock_v1_mainnet_transaction_bytes() -> Vec { + let fixture: Value = serde_json::from_str(include_str!("../../testdata/transaction_v1_mainnet.json")).unwrap(); + decode_base64(fixture["transaction"][0].as_str().unwrap()).unwrap() +} diff --git a/core/crates/gem_solana/src/types/transaction/decoder.rs b/core/crates/gem_solana/src/types/transaction/decoder.rs index 578e453f8d..dfa92eccd0 100644 --- a/core/crates/gem_solana/src/types/transaction/decoder.rs +++ b/core/crates/gem_solana/src/types/transaction/decoder.rs @@ -265,3 +265,143 @@ fn decode_address_table_lookups(decoder: &mut Decoder<'_>) -> Result Vec { + let mut bytes = header.to_vec(); + bytes.push(num_accounts); + bytes.extend(repeat_n(0u8, 32 * num_accounts as usize)); + bytes.extend_from_slice(&[0u8; 32]); + bytes + } + + fn transaction_bytes(message: &[u8], signature_count: u8) -> Vec { + let mut bytes = vec![signature_count]; + bytes.extend(repeat_n(0u8, 64 * signature_count as usize)); + bytes.extend_from_slice(message); + bytes + } + + #[test] + fn test_deserialize_rejects_huge_instruction_count() { + let mut message = legacy_message_bytes([1, 0, 0], 1); + message.extend(encode_length_to_compact_u16_bytes(60_000).unwrap()); + + assert!(VersionedTransaction::deserialize_with_version(&transaction_bytes(&message, 1)).is_err()); + } + + #[test] + fn test_deserialize_rejects_inconsistent_header() { + for header in [[1, 0, 5], [1, 0, 2], [1, 2, 0], [1, 1, 0], [0, 0, 0], [3, 0, 0]] { + let mut message = legacy_message_bytes(header, 2); + message.push(0); + let bytes = transaction_bytes(&message, header[0]); + assert!(VersionedTransaction::deserialize_with_version(&bytes).is_err(), "header {header:?} must be rejected"); + } + + let mut message = legacy_message_bytes([1, 0, 2], 2); + message.push(0); + message.push(0); + message[0] |= MESSAGE_VERSION_PREFIX; + assert!(VersionedTransaction::deserialize_with_version(&transaction_bytes(&message, 1)).is_err()); + } + + #[test] + fn test_deserialize_rejects_signature_count_mismatch() { + let mut message = legacy_message_bytes([2, 0, 0], 2); + message.push(0); + + assert!(VersionedTransaction::deserialize_with_version(&transaction_bytes(&message, 2)).is_ok()); + assert!(VersionedTransaction::deserialize_with_version(&transaction_bytes(&message, 1)).is_err()); + assert!(VersionedTransaction::deserialize_with_version(&transaction_bytes(&message, 3)).is_err()); + } + + #[test] + fn test_deserialize_rejects_trailing_bytes_and_missing_lookup_count() { + let mut message = legacy_message_bytes([1, 0, 0], 1); + message.push(0); + let mut legacy = transaction_bytes(&message, 1); + assert!(VersionedTransaction::deserialize_with_version(&legacy).is_ok()); + legacy.push(0); + assert!(VersionedTransaction::deserialize_with_version(&legacy).is_err()); + + let mut message = legacy_message_bytes([1, 0, 0], 1); + message.insert(0, MESSAGE_VERSION_PREFIX); + message.push(0); + let mut v0 = transaction_bytes(&message, 1); + assert!(VersionedTransaction::deserialize_with_version(&v0).is_err()); + v0.push(0); + assert!(VersionedTransaction::deserialize_with_version(&v0).is_ok()); + } + + #[test] + fn test_deserialize_rejects_too_many_account_keys() { + let mut message = legacy_message_bytes([1, 0, 0], 1); + message.insert(0, MESSAGE_VERSION_PREFIX); + message.push(0); + message.push(1); + message.extend_from_slice(&[7u8; 32]); + message.extend(encode_length_to_compact_u16_bytes(255).unwrap()); + message.extend(0..=254u8); + let mut full = message.clone(); + message.push(0); + assert!(VersionedTransaction::deserialize_with_version(&transaction_bytes(&message, 1)).is_ok()); + + full.push(1); + full.push(255); + assert!(VersionedTransaction::deserialize_with_version(&transaction_bytes(&full, 1)).is_err()); + } + + #[test] + fn test_deserialize_rejects_unsupported_version() { + let mut message = legacy_message_bytes([1, 0, 0], 1); + message.insert(0, MESSAGE_VERSION_PREFIX | 1); + message.push(0); + message.push(0); + + assert!(VersionedTransaction::deserialize_with_version(&transaction_bytes(&message, 1)).is_err()); + } + + #[test] + fn test_v1_rejects_malformed_wire_data() { + let transaction = mock_v1_transaction(1, 0); + let bytes = transaction.serialize().unwrap(); + + let mut unknown_mask = bytes.clone(); + unknown_mask[4] |= 0b100000; + assert!(VersionedTransaction::deserialize_with_version(&unknown_mask).is_err()); + + let mut partial_priority_fee = bytes.clone(); + partial_priority_fee[4] &= !0b10; + assert!(VersionedTransaction::deserialize_with_version(&partial_priority_fee).is_err()); + + let mut duplicate_account = bytes.clone(); + duplicate_account.copy_within(42..74, 74); + assert!(VersionedTransaction::deserialize_with_version(&duplicate_account).is_err()); + + let mut invalid_program_index = bytes.clone(); + invalid_program_index[126] = 2; + assert!(VersionedTransaction::deserialize_with_version(&invalid_program_index).is_err()); + + let mut truncated = bytes.clone(); + truncated.pop(); + assert!(VersionedTransaction::deserialize_with_version(&truncated).is_err()); + + let mut trailing = bytes; + trailing.push(0); + assert!(VersionedTransaction::deserialize_with_version(&trailing).is_err()); + + let mut oversized_wire = mock_v1_mainnet_transaction_bytes(); + oversized_wire[124..126].copy_from_slice(&3906u16.to_le_bytes()); + oversized_wire.insert(4032, 0); + assert_eq!(oversized_wire.len(), MAX_V1_TRANSACTION_SIZE + 1); + assert!(VersionedTransaction::deserialize_with_version(&oversized_wire).is_err()); + } +} diff --git a/core/crates/gem_solana/src/types/transaction/mod.rs b/core/crates/gem_solana/src/types/transaction/mod.rs index 0efe4338cd..b0422014b2 100644 --- a/core/crates/gem_solana/src/types/transaction/mod.rs +++ b/core/crates/gem_solana/src/types/transaction/mod.rs @@ -1,464 +1,4 @@ -use crate::{ - Result, encode_length_to_compact_u16_bytes, - instructions::{ - compute_budget::{find_unique_compute_unit_limit, find_unique_compute_unit_price, parse_compute_unit_limit_data, parse_compute_unit_price_data, set_compute_unit_limit, set_compute_unit_price}, - program_ids::compute_budget_program, - }, - types::{CompiledInstruction, MAX_V1_TRANSACTION_SIZE, Message, Pubkey, SignatureBytes, TransactionConfig, VersionedMessageV0, VersionedMessageV1, invalid_transaction}, -}; - -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum VersionedTransaction { - Legacy { signatures: Vec, message: Message }, - V0 { signatures: Vec, message: VersionedMessageV0 }, - V1 { signatures: Vec, message: VersionedMessageV1 }, -} - -impl VersionedTransaction { - pub fn message(&self) -> &Message { - match self { - Self::Legacy { message, .. } => message, - Self::V0 { message, .. } => &message.message, - Self::V1 { message, .. } => &message.message, - } - } - - pub fn message_mut(&mut self) -> &mut Message { - match self { - Self::Legacy { message, .. } => message, - Self::V0 { message, .. } => &mut message.message, - Self::V1 { message, .. } => &mut message.message, - } - } - - pub fn num_required_signatures(&self) -> u8 { - self.message().header.num_required_signatures - } - - pub fn account_keys(&self) -> &[Pubkey] { - &self.message().account_keys - } - - pub fn recent_blockhash(&self) -> &[u8; 32] { - &self.message().recent_blockhash - } - - pub fn instructions(&self) -> &[CompiledInstruction] { - &self.message().instructions - } - - pub fn signatures(&self) -> &[SignatureBytes] { - match self { - Self::Legacy { signatures, .. } => signatures, - Self::V0 { signatures, .. } => signatures, - Self::V1 { signatures, .. } => signatures, - } - } - - pub fn signatures_mut(&mut self) -> &mut Vec { - match self { - Self::Legacy { signatures, .. } => signatures, - Self::V0 { signatures, .. } => signatures, - Self::V1 { signatures, .. } => signatures, - } - } - - pub fn add_signature(&mut self, signature: SignatureBytes) { - self.signatures_mut().push(signature); - } - - pub fn get_compute_unit_price(&self) -> Option { - match self { - Self::Legacy { .. } | Self::V0 { .. } => find_unique_compute_unit_price(self.compute_budget_data()), - Self::V1 { .. } => None, - } - } - - pub fn set_compute_unit_price(&mut self, micro_lamports: u64) -> bool { - match self { - Self::Legacy { .. } | Self::V0 { .. } => self.replace_compute_budget_data(|data| parse_compute_unit_price_data(data).is_some(), set_compute_unit_price(micro_lamports).data), - Self::V1 { .. } => false, - } - } - - pub fn get_priority_fee(&self) -> Option { - self.transaction_config().and_then(|config| config.priority_fee) - } - - pub fn transaction_config(&self) -> Option<&TransactionConfig> { - match self { - Self::Legacy { .. } | Self::V0 { .. } => None, - Self::V1 { message, .. } => Some(&message.config), - } - } - - pub fn transaction_config_mut(&mut self) -> Option<&mut TransactionConfig> { - match self { - Self::Legacy { .. } | Self::V0 { .. } => None, - Self::V1 { message, .. } => Some(&mut message.config), - } - } - - pub fn get_compute_unit_limit(&self) -> Option { - match self { - Self::V1 { message, .. } => message.config.compute_unit_limit, - Self::Legacy { .. } | Self::V0 { .. } => find_unique_compute_unit_limit(self.compute_budget_data()), - } - } - - pub fn set_compute_unit_limit(&mut self, units: u32) -> bool { - match self { - Self::V1 { message, .. } => { - message.config.compute_unit_limit = Some(units); - true - } - Self::Legacy { .. } | Self::V0 { .. } => self.replace_compute_budget_data(|data| parse_compute_unit_limit_data(data).is_some(), set_compute_unit_limit(units).data), - } - } - - pub fn serialize_message(&self) -> Result> { - match self { - Self::Legacy { message, .. } => message.serialize_for_signing(), - Self::V0 { message, .. } => message.serialize_for_signing(), - Self::V1 { message, .. } => message.serialize_for_signing(), - } - } - - pub fn serialize(&self) -> Result> { - match self { - Self::Legacy { .. } | Self::V0 { .. } => { - let mut bytes = encode_length_to_compact_u16_bytes(self.signatures().len())?; - for signature in self.signatures() { - bytes.extend_from_slice(signature.as_bytes()); - } - bytes.extend(self.serialize_message()?); - Ok(bytes) - } - Self::V1 { signatures, message } => { - if signatures.len() != message.message.header.num_required_signatures as usize { - return Err(invalid_transaction("V1 signature count does not match the message header")); - } - let mut bytes = self.serialize_message()?; - for signature in signatures { - bytes.extend_from_slice(signature.as_bytes()); - } - if bytes.len() > MAX_V1_TRANSACTION_SIZE { - return Err(invalid_transaction("V1 transaction size exceeds 4096 bytes")); - } - Ok(bytes) - } - } - } - - fn compute_budget_program_index(&self) -> Option { - self.account_keys().iter().position(|key| *key == compute_budget_program()).and_then(|index| u8::try_from(index).ok()) - } - - fn compute_budget_data(&self) -> impl Iterator { - let program_id_index = self.compute_budget_program_index(); - self.instructions() - .iter() - .filter(move |instruction| Some(instruction.program_id_index) == program_id_index) - .map(|instruction| instruction.data.as_slice()) - } - - fn replace_compute_budget_data(&mut self, matches: impl Fn(&[u8]) -> bool, data: Vec) -> bool { - let Some(program_id_index) = self.compute_budget_program_index() else { - return false; - }; - let instruction = self.message_mut().instructions.iter_mut().find(|instruction| instruction.program_id_index == program_id_index && matches(&instruction.data)); - match instruction { - Some(instruction) => { - instruction.data = data; - true - } - None => false, - } - } -} - mod decoder; +mod versioned; -#[cfg(test)] -mod tests { - use std::iter::repeat_n; - - use super::*; - use crate::testkit::{mock_transaction_with_accounts, mock_v1_transaction}; - use crate::types::message::MESSAGE_VERSION_PREFIX; - use gem_encoding::decode_base64; - use serde_json::Value; - - const LEGACY_TX: &str = "AQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABAAgWAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEbrtjJdvWJAv9GZTGL8LaZtMvDe4j2ery4z7rOkRbioxZflXLFqWqlAt1REFSiam0ljvfB1tbBruEpGRTcUQIyQ+ddH9NRneQZQXje5U/3c4cZ2f1JESi76CvBvRoQ6I1LeNzfZ4ZONkowCnqCyeo5+D6Q21gn3U7HVw/KD3HyUW5gVpu5F8ZojWkXLg/+3N6q3ojiaqYyBIbz7VP7jS5Yktrxv5b22C/EFSDs5jUPA7Gz3GLdBNs0iwBHlqUqNEeyNpDX0HWNHV2LiVDOx6m018ea6P+1xroNvWKhmDeTW7oqHXAEK1ih5IO68BBiiKqWNR5VZdBgBsnR+rZKfpfuyE3yQziYO+SoWzCXuvQLyVcRCNKJrACzaN8XXUR1z3rOt8T1lYUIIAQS7tqgcLRsn18N4vVQgXQyv3bQWjh3JtpQT3Bgy9N9myGC4PDjGuVnx2Y7mF4eqlysb0rgrdrB2+FMK6YBPXtlXF4QPTY6rEe+hxkBpCoGK7UJu5BHUK4gJhAewgMolkoyq6sTbFQFuR86447k9ky2veh5uGg40gAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAjJclj04kifG7PRApFI4NgwtaE5na/xCEBI572Nvp+FkDBkZv5SEXMv/srbpyw5vnvIzlu8X3EmssQ5s6QAAAAMb6evO+2606PWXzaqvJdDGxu+TC0vbg5HymAgNFL11hBUpTWpkpIQZNJOhxYNo4fHw1td28kruB5B+oQEEFRI0Gm4hX/quBhPtof2NGGMA12sQ53BrrO1WYoPAAAAAAAQbd9uHXZaGT2cvhRs7reawctIXtX1s3kTqM9YV+/wCpDgNoX46QkFPkWBIcZvWnau3HcGqhHIL4qpUqjyt4ealuCa42Moiy1mB8REcWJlkis4eCMyKfY2HMRfldn8r2XwcQAAUCoGgGABAACQNwEQEAAAAAAA8GAAYAEw4UAQAVERQUEgAHExEGCQoCBAULDAgBMSsE7QsayR5iC50OAAAAAAA8XqkAAAAAAAAAAAAAAAAAAAAAAAAAAAABAAEBAAAABgIUAwYAAAEJFAMKAwAJA8wSAAAAAAAADgIADQwCAAAAODEAAAAAAAA="; - - const MAYAN_V0_TX: &str = "ATzYOiofQZSWsNe3SxxEPip+Xp9A2Fji+h0xfs7FkmvQxNNgwjeEbTlMr7+e42q9vcvExw2CX4PgNBRuY77O+waAAQAEDPlBHYJN7SVAqQdmNtdFQsCIDVJuEnf59VTtTCOGI7yLh4jpmImexNtJSORTO+sbJ63Aysdx88si41jIW1Wf65qHxwlVbaZ8xI24o/VzmleK1NqPB2lMTcy78ZFbqJ6agIqQAqWC7XmuIVDA/VxhSMZPxFOazPZMJbWyD+TYtXxA3sS/qzC61MydFxPOY3xt62Ug5Tp3r/hC0NimkXNfrMH0UmoX+WTY7c2jVeACjg8EqVgtZZSXgaQRvotGaelPhCySBd5s0S8tvrZZSGGBUknE3Jjh4aGsgXpNY0QHkFnJayU0QDsmAQ7sF/E5yI6Oq1k8w8tnKB6wJR28JzZwp3KVGAf9PgfpG6VoBYOYtT4QWhLzz8wJo5Da/9f9tVVfo7Qj5Z1paZLqq3kUJ1PAm9bYE1qpQE9jUkcSHEnSn0OVAwZGb+UhFzL/7K26csOb57yM5bvF9xJrLEObOkAAAAAGTCSuZOXkbU4/LKndRkF4gm16E7to0DdpTPoefoS0rYF08m4FFLws+yIpIkWYIyALDIz0sekCn1BgZGSqLNo5CwsAF0FkUEJ2ZE5kVGxlWmNsc25JeDVkeUExCgAFAhxCBwAKAAkDBBcBAAAAAAAJBxUABgUWGRQACQcVAAEAGxkUAQEJAxkAAQwCAAAAC/UHPQYAAAAJAhQBAREJBxUAAwAWGRQBAQgoHAABAxsWFBQGHRwhACITAQMPERIUBBACBxweAA4gHw0MAQMbFhQUGjIBLQAAALtk+swxxK8UC/UHPQYAAAD8nvqKAAAAAGQAAAAAAAIAAAAaQAYAAl8A0CAAAgkEFAEAAAEJCQoYAAAFBgMWFxQZxgEgTCkMJ6KE2yRjS4oAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAhOLnPgTBemY6Iqc117gEL72WnXMeAAAAAAAAAAAAAAAAAIM1ifzW7bbgj0x8MtT3G1S9oCkT/ySLiQAAAAAAAAAAAAAAAG4DAAAAAAAA8dcFAAAAAAA7a4ppAAAAAAAAAAAAAAAAAAAAAN3bmpXkQ6IE64ZQ1epXjtcH/iEjAAMC0SsrhG32PclzqA5blk8lqOrlLpR5OoOt60ksQpGLgw4D2cMN+5YRja4DNaX11bThHmwP8vCzdRYtSPXpFGWT2KsACgUGESAhKSowMTQme3jXiWKuyj4qkRn+CZK3WspZpXBM+tnHyaYm4WA/BAMICgsDBgkMIbxt+8RM8X78HZP9nB+0Ah2xfOX9io4UH0AdkLgPT00Fdnd6fH4CdHU="; - - fn decode_legacy_transaction() -> VersionedTransaction { - let data = decode_base64(LEGACY_TX).unwrap(); - VersionedTransaction::deserialize_with_version(&data).unwrap() - } - - fn decode_mayan_transaction() -> VersionedTransaction { - let data = decode_base64(MAYAN_V0_TX).unwrap(); - VersionedTransaction::deserialize_with_version(&data).unwrap() - } - - fn v1_mainnet_bytes() -> Vec { - let fixture: Value = serde_json::from_str(include_str!("../../../testdata/transaction_v1_mainnet.json")).unwrap(); - decode_base64(fixture["transaction"][0].as_str().unwrap()).unwrap() - } - - #[test] - fn test_get_compute_unit_price() { - assert_eq!(decode_legacy_transaction().get_compute_unit_price(), Some(70_000)); - assert_eq!(decode_mayan_transaction().get_compute_unit_price(), Some(71_428)); - } - - #[test] - fn test_set_compute_unit_price() { - let mut transaction = decode_legacy_transaction(); - assert!(transaction.set_compute_unit_price(999_999)); - assert_eq!(transaction.get_compute_unit_price(), Some(999_999)); - - let mut without_budget = mock_transaction_with_accounts(vec![Pubkey::new([1; 32])], vec![]); - assert!(!without_budget.set_compute_unit_price(1)); - assert_eq!(without_budget.get_compute_unit_price(), None); - } - - #[test] - fn test_get_compute_unit_limit() { - assert_eq!(decode_legacy_transaction().get_compute_unit_limit(), Some(420_000)); - assert_eq!(decode_mayan_transaction().get_compute_unit_limit(), Some(475_676)); - } - - #[test] - fn test_set_compute_unit_limit() { - let mut transaction = decode_legacy_transaction(); - assert!(transaction.set_compute_unit_limit(500_000)); - assert_eq!(transaction.get_compute_unit_limit(), Some(500_000)); - } - - fn legacy_message_bytes(header: [u8; 3], num_accounts: u8) -> Vec { - let mut bytes = header.to_vec(); - bytes.push(num_accounts); - bytes.extend(repeat_n(0u8, 32 * num_accounts as usize)); - bytes.extend_from_slice(&[0u8; 32]); - bytes - } - - fn transaction_bytes(message: &[u8], signature_count: u8) -> Vec { - let mut bytes = vec![signature_count]; - bytes.extend(repeat_n(0u8, 64 * signature_count as usize)); - bytes.extend_from_slice(message); - bytes - } - - #[test] - fn test_deserialize_rejects_huge_instruction_count() { - let mut message = legacy_message_bytes([1, 0, 0], 1); - message.extend(encode_length_to_compact_u16_bytes(60_000).unwrap()); - - assert!(VersionedTransaction::deserialize_with_version(&transaction_bytes(&message, 1)).is_err()); - } - - #[test] - fn test_deserialize_rejects_inconsistent_header() { - for header in [[1, 0, 5], [1, 0, 2], [1, 2, 0], [1, 1, 0], [0, 0, 0], [3, 0, 0]] { - let mut message = legacy_message_bytes(header, 2); - message.push(0); - let bytes = transaction_bytes(&message, header[0]); - assert!(VersionedTransaction::deserialize_with_version(&bytes).is_err(), "header {header:?} must be rejected"); - } - - let mut message = legacy_message_bytes([1, 0, 2], 2); - message.push(0); - message.push(0); - message[0] |= MESSAGE_VERSION_PREFIX; - assert!(VersionedTransaction::deserialize_with_version(&transaction_bytes(&message, 1)).is_err()); - } - - #[test] - fn test_deserialize_rejects_signature_count_mismatch() { - let mut message = legacy_message_bytes([2, 0, 0], 2); - message.push(0); - - assert!(VersionedTransaction::deserialize_with_version(&transaction_bytes(&message, 2)).is_ok()); - assert!(VersionedTransaction::deserialize_with_version(&transaction_bytes(&message, 1)).is_err()); - assert!(VersionedTransaction::deserialize_with_version(&transaction_bytes(&message, 3)).is_err()); - } - - #[test] - fn test_deserialize_rejects_trailing_bytes_and_missing_lookup_count() { - let mut message = legacy_message_bytes([1, 0, 0], 1); - message.push(0); - let mut legacy = transaction_bytes(&message, 1); - assert!(VersionedTransaction::deserialize_with_version(&legacy).is_ok()); - legacy.push(0); - assert!(VersionedTransaction::deserialize_with_version(&legacy).is_err()); - - let mut message = legacy_message_bytes([1, 0, 0], 1); - message.insert(0, MESSAGE_VERSION_PREFIX); - message.push(0); - let mut v0 = transaction_bytes(&message, 1); - assert!(VersionedTransaction::deserialize_with_version(&v0).is_err()); - v0.push(0); - assert!(VersionedTransaction::deserialize_with_version(&v0).is_ok()); - } - - #[test] - fn test_deserialize_rejects_too_many_account_keys() { - let mut message = legacy_message_bytes([1, 0, 0], 1); - message.insert(0, MESSAGE_VERSION_PREFIX); - message.push(0); - message.push(1); - message.extend_from_slice(&[7u8; 32]); - message.extend(encode_length_to_compact_u16_bytes(255).unwrap()); - message.extend(0..=254u8); - let mut full = message.clone(); - message.push(0); - assert!(VersionedTransaction::deserialize_with_version(&transaction_bytes(&message, 1)).is_ok()); - - full.push(1); - full.push(255); - assert!(VersionedTransaction::deserialize_with_version(&transaction_bytes(&full, 1)).is_err()); - } - - #[test] - fn test_deserialize_rejects_unsupported_version() { - let mut message = legacy_message_bytes([1, 0, 0], 1); - message.insert(0, MESSAGE_VERSION_PREFIX | 1); - message.push(0); - message.push(0); - - assert!(VersionedTransaction::deserialize_with_version(&transaction_bytes(&message, 1)).is_err()); - } - - #[test] - fn test_serialize_roundtrip_legacy() { - let data = decode_base64(LEGACY_TX).unwrap(); - let transaction = VersionedTransaction::deserialize_with_version(&data).unwrap(); - - assert_eq!(transaction.serialize_message().unwrap()[0], 1); - assert_eq!(transaction.signatures().len(), 1); - assert_eq!(transaction.account_keys().len(), 22); - assert_eq!(transaction.instructions().len(), 7); - - let reserialized = transaction.serialize().unwrap(); - assert_eq!(reserialized, data, "byte-exact roundtrip failed"); - - let decoded = VersionedTransaction::deserialize_with_version(&reserialized).unwrap(); - assert_eq!(decoded, transaction); - } - - #[test] - fn test_serialize_roundtrip_v0() { - let data = decode_base64(MAYAN_V0_TX).unwrap(); - let transaction = VersionedTransaction::deserialize_with_version(&data).unwrap(); - - let reserialized = transaction.serialize().unwrap(); - assert_eq!(reserialized, data, "byte-exact roundtrip failed"); - - let decoded = VersionedTransaction::deserialize_with_version(&reserialized).unwrap(); - assert_eq!(decoded, transaction); - } - - #[test] - fn test_serialize_roundtrip_v1_mainnet_transaction() { - let data = v1_mainnet_bytes(); - let transaction = VersionedTransaction::deserialize_with_version(&data).unwrap(); - - assert_eq!(data.len(), MAX_V1_TRANSACTION_SIZE); - assert_eq!(transaction.signatures().len(), 1); - assert_eq!(transaction.account_keys().len(), 2); - assert_eq!(transaction.instructions().len(), 1); - assert_eq!(transaction.instructions()[0].data.len(), 3905); - assert_eq!(transaction.get_priority_fee(), Some(10_000)); - assert_eq!(transaction.get_compute_unit_limit(), Some(10_000)); - assert_eq!(transaction.transaction_config().unwrap().loaded_accounts_data_size_limit, Some(8192)); - assert_eq!(transaction.transaction_config().unwrap().heap_size, None); - assert_eq!(transaction.serialize_message().unwrap().len(), 4032); - assert_eq!(transaction.serialize().unwrap(), data); - } - - #[test] - fn test_v1_preserves_omitted_and_explicit_zero_config() { - let mut omitted = mock_v1_transaction(1, 0); - omitted.transaction_config_mut().unwrap().priority_fee = None; - let omitted_bytes = omitted.serialize().unwrap(); - let decoded_omitted = VersionedTransaction::deserialize_with_version(&omitted_bytes).unwrap(); - - let mut explicit_zero = omitted; - explicit_zero.transaction_config_mut().unwrap().priority_fee = Some(0); - let explicit_zero_bytes = explicit_zero.serialize().unwrap(); - let decoded_explicit_zero = VersionedTransaction::deserialize_with_version(&explicit_zero_bytes).unwrap(); - - assert_eq!(decoded_omitted.get_priority_fee(), None); - assert_eq!(decoded_explicit_zero.get_priority_fee(), Some(0)); - assert_eq!(explicit_zero_bytes.len(), omitted_bytes.len() + 8); - assert_eq!(decoded_omitted.serialize().unwrap(), omitted_bytes); - assert_eq!(decoded_explicit_zero.serialize().unwrap(), explicit_zero_bytes); - } - - #[test] - fn test_v1_rejects_malformed_wire_data() { - let transaction = mock_v1_transaction(1, 0); - let bytes = transaction.serialize().unwrap(); - - let mut unknown_mask = bytes.clone(); - unknown_mask[4] |= 0b100000; - assert!(VersionedTransaction::deserialize_with_version(&unknown_mask).is_err()); - - let mut partial_priority_fee = bytes.clone(); - partial_priority_fee[4] &= !0b10; - assert!(VersionedTransaction::deserialize_with_version(&partial_priority_fee).is_err()); - - let mut duplicate_account = bytes.clone(); - duplicate_account.copy_within(42..74, 74); - assert!(VersionedTransaction::deserialize_with_version(&duplicate_account).is_err()); - - let mut invalid_program_index = bytes.clone(); - invalid_program_index[126] = 2; - assert!(VersionedTransaction::deserialize_with_version(&invalid_program_index).is_err()); - - let mut truncated = bytes.clone(); - truncated.pop(); - assert!(VersionedTransaction::deserialize_with_version(&truncated).is_err()); - - let mut trailing = bytes; - trailing.push(0); - assert!(VersionedTransaction::deserialize_with_version(&trailing).is_err()); - - let mut oversized_wire = v1_mainnet_bytes(); - oversized_wire[124..126].copy_from_slice(&3906u16.to_le_bytes()); - oversized_wire.insert(4032, 0); - assert_eq!(oversized_wire.len(), MAX_V1_TRANSACTION_SIZE + 1); - assert!(VersionedTransaction::deserialize_with_version(&oversized_wire).is_err()); - } - - #[test] - fn test_v1_serialize_rejects_invalid_transactions() { - let mut missing_signature = mock_v1_transaction(1, 0); - missing_signature.signatures_mut().clear(); - assert!(missing_signature.serialize().is_err()); - - let mut oversized_transaction = VersionedTransaction::deserialize_with_version(&v1_mainnet_bytes()).unwrap(); - oversized_transaction.message_mut().instructions[0].data.push(0); - assert!(oversized_transaction.serialize().is_err()); - - assert!(mock_v1_transaction(13, 0).serialize().is_err()); - - let mut too_many_accounts = mock_v1_transaction(1, 0); - too_many_accounts.message_mut().account_keys.extend((2..=64).map(|value| Pubkey::new([value; 32]))); - assert_eq!(too_many_accounts.account_keys().len(), 65); - assert!(too_many_accounts.serialize().is_err()); - - let mut too_many_instructions = mock_v1_transaction(1, 0); - let instruction = too_many_instructions.instructions()[0].clone(); - too_many_instructions.message_mut().instructions.resize(65, instruction); - assert!(too_many_instructions.serialize().is_err()); - - let mut invalid_heap_size = mock_v1_transaction(1, 0); - invalid_heap_size.transaction_config_mut().unwrap().heap_size = Some(32 * 1024 + 1); - assert!(invalid_heap_size.serialize().is_err()); - } -} +pub use versioned::VersionedTransaction; diff --git a/core/crates/gem_solana/src/types/transaction/versioned.rs b/core/crates/gem_solana/src/types/transaction/versioned.rs new file mode 100644 index 0000000000..b0421eeba8 --- /dev/null +++ b/core/crates/gem_solana/src/types/transaction/versioned.rs @@ -0,0 +1,312 @@ +use crate::{ + Result, encode_length_to_compact_u16_bytes, + instructions::{ + compute_budget::{find_unique_compute_unit_limit, find_unique_compute_unit_price, parse_compute_unit_limit_data, parse_compute_unit_price_data, set_compute_unit_limit, set_compute_unit_price}, + program_ids::compute_budget_program, + }, + types::{CompiledInstruction, MAX_V1_TRANSACTION_SIZE, Message, Pubkey, SignatureBytes, TransactionConfig, VersionedMessageV0, VersionedMessageV1, invalid_transaction}, +}; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum VersionedTransaction { + Legacy { signatures: Vec, message: Message }, + V0 { signatures: Vec, message: VersionedMessageV0 }, + V1 { signatures: Vec, message: VersionedMessageV1 }, +} + +impl VersionedTransaction { + pub fn message(&self) -> &Message { + match self { + Self::Legacy { message, .. } => message, + Self::V0 { message, .. } => &message.message, + Self::V1 { message, .. } => &message.message, + } + } + + pub fn message_mut(&mut self) -> &mut Message { + match self { + Self::Legacy { message, .. } => message, + Self::V0 { message, .. } => &mut message.message, + Self::V1 { message, .. } => &mut message.message, + } + } + + pub fn num_required_signatures(&self) -> u8 { + self.message().header.num_required_signatures + } + + pub fn account_keys(&self) -> &[Pubkey] { + &self.message().account_keys + } + + pub fn recent_blockhash(&self) -> &[u8; 32] { + &self.message().recent_blockhash + } + + pub fn instructions(&self) -> &[CompiledInstruction] { + &self.message().instructions + } + + pub fn signatures(&self) -> &[SignatureBytes] { + match self { + Self::Legacy { signatures, .. } => signatures, + Self::V0 { signatures, .. } => signatures, + Self::V1 { signatures, .. } => signatures, + } + } + + pub fn signatures_mut(&mut self) -> &mut Vec { + match self { + Self::Legacy { signatures, .. } => signatures, + Self::V0 { signatures, .. } => signatures, + Self::V1 { signatures, .. } => signatures, + } + } + + pub fn add_signature(&mut self, signature: SignatureBytes) { + self.signatures_mut().push(signature); + } + + pub fn get_compute_unit_price(&self) -> Option { + match self { + Self::Legacy { .. } | Self::V0 { .. } => find_unique_compute_unit_price(self.compute_budget_data()), + Self::V1 { .. } => None, + } + } + + pub fn set_compute_unit_price(&mut self, micro_lamports: u64) -> bool { + match self { + Self::Legacy { .. } | Self::V0 { .. } => self.replace_compute_budget_data(|data| parse_compute_unit_price_data(data).is_some(), set_compute_unit_price(micro_lamports).data), + Self::V1 { .. } => false, + } + } + + pub fn get_priority_fee(&self) -> Option { + self.transaction_config().and_then(|config| config.priority_fee) + } + + pub fn transaction_config(&self) -> Option<&TransactionConfig> { + match self { + Self::Legacy { .. } | Self::V0 { .. } => None, + Self::V1 { message, .. } => Some(&message.config), + } + } + + pub fn transaction_config_mut(&mut self) -> Option<&mut TransactionConfig> { + match self { + Self::Legacy { .. } | Self::V0 { .. } => None, + Self::V1 { message, .. } => Some(&mut message.config), + } + } + + pub fn get_compute_unit_limit(&self) -> Option { + match self { + Self::V1 { message, .. } => message.config.compute_unit_limit, + Self::Legacy { .. } | Self::V0 { .. } => find_unique_compute_unit_limit(self.compute_budget_data()), + } + } + + pub fn set_compute_unit_limit(&mut self, units: u32) -> bool { + match self { + Self::V1 { message, .. } => { + message.config.compute_unit_limit = Some(units); + true + } + Self::Legacy { .. } | Self::V0 { .. } => self.replace_compute_budget_data(|data| parse_compute_unit_limit_data(data).is_some(), set_compute_unit_limit(units).data), + } + } + + pub fn serialize_message(&self) -> Result> { + match self { + Self::Legacy { message, .. } => message.serialize_for_signing(), + Self::V0 { message, .. } => message.serialize_for_signing(), + Self::V1 { message, .. } => message.serialize_for_signing(), + } + } + + pub fn serialize(&self) -> Result> { + match self { + Self::Legacy { .. } | Self::V0 { .. } => { + let mut bytes = encode_length_to_compact_u16_bytes(self.signatures().len())?; + for signature in self.signatures() { + bytes.extend_from_slice(signature.as_bytes()); + } + bytes.extend(self.serialize_message()?); + Ok(bytes) + } + Self::V1 { signatures, message } => { + if signatures.len() != message.message.header.num_required_signatures as usize { + return Err(invalid_transaction("V1 signature count does not match the message header")); + } + let mut bytes = self.serialize_message()?; + for signature in signatures { + bytes.extend_from_slice(signature.as_bytes()); + } + if bytes.len() > MAX_V1_TRANSACTION_SIZE { + return Err(invalid_transaction("V1 transaction size exceeds 4096 bytes")); + } + Ok(bytes) + } + } + } + + fn compute_budget_program_index(&self) -> Option { + self.account_keys().iter().position(|key| *key == compute_budget_program()).and_then(|index| u8::try_from(index).ok()) + } + + fn compute_budget_data(&self) -> impl Iterator { + let program_id_index = self.compute_budget_program_index(); + self.instructions() + .iter() + .filter(move |instruction| Some(instruction.program_id_index) == program_id_index) + .map(|instruction| instruction.data.as_slice()) + } + + fn replace_compute_budget_data(&mut self, matches: impl Fn(&[u8]) -> bool, data: Vec) -> bool { + let Some(program_id_index) = self.compute_budget_program_index() else { + return false; + }; + let instruction = self.message_mut().instructions.iter_mut().find(|instruction| instruction.program_id_index == program_id_index && matches(&instruction.data)); + match instruction { + Some(instruction) => { + instruction.data = data; + true + } + None => false, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::testkit::{TEST_LEGACY_TX, TEST_MAYAN_V0_TX, mock_transaction_with_accounts, mock_v1_mainnet_transaction_bytes, mock_v1_transaction}; + use gem_encoding::decode_base64; + + fn decode(transaction_base64: &str) -> VersionedTransaction { + VersionedTransaction::deserialize_with_version(&decode_base64(transaction_base64).unwrap()).unwrap() + } + + #[test] + fn test_get_compute_unit_price() { + assert_eq!(decode(TEST_LEGACY_TX).get_compute_unit_price(), Some(70_000)); + assert_eq!(decode(TEST_MAYAN_V0_TX).get_compute_unit_price(), Some(71_428)); + } + + #[test] + fn test_set_compute_unit_price() { + let mut transaction = decode(TEST_LEGACY_TX); + assert!(transaction.set_compute_unit_price(999_999)); + assert_eq!(transaction.get_compute_unit_price(), Some(999_999)); + + let mut without_budget = mock_transaction_with_accounts(vec![Pubkey::new([1; 32])], vec![]); + assert!(!without_budget.set_compute_unit_price(1)); + assert_eq!(without_budget.get_compute_unit_price(), None); + } + + #[test] + fn test_get_compute_unit_limit() { + assert_eq!(decode(TEST_LEGACY_TX).get_compute_unit_limit(), Some(420_000)); + assert_eq!(decode(TEST_MAYAN_V0_TX).get_compute_unit_limit(), Some(475_676)); + } + + #[test] + fn test_set_compute_unit_limit() { + let mut transaction = decode(TEST_LEGACY_TX); + assert!(transaction.set_compute_unit_limit(500_000)); + assert_eq!(transaction.get_compute_unit_limit(), Some(500_000)); + } + + #[test] + fn test_serialize_roundtrip_legacy() { + let data = decode_base64(TEST_LEGACY_TX).unwrap(); + let transaction = VersionedTransaction::deserialize_with_version(&data).unwrap(); + + assert_eq!(transaction.serialize_message().unwrap()[0], 1); + assert_eq!(transaction.signatures().len(), 1); + assert_eq!(transaction.account_keys().len(), 22); + assert_eq!(transaction.instructions().len(), 7); + + let reserialized = transaction.serialize().unwrap(); + assert_eq!(reserialized, data, "byte-exact roundtrip failed"); + + let decoded = VersionedTransaction::deserialize_with_version(&reserialized).unwrap(); + assert_eq!(decoded, transaction); + } + + #[test] + fn test_serialize_roundtrip_v0() { + let data = decode_base64(TEST_MAYAN_V0_TX).unwrap(); + let transaction = VersionedTransaction::deserialize_with_version(&data).unwrap(); + + let reserialized = transaction.serialize().unwrap(); + assert_eq!(reserialized, data, "byte-exact roundtrip failed"); + + let decoded = VersionedTransaction::deserialize_with_version(&reserialized).unwrap(); + assert_eq!(decoded, transaction); + } + + #[test] + fn test_serialize_roundtrip_v1_mainnet_transaction() { + let data = mock_v1_mainnet_transaction_bytes(); + let transaction = VersionedTransaction::deserialize_with_version(&data).unwrap(); + + assert_eq!(data.len(), MAX_V1_TRANSACTION_SIZE); + assert_eq!(transaction.signatures().len(), 1); + assert_eq!(transaction.account_keys().len(), 2); + assert_eq!(transaction.instructions().len(), 1); + assert_eq!(transaction.instructions()[0].data.len(), 3905); + assert_eq!(transaction.get_priority_fee(), Some(10_000)); + assert_eq!(transaction.get_compute_unit_limit(), Some(10_000)); + assert_eq!(transaction.transaction_config().unwrap().loaded_accounts_data_size_limit, Some(8192)); + assert_eq!(transaction.transaction_config().unwrap().heap_size, None); + assert_eq!(transaction.serialize_message().unwrap().len(), 4032); + assert_eq!(transaction.serialize().unwrap(), data); + } + + #[test] + fn test_v1_preserves_omitted_and_explicit_zero_config() { + let mut omitted = mock_v1_transaction(1, 0); + omitted.transaction_config_mut().unwrap().priority_fee = None; + let omitted_bytes = omitted.serialize().unwrap(); + let decoded_omitted = VersionedTransaction::deserialize_with_version(&omitted_bytes).unwrap(); + + let mut explicit_zero = omitted; + explicit_zero.transaction_config_mut().unwrap().priority_fee = Some(0); + let explicit_zero_bytes = explicit_zero.serialize().unwrap(); + let decoded_explicit_zero = VersionedTransaction::deserialize_with_version(&explicit_zero_bytes).unwrap(); + + assert_eq!(decoded_omitted.get_priority_fee(), None); + assert_eq!(decoded_explicit_zero.get_priority_fee(), Some(0)); + assert_eq!(explicit_zero_bytes.len(), omitted_bytes.len() + 8); + assert_eq!(decoded_omitted.serialize().unwrap(), omitted_bytes); + assert_eq!(decoded_explicit_zero.serialize().unwrap(), explicit_zero_bytes); + } + + #[test] + fn test_v1_serialize_rejects_invalid_transactions() { + let mut missing_signature = mock_v1_transaction(1, 0); + missing_signature.signatures_mut().clear(); + assert!(missing_signature.serialize().is_err()); + + let mut oversized_transaction = VersionedTransaction::deserialize_with_version(&mock_v1_mainnet_transaction_bytes()).unwrap(); + oversized_transaction.message_mut().instructions[0].data.push(0); + assert!(oversized_transaction.serialize().is_err()); + + assert!(mock_v1_transaction(13, 0).serialize().is_err()); + + let mut too_many_accounts = mock_v1_transaction(1, 0); + too_many_accounts.message_mut().account_keys.extend((2..=64).map(|value| Pubkey::new([value; 32]))); + assert_eq!(too_many_accounts.account_keys().len(), 65); + assert!(too_many_accounts.serialize().is_err()); + + let mut too_many_instructions = mock_v1_transaction(1, 0); + let instruction = too_many_instructions.instructions()[0].clone(); + too_many_instructions.message_mut().instructions.resize(65, instruction); + assert!(too_many_instructions.serialize().is_err()); + + let mut invalid_heap_size = mock_v1_transaction(1, 0); + invalid_heap_size.transaction_config_mut().unwrap().heap_size = Some(32 * 1024 + 1); + assert!(invalid_heap_size.serialize().is_err()); + } +}