diff --git a/Cargo.toml b/Cargo.toml index 16afd21..88bce4b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,5 +1,5 @@ [workspace] -members = ["solana/account"] +members = ["solana/account", "solana/transaction-view"] resolver = "3" [workspace.package] @@ -21,6 +21,7 @@ arc-swap = "1.9.1" bincode = "1.3.3" bitflags = "2.11.1" blake3 = "1.8.5" +criterion = "0.8.2" qualifier_attr = "0.2.2" rand = "0.8.5" rustix = { version = "1.1.4" } @@ -46,9 +47,12 @@ solana-instruction-error = "2.3.0" solana-program-error = "3.0.1" solana-pubkey = "4.2.0" solana-sdk-ids = "3.1.0" +solana-short-vec = "3.2.1" solana-sysvar = "4.0.0" solana-transaction-error = "3.2.0" +[patch.crates-io] +agave-transaction-view = { path = "solana/transaction-view" } [workspace.lints.rust] missing_docs = "deny" diff --git a/solana/transaction-view/Cargo.toml b/solana/transaction-view/Cargo.toml index 68e3c49..cf63293 100644 --- a/solana/transaction-view/Cargo.toml +++ b/solana/transaction-view/Cargo.toml @@ -1,13 +1,14 @@ [package] -name = "agave-transaction-view" +authors = { workspace = true } description = "Zero-copy parser and sanitizer for serialized Solana transactions" documentation = "https://docs.rs/agave-transaction-view" -version = { workspace = true } -authors = { workspace = true } -repository = { workspace = true } +edition = "2024" homepage = { workspace = true } license = { workspace = true } -edition = "2024" +name = "agave-transaction-view" +readme = "README.md" +repository = { workspace = true } +version = "4.1.1" [features] agave-unstable-api = [] @@ -41,9 +42,9 @@ solana-transaction = { workspace = true, features = ["serde", "wincode"] } wincode = { workspace = true } [[bench]] -name = "bytes" harness = false +name = "bytes" [[bench]] -name = "transaction_view" harness = false +name = "transaction_view" diff --git a/solana/transaction-view/README.md b/solana/transaction-view/README.md new file mode 100644 index 0000000..b7b4cc4 --- /dev/null +++ b/solana/transaction-view/README.md @@ -0,0 +1,55 @@ +# `agave-transaction-view` + +This Agave fork parses and sanitizes serialized transactions without fully +deserializing them. Workspace `[patch.crates-io]` entries force the dependency +graph to use this copy. + +The view owns or borrows the original transaction bytes through +`TransactionData` and caches only framing metadata. Accessors and iterators then +read signatures, account keys, instructions, configuration, and address-table +metadata directly from the validated byte layout. + +## Supported formats + +- Legacy and v0 use the standard Solana wire layouts. +- V1 uses the Agave V1 layout with instruction headers, contiguous payloads, + transaction configuration, and trailing signatures. +- `Magicblock` is the Engine-private version `127`. It uses the V1 layout and a + distinct version prefix; it is not a client-facing Solana transaction version. + +For V1 and Magicblock transactions, `message_data()` covers the version byte +through the instruction payloads and excludes the trailing signatures. Engine +construction must sign exactly that range after writing the final version +prefix. + +## Limits and parsing invariants + +Legacy, v0, and V1 transactions may be at most `u16::MAX` bytes, inclusive. +Magicblock transactions may be at most 16 MiB and may use an instruction trace +length of 255. Other structural limits, including the standard signature and +account-index limits, remain enforced by sanitization. + +Compact-u16 values are parsed in their complete canonical one-, two-, or +three-byte form. Absolute offsets and transaction lengths are stored as `u32`. +Fallible parsing validates additions, multiplications, ranges, and conversion to +that representation before any unchecked iterator or typed-slice access. Code +using a sanitized view may rely on those validated frame boundaries. + +## Address lookup tables + +Address lookup tables are unsupported. A transaction containing any lookup +table entry fails sanitization with `TransactionViewError::AddressLookupMismatch`. +A v0 transaction with an empty lookup list remains valid and requires no loaded +addresses. + +## Maintenance constraints + +- Preserve standard Legacy and v0 wire compatibility for client-produced + transactions. +- Keep V1 and Magicblock framing synchronized; only the version, size, account, + and instruction-trace policies intentionally differ. +- Keep the Magicblock prefix, signed message range, and Engine transaction + composer synchronized. +- Treat the sanitizer as the authoritative boundary for rejecting address + lookup tables. +- Validate new frame offsets before exposing them through unchecked accessors. diff --git a/solana/transaction-view/benches/bytes.rs b/solana/transaction-view/benches/bytes.rs index a64e85d..713f6f1 100644 --- a/solana/transaction-view/benches/bytes.rs +++ b/solana/transaction-view/benches/bytes.rs @@ -15,9 +15,8 @@ fn setup() -> Vec<(u16, usize, Vec)> { for value in 0..PACKET_DATA_SIZE as u16 { let short_u16 = ShortU16(value); let mut buffer = vec![0u8; 16]; - let serialized_len = options - .serialized_size(&short_u16) - .expect("Failed to get serialized size"); + let serialized_len = + options.serialized_size(&short_u16).expect("Failed to get serialized size"); serialize_into(&mut buffer[..], &short_u16).expect("Serialization failed"); values.push((value, serialized_len as usize, buffer)); } diff --git a/solana/transaction-view/benches/transaction_view.rs b/solana/transaction-view/benches/transaction_view.rs index 262c5ee..6a94dc3 100644 --- a/solana/transaction-view/benches/transaction_view.rs +++ b/solana/transaction-view/benches/transaction_view.rs @@ -95,11 +95,7 @@ fn simple_transfers() -> Vec { let keypair = Keypair::new(); VersionedTransaction::try_new( VersionedMessage::Legacy(Message::new_with_blockhash( - &[system_instruction::transfer( - &keypair.pubkey(), - &Pubkey::new_unique(), - 1, - )], + &[system_instruction::transfer(&keypair.pubkey(), &Pubkey::new_unique(), 1)], Some(&keypair.pubkey()), &Hash::default(), )), diff --git a/solana/transaction-view/src/bytes.rs b/solana/transaction-view/src/bytes.rs index ece04b8..6a3eac4 100644 --- a/solana/transaction-view/src/bytes.rs +++ b/solana/transaction-view/src/bytes.rs @@ -24,12 +24,9 @@ pub fn check_remaining(bytes: &[u8], offset: usize, num_bytes: usize) -> Result< pub fn read_byte(bytes: &[u8], offset: &mut usize) -> Result { // Implicitly checks that the offset is within bounds, no need // to call `check_remaining` explicitly here. - let value = bytes - .get(*offset) - .copied() - .ok_or(TransactionViewError::ParseError); - *offset = offset.wrapping_add(1); - value + let value = bytes.get(*offset).copied().ok_or(TransactionViewError::ParseError)?; + *offset = offset.checked_add(1).ok_or(TransactionViewError::ParseError)?; + Ok(value) } /// Read a byte and advance the offset without any bounds checks. @@ -65,9 +62,8 @@ pub fn read_compressed_u16(bytes: &[u8], offset: &mut usize) -> Result { for i in 0..3 { // Implicitly checks that the offset is within bounds, no need // to call check_remaining explicitly here. - let byte = *bytes - .get(offset.wrapping_add(i)) - .ok_or(TransactionViewError::ParseError)?; + let index = offset.checked_add(i).ok_or(TransactionViewError::ParseError)?; + let byte = *bytes.get(index).ok_or(TransactionViewError::ParseError)?; // non-minimal encoding or overflow if (i > 0 && byte == 0) || (i == 2 && byte > 3) { return Err(TransactionViewError::ParseError); @@ -81,46 +77,7 @@ pub fn read_compressed_u16(bytes: &[u8], offset: &mut usize) -> Result { } // if we reach here, it means that all 3 bytes were used - *offset = offset.wrapping_add(3); - Ok(result) -} - -/// Domain-specific optimization for reading a compressed u16. -/// -/// The compressed u16's are only used for array-lengths in our transaction -/// format. The transaction packet has a maximum size of 1232 bytes. -/// This means that the maximum array length within a **valid** transaction is -/// 1232. This has a minimally encoded length of 2 bytes. -/// Although the encoding scheme allows for more, any arrays with this length -/// would be too large to fit in a packet. This function optimizes for this -/// case, and reads a maximum of 2 bytes. -/// If the buffer is too short or the encoding is invalid, return Err. -/// `offset` is updated to point to the byte after the compressed u16. -/// -/// * `bytes` - Slice of bytes to read from. -/// * `offset` - Current offset into `bytes`. -#[inline(always)] -pub fn optimized_read_compressed_u16(bytes: &[u8], offset: &mut usize) -> Result { - let mut result = 0u16; - - // First byte - let byte1 = *bytes.get(*offset).ok_or(TransactionViewError::ParseError)?; - result |= (byte1 & 0x7F) as u16; - if byte1 & 0x80 == 0 { - *offset = offset.wrapping_add(1); - return Ok(result); - } - - // Second byte - let byte2 = *bytes - .get(offset.wrapping_add(1)) - .ok_or(TransactionViewError::ParseError)?; - if byte2 == 0 || byte2 & 0x80 != 0 { - return Err(TransactionViewError::ParseError); // non-minimal encoding or overflow - } - result |= ((byte2 & 0x7F) as u16) << 7; - *offset = offset.wrapping_add(2); - + *offset = offset.checked_add(3).ok_or(TransactionViewError::ParseError)?; Ok(result) } @@ -305,9 +262,8 @@ mod tests { serialize_into(&mut buffer[..], &short_u16).expect("Serialization failed"); // Use bincode's size calculation to determine the length of the serialized data - let serialized_len = options - .serialized_size(&short_u16) - .expect("Failed to get serialized size"); + let serialized_len = + options.serialized_size(&short_u16).expect("Failed to get serialized size"); // Reset offset offset = 0; @@ -343,55 +299,6 @@ mod tests { assert!(read_compressed_u16(&[0x81, 0x80, 0x00], &mut 0).is_err()); } - #[test] - fn test_optimized_read_compressed_u16() { - let mut buffer = [0u8; 1024]; - let options = DefaultOptions::new().with_fixint_encoding(); // Ensure fixed-int encoding - - // Test all possible u16 values under the packet length - for value in 0..=PACKET_DATA_SIZE as u16 { - let mut offset; - let short_u16 = ShortU16(value); - - // Serialize the value into the buffer - serialize_into(&mut buffer[..], &short_u16).expect("Serialization failed"); - - // Use bincode's size calculation to determine the length of the serialized data - let serialized_len = options - .serialized_size(&short_u16) - .expect("Failed to get serialized size"); - - // Reset offset - offset = 0; - - // Read the value back using unchecked_read_u16_compressed - let read_value = optimized_read_compressed_u16(&buffer, &mut offset); - - // Assert that the read value matches the original value - assert_eq!(read_value, Ok(value), "Value mismatch for: {value}"); - - // Assert that the offset matches the serialized length - assert_eq!( - offset, serialized_len as usize, - "Offset mismatch for: {value}" - ); - } - - // Test bounds. - // All 0s => 0 - assert_eq!(Ok(0), optimized_read_compressed_u16(&[0; 3], &mut 0)); - // Overflow - assert!(optimized_read_compressed_u16(&[0xFF, 0xFF, 0x04], &mut 0).is_err()); - assert!(optimized_read_compressed_u16(&[0xFF, 0x80], &mut 0).is_err()); - - // overflow errors - assert!(optimized_read_compressed_u16(&[u8::MAX; 1], &mut 0).is_err()); - assert!(optimized_read_compressed_u16(&[u8::MAX; 2], &mut 0).is_err()); - - // Minimal encoding checks - assert!(optimized_read_compressed_u16(&[0x81, 0x00], &mut 0).is_err()); - } - #[test] fn test_advance_offset_for_array() { #[repr(C)] diff --git a/solana/transaction-view/src/instructions_frame.rs b/solana/transaction-view/src/instructions_frame.rs index 721de14..8b9989c 100644 --- a/solana/transaction-view/src/instructions_frame.rs +++ b/solana/transaction-view/src/instructions_frame.rs @@ -176,29 +176,23 @@ impl InstructionsFrame { #[inline(always)] pub(crate) fn num_instructions(&self) -> u16 { match self { - Self::LegacyAndV0 { - num_instructions, .. - } => *num_instructions, - Self::V1 { - num_instructions, .. - } => *num_instructions, + Self::LegacyAndV0 { num_instructions, .. } => *num_instructions, + Self::V1 { num_instructions, .. } => *num_instructions, } } #[inline(always)] pub(crate) fn iter<'a>(&'a self, bytes: &'a [u8]) -> InstructionsIterator<'a> { match self { - Self::LegacyAndV0 { - num_instructions, - offset, - frames, - } => InstructionsIterator::LegacyAndV0 { - bytes, - offset: *offset as usize, - index: 0, - num_instructions: *num_instructions, - frames, - }, + Self::LegacyAndV0 { num_instructions, offset, frames } => { + InstructionsIterator::LegacyAndV0 { + bytes, + offset: *offset as usize, + index: 0, + num_instructions: *num_instructions, + frames, + } + } Self::V1 { num_instructions, headers_offset, @@ -352,11 +346,7 @@ unsafe fn for_legacy_and_v0<'a>( // - Offset and length checks have been done in the initial parsing. let data = unsafe { unchecked_read_slice_data::(bytes, offset, data_len) }; - SVMInstruction { - program_id_index, - accounts, - data, - } + SVMInstruction { program_id_index, accounts, data } } /// Builds SMVInstruction from v1 pre-validated frame metadata. @@ -403,26 +393,18 @@ unsafe fn for_v1<'a>( // - Offset and length checks have been done in the initial parsing. let data = unsafe { unchecked_read_slice_data::(bytes, payloads_offset, data_len) }; - SVMInstruction { - program_id_index, - accounts, - data, - } + SVMInstruction { program_id_index, accounts, data } } impl ExactSizeIterator for InstructionsIterator<'_> { fn len(&self) -> usize { match self { - Self::LegacyAndV0 { - num_instructions, - index, - .. - } => usize::from(num_instructions.wrapping_sub(*index)), - Self::V1 { - num_instructions, - index, - .. - } => usize::from(num_instructions.wrapping_sub(*index)), + Self::LegacyAndV0 { num_instructions, index, .. } => { + usize::from(num_instructions.wrapping_sub(*index)) + } + Self::V1 { num_instructions, index, .. } => { + usize::from(num_instructions.wrapping_sub(*index)) + } } } } @@ -564,9 +546,9 @@ mod tests { ..solana_message::v1::Message::default() }; - let serialized = solana_message::v1::serialize(&message); + let serialized = message.serialize(); - let mut offset = 41; // instruction headers starts at offset 41 for no config, 0 addresses, per spec + let mut offset = 42; // instruction headers start after the V1 prefix for no config, 0 addresses let instructions_frame = InstructionsFrame::try_new_for_v1(&serialized, &mut offset, 2).unwrap(); @@ -624,11 +606,7 @@ mod tests { assert_eq!(offset, bytes.len()); match frame { - InstructionsFrame::LegacyAndV0 { - num_instructions, - offset, - frames, - } => { + InstructionsFrame::LegacyAndV0 { num_instructions, offset, frames } => { assert_eq!(num_instructions, 1); assert_eq!(offset, 1); assert_eq!(frames.len(), 1); @@ -661,11 +639,7 @@ mod tests { assert_eq!(offset, bytes.len()); match frame { - InstructionsFrame::LegacyAndV0 { - num_instructions, - offset, - frames, - } => { + InstructionsFrame::LegacyAndV0 { num_instructions, offset, frames } => { assert_eq!(num_instructions, 1); assert_eq!(offset, 1); assert_eq!(frames.len(), 1); @@ -832,9 +806,7 @@ mod tests { let bytes = vec![1, 1, 0xff, 0xff]; let mut offset = 0; assert_eq!( - InstructionsFrame::try_new_for_v1(&bytes, &mut offset, 1) - .err() - .unwrap(), + InstructionsFrame::try_new_for_v1(&bytes, &mut offset, 1).err().unwrap(), TransactionViewError::ParseError ); } @@ -848,11 +820,7 @@ mod tests { assert_eq!(offset, 1); match frame { - InstructionsFrame::LegacyAndV0 { - num_instructions, - offset, - frames, - } => { + InstructionsFrame::LegacyAndV0 { num_instructions, offset, frames } => { assert_eq!(num_instructions, 0); assert_eq!(offset, 1); assert!(frames.is_empty()); diff --git a/solana/transaction-view/src/resolved_transaction_view.rs b/solana/transaction-view/src/resolved_transaction_view.rs index b16202a..260f5ef 100644 --- a/solana/transaction-view/src/resolved_transaction_view.rs +++ b/solana/transaction-view/src/resolved_transaction_view.rs @@ -225,9 +225,7 @@ impl SVMMessage for ResolvedTransactionView { let Ok(index) = u8::try_from(key_index) else { return false; }; - self.view - .instructions_iter() - .any(|ix| ix.program_id_index == index) + self.view.instructions_iter().any(|ix| ix.program_id_index == index) } } @@ -243,9 +241,7 @@ impl SVMTransaction for ResolvedTransactionView { impl Debug for ResolvedTransactionView { fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { - f.debug_struct("ResolvedTransactionView") - .field("view", &self.view) - .finish() + f.debug_struct("ResolvedTransactionView").field("view", &self.view).finish() } } diff --git a/solana/transaction-view/src/sanitize.rs b/solana/transaction-view/src/sanitize.rs index ada57af..4cec905 100644 --- a/solana/transaction-view/src/sanitize.rs +++ b/solana/transaction-view/src/sanitize.rs @@ -63,9 +63,7 @@ fn sanitize_message_header(view: &UnsanitizedTransactionView view - .num_static_account_keys() - .wrapping_sub(view.num_required_signatures()) + > view.num_static_account_keys().wrapping_sub(view.num_required_signatures()) { return Err(TransactionViewError::SanitizeError); } @@ -77,9 +75,8 @@ fn sanitize_message_header(view: &UnsanitizedTransactionView) -> Result<()> { #[allow(clippy::collapsible_if)] - if let Some(requested_heap_bytes) = view - .transaction_config() - .and_then(|config| config.requested_heap_size()) + if let Some(requested_heap_bytes) = + view.transaction_config().and_then(|config| config.requested_heap_size()) { if !(MIN_HEAP_FRAME_BYTES..=MAX_HEAP_FRAME_BYTES).contains(&requested_heap_bytes) || !requested_heap_bytes.is_multiple_of(1024) @@ -654,11 +651,7 @@ mod tests { num_readonly_signed_accounts: 0, num_readonly_unsigned_accounts: 1, }; - let account_keys = vec![ - Pubkey::new_unique(), - Pubkey::new_unique(), - Pubkey::new_unique(), - ]; + let account_keys = vec![Pubkey::new_unique(), Pubkey::new_unique(), Pubkey::new_unique()]; let valid_instructions = vec![ CompiledInstruction { program_id_index: 1, @@ -760,9 +753,7 @@ mod tests { // Invalid account index. { let mut instructions = valid_instructions.clone(); - instructions[instruction_index] - .accounts - .push(account_keys.len() as u8); + instructions[instruction_index].accounts.push(account_keys.len() as u8); let transaction = create_legacy_transaction( num_signatures, header, @@ -783,9 +774,7 @@ mod tests { atls[0].writable_indexes.len() + atls[0].readonly_indexes.len(); let total_accounts = (account_keys.len() + num_lookup_accounts) as u8; let mut instructions = valid_instructions.clone(); - instructions[instruction_index] - .accounts - .push(total_accounts); + instructions[instruction_index].accounts.push(total_accounts); let transaction = create_v0_transaction( num_signatures, header, @@ -804,12 +793,8 @@ mod tests { // SIMD-0160, too many instructions are invalid { - let too_many_instructions: Vec<_> = valid_instructions - .iter() - .cycle() - .take(65) - .cloned() - .collect(); + let too_many_instructions: Vec<_> = + valid_instructions.iter().cycle().take(65).cloned().collect(); let transaction = create_legacy_transaction( num_signatures, header, diff --git a/solana/transaction-view/src/transaction_frame.rs b/solana/transaction-view/src/transaction_frame.rs index 7b49b9e..219c356 100644 --- a/solana/transaction-view/src/transaction_frame.rs +++ b/solana/transaction-view/src/transaction_frame.rs @@ -341,10 +341,7 @@ impl TransactionFrame { let account_bytes = &bytes[start..end]; unsafe { core::slice::from_raw_parts( - bytes - .as_ptr() - .add(usize::from(self.static_account_keys.offset)) - as *const Pubkey, + account_bytes.as_ptr() as *const Pubkey, usize::from(self.static_account_keys.num_static_accounts), ) } @@ -365,11 +362,9 @@ impl TransactionFrame { // is not initialized properly. // - Aliasing rules are respected because the lifetime of the returned // reference is the same as the input/source `bytes`. - unsafe { - &*(bytes - .as_ptr() - .add(usize::from(self.recent_blockhash_offset)) as *const Hash) - } + let start = self.recent_blockhash_offset as usize; + let hash_bytes = &bytes[start..start + size_of::()]; + unsafe { &*(hash_bytes.as_ptr() as *const Hash) } } /// Return an iterator over the instructions in the transaction. @@ -457,10 +452,7 @@ mod tests { ); assert_eq!( frame.address_table_lookup.num_address_table_lookups, - tx.message - .address_table_lookups() - .map(|x| x.len() as u8) - .unwrap_or(0) + tx.message.address_table_lookups().map(|x| x.len() as u8).unwrap_or(0) ); } @@ -485,11 +477,7 @@ mod tests { VersionedTransaction { signatures: vec![Signature::default()], // 1 signature to be valid. message: VersionedMessage::Legacy(Message::new( - &[system_instruction::transfer( - &payer, - &Pubkey::new_unique(), - 1, - )], + &[system_instruction::transfer(&payer, &Pubkey::new_unique(), 1)], Some(&payer), )), } @@ -502,11 +490,7 @@ mod tests { message: VersionedMessage::V0( v0::Message::try_compile( &payer, - &[system_instruction::transfer( - &payer, - &Pubkey::new_unique(), - 1, - )], + &[system_instruction::transfer(&payer, &Pubkey::new_unique(), 1)], &[], Hash::default(), ) diff --git a/solana/transaction-view/src/transaction_view.rs b/solana/transaction-view/src/transaction_view.rs index 58f236e..7496fdf 100644 --- a/solana/transaction-view/src/transaction_view.rs +++ b/solana/transaction-view/src/transaction_view.rs @@ -164,12 +164,10 @@ impl TransactionView { #[inline] pub fn transaction_config(&self) -> Option> { let transaction_config_frame = self.frame.transaction_config_frame(); - transaction_config_frame - .is_present() - .then_some(TransactionConfigView { - transaction_config_frame, - bytes: self.data(), - }) + transaction_config_frame.is_present().then_some(TransactionConfigView { + transaction_config_frame, + bytes: self.data(), + }) } /// Return the full serialized transaction data. @@ -213,8 +211,7 @@ impl TransactionView { /// Return the number of unsigned static account keys. #[inline] pub(crate) fn num_static_unsigned_static_accounts(&self) -> u8 { - self.num_static_account_keys() - .wrapping_sub(self.num_required_signatures()) + self.num_static_account_keys().wrapping_sub(self.num_required_signatures()) } /// Return the number of writable unsigned static accounts. @@ -410,10 +407,7 @@ mod tests { ); assert_eq!( view.num_address_table_lookups(), - tx.message - .address_table_lookups() - .map(|x| x.len() as u8) - .unwrap_or(0) + tx.message.address_table_lookups().map(|x| x.len() as u8).unwrap_or(0) ); assert!(view.transaction_config().is_none());