diff --git a/Cargo.lock b/Cargo.lock index 4ed72becc..28ef9f584 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3592,6 +3592,7 @@ dependencies = [ "borsh", "itertools 0.12.1", "lazy_static", + "proptest", "serde", "serde_derive", "serde_json", diff --git a/message/Cargo.toml b/message/Cargo.toml index 4db738360..af81b85ea 100644 --- a/message/Cargo.toml +++ b/message/Cargo.toml @@ -63,6 +63,7 @@ anyhow = { workspace = true } bitflags = { workspace = true } borsh = { workspace = true } itertools = { workspace = true } +proptest = { workspace = true } serde_json = { workspace = true } solana-address-lookup-table-interface = { workspace = true, features = [ "bincode", diff --git a/message/src/sanitized.rs b/message/src/sanitized.rs index 279d5e057..cf942c495 100644 --- a/message/src/sanitized.rs +++ b/message/src/sanitized.rs @@ -3,7 +3,7 @@ use { compiled_instruction::CompiledInstruction, legacy, v0::{self, LoadedAddresses}, - AccountKeys, AddressLoader, MessageHeader, SanitizedVersionedMessage, VersionedMessage, + v1, AccountKeys, AddressLoader, MessageHeader, SanitizedVersionedMessage, VersionedMessage, }, solana_address::Address, solana_hash::Hash, @@ -79,6 +79,8 @@ pub enum SanitizedMessage { Legacy(LegacyMessage<'static>), /// Sanitized version #0 message with dynamically loaded addresses V0(v0::LoadedMessage<'static>), + /// Sanitized version #1 message (4KB transactions, no address lookup tables) + V1(v1::LoadedMessage), } impl SanitizedMessage { @@ -103,6 +105,10 @@ impl SanitizedMessage { reserved_account_keys, )) } + VersionedMessage::V1(message) => { + // V1 messages do not use address lookup tables - all addresses are inline + SanitizedMessage::V1(v1::LoadedMessage::new(message, reserved_account_keys)) + } }) } @@ -123,6 +129,7 @@ impl SanitizedMessage { match self { SanitizedMessage::Legacy(message) => message.has_duplicates(), SanitizedMessage::V0(message) => message.has_duplicates(), + SanitizedMessage::V1(message) => message.has_duplicates(), } } @@ -132,6 +139,7 @@ impl SanitizedMessage { match self { Self::Legacy(legacy_message) => &legacy_message.message.header, Self::V0(loaded_msg) => &loaded_msg.message.header, + Self::V1(v1_msg) => &v1_msg.message().header, } } @@ -156,6 +164,7 @@ impl SanitizedMessage { match self { Self::Legacy(legacy_message) => &legacy_message.message.recent_blockhash, Self::V0(loaded_msg) => &loaded_msg.message.recent_blockhash, + Self::V1(v1_msg) => &v1_msg.message().lifetime_specifier, } } @@ -165,6 +174,7 @@ impl SanitizedMessage { match self { Self::Legacy(legacy_message) => &legacy_message.message.instructions, Self::V0(loaded_msg) => &loaded_msg.message.instructions, + Self::V1(v1_msg) => &v1_msg.message().instructions, } } @@ -188,6 +198,7 @@ impl SanitizedMessage { match self { Self::Legacy(legacy_message) => &legacy_message.message.account_keys, Self::V0(loaded_msg) => &loaded_msg.message.account_keys, + Self::V1(v1_msg) => &v1_msg.message().account_keys, } } @@ -196,6 +207,7 @@ impl SanitizedMessage { match self { Self::Legacy(message) => message.account_keys(), Self::V0(message) => message.account_keys(), + Self::V1(message) => message.account_keys(), } } @@ -204,6 +216,7 @@ impl SanitizedMessage { match self { Self::Legacy(_message) => &[], Self::V0(message) => &message.message.address_table_lookups, + Self::V1(_message) => &[], // V1 does not use address lookup tables } } @@ -225,6 +238,7 @@ impl SanitizedMessage { match self { Self::Legacy(message) => message.is_key_called_as_program(key_index), Self::V0(message) => message.is_key_called_as_program(key_index), + Self::V1(message) => message.is_key_called_as_program(key_index), } } @@ -234,6 +248,7 @@ impl SanitizedMessage { match self { Self::Legacy(message) => message.is_writable(index), Self::V0(message) => message.is_writable(index), + Self::V1(message) => message.is_writable(index), } } @@ -294,6 +309,7 @@ impl SanitizedMessage { match self { Self::Legacy(message) => message.is_upgradeable_loader_present(), Self::V0(message) => message.is_upgradeable_loader_present(), + Self::V1(message) => message.is_upgradeable_loader_present(), } } diff --git a/message/src/versions/mod.rs b/message/src/versions/mod.rs index df16f4f35..0e45ed55b 100644 --- a/message/src/versions/mod.rs +++ b/message/src/versions/mod.rs @@ -33,13 +33,14 @@ use { mod sanitized; pub mod v0; +pub mod v1; pub use sanitized::*; /// Bit mask that indicates whether a serialized message is versioned. pub const MESSAGE_VERSION_PREFIX: u8 = 0x80; -/// Either a legacy message or a v0 message. +/// Either a legacy message, v0 message, or v1 message. /// /// # Serialization /// @@ -49,13 +50,14 @@ pub const MESSAGE_VERSION_PREFIX: u8 = 0x80; /// format. #[cfg_attr( feature = "frozen-abi", - frozen_abi(digest = "Hndd1SDxQ5qNZvzHo77dpW6uD5c1DJNVjtg8tE6hc432"), + frozen_abi(digest = "3FVVnsLRS9Ue7MwhQPLxod48B1HxvDuX4nt4UhKV4jcs"), derive(AbiEnumVisitor, AbiExample) )] #[derive(Debug, PartialEq, Eq, Clone)] pub enum VersionedMessage { Legacy(LegacyMessage), V0(v0::Message), + V1(v1::Message), } impl VersionedMessage { @@ -63,6 +65,7 @@ impl VersionedMessage { match self { Self::Legacy(message) => message.sanitize(), Self::V0(message) => message.sanitize(), + Self::V1(message) => message.sanitize(), } } @@ -70,6 +73,7 @@ impl VersionedMessage { match self { Self::Legacy(message) => &message.header, Self::V0(message) => &message.header, + Self::V1(message) => &message.header, } } @@ -77,6 +81,7 @@ impl VersionedMessage { match self { Self::Legacy(message) => &message.account_keys, Self::V0(message) => &message.account_keys, + Self::V1(message) => &message.account_keys, } } @@ -84,6 +89,7 @@ impl VersionedMessage { match self { Self::Legacy(_) => None, Self::V0(message) => Some(&message.address_table_lookups), + Self::V1(_) => None, } } @@ -105,6 +111,7 @@ impl VersionedMessage { match self { Self::Legacy(message) => message.is_maybe_writable(index, reserved_account_keys), Self::V0(message) => message.is_maybe_writable(index, reserved_account_keys), + Self::V1(message) => message.is_maybe_writable(index, reserved_account_keys), } } @@ -124,6 +131,7 @@ impl VersionedMessage { match self { Self::Legacy(message) => message.is_key_called_as_program(key_index), Self::V0(message) => message.is_key_called_as_program(key_index), + Self::V1(message) => message.is_key_called_as_program(key_index), } } @@ -137,6 +145,7 @@ impl VersionedMessage { match self { Self::Legacy(message) => &message.recent_blockhash, Self::V0(message) => &message.recent_blockhash, + Self::V1(message) => &message.lifetime_specifier, } } @@ -144,6 +153,7 @@ impl VersionedMessage { match self { Self::Legacy(message) => message.recent_blockhash = recent_blockhash, Self::V0(message) => message.recent_blockhash = recent_blockhash, + Self::V1(message) => message.lifetime_specifier = recent_blockhash, } } @@ -153,12 +163,18 @@ impl VersionedMessage { match self { Self::Legacy(message) => &message.instructions, Self::V0(message) => &message.instructions, + Self::V1(message) => &message.instructions, } } #[cfg(feature = "bincode")] pub fn serialize(&self) -> Vec { - bincode::serialize(self).unwrap() + match self { + Self::Legacy(_) | Self::V0(_) => bincode::serialize(self).unwrap(), + Self::V1(message) => message + .to_bytes() + .expect("V1 message exceeds serialization limits"), + } } #[cfg(all(feature = "bincode", feature = "blake3"))] @@ -170,10 +186,30 @@ impl VersionedMessage { #[cfg(feature = "blake3")] /// Compute the blake3 hash of a raw transaction message + /// + /// Automatically detects the message version from the first byte and + /// applies the appropriate domain separator: + /// - Legacy (first byte < 0x80): no domain prefix + /// - V0 (first byte == 0x80): `solana-tx-message-v0` + /// - V1 (first byte == 0x81): `solana-tx-message-v1` pub fn hash_raw_message(message_bytes: &[u8]) -> Hash { use blake3::traits::digest::Digest; + let mut hasher = blake3::Hasher::new(); - hasher.update(b"solana-tx-message-v1"); + + if let Some(&first_byte) = message_bytes.first() { + if first_byte & MESSAGE_VERSION_PREFIX != 0 { + let version = first_byte & !MESSAGE_VERSION_PREFIX; + match version { + 0 => hasher.update(b"solana-tx-message-v0"), + 1 => hasher.update(b"solana-tx-message-v1"), + // Unknown versioned message - use version number in prefix + v => hasher.update(format!("solana-tx-message-v{v}").as_bytes()), + }; + } + // Legacy messages (first byte < 0x80) get no domain prefix + } + hasher.update(message_bytes); let hash_bytes: [u8; solana_hash::HASH_BYTES] = hasher.finalize().into(); hash_bytes.into() @@ -204,6 +240,22 @@ impl serde::Serialize for VersionedMessage { seq.serialize_element(message)?; seq.end() } + Self::V1(message) => { + if serializer.is_human_readable() { + // JSON: encode as (0x81, { ...message fields... }) + let mut seq = serializer.serialize_tuple(2)?; + seq.serialize_element(&(MESSAGE_VERSION_PREFIX | 1))?; + seq.serialize_element(message)?; + seq.end() + } else { + // Binary formats like bincode: V1 messages cannot be serialized via bincode + // because the wire format (per SIMD-0385) is incompatible with bincode's + // data model. Use `VersionedMessage::serialize()` to get the correct wire bytes. + Err(serde::ser::Error::custom( + "V1 messages cannot be serialized via bincode. Use VersionedMessage::serialize() for wire bytes.", + )) + } + } } } } @@ -257,7 +309,9 @@ impl<'de> serde::Deserialize<'de> for VersionedMessage { where D: Deserializer<'de>, { - struct MessageVisitor; + struct MessageVisitor { + human_readable: bool, + } impl<'de> Visitor<'de> for MessageVisitor { type Value = VersionedMessage; @@ -316,6 +370,21 @@ impl<'de> serde::Deserialize<'de> for VersionedMessage { }, )?)) } + 1 => { + if self.human_readable { + Ok(VersionedMessage::V1( + seq.next_element()? + .ok_or_else(|| de::Error::invalid_length(1, &self))?, + )) + } else { + // V1 messages cannot be deserialized via bincode + // because the wire format (per SIMD-0385) is incompatible. + // Use `v1::Message::from_bytes()` to parse wire-format bytes. + Err(de::Error::custom( + "V1 messages cannot be serialized via bincode. Use VersionedMessage::serialize() for wire bytes.", + )) + } + } 127 => { // 0xff is used as the first byte of the off-chain messages // which corresponds to version 127 of the versioned messages. @@ -333,7 +402,8 @@ impl<'de> serde::Deserialize<'de> for VersionedMessage { } } - deserializer.deserialize_tuple(2, MessageVisitor) + let human_readable = deserializer.is_human_readable(); + deserializer.deserialize_tuple(2, MessageVisitor { human_readable }) } } @@ -348,6 +418,11 @@ impl SchemaWrite for VersionedMessage { // +1 for message version prefix #[expect(clippy::arithmetic_side_effects)] VersionedMessage::V0(message) => Ok(1 + v0::Message::size_of(message)?), + // V1 uses custom binary format (no bincode dependency) + VersionedMessage::V1(message) => Ok(message + .to_bytes() + .expect("V1 message exceeds serialization limits") + .len()), } } @@ -359,6 +434,16 @@ impl SchemaWrite for VersionedMessage { u8::write(writer, &MESSAGE_VERSION_PREFIX)?; v0::Message::write(writer, message) } + // V1 uses custom binary format (no bincode dependency) + VersionedMessage::V1(message) => { + for byte in message + .to_bytes() + .expect("V1 message exceeds serialization limits") + { + u8::write(writer, &byte)?; + } + Ok(()) + } } } } @@ -384,6 +469,25 @@ impl<'de> SchemaRead<'de> for VersionedMessage { dst.write(VersionedMessage::V0(msg)); Ok(()) } + 1 => { + // V1 uses a custom binary format. Peek available bytes, + // prepend version byte, and delegate to from_bytes_partial + // which handles all parsing and returns bytes consumed. + use v1::MAX_TRANSACTION_SIZE; + + // Peek up to max message size (minus version byte already read) + let mut bytes = vec![variant]; // 0x81 version byte + bytes.extend_from_slice(reader.fill_buf(MAX_TRANSACTION_SIZE - 1)?); + + // Parse - from_bytes_partial tells us actual consumed size + let (msg, consumed) = v1::Message::from_bytes_partial(&bytes) + .map_err(|_| invalid_tag_encoding(1))?; + + // Advance reader by message size (minus version byte) + reader.consume(consumed.saturating_sub(1))?; + dst.write(VersionedMessage::V1(msg)); + Ok(()) + } _ => Err(invalid_tag_encoding(version as usize)), }; } @@ -416,6 +520,8 @@ impl<'de> SchemaRead<'de> for VersionedMessage { #[cfg(test)] mod tests { + #[cfg(feature = "blake3")] + use blake3::traits::digest::Digest; use { super::*, crate::v0::MessageAddressTableLookup, @@ -507,4 +613,291 @@ mod tests { let message_from_string: VersionedMessage = serde_json::from_str(&string).unwrap(); assert_eq!(message, message_from_string); } + + #[test] + fn test_v1_message_raw_bytes_roundtrip() { + let message = v1::Message::builder() + .num_required_signatures(1) + .lifetime_specifier(Hash::new_unique()) + .account_keys(vec![Address::new_unique(), Address::new_unique()]) + .priority_fee(1000) + .compute_unit_limit(200_000) + .instruction(CompiledInstruction { + program_id_index: 1, + accounts: vec![0], + data: vec![1, 2, 3, 4], + }) + .build() + .unwrap(); + + // Serialize V1 to raw bytes + let bytes = message.to_bytes().unwrap(); + + // Deserialize from raw bytes + let parsed = v1::Message::from_bytes(&bytes).unwrap(); + assert_eq!(message, parsed); + + // Wrap in VersionedMessage and test serialize() + let versioned = VersionedMessage::V1(message.clone()); + let serialized = versioned.serialize(); + assert_eq!(serialized, bytes); + } + + #[test] + fn test_v1_versioned_message_json_roundtrip() { + let msg = v1::Message::builder() + .num_required_signatures(1) + .lifetime_specifier(Hash::new_unique()) + .account_keys(vec![Address::new_unique(), Address::new_unique()]) + .priority_fee(1000) + .compute_unit_limit(200_000) + .instruction(CompiledInstruction { + program_id_index: 1, + accounts: vec![0], + data: vec![1, 2, 3, 4], + }) + .build() + .unwrap(); + + let vm = VersionedMessage::V1(msg); + let s = serde_json::to_string(&vm).unwrap(); + let back: VersionedMessage = serde_json::from_str(&s).unwrap(); + assert_eq!(vm, back); + } + + #[test] + fn test_v1_versioned_message_bincode_blocked() { + let msg = v1::Message::builder() + .num_required_signatures(1) + .lifetime_specifier(Hash::new_unique()) + .account_keys(vec![Address::new_unique(), Address::new_unique()]) + .instruction(CompiledInstruction { + program_id_index: 1, + accounts: vec![0], + data: vec![], + }) + .build() + .unwrap(); + + let vm = VersionedMessage::V1(msg); + + // bincode serialization should fail + let result = bincode::serialize(&vm); + assert!(result.is_err()); + assert!(result + .unwrap_err() + .to_string() + .contains("cannot be serialized via bincode")); + } + + #[cfg(feature = "wincode")] + #[test] + fn test_v1_wincode_roundtrip() { + let test_messages = [ + // Minimal message + v1::Message::builder() + .num_required_signatures(1) + .lifetime_specifier(Hash::new_unique()) + .account_keys(vec![Address::new_unique(), Address::new_unique()]) + .instruction(CompiledInstruction { + program_id_index: 1, + accounts: vec![0], + data: vec![], + }) + .build() + .unwrap(), + // With config + v1::Message::builder() + .num_required_signatures(1) + .lifetime_specifier(Hash::new_unique()) + .account_keys(vec![Address::new_unique(), Address::new_unique()]) + .priority_fee(1000) + .compute_unit_limit(200_000) + .instruction(CompiledInstruction { + program_id_index: 1, + accounts: vec![0], + data: vec![1, 2, 3, 4], + }) + .build() + .unwrap(), + // Multiple instructions + v1::Message::builder() + .num_required_signatures(2) + .lifetime_specifier(Hash::new_unique()) + .account_keys(vec![ + Address::new_unique(), + Address::new_unique(), + Address::new_unique(), + ]) + .heap_size(65536) + .instructions(vec![ + CompiledInstruction { + program_id_index: 2, + accounts: vec![0, 1], + data: vec![0xAA, 0xBB], + }, + CompiledInstruction { + program_id_index: 2, + accounts: vec![1], + data: vec![0xCC], + }, + ]) + .build() + .unwrap(), + ]; + + for message in test_messages { + let versioned = VersionedMessage::V1(message.clone()); + + // Wincode roundtrip + let bytes = wincode::serialize(&versioned).expect("Wincode serialize failed"); + let deserialized: VersionedMessage = + wincode::deserialize(&bytes).expect("Wincode deserialize failed"); + + match deserialized { + VersionedMessage::V1(parsed) => { + assert_eq!(parsed.header, message.header); + assert_eq!(parsed.lifetime_specifier, message.lifetime_specifier); + assert_eq!(parsed.account_keys, message.account_keys); + assert_eq!(parsed.config, message.config); + assert_eq!(parsed.instructions, message.instructions); + } + _ => panic!("Expected V1 message"), + } + } + } + + #[cfg(feature = "blake3")] + #[test] + fn test_hash_raw_message_uses_version_specific_prefix() { + // Test V1 message (0x81 prefix) + let v1_bytes = &[0x81, 0x01, 0x02, 0x03]; + let v1_hash = VersionedMessage::hash_raw_message(v1_bytes); + + // Manually compute expected hash with v1 prefix + let mut expected_hasher = blake3::Hasher::new(); + expected_hasher.update(b"solana-tx-message-v1"); + expected_hasher.update(v1_bytes); + let expected_v1: [u8; 32] = expected_hasher.finalize().into(); + assert_eq!(v1_hash.as_ref(), &expected_v1); + + // Test V0 message (0x80 prefix) + let v0_bytes = &[0x80, 0x01, 0x02, 0x03]; + let v0_hash = VersionedMessage::hash_raw_message(v0_bytes); + + let mut expected_hasher = blake3::Hasher::new(); + expected_hasher.update(b"solana-tx-message-v0"); + expected_hasher.update(v0_bytes); + let expected_v0: [u8; 32] = expected_hasher.finalize().into(); + assert_eq!(v0_hash.as_ref(), &expected_v0); + + // Test Legacy message (first byte < 0x80, no prefix) + let legacy_bytes = &[0x01, 0x02, 0x03, 0x04]; + let legacy_hash = VersionedMessage::hash_raw_message(legacy_bytes); + + let mut expected_hasher = blake3::Hasher::new(); + // No prefix for legacy + expected_hasher.update(legacy_bytes); + let expected_legacy: [u8; 32] = expected_hasher.finalize().into(); + assert_eq!(legacy_hash.as_ref(), &expected_legacy); + + // Verify all three hashes are different + assert_ne!(v1_hash, v0_hash); + assert_ne!(v1_hash, legacy_hash); + assert_ne!(v0_hash, legacy_hash); + } + + #[cfg(feature = "blake3")] + #[test] + fn test_hash_domain_separation_prevents_collision() { + // Same payload bytes but different version prefixes must produce different hashes + let payload = &[0x01, 0x02, 0x03, 0x04, 0x05]; + + // Create messages with same payload but different versions + let mut legacy_bytes = vec![0x7F]; // Legacy (< 0x80) + legacy_bytes.extend_from_slice(payload); + + let mut v0_bytes = vec![0x80]; // V0 + v0_bytes.extend_from_slice(payload); + + let mut v1_bytes = vec![0x81]; // V1 + v1_bytes.extend_from_slice(payload); + + let legacy_hash = VersionedMessage::hash_raw_message(&legacy_bytes); + let v0_hash = VersionedMessage::hash_raw_message(&v0_bytes); + let v1_hash = VersionedMessage::hash_raw_message(&v1_bytes); + + // All hashes must be different despite similar content + assert_ne!(legacy_hash, v0_hash, "Legacy and V0 hashes should differ"); + assert_ne!(legacy_hash, v1_hash, "Legacy and V1 hashes should differ"); + assert_ne!(v0_hash, v1_hash, "V0 and V1 hashes should differ"); + } + + #[cfg(feature = "blake3")] + #[test] + fn test_hash_empty_message_edge_case() { + // Empty message edge case + let empty_hash = VersionedMessage::hash_raw_message(&[]); + + // Empty input should just hash empty bytes (no version prefix) + let mut hasher = blake3::Hasher::new(); + hasher.update(&[]); + let expected: [u8; 32] = hasher.finalize().into(); + assert_eq!(empty_hash.as_ref(), &expected); + } + + #[cfg(feature = "blake3")] + #[test] + fn test_hash_unknown_version_uses_dynamic_prefix() { + // Test unknown version byte (e.g., 0x85 = version 5) + let future_bytes = &[0x85, 0x01, 0x02, 0x03]; + let future_hash = VersionedMessage::hash_raw_message(future_bytes); + + // Should use dynamic prefix "solana-tx-message-v5" + let mut expected_hasher = blake3::Hasher::new(); + expected_hasher.update(b"solana-tx-message-v5"); + expected_hasher.update(future_bytes); + let expected: [u8; 32] = expected_hasher.finalize().into(); + assert_eq!(future_hash.as_ref(), &expected); + } + + #[cfg(all(feature = "blake3", feature = "bincode"))] + #[test] + fn test_hash_real_v1_message() { + let message = v1::Message::builder() + .num_required_signatures(1) + .lifetime_specifier(Hash::new_from_array([0xAB; 32])) + .account_keys(vec![ + Address::new_from_array([1u8; 32]), + Address::new_from_array([2u8; 32]), + ]) + .priority_fee(1000) + .compute_unit_limit(200_000) + .instruction(CompiledInstruction { + program_id_index: 1, + accounts: vec![0], + data: vec![1, 2, 3, 4], + }) + .build() + .unwrap(); + + let versioned = VersionedMessage::V1(message); + let bytes = versioned.serialize(); + + // Verify first byte is V1 version + assert_eq!(bytes[0], 0x81); + + let hash = versioned.hash(); + + // Hash via raw bytes should match + let hash_from_raw = VersionedMessage::hash_raw_message(&bytes); + assert_eq!(hash, hash_from_raw); + + // Manually verify the domain separation + let mut hasher = blake3::Hasher::new(); + hasher.update(b"solana-tx-message-v1"); + hasher.update(&bytes); + let expected: [u8; 32] = hasher.finalize().into(); + assert_eq!(hash.as_ref(), &expected); + } } diff --git a/message/src/versions/v1/builder.rs b/message/src/versions/v1/builder.rs new file mode 100644 index 000000000..fef173096 --- /dev/null +++ b/message/src/versions/v1/builder.rs @@ -0,0 +1,413 @@ +//! Builder pattern for constructing V1 messages. + +use { + super::{ + Message, MessageError, TransactionConfig, TransactionConfigMask, MAX_ADDRESSES, + MAX_INSTRUCTIONS, MAX_SIGNATURES, MAX_TRANSACTION_SIZE, + }, + crate::{compiled_instruction::CompiledInstruction, MessageHeader}, + solana_address::Address, + solana_hash::Hash, +}; + +/// Builder for constructing V1 messages. +#[derive(Debug, Clone, Default)] +pub struct MessageBuilder { + header: MessageHeader, + config: TransactionConfig, + lifetime_specifier: Option, + account_keys: Vec
, + instructions: Vec, +} + +impl MessageBuilder { + pub fn new() -> Self { + Self::default() + } + + #[must_use] + pub fn num_required_signatures(mut self, count: u8) -> Self { + self.header.num_required_signatures = count; + self + } + + #[must_use] + pub fn num_readonly_signed_accounts(mut self, count: u8) -> Self { + self.header.num_readonly_signed_accounts = count; + self + } + + #[must_use] + pub fn num_readonly_unsigned_accounts(mut self, count: u8) -> Self { + self.header.num_readonly_unsigned_accounts = count; + self + } + + #[must_use] + pub fn lifetime_specifier(mut self, hash: Hash) -> Self { + self.lifetime_specifier = Some(hash); + self + } + + #[must_use] + pub fn config(mut self, config: TransactionConfig) -> Self { + self.config = config; + self + } + + #[must_use] + pub fn priority_fee(mut self, fee: u64) -> Self { + self.config.priority_fee = Some(fee); + self + } + + #[must_use] + pub fn compute_unit_limit(mut self, limit: u32) -> Self { + self.config.compute_unit_limit = Some(limit); + self + } + + #[must_use] + pub fn loaded_accounts_data_size_limit(mut self, limit: u32) -> Self { + self.config.loaded_accounts_data_size_limit = Some(limit); + self + } + + #[must_use] + pub fn heap_size(mut self, size: u32) -> Self { + self.config.heap_size = Some(size); + self + } + + #[must_use] + pub fn account_key(mut self, key: Address) -> Self { + self.account_keys.push(key); + self + } + + #[must_use] + pub fn account_keys(mut self, keys: Vec
) -> Self { + self.account_keys = keys; + self + } + + #[must_use] + pub fn instruction(mut self, instruction: CompiledInstruction) -> Self { + self.instructions.push(instruction); + self + } + + #[must_use] + pub fn instructions(mut self, instructions: Vec) -> Self { + self.instructions = instructions; + self + } + + /// Build the message, validating all constraints. + pub fn build(self) -> Result { + let lifetime_specifier = self + .lifetime_specifier + .ok_or(MessageError::MissingLifetimeSpecifier)?; + + // Validate signer count + if self.header.num_required_signatures == 0 { + return Err(MessageError::ZeroSigners); + } + if self.header.num_required_signatures > MAX_SIGNATURES { + return Err(MessageError::TooManySignatures); + } + + // Validate address count + if self.account_keys.len() > MAX_ADDRESSES as usize { + return Err(MessageError::TooManyAddresses); + } + if (self.header.num_required_signatures as usize) > self.account_keys.len() { + return Err(MessageError::NotEnoughAddressesForSignatures); + } + + // Validate instruction count + if self.instructions.len() > MAX_INSTRUCTIONS as usize { + return Err(MessageError::TooManyInstructions); + } + + // Validate config mask (priority fee bits must be both set or both unset) + let mask = TransactionConfigMask::from_config(&self.config); + if mask.has_invalid_priority_fee_bits() { + return Err(MessageError::InvalidConfigMask); + } + + // Validate heap size alignment + if let Some(heap_size) = self.config.heap_size { + if heap_size % 1024 != 0 { + return Err(MessageError::InvalidHeapSize); + } + } + + // Validate instruction constraints + for ix in &self.instructions { + if ix.accounts.len() > u8::MAX as usize { + return Err(MessageError::InstructionAccountsTooLarge); + } + if ix.data.len() > u16::MAX as usize { + return Err(MessageError::InstructionDataTooLarge); + } + } + + let message = Message { + header: self.header, + config: self.config, + lifetime_specifier, + account_keys: self.account_keys, + instructions: self.instructions, + }; + + // Validate transaction size (message + signatures) + if message.transaction_size() > MAX_TRANSACTION_SIZE { + return Err(MessageError::TransactionTooLarge); + } + + Ok(message) + } +} + +#[cfg(test)] +mod tests { + use { + super::{ + super::{FIXED_HEADER_SIZE, INSTRUCTION_HEADER_SIZE, SIGNATURE_SIZE}, + *, + }, + solana_address::ADDRESS_BYTES, + }; + + #[test] + fn builder_requires_lifetime_specifier() { + let result = MessageBuilder::new() + .num_required_signatures(1) + .account_key(Address::new_unique()) + .build(); + + assert_eq!(result, Err(MessageError::MissingLifetimeSpecifier)); + } + + #[test] + fn builder_rejects_zero_signers() { + let result = MessageBuilder::new() + .num_required_signatures(0) + .lifetime_specifier(Hash::new_unique()) + .account_key(Address::new_unique()) + .build(); + + assert_eq!(result, Err(MessageError::ZeroSigners)); + } + + #[test] + fn builder_rejects_too_many_signatures() { + let result = MessageBuilder::new() + .num_required_signatures(MAX_SIGNATURES + 1) + .lifetime_specifier(Hash::new_unique()) + .account_keys((0..20).map(|_| Address::new_unique()).collect()) + .build(); + + assert_eq!(result, Err(MessageError::TooManySignatures)); + } + + #[test] + fn builder_rejects_too_many_addresses() { + let result = MessageBuilder::new() + .num_required_signatures(1) + .lifetime_specifier(Hash::new_unique()) + .account_keys((0..65).map(|_| Address::new_unique()).collect()) + .build(); + + assert_eq!(result, Err(MessageError::TooManyAddresses)); + } + + #[test] + fn builder_rejects_not_enough_addresses() { + let result = MessageBuilder::new() + .num_required_signatures(5) + .lifetime_specifier(Hash::new_unique()) + .account_keys(vec![Address::new_unique(), Address::new_unique()]) + .build(); + + assert_eq!(result, Err(MessageError::NotEnoughAddressesForSignatures)); + } + + #[test] + fn builder_rejects_too_many_instructions() { + let result = MessageBuilder::new() + .num_required_signatures(1) + .lifetime_specifier(Hash::new_unique()) + .account_keys(vec![Address::new_unique(), Address::new_unique()]) + .instructions( + (0..129) + .map(|_| CompiledInstruction { + program_id_index: 1, + accounts: vec![], + data: vec![], + }) + .collect(), + ) + .build(); + + assert_eq!(result, Err(MessageError::TooManyInstructions)); + } + + #[test] + fn builder_rejects_unaligned_heap_size() { + let result = MessageBuilder::new() + .num_required_signatures(1) + .lifetime_specifier(Hash::new_unique()) + .account_key(Address::new_unique()) + .heap_size(1025) + .build(); + + assert_eq!(result, Err(MessageError::InvalidHeapSize)); + } + + #[test] + fn builder_accepts_valid_heap_sizes() { + for size in [1024, 32768, 65536, 256 * 1024] { + let result = MessageBuilder::new() + .num_required_signatures(1) + .lifetime_specifier(Hash::new_unique()) + .account_key(Address::new_unique()) + .heap_size(size) + .build(); + + assert!(result.is_ok(), "heap_size {size} should be valid"); + } + } + + #[test] + fn builder_rejects_instruction_with_too_many_accounts() { + let result = MessageBuilder::new() + .num_required_signatures(1) + .lifetime_specifier(Hash::new_unique()) + .account_keys(vec![Address::new_unique(), Address::new_unique()]) + .instruction(CompiledInstruction { + program_id_index: 1, + accounts: vec![0; 256], // Max is 255 + data: vec![], + }) + .build(); + + assert_eq!(result, Err(MessageError::InstructionAccountsTooLarge)); + } + + #[test] + fn builder_creates_valid_message() { + let fee_payer = Address::new_unique(); + let program = Address::new_unique(); + let blockhash = Hash::new_unique(); + + let message = MessageBuilder::new() + .num_required_signatures(1) + .num_readonly_unsigned_accounts(0) + .lifetime_specifier(blockhash) + .account_keys(vec![fee_payer, program]) + .instruction(CompiledInstruction { + program_id_index: 1, + accounts: vec![0], + data: vec![1, 2, 3], + }) + .compute_unit_limit(200_000) + .build() + .unwrap(); + + assert_eq!(message.header.num_required_signatures, 1); + assert_eq!(message.lifetime_specifier, blockhash); + assert_eq!(message.account_keys.len(), 2); + assert_eq!(message.config.compute_unit_limit, Some(200_000)); + } + + #[test] + fn builder_sets_all_config_fields() { + let message = MessageBuilder::new() + .num_required_signatures(1) + .lifetime_specifier(Hash::new_unique()) + .account_key(Address::new_unique()) + .priority_fee(1000) + .compute_unit_limit(200_000) + .loaded_accounts_data_size_limit(64 * 1024) + .heap_size(64 * 1024) + .build() + .unwrap(); + + assert_eq!(message.config.priority_fee, Some(1000)); + assert_eq!(message.config.compute_unit_limit, Some(200_000)); + assert_eq!( + message.config.loaded_accounts_data_size_limit, + Some(64 * 1024) + ); + assert_eq!(message.config.heap_size, Some(64 * 1024)); + } + + #[test] + fn builder_rejects_transaction_too_large() { + // Create a message that would exceed 4096 bytes when serialized as a transaction. + // With 12 signatures (64 bytes each) = 768 bytes for signatures + // Plus message overhead, we can use large instruction data to exceed the limit. + let large_data = vec![0u8; 3500]; // Large instruction data + + let result = MessageBuilder::new() + .num_required_signatures(MAX_SIGNATURES) // 12 signatures = 768 bytes + .lifetime_specifier(Hash::new_unique()) + .account_keys( + (0..MAX_SIGNATURES as usize) + .map(|_| Address::new_unique()) + .collect(), + ) + .instruction(CompiledInstruction { + program_id_index: 1, + accounts: vec![0], + data: large_data, + }) + .build(); + + assert_eq!(result, Err(MessageError::TransactionTooLarge)); + } + + #[test] + fn builder_accepts_transaction_at_max_size() { + // Calculate exact max data size for a transaction at the limit: + // - 1 signature + // - Fixed header (version + MessageHeader + config mask + lifetime + num_ix + num_addr) + // - 2 addresses + // - No config values (mask = 0) + // - 1 instruction header + // - 1 account index in instruction + const NUM_SIGNATURES: usize = 1; + const NUM_ADDRESSES: usize = 2; + const NUM_INSTRUCTION_ACCOUNTS: usize = 1; + + let overhead = (NUM_SIGNATURES * SIGNATURE_SIZE) + + FIXED_HEADER_SIZE + + (NUM_ADDRESSES * ADDRESS_BYTES) + + INSTRUCTION_HEADER_SIZE + + NUM_INSTRUCTION_ACCOUNTS; + + let max_data_size = MAX_TRANSACTION_SIZE - overhead; + let data = vec![0u8; max_data_size]; + + let result = MessageBuilder::new() + .num_required_signatures(NUM_SIGNATURES as u8) + .lifetime_specifier(Hash::new_unique()) + .account_keys(vec![Address::new_unique(), Address::new_unique()]) + .instruction(CompiledInstruction { + program_id_index: 1, + accounts: vec![0], + data, + }) + .build(); + + assert!(result.is_ok(), "Expected message to build successfully"); + let message = result.unwrap(); + assert_eq!( + message.transaction_size(), + MAX_TRANSACTION_SIZE, + "Transaction should be exactly at max size" + ); + } +} diff --git a/message/src/versions/v1/config.rs b/message/src/versions/v1/config.rs new file mode 100644 index 000000000..bb888309b --- /dev/null +++ b/message/src/versions/v1/config.rs @@ -0,0 +1,268 @@ +//! Transaction configuration types for V1 messages. + +#[cfg(feature = "serde")] +use serde_derive::{Deserialize, Serialize}; +#[cfg(feature = "frozen-abi")] +use solana_frozen_abi_macro::AbiExample; +use std::mem::size_of; + +/// Bitmask indicating which configuration values are present in a V1 transaction. +/// +/// Each bit (or bit pair) corresponds to a specific configuration field. +/// The config values array contains entries only for fields whose bits are set. +#[cfg_attr(feature = "frozen-abi", derive(AbiExample))] +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub struct TransactionConfigMask(pub u32); + +impl TransactionConfigMask { + /// Bits 0-1: Priority fee (requires both bits set, 8 bytes as u64 LE). + pub const PRIORITY_FEE_BITS: u32 = 0b11; + + /// Bit 2: Compute unit limit (4 bytes as u32 LE). + pub const COMPUTE_UNIT_LIMIT_BIT: u32 = 0b100; + + /// Bit 3: Loaded accounts data size limit (4 bytes as u32 LE). + pub const LOADED_ACCOUNTS_DATA_SIZE_BIT: u32 = 0b1000; + + /// Bit 4: Requested heap size (4 bytes as u32 LE). + pub const HEAP_SIZE_BIT: u32 = 0b10000; + + /// Mask of all known/supported bits (bits 0-4). + pub const KNOWN_BITS_MASK: u32 = 0b11111; + + pub const fn new(value: u32) -> Self { + Self(value) + } + + /// Returns true if any unknown bits are set (bits 5-31). + /// + /// Unknown bits indicate a config field the parser doesn't recognize. + /// Since config values are packed sequentially and the parser doesn't + /// know the byte size of unknown fields, it cannot skip them correctly. + /// Attempting to parse would read subsequent fields from wrong offsets. + pub const fn has_unknown_bits(&self) -> bool { + (self.0 & !Self::KNOWN_BITS_MASK) != 0 + } + + pub const fn has_priority_fee(&self) -> bool { + (self.0 & Self::PRIORITY_FEE_BITS) == Self::PRIORITY_FEE_BITS + } + + /// Returns true if only one of the two priority fee bits is set (invalid). + pub const fn has_invalid_priority_fee_bits(&self) -> bool { + let bits = self.0 & Self::PRIORITY_FEE_BITS; + bits != 0 && bits != Self::PRIORITY_FEE_BITS + } + + pub const fn has_compute_unit_limit(&self) -> bool { + (self.0 & Self::COMPUTE_UNIT_LIMIT_BIT) != 0 + } + + pub const fn has_loaded_accounts_data_size(&self) -> bool { + (self.0 & Self::LOADED_ACCOUNTS_DATA_SIZE_BIT) != 0 + } + + pub const fn has_heap_size(&self) -> bool { + (self.0 & Self::HEAP_SIZE_BIT) != 0 + } + + /// Total size in bytes of config values indicated by this mask. + pub const fn config_values_size(&self) -> usize { + let mut size: usize = 0; + if self.has_priority_fee() { + size = size.saturating_add(size_of::()); + } + if self.has_compute_unit_limit() { + size = size.saturating_add(size_of::()); + } + if self.has_loaded_accounts_data_size() { + size = size.saturating_add(size_of::()); + } + if self.has_heap_size() { + size = size.saturating_add(size_of::()); + } + size + } + + pub const fn from_config(config: &TransactionConfig) -> Self { + let mut mask = 0u32; + if config.priority_fee.is_some() { + mask |= Self::PRIORITY_FEE_BITS; + } + if config.compute_unit_limit.is_some() { + mask |= Self::COMPUTE_UNIT_LIMIT_BIT; + } + if config.loaded_accounts_data_size_limit.is_some() { + mask |= Self::LOADED_ACCOUNTS_DATA_SIZE_BIT; + } + if config.heap_size.is_some() { + mask |= Self::HEAP_SIZE_BIT; + } + Self(mask) + } +} + +/// Compute budget configuration for V1 transactions. +#[cfg_attr(feature = "frozen-abi", derive(AbiExample))] +#[cfg_attr( + feature = "serde", + derive(Serialize, Deserialize), + serde(rename_all = "camelCase") +)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub struct TransactionConfig { + /// Priority fee in lamports. + pub priority_fee: Option, + + /// Maximum compute units. None means use runtime default. + pub compute_unit_limit: Option, + + /// Maximum bytes of account data that may be loaded. None means use runtime default. + pub loaded_accounts_data_size_limit: Option, + + /// Heap size in bytes. Must be multiple of 1024. `None` = 32KB. + pub heap_size: Option, +} + +impl TransactionConfig { + pub const fn new() -> Self { + Self { + priority_fee: None, + compute_unit_limit: None, + loaded_accounts_data_size_limit: None, + heap_size: None, + } + } + + #[must_use] + pub const fn with_priority_fee(mut self, fee: u64) -> Self { + self.priority_fee = Some(fee); + self + } + + #[must_use] + pub const fn with_compute_unit_limit(mut self, limit: u32) -> Self { + self.compute_unit_limit = Some(limit); + self + } + + #[must_use] + pub const fn with_loaded_accounts_data_size_limit(mut self, limit: u32) -> Self { + self.loaded_accounts_data_size_limit = Some(limit); + self + } + + /// Heap size must be a multiple of 1024. Validated during deserialization. + #[must_use] + pub const fn with_heap_size(mut self, size: u32) -> Self { + self.heap_size = Some(size); + self + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn has_unknown_bits_detects_unsupported_bits() { + assert!(!TransactionConfigMask::new(0).has_unknown_bits()); + assert!(!TransactionConfigMask::new(0b11111).has_unknown_bits()); + assert!(TransactionConfigMask::new(0b100000).has_unknown_bits()); + assert!(TransactionConfigMask::new(0x80000000).has_unknown_bits()); + assert!(TransactionConfigMask::new(0b111111).has_unknown_bits()); + } + + #[test] + fn has_priority_fee_requires_both_bits() { + assert!(!TransactionConfigMask::new(0).has_priority_fee()); + assert!(!TransactionConfigMask::new(0b01).has_priority_fee()); + assert!(!TransactionConfigMask::new(0b10).has_priority_fee()); + assert!(TransactionConfigMask::new(0b11).has_priority_fee()); + } + + #[test] + fn has_invalid_priority_fee_bits_detects_partial() { + assert!(!TransactionConfigMask::new(0).has_invalid_priority_fee_bits()); + assert!(TransactionConfigMask::new(0b01).has_invalid_priority_fee_bits()); + assert!(TransactionConfigMask::new(0b10).has_invalid_priority_fee_bits()); + assert!(!TransactionConfigMask::new(0b11).has_invalid_priority_fee_bits()); + } + + #[test] + fn has_field_methods_check_individual_bits() { + let mask = TransactionConfigMask::new(0b11100); + assert!(mask.has_compute_unit_limit()); + assert!(mask.has_loaded_accounts_data_size()); + assert!(mask.has_heap_size()); + + let mask = TransactionConfigMask::new(0); + assert!(!mask.has_compute_unit_limit()); + assert!(!mask.has_loaded_accounts_data_size()); + assert!(!mask.has_heap_size()); + } + + #[test] + fn config_values_size_sums_field_sizes() { + assert_eq!(TransactionConfigMask::new(0).config_values_size(), 0); + assert_eq!(TransactionConfigMask::new(0b11).config_values_size(), 8); + assert_eq!(TransactionConfigMask::new(0b100).config_values_size(), 4); + assert_eq!(TransactionConfigMask::new(0b11111).config_values_size(), 20); + } + + #[test] + fn from_config_sets_correct_bits() { + let config = TransactionConfig::new() + .with_priority_fee(1000) + .with_compute_unit_limit(200_000); + + let mask = TransactionConfigMask::from_config(&config); + assert!(mask.has_priority_fee()); + assert!(mask.has_compute_unit_limit()); + assert!(!mask.has_loaded_accounts_data_size()); + assert!(!mask.has_heap_size()); + } + + #[test] + fn mask_invariants_hold_for_all_known_bit_patterns() { + for raw in 0u32..(1u32 << 5) { + let mask = TransactionConfigMask::new(raw); + + assert!(!mask.has_unknown_bits()); + + if mask.has_priority_fee() { + assert!(!mask.has_invalid_priority_fee_bits()); + } + + let mut expected_size = 0; + if mask.has_priority_fee() { + expected_size += size_of::(); + } + if mask.has_compute_unit_limit() { + expected_size += size_of::(); + } + if mask.has_loaded_accounts_data_size() { + expected_size += size_of::(); + } + if mask.has_heap_size() { + expected_size += size_of::(); + } + assert_eq!(mask.config_values_size(), expected_size); + } + } + + #[test] + fn builder_sets_all_fields() { + let config = TransactionConfig::new() + .with_priority_fee(1000) + .with_compute_unit_limit(200_000) + .with_loaded_accounts_data_size_limit(64 * 1024) + .with_heap_size(64 * 1024); + + assert_eq!(config.priority_fee, Some(1000)); + assert_eq!(config.compute_unit_limit, Some(200_000)); + assert_eq!(config.loaded_accounts_data_size_limit, Some(64 * 1024)); + assert_eq!(config.heap_size, Some(64 * 1024)); + } +} diff --git a/message/src/versions/v1/constants.rs b/message/src/versions/v1/constants.rs new file mode 100644 index 000000000..0e4514d2a --- /dev/null +++ b/message/src/versions/v1/constants.rs @@ -0,0 +1,35 @@ +//! Constants for V1 message format (SIMD-0385). + +use {super::TransactionConfigMask, crate::MessageHeader, solana_hash::Hash, std::mem::size_of}; + +/// Version byte for V1 messages (`MESSAGE_VERSION_PREFIX | 1` = decimal 129). +pub const V1_VERSION_BYTE: u8 = 0x81; + +/// Maximum transaction size for V1 format in bytes. +pub const MAX_TRANSACTION_SIZE: usize = 4096; + +/// Maximum number of account addresses in a V1 message. +pub const MAX_ADDRESSES: u8 = 64; + +/// Maximum number of instructions in a V1 message. +pub const MAX_INSTRUCTIONS: u8 = 64; + +/// Maximum number of signatures in a V1 transaction. +pub const MAX_SIGNATURES: u8 = 12; + +/// Default heap size in bytes when not specified (32KB). +pub const DEFAULT_HEAP_SIZE: u32 = 32_768; + +/// Size of the fixed header portion of a serialized V1 message. +pub const FIXED_HEADER_SIZE: usize = size_of::() // version + + size_of::() // legacy header + + size_of::() // config mask + + size_of::() // lifetime_specifier + + size_of::() // num_instructions + + size_of::(); // num_addresses + +/// Size of an instruction header: program_id (u8) + num_accounts (u8) + data_len (u16). +pub const INSTRUCTION_HEADER_SIZE: usize = size_of::() + size_of::() + size_of::(); + +/// Size of a single Ed25519 signature (64 bytes). +pub const SIGNATURE_SIZE: usize = 64; diff --git a/message/src/versions/v1/error.rs b/message/src/versions/v1/error.rs new file mode 100644 index 000000000..6b26e52aa --- /dev/null +++ b/message/src/versions/v1/error.rs @@ -0,0 +1,100 @@ +//! Error types for V1 message operations. + +use solana_sanitize::SanitizeError; + +/// Errors that can occur when working with V1 messages. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum MessageError { + /// Input buffer is too small during deserialization. + BufferTooSmall, + /// Heap size is not a multiple of 1024. + InvalidHeapSize, + /// Instruction has too many accounts (> 255). + InstructionAccountsTooLarge, + /// Instruction data is too large (> 65535 bytes). + InstructionDataTooLarge, + /// Invalid TransactionConfigMask. + InvalidConfigMask, + /// Instruction account index is out of bounds. + InvalidInstructionAccountIndex, + /// Program ID index is invalid (out of bounds or fee payer). + InvalidProgramIdIndex, + /// Invalid or missing version byte (expected 0x81). + InvalidVersion, + /// Lifetime specifier (blockhash) is required. + MissingLifetimeSpecifier, + /// Not enough addresses for the number of required signatures. + NotEnoughAddressesForSignatures, + /// Too many addresses (> 64). + TooManyAddresses, + /// Too many instructions (> 64). + TooManyInstructions, + /// Too many signatures (> 12). + TooManySignatures, + /// Unexpected trailing data after message. + TrailingData, + /// Transaction exceeds maximum size (4096 bytes). + TransactionTooLarge, + /// Must have at least one signer (fee payer). + ZeroSigners, +} + +impl std::fmt::Display for MessageError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::BufferTooSmall => write!(f, "buffer too small"), + Self::InvalidHeapSize => write!(f, "heap size must be a multiple of 1024"), + Self::InstructionAccountsTooLarge => { + write!(f, "instruction has too many accounts (max 255)") + } + Self::InstructionDataTooLarge => { + write!(f, "instruction data too large (max 65535 bytes)") + } + Self::InvalidConfigMask => write!(f, "invalid transaction config mask"), + Self::InvalidInstructionAccountIndex => { + write!(f, "instruction account index out of bounds") + } + Self::InvalidProgramIdIndex => { + write!(f, "program ID index out of bounds or is fee payer") + } + Self::InvalidVersion => write!(f, "invalid version byte (expected 0x81)"), + Self::MissingLifetimeSpecifier => { + write!(f, "lifetime specifier (blockhash) is required") + } + Self::NotEnoughAddressesForSignatures => { + write!(f, "not enough addresses for required signatures") + } + Self::TooManyAddresses => write!(f, "too many addresses (max 64)"), + Self::TooManyInstructions => write!(f, "too many instructions (max 64)"), + Self::TooManySignatures => write!(f, "too many signatures (max 12)"), + Self::TrailingData => write!(f, "unexpected trailing data"), + Self::TransactionTooLarge => write!(f, "transaction exceeds max size (4096 bytes)"), + Self::ZeroSigners => write!(f, "must have at least one signer (fee payer)"), + } + } +} + +impl std::error::Error for MessageError {} + +impl From for SanitizeError { + fn from(err: MessageError) -> Self { + match err { + MessageError::BufferTooSmall + | MessageError::InvalidHeapSize + | MessageError::InstructionAccountsTooLarge + | MessageError::InstructionDataTooLarge + | MessageError::InvalidConfigMask + | MessageError::InvalidVersion + | MessageError::MissingLifetimeSpecifier + | MessageError::TrailingData + | MessageError::TransactionTooLarge + | MessageError::ZeroSigners => SanitizeError::InvalidValue, + MessageError::InvalidInstructionAccountIndex + | MessageError::InvalidProgramIdIndex + | MessageError::NotEnoughAddressesForSignatures + | MessageError::TooManyAddresses + | MessageError::TooManyInstructions + | MessageError::TooManySignatures => SanitizeError::IndexOutOfBounds, + } + } +} diff --git a/message/src/versions/v1/message.rs b/message/src/versions/v1/message.rs new file mode 100644 index 000000000..7db1fde9a --- /dev/null +++ b/message/src/versions/v1/message.rs @@ -0,0 +1,630 @@ +//! Core Message type for V1 transactions (SIMD-0385). + +#[cfg(feature = "serde")] +use serde_derive::{Deserialize, Serialize}; +#[cfg(feature = "frozen-abi")] +use solana_frozen_abi_macro::AbiExample; +use { + super::{ + TransactionConfig, TransactionConfigMask, MAX_ADDRESSES, MAX_INSTRUCTIONS, MAX_SIGNATURES, + MAX_TRANSACTION_SIZE, + }, + crate::{compiled_instruction::CompiledInstruction, MessageHeader}, + solana_address::Address, + solana_hash::Hash, + solana_sanitize::{Sanitize, SanitizeError}, + solana_sdk_ids::bpf_loader_upgradeable, + std::collections::HashSet, +}; + +/// A V1 transaction message (SIMD-0385) supporting 4KB transactions with inline compute budget. +#[cfg_attr(feature = "frozen-abi", derive(AbiExample))] +#[cfg_attr( + feature = "serde", + derive(Serialize, Deserialize), + serde(rename_all = "camelCase") +)] +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct Message { + /// The message header describing signer/readonly account counts. + pub header: MessageHeader, + + /// Compute budget configuration embedded in the message header. + /// Replaces separate ComputeBudget program instructions. + pub config: TransactionConfig, + + /// The lifetime specifier (blockhash) that determines when this transaction expires. + pub lifetime_specifier: Hash, + + /// All account addresses referenced by this message. + /// Unlike V0, V1 does not support address lookup tables. + #[cfg_attr(feature = "serde", serde(with = "solana_short_vec"))] + pub account_keys: Vec
, + + /// Program instructions to execute. + #[cfg_attr(feature = "serde", serde(with = "solana_short_vec"))] + pub instructions: Vec, +} + +impl Message { + /// Returns the fee payer address (first account key). + pub fn fee_payer(&self) -> Option<&Address> { + self.account_keys.first() + } + + /// Account keys are ordered with signers first: `[signers..., non-signers...]`. + /// An index falls in the signer region if it's less than `num_required_signatures`. + pub fn is_signer(&self, index: usize) -> bool { + index < usize::from(self.header.num_required_signatures) + } + + /// Returns true if the account at this index is both a signer and writable. + pub fn is_signer_writable(&self, index: usize) -> bool { + if !self.is_signer(index) { + return false; + } + // Within the signer region, the first (num_required_signatures - num_readonly_signed) + // accounts are writable signers. + let num_writable_signers = usize::from(self.header.num_required_signatures) + .saturating_sub(usize::from(self.header.num_readonly_signed_accounts)); + index < num_writable_signers + } + + /// Returns true if any instruction invokes the account at this index as a program. + pub fn is_key_called_as_program(&self, key_index: usize) -> bool { + if let Ok(key_index) = u8::try_from(key_index) { + self.instructions + .iter() + .any(|ix| ix.program_id_index == key_index) + } else { + false + } + } + + /// Returns true if the account at the specified index was requested as writable. + /// + /// Account keys are ordered: `[writable signers][readonly signers][writable non-signers][readonly non-signers]`. + /// This checks which region the index falls into based on the header counts. + fn is_writable_index(&self, key_index: usize) -> bool { + let num_account_keys = self.account_keys.len(); + let num_signed_accounts = usize::from(self.header.num_required_signatures); + + if key_index >= num_account_keys { + return false; + } + + if key_index >= num_signed_accounts { + // Non-signer region + let num_unsigned_accounts = num_account_keys.saturating_sub(num_signed_accounts); + let num_writable_unsigned_accounts = num_unsigned_accounts + .saturating_sub(usize::from(self.header.num_readonly_unsigned_accounts)); + let unsigned_account_index = key_index.saturating_sub(num_signed_accounts); + unsigned_account_index < num_writable_unsigned_accounts + } else { + // Signer region + let num_writable_signed_accounts = num_signed_accounts + .saturating_sub(usize::from(self.header.num_readonly_signed_accounts)); + key_index < num_writable_signed_accounts + } + } + + /// Returns true if the BPF upgradeable loader is present in the account keys. + pub fn is_upgradeable_loader_present(&self) -> bool { + self.account_keys + .iter() + .any(|&key| key == bpf_loader_upgradeable::id()) + } + + /// Returns true if the account at the specified index was requested as writable. + /// + /// The `reserved_account_keys` parameter allows demoting reserved accounts to readonly. + pub fn is_maybe_writable( + &self, + key_index: usize, + reserved_account_keys: Option<&HashSet
>, + ) -> bool { + if !self.is_writable_index(key_index) { + return false; + } + + // Check if reserved + if let Some(reserved) = reserved_account_keys { + if let Some(key) = self.account_keys.get(key_index) { + if reserved.contains(key) { + return false; + } + } + } + + // Demote program IDs, unless the upgradeable loader is present + // (upgradeable programs need to be writable for upgrades) + if self.is_key_called_as_program(key_index) && !self.is_upgradeable_loader_present() { + return false; + } + + true + } + + /// Create a new builder for constructing V1 messages. + pub fn builder() -> super::MessageBuilder { + super::MessageBuilder::new() + } +} + +impl Sanitize for Message { + fn sanitize(&self) -> Result<(), SanitizeError> { + // Transaction size (message + signatures) must fit in 4096 bytes + if self.transaction_size() > MAX_TRANSACTION_SIZE { + return Err(SanitizeError::InvalidValue); + } + + // Must have at least one signer (the fee payer) + if self.header.num_required_signatures == 0 { + return Err(SanitizeError::InvalidValue); + } + + // num_required_signatures <= 12 + if self.header.num_required_signatures > MAX_SIGNATURES { + return Err(SanitizeError::IndexOutOfBounds); + } + + // Lifetime specifier must not be zero + if self.lifetime_specifier == Hash::default() { + return Err(SanitizeError::InvalidValue); + } + + let num_account_keys = self.account_keys.len(); + + // num_addresses <= 64 + if num_account_keys > MAX_ADDRESSES as usize { + return Err(SanitizeError::IndexOutOfBounds); + } + + // num_instructions <= 64 + if self.instructions.len() > MAX_INSTRUCTIONS as usize { + return Err(SanitizeError::IndexOutOfBounds); + } + + // num_addresses >= num_required_signatures + num_readonly_unsigned_accounts + let min_accounts = usize::from(self.header.num_required_signatures) + .saturating_add(usize::from(self.header.num_readonly_unsigned_accounts)); + if num_account_keys < min_accounts { + return Err(SanitizeError::IndexOutOfBounds); + } + + // Must have at least 1 RW fee-payer (num_readonly_signed < num_required_signatures) + if self.header.num_readonly_signed_accounts >= self.header.num_required_signatures { + return Err(SanitizeError::InvalidValue); + } + + // No duplicate addresses + let unique_keys: HashSet<_> = self.account_keys.iter().collect(); + if unique_keys.len() != num_account_keys { + return Err(SanitizeError::InvalidValue); + } + + // Validate config mask (2-bit fields must have both bits set or neither) + let mask = TransactionConfigMask::from_config(&self.config); + if mask.has_invalid_priority_fee_bits() { + return Err(SanitizeError::InvalidValue); + } + + // Heap size must be a multiple of 1024 + if let Some(heap_size) = self.config.heap_size { + if heap_size % 1024 != 0 { + return Err(SanitizeError::InvalidValue); + } + } + + // Instruction account indices must be < num_addresses + let max_account_index = num_account_keys + .checked_sub(1) + .ok_or(SanitizeError::InvalidValue)?; + + for instruction in &self.instructions { + // Program ID must be in static accounts + if usize::from(instruction.program_id_index) > max_account_index { + return Err(SanitizeError::IndexOutOfBounds); + } + + // Program cannot be fee payer + if instruction.program_id_index == 0 { + return Err(SanitizeError::IndexOutOfBounds); + } + + // Instruction accounts count must fit in u8 + if instruction.accounts.len() > u8::MAX as usize { + return Err(SanitizeError::InvalidValue); + } + + // Instruction data length must fit in u16 + if instruction.data.len() > u16::MAX as usize { + return Err(SanitizeError::InvalidValue); + } + + // All account indices must be valid + for &account_index in &instruction.accounts { + if usize::from(account_index) > max_account_index { + return Err(SanitizeError::IndexOutOfBounds); + } + } + } + + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn create_test_message() -> Message { + Message::builder() + .num_required_signatures(1) + .num_readonly_unsigned_accounts(1) + .lifetime_specifier(Hash::new_unique()) + .account_keys(vec![ + Address::new_unique(), // fee payer + Address::new_unique(), // program + Address::new_unique(), // readonly account + ]) + .compute_unit_limit(200_000) + .instruction(CompiledInstruction { + program_id_index: 1, + accounts: vec![0, 2], + data: vec![1, 2, 3, 4], + }) + .build() + .unwrap() + } + + #[test] + fn fee_payer_returns_first_account() { + let fee_payer = Address::new_unique(); + let message = Message::builder() + .num_required_signatures(1) + .lifetime_specifier(Hash::new_unique()) + .account_keys(vec![fee_payer, Address::new_unique()]) + .build() + .unwrap(); + + assert_eq!(message.fee_payer(), Some(&fee_payer)); + } + + #[test] + fn fee_payer_returns_none_for_empty_accounts() { + // Direct construction to bypass builder validation + let message = Message { + header: MessageHeader::default(), + config: TransactionConfig::default(), + lifetime_specifier: Hash::new_unique(), + account_keys: vec![], + instructions: vec![], + }; + + assert_eq!(message.fee_payer(), None); + } + + #[test] + fn is_signer_checks_signature_requirement() { + let message = create_test_message(); + assert!(message.is_signer(0)); // Fee payer is signer + assert!(!message.is_signer(1)); // Program is not signer + assert!(!message.is_signer(2)); // Readonly account is not signer + } + + #[test] + fn is_signer_writable_identifies_writable_signers() { + let message = Message::builder() + .num_required_signatures(3) + .num_readonly_signed_accounts(1) // Last signer is readonly + .lifetime_specifier(Hash::new_unique()) + .account_keys(vec![ + Address::new_unique(), // 0: writable signer + Address::new_unique(), // 1: writable signer + Address::new_unique(), // 2: readonly signer + Address::new_unique(), // 3: non-signer + ]) + .build() + .unwrap(); + + // Writable signers + assert!(message.is_signer_writable(0)); + assert!(message.is_signer_writable(1)); + // Readonly signer + assert!(!message.is_signer_writable(2)); + // Non-signers + assert!(!message.is_signer_writable(3)); + assert!(!message.is_signer_writable(100)); + } + + #[test] + fn is_signer_writable_all_writable_when_no_readonly() { + let message = Message::builder() + .num_required_signatures(2) + .num_readonly_signed_accounts(0) // All signers are writable + .lifetime_specifier(Hash::new_unique()) + .account_keys(vec![ + Address::new_unique(), + Address::new_unique(), + Address::new_unique(), + ]) + .build() + .unwrap(); + + assert!(message.is_signer_writable(0)); + assert!(message.is_signer_writable(1)); + assert!(!message.is_signer_writable(2)); // Not a signer + } + + #[test] + fn is_key_called_as_program_detects_program_indices() { + let message = create_test_message(); + // program_id_index = 1 in create_test_message + assert!(message.is_key_called_as_program(1)); + assert!(!message.is_key_called_as_program(0)); + assert!(!message.is_key_called_as_program(2)); + // Index > u8::MAX can't match any program_id_index + assert!(!message.is_key_called_as_program(256)); + assert!(!message.is_key_called_as_program(10_000)); + } + + #[test] + fn is_upgradeable_loader_present_detects_loader() { + let message = create_test_message(); + assert!(!message.is_upgradeable_loader_present()); + + let mut message_with_loader = create_test_message(); + message_with_loader + .account_keys + .push(bpf_loader_upgradeable::id()); + assert!(message_with_loader.is_upgradeable_loader_present()); + } + + #[test] + fn is_writable_index_respects_header_layout() { + let message = create_test_message(); + // Account layout: [writable signer (fee payer), writable unsigned (program), readonly unsigned] + assert!(message.is_writable_index(0)); // Fee payer is writable + assert!(message.is_writable_index(1)); // Program position is writable unsigned + assert!(!message.is_writable_index(2)); // Last account is readonly + } + + #[test] + fn is_writable_index_handles_mixed_signer_permissions() { + let mut message = create_test_message(); + // 2 signers: first writable, second readonly + message.header.num_required_signatures = 2; + message.header.num_readonly_signed_accounts = 1; + message.header.num_readonly_unsigned_accounts = 1; + message.account_keys = vec![ + Address::new_unique(), // writable signer + Address::new_unique(), // readonly signer + Address::new_unique(), // readonly unsigned + ]; + message.instructions[0].program_id_index = 2; + message.instructions[0].accounts = vec![0, 1]; + + assert!(message.sanitize().is_ok()); + assert!(message.is_writable_index(0)); // writable signer + assert!(!message.is_writable_index(1)); // readonly signer + assert!(!message.is_writable_index(2)); // readonly unsigned + assert!(!message.is_writable_index(999)); // out of bounds + } + + #[test] + fn is_maybe_writable_returns_false_for_readonly_index() { + let message = create_test_message(); + // Index 2 is readonly unsigned + assert!(!message.is_writable_index(2)); + assert!(!message.is_maybe_writable(2, None)); + // Even with empty reserved set + assert!(!message.is_maybe_writable(2, Some(&HashSet::new()))); + } + + #[test] + fn is_maybe_writable_demotes_reserved_accounts() { + let message = create_test_message(); + let reserved = HashSet::from([message.account_keys[0]]); + // Fee payer is writable by index, but reserved → demoted + assert!(message.is_writable_index(0)); + assert!(!message.is_maybe_writable(0, Some(&reserved))); + } + + #[test] + fn is_maybe_writable_demotes_programs_without_upgradeable_loader() { + let message = create_test_message(); + // Index 1 is writable unsigned, called as program, no upgradeable loader + assert!(message.is_writable_index(1)); + assert!(message.is_key_called_as_program(1)); + assert!(!message.is_upgradeable_loader_present()); + assert!(!message.is_maybe_writable(1, None)); + } + + #[test] + fn is_maybe_writable_preserves_programs_with_upgradeable_loader() { + let mut message = create_test_message(); + // Add upgradeable loader to account keys + message.account_keys.push(bpf_loader_upgradeable::id()); + + assert!(message.sanitize().is_ok()); + assert!(message.is_writable_index(1)); + assert!(message.is_key_called_as_program(1)); + assert!(message.is_upgradeable_loader_present()); + // Program not demoted because upgradeable loader is present + assert!(message.is_maybe_writable(1, None)); + } + + #[test] + fn sanitize_accepts_valid_message() { + let message = create_test_message(); + assert!(message.sanitize().is_ok()); + } + + #[test] + fn sanitize_rejects_oversized_transaction() { + let mut message = create_test_message(); + // Inflate instruction data to exceed MAX_TRANSACTION_SIZE (4096) + message.instructions[0].data = vec![0u8; MAX_TRANSACTION_SIZE]; + assert_eq!(message.sanitize(), Err(SanitizeError::InvalidValue)); + } + + #[test] + fn sanitize_rejects_zero_signers() { + let mut message = create_test_message(); + message.header.num_required_signatures = 0; + assert_eq!(message.sanitize(), Err(SanitizeError::InvalidValue)); + } + + #[test] + fn sanitize_rejects_over_12_signatures() { + let mut message = create_test_message(); + message.header.num_required_signatures = MAX_SIGNATURES + 1; + message.account_keys = (0..MAX_SIGNATURES + 1) + .map(|_| Address::new_unique()) + .collect(); + assert_eq!(message.sanitize(), Err(SanitizeError::IndexOutOfBounds)); + } + + #[test] + fn sanitize_rejects_zero_lifetime_specifier() { + let mut message = create_test_message(); + message.lifetime_specifier = Hash::default(); + assert_eq!(message.sanitize(), Err(SanitizeError::InvalidValue)); + } + + #[test] + fn sanitize_rejects_over_64_addresses() { + let mut message = create_test_message(); + message.account_keys = (0..65).map(|_| Address::new_unique()).collect(); + assert_eq!(message.sanitize(), Err(SanitizeError::IndexOutOfBounds)); + } + + #[test] + fn sanitize_rejects_over_64_instructions() { + let mut message = create_test_message(); + message.instructions = (0..65) // exceeds 64 max + .map(|_| CompiledInstruction { + program_id_index: 1, + accounts: vec![0], + data: vec![], + }) + .collect(); + assert_eq!(message.sanitize(), Err(SanitizeError::IndexOutOfBounds)); + } + + #[test] + fn sanitize_rejects_insufficient_accounts_for_header() { + let mut message = create_test_message(); + // min_accounts = num_required_signatures + num_readonly_unsigned_accounts + // Set readonly_unsigned high so min_accounts > account_keys.len() + message.header.num_readonly_unsigned_accounts = 10; + assert_eq!(message.sanitize(), Err(SanitizeError::IndexOutOfBounds)); + } + + #[test] + fn sanitize_rejects_all_signers_readonly() { + let mut message = create_test_message(); + message.header.num_readonly_signed_accounts = 1; // All signers readonly + assert_eq!(message.sanitize(), Err(SanitizeError::InvalidValue)); + } + + #[test] + fn sanitize_rejects_duplicate_addresses() { + let mut message = create_test_message(); + let dup = message.account_keys[0]; + message.account_keys[1] = dup; + assert_eq!(message.sanitize(), Err(SanitizeError::InvalidValue)); + } + + #[test] + fn sanitize_rejects_unaligned_heap_size() { + let mut message = create_test_message(); + message.config.heap_size = Some(1025); // Not a multiple of 1024 + assert_eq!(message.sanitize(), Err(SanitizeError::InvalidValue)); + } + + #[test] + fn sanitize_accepts_aligned_heap_size() { + let mut message = create_test_message(); + message.config.heap_size = Some(65536); // 64KB, valid + assert!(message.sanitize().is_ok()); + } + + #[test] + fn sanitize_rejects_invalid_program_id_index() { + let mut message = create_test_message(); + message.instructions[0].program_id_index = 99; + assert_eq!(message.sanitize(), Err(SanitizeError::IndexOutOfBounds)); + } + + #[test] + fn sanitize_rejects_fee_payer_as_program() { + let mut message = create_test_message(); + message.instructions[0].program_id_index = 0; + assert_eq!(message.sanitize(), Err(SanitizeError::IndexOutOfBounds)); + } + + #[test] + fn sanitize_rejects_instruction_with_too_many_accounts() { + let mut message = create_test_message(); + message.instructions[0].accounts = vec![0u8; (u8::MAX as usize) + 1]; + assert_eq!(message.sanitize(), Err(SanitizeError::InvalidValue)); + } + + #[test] + fn sanitize_rejects_invalid_instruction_account_index() { + let mut message = create_test_message(); + message.instructions[0].accounts = vec![0, 99]; // 99 is out of bounds + assert_eq!(message.sanitize(), Err(SanitizeError::IndexOutOfBounds)); + } + + #[test] + fn sanitize_accepts_64_addresses() { + let mut message = create_test_message(); + message.account_keys = (0..MAX_ADDRESSES).map(|_| Address::new_unique()).collect(); + message.header.num_required_signatures = 1; + message.header.num_readonly_signed_accounts = 0; + message.header.num_readonly_unsigned_accounts = 1; + message.instructions[0].program_id_index = 1; + message.instructions[0].accounts = vec![0, 2]; + assert!(message.sanitize().is_ok()); + } + + #[test] + fn sanitize_accepts_64_instructions() { + let mut message = create_test_message(); + message.instructions = (0..MAX_INSTRUCTIONS) + .map(|_| CompiledInstruction { + program_id_index: 1, + accounts: vec![0, 2], + data: vec![1, 2, 3], + }) + .collect(); + assert!(message.sanitize().is_ok()); + } + + #[test] + fn sanitize_accepts_transaction_at_exactly_4096_bytes() { + let mut message = create_test_message(); + // Calculate current size and pad to exactly 4096 + let current_size = message.transaction_size(); + let padding_needed = MAX_TRANSACTION_SIZE.saturating_sub(current_size); + message.instructions[0].data = + vec![0u8; message.instructions[0].data.len() + padding_needed]; + assert_eq!(message.transaction_size(), MAX_TRANSACTION_SIZE); + assert!(message.sanitize().is_ok()); + } + + #[test] + fn sanitize_rejects_transaction_at_4097_bytes() { + let mut message = create_test_message(); + // Pad to exactly 4096, then add one more byte + let current_size = message.transaction_size(); + let padding_needed = MAX_TRANSACTION_SIZE.saturating_sub(current_size) + 1; + message.instructions[0].data = + vec![0u8; message.instructions[0].data.len() + padding_needed]; + assert_eq!(message.transaction_size(), MAX_TRANSACTION_SIZE + 1); + assert_eq!(message.sanitize(), Err(SanitizeError::InvalidValue)); + } +} diff --git a/message/src/versions/v1/mod.rs b/message/src/versions/v1/mod.rs new file mode 100644 index 000000000..41a81c3cc --- /dev/null +++ b/message/src/versions/v1/mod.rs @@ -0,0 +1,15 @@ +//! V1 Message format for 4KB transactions (SIMD-0385). +//! +//! This message format supports larger transactions (up to 4KB) with inline +//! compute budget configuration. Unlike V0, V1 does not support address lookup +//! tables. All account addresses must be inline. + +pub mod builder; +pub mod config; +pub mod constants; +pub mod error; +pub mod message; +pub mod runtime; +pub mod serialization; + +pub use {builder::*, config::*, constants::*, error::*, message::*, runtime::*}; diff --git a/message/src/versions/v1/runtime.rs b/message/src/versions/v1/runtime.rs new file mode 100644 index 000000000..7eb9769b2 --- /dev/null +++ b/message/src/versions/v1/runtime.rs @@ -0,0 +1,355 @@ +//! Runtime wrapper for V1 messages with cached writability. + +use {super::Message, crate::AccountKeys, solana_address::Address, std::collections::HashSet}; + +/// Wrapper that precomputes account writability for efficient runtime access. +#[derive(Debug, Clone, Eq, PartialEq)] +pub struct LoadedMessage { + message: Message, + writability: Vec, +} + +impl LoadedMessage { + pub fn new(message: Message, reserved_account_keys: &HashSet
) -> Self { + let writability = (0..message.account_keys.len()) + .map(|i| message.is_maybe_writable(i, Some(reserved_account_keys))) + .collect(); + Self { + message, + writability, + } + } + + pub fn message(&self) -> &Message { + &self.message + } + + pub fn has_duplicates(&self) -> bool { + let unique: HashSet<_> = self.message.account_keys.iter().collect(); + unique.len() != self.message.account_keys.len() + } + + pub fn is_key_called_as_program(&self, key_index: usize) -> bool { + self.message.is_key_called_as_program(key_index) + } + + pub fn is_upgradeable_loader_present(&self) -> bool { + self.message.is_upgradeable_loader_present() + } + + pub fn account_keys(&self) -> AccountKeys<'_> { + AccountKeys::new(&self.message.account_keys, None) + } + + pub fn is_writable(&self, index: usize) -> bool { + *self.writability.get(index).unwrap_or(&false) + } +} + +#[cfg(test)] +mod tests { + use { + super::*, + crate::{compiled_instruction::CompiledInstruction, MessageHeader}, + proptest::prelude::*, + solana_hash::Hash, + }; + + fn create_test_message() -> Message { + Message::builder() + .num_required_signatures(2) + .num_readonly_signed_accounts(1) + .num_readonly_unsigned_accounts(1) + .lifetime_specifier(Hash::new_unique()) + .account_keys(vec![ + Address::new_unique(), // 0: writable signer (fee payer) + Address::new_unique(), // 1: readonly signer + Address::new_unique(), // 2: writable unsigned + Address::new_unique(), // 3: readonly unsigned + ]) + .instruction(CompiledInstruction { + program_id_index: 2, + accounts: vec![0, 1, 3], + data: vec![1, 2, 3], + }) + .build() + .unwrap() + } + + #[test] + fn cache_matches_underlying() { + let message = create_test_message(); + let reserved = HashSet::new(); + let v1_msg = LoadedMessage::new(message.clone(), &reserved); + + for i in 0..message.account_keys.len() { + assert_eq!( + v1_msg.is_writable(i), + message.is_maybe_writable(i, Some(&reserved)) + ); + } + assert!(!v1_msg.is_writable(message.account_keys.len())); + } + + #[test] + fn clone_preserves_writability_cache() { + let message = create_test_message(); + let v1_msg = LoadedMessage::new(message, &HashSet::new()); + let cloned = v1_msg.clone(); + + for i in 0..4 { + assert_eq!(v1_msg.is_writable(i), cloned.is_writable(i)); + } + } + + fn valid_header_strategy() -> impl Strategy { + // Start at 2 to ensure room for fee payer + at least one program + (2usize..=10).prop_flat_map(|num_keys| { + (1u8..=(num_keys as u8)).prop_flat_map(move |num_req_sigs| { + let max_ro_signed = num_req_sigs.saturating_sub(1); + let num_unsigned = num_keys.saturating_sub(num_req_sigs as usize); + (0u8..=max_ro_signed).prop_flat_map(move |num_ro_signed| { + (0u8..=(num_unsigned as u8)).prop_map(move |num_ro_unsigned| { + ( + num_keys, + MessageHeader { + num_required_signatures: num_req_sigs, + num_readonly_signed_accounts: num_ro_signed, + num_readonly_unsigned_accounts: num_ro_unsigned, + }, + ) + }) + }) + }) + }) + } + + proptest! { + #[test] + fn cache_matches_underlying_randomized( + (num_keys, header) in valid_header_strategy(), + // Start at 1 to avoid fee payer as program (invalid per sanitize) + program_idx in proptest::option::of(1usize..10), + // Only 10 bits needed since num_keys maxes at 10 + reserved_mask in 0u16..=0x03FF, + // Test with upgradeable loader present (affects program writability) + include_loader in proptest::bool::ANY, + ) { + let mut account_keys: Vec<_> = (0..num_keys).map(|_| Address::new_unique()).collect(); + + // Optionally add upgradeable loader to test program writability preservation + if include_loader { + account_keys.push(solana_sdk_ids::bpf_loader_upgradeable::id()); + } + + let reserved: HashSet<_> = account_keys + .iter() + .enumerate() + .filter(|(i, _)| reserved_mask & (1u16 << i) != 0) + .map(|(_, k)| *k) + .collect(); + + let instructions = match program_idx { + Some(idx) if idx < num_keys => vec![CompiledInstruction { + program_id_index: idx as u8, + accounts: vec![0], + data: vec![], + }], + _ => vec![], + }; + + let message = Message::builder() + .num_required_signatures(header.num_required_signatures) + .num_readonly_signed_accounts(header.num_readonly_signed_accounts) + .num_readonly_unsigned_accounts(header.num_readonly_unsigned_accounts) + .lifetime_specifier(Hash::new_unique()) + .account_keys(account_keys) + .instructions(instructions) + .build() + .unwrap(); + + let v1_msg = LoadedMessage::new(message.clone(), &reserved); + + for i in 0..message.account_keys.len() { + prop_assert_eq!( + v1_msg.is_writable(i), + message.is_maybe_writable(i, Some(&reserved)) + ); + } + prop_assert!(!v1_msg.is_writable(message.account_keys.len())); + prop_assert!(!v1_msg.is_writable(999)); + } + } + + #[test] + fn has_duplicates_detects_adjacent() { + let mut message = create_test_message(); + let v1_msg = LoadedMessage::new(message.clone(), &HashSet::new()); + assert!(!v1_msg.has_duplicates()); + + let dup = message.account_keys[0]; + message.account_keys[1] = dup; + let v1_msg = LoadedMessage::new(message, &HashSet::new()); + assert!(v1_msg.has_duplicates()); + } + + #[test] + fn has_duplicates_detects_non_adjacent() { + let dup_key = Address::new_unique(); + let message = Message::builder() + .num_required_signatures(1) + .lifetime_specifier(Hash::new_unique()) + .account_keys(vec![ + dup_key, + Address::new_unique(), + Address::new_unique(), + dup_key, + ]) + .build() + .unwrap(); + let v1_msg = LoadedMessage::new(message, &HashSet::new()); + assert!(v1_msg.has_duplicates()); + } + + #[test] + fn is_key_called_as_program_delegates_to_message() { + let message = create_test_message(); + let v1_msg = LoadedMessage::new(message, &HashSet::new()); + + assert!(v1_msg.is_key_called_as_program(2)); + assert!(!v1_msg.is_key_called_as_program(0)); + assert!(!v1_msg.is_key_called_as_program(1)); + assert!(!v1_msg.is_key_called_as_program(3)); + } + + #[test] + fn is_upgradeable_loader_present_delegates_to_message() { + let message = create_test_message(); + let v1_msg = LoadedMessage::new(message.clone(), &HashSet::new()); + + assert_eq!( + v1_msg.is_upgradeable_loader_present(), + message.is_upgradeable_loader_present() + ); + } + + #[test] + fn account_keys_returns_correct_length() { + let message = create_test_message(); + let expected_len = message.account_keys.len(); + let v1_msg = LoadedMessage::new(message, &HashSet::new()); + + assert_eq!(v1_msg.account_keys().len(), expected_len); + } + + #[test] + fn account_keys_returns_correct_content() { + let message = create_test_message(); + let v1_msg = LoadedMessage::new(message.clone(), &HashSet::new()); + + let keys = v1_msg.account_keys(); + assert_eq!(keys.len(), message.account_keys.len()); + for (i, key) in keys.iter().enumerate() { + assert_eq!(*key, message.account_keys[i]); + } + } + + #[test] + fn is_writable_uses_cached_values() { + let message = create_test_message(); + let v1_msg = LoadedMessage::new(message, &HashSet::new()); + + assert!(v1_msg.is_writable(0)); + assert!(!v1_msg.is_writable(1)); + assert!(!v1_msg.is_writable(2)); + assert!(!v1_msg.is_writable(3)); + assert!(!v1_msg.is_writable(99)); + } + + #[test] + fn is_writable_demotes_programs() { + let message = Message::builder() + .num_required_signatures(1) + .lifetime_specifier(Hash::new_unique()) + .account_keys(vec![ + Address::new_unique(), + Address::new_unique(), + Address::new_unique(), + ]) + .instruction(CompiledInstruction { + program_id_index: 1, + accounts: vec![0], + data: vec![], + }) + .instruction(CompiledInstruction { + program_id_index: 2, + accounts: vec![0], + data: vec![], + }) + .build() + .unwrap(); + let v1_msg = LoadedMessage::new(message, &HashSet::new()); + + assert!(v1_msg.is_writable(0)); + assert!(!v1_msg.is_writable(1)); + assert!(!v1_msg.is_writable(2)); + } + + #[test] + fn is_writable_demotes_reserved_fee_payer() { + let message = create_test_message(); + let fee_payer = message.account_keys[0]; + + let v1_msg = LoadedMessage::new(message.clone(), &HashSet::new()); + assert!(v1_msg.is_writable(0)); + + let reserved = HashSet::from([fee_payer]); + let v1_msg = LoadedMessage::new(message, &reserved); + assert!(!v1_msg.is_writable(0)); + } + + #[test] + fn is_writable_demotes_reserved_non_signer() { + let message = Message::builder() + .num_required_signatures(1) + .lifetime_specifier(Hash::new_unique()) + .account_keys(vec![Address::new_unique(), Address::new_unique()]) + .build() + .unwrap(); + let writable_key = message.account_keys[1]; + + let v1_msg = LoadedMessage::new(message.clone(), &HashSet::new()); + assert!(v1_msg.is_writable(1)); + + let reserved = HashSet::from([writable_key]); + let v1_msg = LoadedMessage::new(message, &reserved); + assert!(!v1_msg.is_writable(1)); + } + + #[test] + fn is_writable_ignores_reserved_for_readonly() { + let message = create_test_message(); + let readonly_key = message.account_keys[3]; + + let v1_msg = LoadedMessage::new(message.clone(), &HashSet::new()); + assert!(!v1_msg.is_writable(3)); + + let reserved = HashSet::from([readonly_key]); + let v1_msg = LoadedMessage::new(message, &reserved); + assert!(!v1_msg.is_writable(3)); + } + + #[test] + fn is_writable_handles_single_account_message() { + let message = Message::builder() + .num_required_signatures(1) + .lifetime_specifier(Hash::new_unique()) + .account_key(Address::new_unique()) + .build() + .unwrap(); + let v1_msg = LoadedMessage::new(message, &HashSet::new()); + + assert!(v1_msg.is_writable(0)); + assert!(!v1_msg.is_writable(1)); + } +} diff --git a/message/src/versions/v1/serialization.rs b/message/src/versions/v1/serialization.rs new file mode 100644 index 000000000..c4f09cd78 --- /dev/null +++ b/message/src/versions/v1/serialization.rs @@ -0,0 +1,1197 @@ +//! Serialization and deserialization for V1 messages. +//! +//! This module implements the SIMD-0385 binary format for V1 messages. +//! +//! # Binary Format +//! +//! ```text +//! ┌────────────────────────────────────────────────────────┐ +//! │ Version (u8 = 0x81) │ +//! │ LegacyHeader (3 x u8) │ +//! │ TransactionConfigMask (u32, little-endian) │ +//! │ LifetimeSpecifier [u8; 32] (blockhash) │ +//! │ NumInstructions (u8, max 64) │ +//! │ NumAddresses (u8, max 64) │ +//! │ Addresses [[u8; 32] x NumAddresses] │ +//! │ ConfigValues (variable based on mask) │ +//! │ InstructionHeaders [(u8, u8, u16) x NumInstructions] │ +//! │ InstructionPayloads (concatenated accounts + data) │ +//! └────────────────────────────────────────────────────────┘ +//! ``` +//! +//! Note: Signatures are not part of the message. They appear at the end of the +//! full transaction and are handled by `VersionedTransaction`. + +use { + super::{ + Message, MessageError, TransactionConfig, TransactionConfigMask, FIXED_HEADER_SIZE, + INSTRUCTION_HEADER_SIZE, MAX_ADDRESSES, MAX_INSTRUCTIONS, MAX_SIGNATURES, SIGNATURE_SIZE, + V1_VERSION_BYTE, + }, + crate::{compiled_instruction::CompiledInstruction, MessageHeader}, + solana_address::Address, + solana_hash::Hash, + std::mem::size_of, +}; + +/// Read a fixed-size array from a byte slice at the given offset. +fn read_at(bytes: &[u8], offset: usize) -> Result<[u8; N], MessageError> { + let end = offset.checked_add(N).ok_or(MessageError::BufferTooSmall)?; + bytes + .get(offset..end) + .and_then(|slice| slice.try_into().ok()) + .ok_or(MessageError::BufferTooSmall) +} + +impl Message { + /// Calculate the size of this message in bytes. + pub fn size(&self) -> usize { + let addresses_size = self.account_keys.len().saturating_mul(size_of::
()); + let config_size = TransactionConfigMask::from_config(&self.config).config_values_size(); + let instruction_headers_size = self + .instructions + .len() + .saturating_mul(INSTRUCTION_HEADER_SIZE); + let instruction_payloads_size: usize = self + .instructions + .iter() + .map(|ix| ix.accounts.len().saturating_add(ix.data.len())) + .fold(0usize, |acc, x| acc.saturating_add(x)); + + FIXED_HEADER_SIZE + .saturating_add(addresses_size) + .saturating_add(config_size) + .saturating_add(instruction_headers_size) + .saturating_add(instruction_payloads_size) + } + + /// Calculate the total transaction size including signatures. + pub fn transaction_size(&self) -> usize { + let signatures_size = + (self.header.num_required_signatures as usize).saturating_mul(SIGNATURE_SIZE); + self.size().saturating_add(signatures_size) + } + + /// Serialize this V1 message to bytes. + pub fn to_bytes(&self) -> Result, MessageError> { + if self.instructions.len() > MAX_INSTRUCTIONS as usize { + return Err(MessageError::TooManyInstructions); + } + if self.account_keys.len() > MAX_ADDRESSES as usize { + return Err(MessageError::TooManyAddresses); + } + for ix in &self.instructions { + if ix.accounts.len() > u8::MAX as usize { + return Err(MessageError::InstructionAccountsTooLarge); + } + if ix.data.len() > u16::MAX as usize { + return Err(MessageError::InstructionDataTooLarge); + } + } + + let total_size = self.size(); + let mut bytes = Vec::with_capacity(total_size); + let config_mask = TransactionConfigMask::from_config(&self.config); + + // Fixed header + bytes.push(V1_VERSION_BYTE); + bytes.push(self.header.num_required_signatures); + bytes.push(self.header.num_readonly_signed_accounts); + bytes.push(self.header.num_readonly_unsigned_accounts); + bytes.extend_from_slice(&config_mask.0.to_le_bytes()); + bytes.extend_from_slice(self.lifetime_specifier.as_ref()); + bytes.push(self.instructions.len() as u8); + bytes.push(self.account_keys.len() as u8); + + // Addresses + for key in &self.account_keys { + bytes.extend_from_slice(key.as_ref()); + } + + // Config values (order must match mask bit order) + if let Some(fee) = self.config.priority_fee { + bytes.extend_from_slice(&fee.to_le_bytes()); + } + if let Some(limit) = self.config.compute_unit_limit { + bytes.extend_from_slice(&limit.to_le_bytes()); + } + if let Some(limit) = self.config.loaded_accounts_data_size_limit { + bytes.extend_from_slice(&limit.to_le_bytes()); + } + if let Some(size) = self.config.heap_size { + bytes.extend_from_slice(&size.to_le_bytes()); + } + + // Instruction headers (program_id_index, num_accounts, data_len) + for ix in &self.instructions { + bytes.push(ix.program_id_index); + bytes.push(ix.accounts.len() as u8); + bytes.extend_from_slice(&(ix.data.len() as u16).to_le_bytes()); + } + + // Instruction payloads (accounts then data, concatenated) + for ix in &self.instructions { + bytes.extend_from_slice(&ix.accounts); + bytes.extend_from_slice(&ix.data); + } + + Ok(bytes) + } + + /// Deserialize a V1 message from bytes. + /// + /// Use this when parsing a standalone message buffer. Returns an error if + /// there are unexpected bytes after the message. The input must start with the version byte (0x81). + pub fn from_bytes(bytes: &[u8]) -> Result { + let (message, bytes_consumed) = Self::from_bytes_partial(bytes)?; + if bytes_consumed != bytes.len() { + return Err(MessageError::TrailingData); + } + Ok(message) + } + + /// Deserialize a V1 message from a byte slice, returning bytes consumed. + /// + /// Use this when the message is embedded in a larger buffer, such as when + /// parsing a V1 transaction where signatures follow the message. The returned + /// `usize` indicates where the message ends, so you can parse subsequent data. + /// The input must start with the version byte (0x81). + pub fn from_bytes_partial(bytes: &[u8]) -> Result<(Self, usize), MessageError> { + if bytes.len() < FIXED_HEADER_SIZE { + return Err(MessageError::BufferTooSmall); + } + + // Track position as we parse each field sequentially. + // We use saturating_add for offset advances. Overflow produces usize::MAX + // which will fail the next bounds check with BufferTooSmall. + let mut offset = 0; + + // Version byte + if bytes[offset] != V1_VERSION_BYTE { + return Err(MessageError::InvalidVersion); + } + offset = offset.saturating_add(size_of::()); + + // Message header (3 bytes) - bounds already checked via FIXED_HEADER_SIZE + let header = MessageHeader { + num_required_signatures: bytes[offset], + num_readonly_signed_accounts: bytes + .get(offset.saturating_add(1)) + .copied() + .ok_or(MessageError::BufferTooSmall)?, + num_readonly_unsigned_accounts: bytes + .get(offset.saturating_add(2)) + .copied() + .ok_or(MessageError::BufferTooSmall)?, + }; + offset = offset.saturating_add(size_of::()); + + if header.num_required_signatures > MAX_SIGNATURES { + return Err(MessageError::TooManySignatures); + } + + let config_mask = TransactionConfigMask::new(u32::from_le_bytes(read_at(bytes, offset)?)); + offset = offset.saturating_add(size_of::()); + + if config_mask.has_unknown_bits() || config_mask.has_invalid_priority_fee_bits() { + return Err(MessageError::InvalidConfigMask); + } + + let lifetime_specifier = Hash::new_from_array(read_at(bytes, offset)?); + offset = offset.saturating_add(size_of::()); + + let num_instructions = *bytes.get(offset).ok_or(MessageError::BufferTooSmall)?; + offset = offset.saturating_add(size_of::()); + if num_instructions > MAX_INSTRUCTIONS { + return Err(MessageError::TooManyInstructions); + } + + let num_addresses = *bytes.get(offset).ok_or(MessageError::BufferTooSmall)?; + offset = offset.saturating_add(size_of::()); + if num_addresses > MAX_ADDRESSES { + return Err(MessageError::TooManyAddresses); + } + + // Validate that we have enough addresses for all required signatures + if header.num_required_signatures > num_addresses { + return Err(MessageError::NotEnoughAddressesForSignatures); + } + + // Addresses - use checked_mul for untrusted count, saturating for offset + let addresses_size = (num_addresses as usize) + .checked_mul(size_of::
()) + .ok_or(MessageError::BufferTooSmall)?; + if bytes.len() < offset.saturating_add(addresses_size) { + return Err(MessageError::BufferTooSmall); + } + + let mut account_keys = Vec::with_capacity(num_addresses as usize); + for _ in 0..num_addresses { + account_keys.push(Address::new_from_array(read_at(bytes, offset)?)); + offset = offset.saturating_add(size_of::
()); + } + + // Config values - parsed in bit order per SIMD-0385 wire format + let config_size = config_mask.config_values_size(); + if bytes.len() < offset.saturating_add(config_size) { + return Err(MessageError::BufferTooSmall); + } + + let mut config = TransactionConfig::default(); + if config_mask.has_priority_fee() { + config.priority_fee = Some(u64::from_le_bytes(read_at(bytes, offset)?)); + offset = offset.saturating_add(size_of::()); + } + if config_mask.has_compute_unit_limit() { + config.compute_unit_limit = Some(u32::from_le_bytes(read_at(bytes, offset)?)); + offset = offset.saturating_add(size_of::()); + } + if config_mask.has_loaded_accounts_data_size() { + config.loaded_accounts_data_size_limit = + Some(u32::from_le_bytes(read_at(bytes, offset)?)); + offset = offset.saturating_add(size_of::()); + } + if config_mask.has_heap_size() { + let heap_size = u32::from_le_bytes(read_at(bytes, offset)?); + if heap_size % 1024 != 0 { + return Err(MessageError::InvalidHeapSize); + } + config.heap_size = Some(heap_size); + offset = offset.saturating_add(size_of::()); + } + + // Instruction headers: (program_id_index: u8, num_accounts: u8, data_len: u16) + let instruction_headers_size = (num_instructions as usize) + .checked_mul(INSTRUCTION_HEADER_SIZE) + .ok_or(MessageError::BufferTooSmall)?; + if bytes.len() < offset.saturating_add(instruction_headers_size) { + return Err(MessageError::BufferTooSmall); + } + + let mut instruction_headers = Vec::with_capacity(num_instructions as usize); + for _ in 0..num_instructions { + let program_id_index = *bytes.get(offset).ok_or(MessageError::BufferTooSmall)?; + // Validate program_id_index: must be < num_addresses and != 0 (fee payer) + if program_id_index == 0 || program_id_index >= num_addresses { + return Err(MessageError::InvalidProgramIdIndex); + } + let num_accounts = *bytes + .get(offset.saturating_add(1)) + .ok_or(MessageError::BufferTooSmall)?; + let num_data_bytes = u16::from_le_bytes(read_at(bytes, offset.saturating_add(2))?); + instruction_headers.push((program_id_index, num_accounts, num_data_bytes)); + offset = offset.saturating_add(INSTRUCTION_HEADER_SIZE); + } + + // Instruction payloads + let mut instructions = Vec::with_capacity(num_instructions as usize); + for (program_id_index, num_accounts, num_data_bytes) in instruction_headers { + let accounts_size = num_accounts as usize; + let data_size = num_data_bytes as usize; + let payload_size = accounts_size.saturating_add(data_size); + + if bytes.len() < offset.saturating_add(payload_size) { + return Err(MessageError::BufferTooSmall); + } + + let accounts_end = offset.saturating_add(accounts_size); + let accounts = bytes[offset..accounts_end].to_vec(); + // Validate all account indices are < num_addresses + for &account_index in &accounts { + if account_index >= num_addresses { + return Err(MessageError::InvalidInstructionAccountIndex); + } + } + offset = accounts_end; + + let data_end = offset.saturating_add(data_size); + let data = bytes[offset..data_end].to_vec(); + offset = data_end; + + instructions.push(CompiledInstruction { + program_id_index, + accounts, + data, + }); + } + + Ok(( + Self { + header, + lifetime_specifier, + account_keys, + config, + instructions, + }, + offset, + )) + } +} + +#[cfg(test)] +#[allow(clippy::vec_init_then_push)] +mod tests { + use { + super::*, + proptest::prelude::*, + std::{ + collections::hash_map::DefaultHasher, + hash::{Hash as StdHash, Hasher}, + }, + }; + + #[test] + fn size_matches_serialized_length() { + let test_cases = [ + // Minimal message + Message::builder() + .num_required_signatures(1) + .lifetime_specifier(Hash::new_unique()) + .account_keys(vec![Address::new_unique()]) + .build() + .unwrap(), + // With config + Message::builder() + .num_required_signatures(1) + .lifetime_specifier(Hash::new_unique()) + .account_keys(vec![Address::new_unique(), Address::new_unique()]) + .priority_fee(1000) + .compute_unit_limit(200_000) + .instruction(CompiledInstruction { + program_id_index: 1, + accounts: vec![0], + data: vec![1, 2, 3, 4], + }) + .build() + .unwrap(), + // Multiple instructions with varying data + Message::builder() + .num_required_signatures(2) + .num_readonly_signed_accounts(1) + .num_readonly_unsigned_accounts(1) + .lifetime_specifier(Hash::new_unique()) + .account_keys(vec![ + Address::new_unique(), + Address::new_unique(), + Address::new_unique(), + Address::new_unique(), + ]) + .heap_size(65536) + .instructions(vec![ + CompiledInstruction { + program_id_index: 2, + accounts: vec![0, 1], + data: vec![], + }, + CompiledInstruction { + program_id_index: 3, + accounts: vec![0, 1, 2], + data: vec![0xAA; 100], + }, + ]) + .build() + .unwrap(), + ]; + + for message in &test_cases { + assert_eq!(message.size(), message.to_bytes().unwrap().len()); + } + } + + #[test] + fn transaction_size_includes_signatures() { + // Note: num_sigs must be >= 1 (fee payer required) + for num_sigs in [1u8, 2, 5, 12] { + let message = Message::builder() + .num_required_signatures(num_sigs) + .lifetime_specifier(Hash::new_unique()) + .account_keys( + (0..num_sigs as usize) + .map(|_| Address::new_unique()) + .collect(), + ) + .build() + .unwrap(); + + let expected = message.size() + (num_sigs as usize * 64); + assert_eq!(message.transaction_size(), expected); + } + } + + #[test] + fn byte_layout_without_config() { + let fee_payer = Address::new_from_array([1u8; 32]); + let program = Address::new_from_array([2u8; 32]); + let blockhash = Hash::new_from_array([0xAB; 32]); + + let message = Message::builder() + .num_required_signatures(1) + .lifetime_specifier(blockhash) + .account_keys(vec![fee_payer, program]) + .instruction(CompiledInstruction { + program_id_index: 1, + accounts: vec![0], + data: vec![0xDE, 0xAD], + }) + .build() + .unwrap(); + + let bytes = message.to_bytes().unwrap(); + + // Build expected bytes manually per SIMD-0385 + let mut expected = Vec::new(); + expected.push(0x81); // Version + expected.push(1); // num_required_signatures + expected.push(0); // num_readonly_signed_accounts + expected.push(0); // num_readonly_unsigned_accounts + expected.extend_from_slice(&0u32.to_le_bytes()); // ConfigMask = 0 + expected.extend_from_slice(&[0xAB; 32]); // LifetimeSpecifier + expected.push(1); // NumInstructions + expected.push(2); // NumAddresses + expected.extend_from_slice(&[1u8; 32]); // fee_payer + expected.extend_from_slice(&[2u8; 32]); // program + // ConfigValues: none + expected.push(1); // program_id_index + expected.push(1); // num_accounts + expected.extend_from_slice(&2u16.to_le_bytes()); // data_len + expected.push(0); // account index 0 + expected.extend_from_slice(&[0xDE, 0xAD]); // data + + assert_eq!(bytes, expected); + } + + #[test] + fn byte_layout_with_config() { + let fee_payer = Address::new_from_array([1u8; 32]); + let program = Address::new_from_array([2u8; 32]); + let blockhash = Hash::new_from_array([0xBB; 32]); + + let message = Message::builder() + .num_required_signatures(1) + .lifetime_specifier(blockhash) + .account_keys(vec![fee_payer, program]) + .priority_fee(0x0102030405060708u64) + .compute_unit_limit(0x11223344u32) + .instruction(CompiledInstruction { + program_id_index: 1, + accounts: vec![], + data: vec![], + }) + .build() + .unwrap(); + + let bytes = message.to_bytes().unwrap(); + + let mut expected = Vec::new(); + expected.push(0x81); + expected.push(1); + expected.push(0); + expected.push(0); + // ConfigMask: priority fee (bits 0,1) + CU limit (bit 2) = 0b111 = 7 + expected.extend_from_slice(&7u32.to_le_bytes()); + expected.extend_from_slice(&[0xBB; 32]); + expected.push(1); + expected.push(2); + expected.extend_from_slice(&[1u8; 32]); + expected.extend_from_slice(&[2u8; 32]); + // Priority fee as u64 LE + expected.extend_from_slice(&0x0102030405060708u64.to_le_bytes()); + // Compute unit limit as u32 LE + expected.extend_from_slice(&0x11223344u32.to_le_bytes()); + expected.push(1); // program_id_index + expected.push(0); // num_accounts + expected.extend_from_slice(&0u16.to_le_bytes()); // data_len + + assert_eq!(bytes, expected); + } + + #[test] + fn byte_layout_with_multiple_instructions() { + let fee_payer = Address::new_from_array([1u8; 32]); + let program1 = Address::new_from_array([2u8; 32]); + let program2 = Address::new_from_array([3u8; 32]); + let blockhash = Hash::new_from_array([0xCC; 32]); + + let message = Message::builder() + .num_required_signatures(1) + .lifetime_specifier(blockhash) + .account_keys(vec![fee_payer, program1, program2]) + .instructions(vec![ + CompiledInstruction { + program_id_index: 1, + accounts: vec![0], + data: vec![0xAA], + }, + CompiledInstruction { + program_id_index: 2, + accounts: vec![0, 1], + data: vec![0xBB, 0xCC, 0xDD], + }, + ]) + .build() + .unwrap(); + + let bytes = message.to_bytes().unwrap(); + + let mut expected = Vec::new(); + expected.push(0x81); + expected.push(1); + expected.push(0); + expected.push(0); + expected.extend_from_slice(&0u32.to_le_bytes()); + expected.extend_from_slice(&[0xCC; 32]); + expected.push(2); // NumInstructions + expected.push(3); // NumAddresses + expected.extend_from_slice(&[1u8; 32]); + expected.extend_from_slice(&[2u8; 32]); + expected.extend_from_slice(&[3u8; 32]); + // Instruction headers + expected.push(1); + expected.push(1); + expected.extend_from_slice(&1u16.to_le_bytes()); + expected.push(2); + expected.push(2); + expected.extend_from_slice(&3u16.to_le_bytes()); + // Instruction payloads + expected.push(0); + expected.push(0xAA); + expected.push(0); + expected.push(1); + expected.extend_from_slice(&[0xBB, 0xCC, 0xDD]); + + assert_eq!(bytes, expected); + } + + #[test] + fn from_bytes_rejects_empty_buffer() { + assert_eq!(Message::from_bytes(&[]), Err(MessageError::BufferTooSmall)); + } + + #[test] + fn from_bytes_rejects_truncated_input() { + let message = Message::builder() + .num_required_signatures(1) + .lifetime_specifier(Hash::new_from_array([0xAB; 32])) + .account_keys(vec![ + Address::new_from_array([1u8; 32]), + Address::new_from_array([2u8; 32]), + ]) + .compute_unit_limit(200_000) + .instruction(CompiledInstruction { + program_id_index: 1, + accounts: vec![0], + data: vec![0xDE, 0xAD], + }) + .build() + .unwrap(); + + let bytes = message.to_bytes().unwrap(); + + for i in 0..bytes.len() { + let truncated = &bytes[..i]; + let err = Message::from_bytes(truncated).unwrap_err(); + assert!(matches!( + err, + MessageError::BufferTooSmall | MessageError::InvalidVersion + )); + } + + assert!(Message::from_bytes(&bytes).is_ok()); + } + + #[test] + fn from_bytes_rejects_invalid_version() { + let message = Message::builder() + .num_required_signatures(1) + .lifetime_specifier(Hash::new_unique()) + .account_keys(vec![Address::new_unique(), Address::new_unique()]) + .build() + .unwrap(); + + let mut bytes = message.to_bytes().unwrap(); + + for bad_version in [0x00, 0x80, 0x82, 0xFF] { + bytes[0] = bad_version; + assert_eq!( + Message::from_bytes(&bytes), + Err(MessageError::InvalidVersion) + ); + } + } + + #[test] + fn from_bytes_rejects_over_12_signatures() { + let mut bytes = Vec::new(); + bytes.push(V1_VERSION_BYTE); + bytes.push(MAX_SIGNATURES + 1); // too many + bytes.push(0); + bytes.push(0); + bytes.extend_from_slice(&0u32.to_le_bytes()); // config mask + bytes.extend_from_slice(&[0u8; 32]); // lifetime_specifier + bytes.push(0); // num_instructions + bytes.push(1); // num_addresses + + assert_eq!( + Message::from_bytes(&bytes), + Err(MessageError::TooManySignatures) + ); + } + + #[test] + fn from_bytes_rejects_invalid_priority_fee_mask() { + let mut bytes = Vec::new(); + bytes.push(V1_VERSION_BYTE); + bytes.push(1); + bytes.push(0); + bytes.push(0); + bytes.extend_from_slice(&1u32.to_le_bytes()); // INVALID: only bit 0 + bytes.extend_from_slice(&[0u8; 32]); + bytes.push(1); + bytes.push(2); + bytes.extend_from_slice(&[1u8; 32]); + bytes.extend_from_slice(&[2u8; 32]); + bytes.extend_from_slice(&0u32.to_le_bytes()); + bytes.push(1); + bytes.push(0); + bytes.extend_from_slice(&0u16.to_le_bytes()); + + assert_eq!( + Message::from_bytes(&bytes), + Err(MessageError::InvalidConfigMask) + ); + + // Also test only bit 1 set + bytes[4] = 2; + bytes[5] = 0; + bytes[6] = 0; + bytes[7] = 0; + assert_eq!( + Message::from_bytes(&bytes), + Err(MessageError::InvalidConfigMask) + ); + } + + #[test] + fn from_bytes_rejects_unknown_config_mask_bits() { + let mut bytes = Vec::new(); + bytes.push(V1_VERSION_BYTE); + bytes.push(1); + bytes.push(0); + bytes.push(0); + bytes.extend_from_slice(&0x00010000u32.to_le_bytes()); // Unknown high bit + bytes.extend_from_slice(&[0u8; 32]); + bytes.push(0); + bytes.push(1); + bytes.extend_from_slice(&[1u8; 32]); + + assert_eq!( + Message::from_bytes(&bytes), + Err(MessageError::InvalidConfigMask) + ); + } + + #[test] + fn from_bytes_rejects_over_64_instructions() { + let mut bytes = Vec::new(); + bytes.push(V1_VERSION_BYTE); + bytes.push(1); + bytes.push(0); + bytes.push(0); + bytes.extend_from_slice(&0u32.to_le_bytes()); + bytes.extend_from_slice(&[0u8; 32]); + bytes.push(MAX_INSTRUCTIONS + 1); // too many (65) + bytes.push(1); + bytes.extend_from_slice(&[1u8; 32]); + + assert_eq!( + Message::from_bytes(&bytes), + Err(MessageError::TooManyInstructions) + ); + } + + #[test] + fn from_bytes_rejects_over_64_addresses() { + let mut bytes = Vec::new(); + bytes.push(V1_VERSION_BYTE); + bytes.push(1); + bytes.push(0); + bytes.push(0); + bytes.extend_from_slice(&0u32.to_le_bytes()); + bytes.extend_from_slice(&[0u8; 32]); + bytes.push(0); + bytes.push(MAX_ADDRESSES + 1); // too many + + assert_eq!( + Message::from_bytes(&bytes), + Err(MessageError::TooManyAddresses) + ); + } + + #[test] + fn from_bytes_rejects_not_enough_addresses_for_signatures() { + // Build bytes manually: claims 5 signers but only has 2 addresses + let mut bytes = Vec::new(); + bytes.push(V1_VERSION_BYTE); + bytes.push(5); // num_required_signatures = 5 + bytes.push(0); // num_readonly_signed + bytes.push(0); // num_readonly_unsigned + bytes.extend_from_slice(&0u32.to_le_bytes()); // config mask + bytes.extend_from_slice(&[0xAB; 32]); // lifetime_specifier + bytes.push(0); // num_instructions + bytes.push(2); // num_addresses = 2 (less than 5!) + bytes.extend_from_slice(&[1u8; 32]); // address 1 + bytes.extend_from_slice(&[2u8; 32]); // address 2 + + assert_eq!( + Message::from_bytes(&bytes), + Err(MessageError::NotEnoughAddressesForSignatures) + ); + } + + #[test] + fn from_bytes_rejects_unaligned_heap_size() { + let mut bytes = Vec::new(); + bytes.push(V1_VERSION_BYTE); + bytes.push(1); // num_required_signatures + bytes.push(0); // num_readonly_signed + bytes.push(0); // num_readonly_unsigned + bytes.extend_from_slice(&TransactionConfigMask::HEAP_SIZE_BIT.to_le_bytes()); + bytes.extend_from_slice(&[1u8; 32]); // lifetime_specifier + bytes.push(0); // num_instructions + bytes.push(1); // num_addresses + bytes.extend_from_slice(&[1u8; 32]); // one address + bytes.extend_from_slice(&1025u32.to_le_bytes()); // heap_size not multiple of 1024 + + assert_eq!( + Message::from_bytes(&bytes), + Err(MessageError::InvalidHeapSize) + ); + } + + #[test] + fn from_bytes_rejects_program_id_index_zero() { + // program_id_index == 0 means fee payer is program, which is invalid + let mut bytes = Vec::new(); + bytes.push(V1_VERSION_BYTE); + bytes.push(1); // 1 signer + bytes.push(0); + bytes.push(0); + bytes.extend_from_slice(&0u32.to_le_bytes()); // no config + bytes.extend_from_slice(&[0xAB; 32]); // blockhash + bytes.push(1); // 1 instruction + bytes.push(2); // 2 addresses + bytes.extend_from_slice(&[1u8; 32]); // fee_payer + bytes.extend_from_slice(&[2u8; 32]); // program + bytes.push(0); // INVALID: program_id_index = 0 (fee payer) + bytes.push(0); // num_accounts + bytes.extend_from_slice(&0u16.to_le_bytes()); // data_len + + assert_eq!( + Message::from_bytes(&bytes), + Err(MessageError::InvalidProgramIdIndex) + ); + } + + #[test] + fn from_bytes_rejects_program_id_index_out_of_bounds() { + let mut bytes = Vec::new(); + bytes.push(V1_VERSION_BYTE); + bytes.push(1); + bytes.push(0); + bytes.push(0); + bytes.extend_from_slice(&0u32.to_le_bytes()); + bytes.extend_from_slice(&[0xAB; 32]); + bytes.push(1); // 1 instruction + bytes.push(2); // 2 addresses + bytes.extend_from_slice(&[1u8; 32]); + bytes.extend_from_slice(&[2u8; 32]); + bytes.push(5); // INVALID: program_id_index = 5 >= num_addresses (2) + bytes.push(0); + bytes.extend_from_slice(&0u16.to_le_bytes()); + + assert_eq!( + Message::from_bytes(&bytes), + Err(MessageError::InvalidProgramIdIndex) + ); + } + + #[test] + fn from_bytes_rejects_instruction_account_index_out_of_bounds() { + let mut bytes = Vec::new(); + bytes.push(V1_VERSION_BYTE); + bytes.push(1); + bytes.push(0); + bytes.push(0); + bytes.extend_from_slice(&0u32.to_le_bytes()); + bytes.extend_from_slice(&[0xAB; 32]); + bytes.push(1); // 1 instruction + bytes.push(2); // 2 addresses + bytes.extend_from_slice(&[1u8; 32]); + bytes.extend_from_slice(&[2u8; 32]); + bytes.push(1); // valid program_id_index + bytes.push(1); // 1 account + bytes.extend_from_slice(&0u16.to_le_bytes()); // 0 data bytes + bytes.push(10); // INVALID: account index 10 >= num_addresses (2) + + assert_eq!( + Message::from_bytes(&bytes), + Err(MessageError::InvalidInstructionAccountIndex) + ); + } + + #[test] + fn from_bytes_rejects_trailing_data() { + let message = Message::builder() + .num_required_signatures(1) + .lifetime_specifier(Hash::new_from_array([0xAB; 32])) + .account_keys(vec![ + Address::new_from_array([1u8; 32]), + Address::new_from_array([2u8; 32]), + ]) + .instruction(CompiledInstruction { + program_id_index: 1, + accounts: vec![], + data: vec![], + }) + .build() + .unwrap(); + + let bytes = message.to_bytes().unwrap(); + assert!(Message::from_bytes(&bytes).is_ok()); + + let mut with_trailing = bytes.clone(); + with_trailing.push(0xFF); + assert_eq!( + Message::from_bytes(&with_trailing), + Err(MessageError::TrailingData) + ); + } + + #[test] + fn from_bytes_accepts_64_instructions() { + // Build a valid message with 64 instructions (max per SIMD-0385) + let num_instructions: u8 = MAX_INSTRUCTIONS; + let mut bytes = Vec::new(); + bytes.push(V1_VERSION_BYTE); + bytes.push(1); // num_required_signatures + bytes.push(0); // num_readonly_signed + bytes.push(0); // num_readonly_unsigned + bytes.extend_from_slice(&0u32.to_le_bytes()); // config mask + bytes.extend_from_slice(&[0xAB; 32]); // lifetime_specifier + bytes.push(num_instructions); // num_instructions = 64 + bytes.push(2); // num_addresses + bytes.extend_from_slice(&[1u8; 32]); // fee_payer + bytes.extend_from_slice(&[2u8; 32]); // program + + // Instruction headers: all point to program (index 1), zero accounts, zero data + for _ in 0..num_instructions { + bytes.push(1); // program_id_index + bytes.push(0); // num_accounts + bytes.extend_from_slice(&0u16.to_le_bytes()); // data_len + } + // No instruction payloads needed (all have 0 accounts and 0 data) + + let result = Message::from_bytes(&bytes); + assert!(result.is_ok()); + assert_eq!(result.unwrap().instructions.len(), 64); + } + + #[test] + fn from_bytes_rejects_65_instructions() { + let mut bytes = Vec::new(); + bytes.push(V1_VERSION_BYTE); + bytes.push(1); + bytes.push(0); + bytes.push(0); + bytes.extend_from_slice(&0u32.to_le_bytes()); + bytes.extend_from_slice(&[0xAB; 32]); + bytes.push(65); // num_instructions = 65 (exceeds max 64 per SIMD-0385) + bytes.push(2); + bytes.extend_from_slice(&[1u8; 32]); + bytes.extend_from_slice(&[2u8; 32]); + + assert_eq!( + Message::from_bytes(&bytes), + Err(MessageError::TooManyInstructions) + ); + } + + #[test] + fn roundtrip_preserves_message() { + let message = Message::builder() + .num_required_signatures(1) + .num_readonly_unsigned_accounts(1) + .lifetime_specifier(Hash::new_unique()) + .account_keys(vec![ + Address::new_unique(), + Address::new_unique(), + Address::new_unique(), + ]) + .compute_unit_limit(200_000) + .instruction(CompiledInstruction { + program_id_index: 1, + accounts: vec![0, 2], + data: vec![1, 2, 3, 4], + }) + .build() + .unwrap(); + let serialized = message.to_bytes().unwrap(); + let deserialized = Message::from_bytes(&serialized).unwrap(); + + assert_eq!(message.header, deserialized.header); + assert_eq!(message.lifetime_specifier, deserialized.lifetime_specifier); + assert_eq!(message.account_keys, deserialized.account_keys); + assert_eq!(message.config, deserialized.config); + assert_eq!(message.instructions, deserialized.instructions); + } + + #[test] + fn roundtrip_preserves_all_config_fields() { + let message = Message::builder() + .num_required_signatures(1) + .lifetime_specifier(Hash::new_unique()) + .account_keys(vec![Address::new_unique(), Address::new_unique()]) + .priority_fee(1000) + .compute_unit_limit(200_000) + .loaded_accounts_data_size_limit(1_000_000) + .heap_size(65536) + .instruction(CompiledInstruction { + program_id_index: 1, + accounts: vec![0], + data: vec![], + }) + .build() + .unwrap(); + + let serialized = message.to_bytes().unwrap(); + let deserialized = Message::from_bytes(&serialized).unwrap(); + assert_eq!(message.config, deserialized.config); + } + + #[test] + fn roundtrip_preserves_sparse_config() { + // Test each config field individually + let configs = [ + TransactionConfig::new().with_priority_fee(1000), + TransactionConfig::new().with_compute_unit_limit(200_000), + TransactionConfig::new().with_loaded_accounts_data_size_limit(1_000_000), + TransactionConfig::new().with_heap_size(65536), + ]; + + for config in configs { + let message = Message::builder() + .num_required_signatures(1) + .lifetime_specifier(Hash::new_unique()) + .account_keys(vec![Address::new_unique(), Address::new_unique()]) + .config(config) + .instruction(CompiledInstruction { + program_id_index: 1, + accounts: vec![], + data: vec![], + }) + .build() + .unwrap(); + + let bytes = message.to_bytes().unwrap(); + let parsed = Message::from_bytes(&bytes).unwrap(); + assert_eq!(parsed.config, config); + } + + // Test gap combinations (skipping fields) + let gap_configs = [ + TransactionConfig::new() + .with_compute_unit_limit(200_000) + .with_heap_size(65536), + TransactionConfig::new() + .with_priority_fee(5000) + .with_loaded_accounts_data_size_limit(500_000), + TransactionConfig::new() + .with_priority_fee(1000) + .with_heap_size(32768), + ]; + + for config in gap_configs { + let message = Message::builder() + .num_required_signatures(1) + .lifetime_specifier(Hash::new_unique()) + .account_keys(vec![Address::new_unique(), Address::new_unique()]) + .config(config) + .instruction(CompiledInstruction { + program_id_index: 1, + accounts: vec![], + data: vec![], + }) + .build() + .unwrap(); + + let bytes = message.to_bytes().unwrap(); + let parsed = Message::from_bytes(&bytes).unwrap(); + assert_eq!(parsed.config, config); + } + } + + #[test] + fn roundtrip_handles_empty_instructions() { + // Zero instructions, minimal addresses + let message = Message::builder() + .num_required_signatures(1) + .lifetime_specifier(Hash::new_unique()) + .account_keys(vec![Address::new_unique()]) + .build() + .unwrap(); + let bytes = message.to_bytes().unwrap(); + let parsed = Message::from_bytes(&bytes).unwrap(); + assert!(parsed.instructions.is_empty()); + + // Instruction with zero accounts and zero data + let message = Message::builder() + .num_required_signatures(1) + .lifetime_specifier(Hash::new_unique()) + .account_keys(vec![Address::new_unique(), Address::new_unique()]) + .instruction(CompiledInstruction { + program_id_index: 1, + accounts: vec![], + data: vec![], + }) + .build() + .unwrap(); + let bytes = message.to_bytes().unwrap(); + let parsed = Message::from_bytes(&bytes).unwrap(); + assert!(parsed.instructions[0].accounts.is_empty()); + assert!(parsed.instructions[0].data.is_empty()); + } + + #[test] + fn from_bytes_partial_returns_bytes_consumed() { + let message = Message::builder() + .num_required_signatures(2) + .lifetime_specifier(Hash::new_unique()) + .account_keys(vec![Address::new_unique(), Address::new_unique()]) + .instruction(CompiledInstruction { + program_id_index: 1, + accounts: vec![0], + data: vec![], + }) + .build() + .unwrap(); + + let message_bytes = message.to_bytes().unwrap(); + let message_len = message_bytes.len(); + + // Append fake signatures (64 bytes each) + let mut buf = message_bytes.clone(); + buf.extend_from_slice(&[0xAA; 64]); + buf.extend_from_slice(&[0xBB; 64]); + + // from_bytes should reject trailing data + assert_eq!(Message::from_bytes(&buf), Err(MessageError::TrailingData)); + + // from_bytes_partial should succeed + let (parsed, bytes_consumed) = Message::from_bytes_partial(&buf).unwrap(); + assert_eq!(bytes_consumed, message_len); + assert_eq!(parsed.header, message.header); + assert_eq!(parsed.account_keys, message.account_keys); + + // Verify we can locate signatures after the message + assert_eq!(&buf[bytes_consumed..bytes_consumed + 64], &[0xAA; 64]); + assert_eq!(&buf[bytes_consumed + 64..bytes_consumed + 128], &[0xBB; 64]); + } + + proptest! { + #[test] + fn arbitrary_bytes_never_panic(bytes in proptest::collection::vec(any::(), 0..1000)) { + // Parser should never panic on arbitrary input + let _ = Message::from_bytes(&bytes); + } + + #[test] + fn arbitrary_bytes_with_valid_prefix_never_panic( + rest in proptest::collection::vec(any::(), 0..1000) + ) { + // Even with valid version byte, parser should handle garbage gracefully + let mut bytes = vec![V1_VERSION_BYTE]; + bytes.extend(rest); + let _ = Message::from_bytes(&bytes); + } + + #[test] + fn roundtrip_preserves_valid_messages( + num_keys in 2usize..=10, + num_instructions in 0usize..=5, + seed in any::(), + ) { + // Use seed to generate deterministic but varied data + let mut hasher = DefaultHasher::new(); + seed.hash(&mut hasher); + let hash_val = hasher.finish(); + + let account_keys: Vec
= (0..num_keys) + .map(|i| { + let mut addr = [0u8; 32]; + addr[0..8].copy_from_slice(&(hash_val.wrapping_add(i as u64)).to_le_bytes()); + addr[8] = i as u8; + Address::new_from_array(addr) + }) + .collect(); + + let instructions: Vec = (0..num_instructions) + .map(|i| CompiledInstruction { + program_id_index: 1, // Always use index 1 as program + accounts: vec![0], // Fee payer + data: vec![(i % 256) as u8; i % 100], // Varied data + }) + .collect(); + + let message = Message::builder() + .num_required_signatures(1) + .lifetime_specifier(Hash::new_from_array([hash_val as u8; 32])) + .account_keys(account_keys) + .instructions(instructions) + .build() + .unwrap(); + + let bytes = message.to_bytes().unwrap(); + let parsed = Message::from_bytes(&bytes).unwrap(); + + prop_assert_eq!(message.header, parsed.header); + prop_assert_eq!(message.lifetime_specifier, parsed.lifetime_specifier); + prop_assert_eq!(message.account_keys, parsed.account_keys); + prop_assert_eq!(message.config, parsed.config); + prop_assert_eq!(message.instructions, parsed.instructions); + } + + #[test] + fn truncated_valid_message_fails( + truncate_at in 1usize..200 + ) { + // Create a valid message + let message = Message::builder() + .num_required_signatures(2) + .lifetime_specifier(Hash::new_from_array([0xCC; 32])) + .account_keys(vec![ + Address::new_from_array([1u8; 32]), + Address::new_from_array([2u8; 32]), + Address::new_from_array([3u8; 32]), + ]) + .priority_fee(1000) + .compute_unit_limit(200_000) + .instruction(CompiledInstruction { + program_id_index: 2, + accounts: vec![0, 1], + data: vec![0xAA; 50], + }) + .build() + .unwrap(); + + let bytes = message.to_bytes().unwrap(); + let truncate_pos = truncate_at.min(bytes.len().saturating_sub(1)); + + if truncate_pos < bytes.len() { + let truncated = &bytes[..truncate_pos]; + let result = Message::from_bytes(truncated); + // Should fail, not panic + prop_assert!(result.is_err()); + } + } + } +} diff --git a/packet/src/lib.rs b/packet/src/lib.rs index 2a08cd617..b02398d9e 100644 --- a/packet/src/lib.rs +++ b/packet/src/lib.rs @@ -26,12 +26,15 @@ use { #[cfg(test)] static_assertions::const_assert_eq!(PACKET_DATA_SIZE, 1232); -/// Maximum over-the-wire size of a Transaction +/// Maximum over-the-wire size of a Transaction (legacy and v0) /// 1280 is IPv6 minimum MTU /// 40 bytes is the size of the IPv6 header /// 8 bytes is the size of the fragment header pub const PACKET_DATA_SIZE: usize = 1280 - 40 - 8; +/// Maximum transaction size for V1 transactions (SIMD-0296/SIMD-0385) +pub const PACKET_DATA_SIZE_V1: usize = 4096; + #[cfg(feature = "bincode")] pub trait Encode { fn encode(&self, writer: W) -> Result<()>; diff --git a/transaction/src/lib.rs b/transaction/src/lib.rs index c27f28680..8b33660ff 100644 --- a/transaction/src/lib.rs +++ b/transaction/src/lib.rs @@ -137,6 +137,7 @@ use { pub mod sanitized; pub mod simple_vote_transaction_checker; +pub mod v1; pub mod versioned; #[derive(PartialEq, Eq, Clone, Copy, Debug)] diff --git a/transaction/src/sanitized.rs b/transaction/src/sanitized.rs index 66375c47e..9d63a89e9 100644 --- a/transaction/src/sanitized.rs +++ b/transaction/src/sanitized.rs @@ -5,7 +5,7 @@ use { solana_message::{ legacy, v0::{self, LoadedAddresses}, - AddressLoader, LegacyMessage, SanitizedMessage, SanitizedVersionedMessage, + v1, AddressLoader, LegacyMessage, SanitizedMessage, SanitizedVersionedMessage, VersionedMessage, }, solana_signature::Signature, @@ -77,6 +77,9 @@ impl SanitizedTransaction { reserved_account_keys, )) } + VersionedMessage::V1(message) => { + SanitizedMessage::V1(v1::LoadedMessage::new(message, reserved_account_keys)) + } }; Ok(Self { @@ -209,6 +212,10 @@ impl SanitizedTransaction { signatures, message: VersionedMessage::Legacy(legacy::Message::clone(&legacy_message.message)), }, + SanitizedMessage::V1(v1_message) => VersionedTransaction { + signatures, + message: VersionedMessage::V1(v1_message.message().clone()), + }, } } @@ -249,6 +256,7 @@ impl SanitizedTransaction { match &self.message { SanitizedMessage::Legacy(_) => LoadedAddresses::default(), SanitizedMessage::V0(message) => LoadedAddresses::clone(&message.loaded_addresses), + SanitizedMessage::V1(_) => LoadedAddresses::default(), } } @@ -264,6 +272,10 @@ impl SanitizedTransaction { match &self.message { SanitizedMessage::Legacy(legacy_message) => legacy_message.message.serialize(), SanitizedMessage::V0(loaded_msg) => loaded_msg.message.serialize(), + SanitizedMessage::V1(v1_msg) => v1_msg + .message() + .to_bytes() + .expect("sanitized V1 message exceeds serialization limits"), } } diff --git a/transaction/src/v1.rs b/transaction/src/v1.rs new file mode 100644 index 000000000..7b696f60c --- /dev/null +++ b/transaction/src/v1.rs @@ -0,0 +1,1085 @@ +//! V1 transaction format per SIMD-0385. +//! +//! V1 transactions use a different wire format than Legacy/V0: +//! - Message bytes come first +//! - Signatures come last with NO length prefix +//! - Signature count is determined by `num_required_signatures` in the message header +//! +//! This format is incompatible with serde/bincode, so Transaction only supports +//! raw byte serialization via [`Transaction::serialize`] and [`Transaction::from_bytes`]. + +use { + crate::versioned::VersionedTransaction, + solana_message::{ + v1::{Message, MessageError}, + VersionedMessage, + }, + solana_sanitize::{Sanitize, SanitizeError}, + solana_signature::{Signature, SIGNATURE_BYTES}, +}; +#[cfg(feature = "bincode")] +use { + solana_signer::{signers::Signers, SignerError}, + std::cmp::Ordering, +}; + +/// Errors that can occur when working with V1 transactions. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum TransactionError { + /// Message parsing or serialization failed. + MessageError(MessageError), + /// Not enough account keys for the required number of signatures. + NotEnoughAccountKeys, + /// Not enough bytes for the expected number of signatures. + NotEnoughSignatureBytes, + /// Size calculation overflowed. + Overflow, + /// Signature count doesn't match num_required_signatures. + SignatureCountMismatch { + /// Expected number of signatures from message header. + expected: usize, + /// Actual number of signatures provided. + actual: usize, + }, + /// Unexpected trailing data after transaction. + TrailingData, +} + +impl std::fmt::Display for TransactionError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::MessageError(e) => write!(f, "message error: {e}"), + Self::NotEnoughAccountKeys => { + write!(f, "not enough account keys for required signatures") + } + Self::NotEnoughSignatureBytes => write!(f, "not enough bytes for signatures"), + Self::Overflow => write!(f, "size calculation overflow"), + Self::SignatureCountMismatch { expected, actual } => { + write!( + f, + "signature count mismatch: expected {expected}, got {actual}" + ) + } + Self::TrailingData => write!(f, "unexpected trailing data after transaction"), + } + } +} + +impl std::error::Error for TransactionError {} + +impl From for TransactionError { + fn from(err: MessageError) -> Self { + Self::MessageError(err) + } +} + +/// A V1 transaction per SIMD-0385. +/// +/// Wire format: `[message bytes][signatures]` +/// - Message bytes include version byte (0x81) through instruction payloads +/// - Signatures are appended directly with NO length prefix +/// - Signature count is determined by `num_required_signatures` from the message header +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Transaction { + /// The V1 message containing instructions and accounts. + pub message: Message, + /// Transaction signatures, one per required signer. + /// Order matches the first `num_required_signatures` accounts in the message. + pub signatures: Vec, +} + +impl Transaction { + pub fn message(&self) -> &Message { + &self.message + } + + pub fn signatures(&self) -> &[Signature] { + &self.signatures + } + + /// Sign a V1 message and create a transaction. + /// + /// Keypairs can be provided in any order; they will be matched to the + /// expected signers (first `num_required_signatures` accounts in the message) + /// by public key. + #[cfg(feature = "bincode")] + pub fn try_sign( + message: Message, + keypairs: &T, + ) -> Result { + message + .sanitize() + .map_err(|e| SignerError::InvalidInput(format!("invalid message: {e}")))?; + + let num_required_signatures = message.header.num_required_signatures as usize; + let signer_keys = keypairs.try_pubkeys()?; + let expected_signer_keys = &message.account_keys[0..num_required_signatures]; + + match signer_keys.len().cmp(&expected_signer_keys.len()) { + Ordering::Greater => Err(SignerError::TooManySigners), + Ordering::Less => Err(SignerError::NotEnoughSigners), + Ordering::Equal => Ok(()), + }?; + + // Get message bytes for signing + let message_data = message + .to_bytes() + .map_err(|e| SignerError::InvalidInput(e.to_string()))?; + + // Map expected signers to provided keypair positions + let signature_indexes: Vec = expected_signer_keys + .iter() + .map(|signer_key| { + signer_keys + .iter() + .position(|key| key.as_ref() == signer_key.as_ref()) + .ok_or(SignerError::KeypairPubkeyMismatch) + }) + .collect::>()?; + + // Sign and reorder signatures to match expected order + let unordered_signatures = keypairs.try_sign_message(&message_data)?; + let signatures: Vec = signature_indexes + .into_iter() + .map(|index| unordered_signatures[index]) + .collect(); + + Ok(Self { + message, + signatures, + }) + } + + /// Serialize the transaction to wire format per SIMD-0385. + /// + /// Wire format: `[message bytes][signatures]` + /// - No length prefix on signatures + /// - Signature count determined by `num_required_signatures` in message header + pub fn serialize(&self) -> Result, TransactionError> { + let mut out = self.message.to_bytes()?; + let num_signatures = self.message.header.num_required_signatures as usize; + let signature_bytes = num_signatures + .checked_mul(SIGNATURE_BYTES) + .ok_or(TransactionError::Overflow)?; + out.reserve(signature_bytes); + for sig in &self.signatures { + out.extend_from_slice(sig.as_ref()); + } + Ok(out) + } + + /// Parse a V1 transaction from wire format bytes. + /// + /// Expects format: `[message bytes][signatures]` + /// Returns an error if the input is truncated or has trailing data. + pub fn from_bytes(bytes: &[u8]) -> Result { + let (message, message_len) = Message::from_bytes_partial(bytes)?; + + // Calculate expected signature bytes with overflow checks + let num_signatures = message.header.num_required_signatures as usize; + let signatures_len = num_signatures + .checked_mul(SIGNATURE_BYTES) + .ok_or(TransactionError::Overflow)?; + let total_len = message_len + .checked_add(signatures_len) + .ok_or(TransactionError::Overflow)?; + + if bytes.len() < total_len { + return Err(TransactionError::NotEnoughSignatureBytes); + } + if bytes.len() > total_len { + return Err(TransactionError::TrailingData); + } + + // Extract signatures (chunks_exact guarantees exactly SIGNATURE_BYTES per chunk) + let sig_bytes = &bytes[message_len..]; + let signatures: Vec = sig_bytes + .chunks_exact(SIGNATURE_BYTES) + .map(|chunk| Signature::from(<[u8; SIGNATURE_BYTES]>::try_from(chunk).unwrap())) + .collect(); + + Ok(Self { + message, + signatures, + }) + } + + /// Verify all signatures against the message. + /// + /// Returns `true` if all signatures are valid, `false` if any is invalid. + /// Returns `Err` if the transaction is malformed or cannot be serialized. + #[cfg(feature = "verify")] + pub fn verify(&self) -> Result { + Ok(self.verify_with_results()?.iter().all(|&valid| valid)) + } + + /// Verify each signature and return individual results. + /// + /// Returns a vector of booleans, one per signature, indicating whether + /// each signature is valid. Returns `Err` if the transaction is malformed + /// or the message cannot be serialized. + #[cfg(feature = "verify")] + pub fn verify_with_results(&self) -> Result, TransactionError> { + let required = self.message.header.num_required_signatures as usize; + + // Ensure we verify exactly the signer region, not a subset + if self.signatures.len() != required { + return Err(TransactionError::SignatureCountMismatch { + expected: required, + actual: self.signatures.len(), + }); + } + if self.message.account_keys.len() < required { + return Err(TransactionError::NotEnoughAccountKeys); + } + + let message_bytes = self.message.to_bytes()?; + + Ok(self + .signatures + .iter() + .zip(self.message.account_keys[..required].iter()) + .map(|(signature, pubkey)| signature.verify(pubkey.as_ref(), &message_bytes)) + .collect()) + } + + /// Calculate the size of this transaction when serialized to wire format. + /// This includes the message bytes plus all signatures. + pub fn serialized_size(&self) -> usize { + self.message.transaction_size() + } +} + +impl Sanitize for Transaction { + fn sanitize(&self) -> Result<(), SanitizeError> { + // Verify signature count matches header + let expected = self.message.header.num_required_signatures as usize; + if self.signatures.len() != expected { + return Err(SanitizeError::ValueOutOfBounds); + } + + self.message.sanitize()?; + + Ok(()) + } +} + +/// Convert a Transaction into a VersionedTransaction. +impl From for VersionedTransaction { + fn from(tx: Transaction) -> Self { + Self { + signatures: tx.signatures, + message: VersionedMessage::V1(tx.message), + } + } +} + +/// Try to convert a VersionedTransaction into a Transaction. +/// +/// Returns `Err` with the original transaction if the message is not V1 +/// or if the signature count doesn't match `num_required_signatures`. +impl TryFrom for Transaction { + type Error = VersionedTransaction; + + fn try_from(tx: VersionedTransaction) -> Result { + match tx.message { + VersionedMessage::V1(message) => { + let expected = message.header.num_required_signatures as usize; + if tx.signatures.len() != expected { + return Err(VersionedTransaction { + signatures: tx.signatures, + message: VersionedMessage::V1(message), + }); + } + Ok(Self { + message, + signatures: tx.signatures, + }) + } + _ => Err(tx), + } + } +} + +#[cfg(test)] +mod tests { + use { + super::*, + solana_address::Address, + solana_hash::Hash, + solana_message::{ + compiled_instruction::CompiledInstruction, + v1::{TransactionConfig, MAX_ADDRESSES, MAX_SIGNATURES, MAX_TRANSACTION_SIZE}, + MessageHeader, + }, + }; + + /// Create a deterministic test signature from a seed byte. + fn test_signature(seed: u8) -> Signature { + let mut bytes = [seed; 64]; + bytes[63] = seed.wrapping_add(1); + Signature::from(bytes) + } + + fn create_test_message() -> Message { + Message::builder() + .num_required_signatures(1) + .lifetime_specifier(Hash::new_unique()) + .account_keys(vec![Address::new_unique(), Address::new_unique()]) + .instruction(CompiledInstruction { + program_id_index: 1, + accounts: vec![0], + data: vec![1, 2, 3, 4], + }) + .build() + .unwrap() + } + + fn create_two_signer_message() -> Message { + Message::builder() + .num_required_signatures(2) + .lifetime_specifier(Hash::new_unique()) + .account_keys(vec![ + Address::new_unique(), + Address::new_unique(), + Address::new_unique(), + ]) + .instruction(CompiledInstruction { + program_id_index: 2, + accounts: vec![0, 1], + data: vec![], + }) + .build() + .unwrap() + } + + #[cfg(feature = "bincode")] + #[test] + fn try_sign_rejects_too_many_signers() { + use {solana_keypair::Keypair, solana_signer::Signer}; + + let keypair0 = Keypair::new(); + let keypair1 = Keypair::new(); + let keypair2 = Keypair::new(); + let program_id = Address::new_unique(); + + // Message expects 2 signers + let message = Message::builder() + .num_required_signatures(2) + .lifetime_specifier(Hash::new_unique()) + .account_keys(vec![keypair0.pubkey(), keypair1.pubkey(), program_id]) + .instruction(CompiledInstruction { + program_id_index: 2, + accounts: vec![0, 1], + data: vec![], + }) + .build() + .unwrap(); + + // Provide 3 signers + let result = Transaction::try_sign(message, &[&keypair0, &keypair1, &keypair2]); + assert!(matches!(result, Err(SignerError::TooManySigners))); + } + + #[cfg(feature = "bincode")] + #[test] + fn try_sign_rejects_not_enough_signers() { + use {solana_keypair::Keypair, solana_signer::Signer}; + + let keypair0 = Keypair::new(); + let keypair1 = Keypair::new(); + let program_id = Address::new_unique(); + + // Message expects 2 signers + let message = Message::builder() + .num_required_signatures(2) + .lifetime_specifier(Hash::new_unique()) + .account_keys(vec![keypair0.pubkey(), keypair1.pubkey(), program_id]) + .instruction(CompiledInstruction { + program_id_index: 2, + accounts: vec![0, 1], + data: vec![], + }) + .build() + .unwrap(); + + // Only provide 1 signer + let result = Transaction::try_sign(message, &[&keypair0]); + assert!(matches!(result, Err(SignerError::NotEnoughSigners))); + } + + #[cfg(feature = "bincode")] + #[test] + fn try_sign_rejects_wrong_keypairs() { + use {solana_keypair::Keypair, solana_signer::Signer}; + + let keypair0 = Keypair::new(); + let wrong_keypair = Keypair::new(); // Not in message + let program_id = Address::new_unique(); + + let message = Message::builder() + .num_required_signatures(1) + .lifetime_specifier(Hash::new_unique()) + .account_keys(vec![keypair0.pubkey(), program_id]) + .instruction(CompiledInstruction { + program_id_index: 1, + accounts: vec![0], + data: vec![], + }) + .build() + .unwrap(); + + let result = Transaction::try_sign(message, &[&wrong_keypair]); + assert!(matches!(result, Err(SignerError::KeypairPubkeyMismatch))); + } + + #[cfg(feature = "bincode")] + #[test] + fn try_sign_signs_correctly() { + use {solana_keypair::Keypair, solana_signer::Signer}; + + let keypair = Keypair::new(); + let program_id = Address::new_unique(); + + let message = Message::builder() + .num_required_signatures(1) + .lifetime_specifier(Hash::new_unique()) + .account_keys(vec![keypair.pubkey(), program_id]) + .instruction(CompiledInstruction { + program_id_index: 1, + accounts: vec![0], + data: vec![], + }) + .build() + .unwrap(); + + let tx = Transaction::try_sign(message, &[&keypair]).unwrap(); + + assert_eq!(tx.signatures.len(), 1); + + #[cfg(feature = "verify")] + assert!(tx.verify().unwrap()); + } + + #[cfg(all(feature = "bincode", feature = "verify"))] + #[test] + fn try_sign_with_multiple_signers() { + use {solana_keypair::Keypair, solana_signer::Signer}; + + let keypair0 = Keypair::new(); + let keypair1 = Keypair::new(); + let program_id = Address::new_unique(); + + let message = Message::builder() + .num_required_signatures(2) + .lifetime_specifier(Hash::new_unique()) + .account_keys(vec![keypair0.pubkey(), keypair1.pubkey(), program_id]) + .instruction(CompiledInstruction { + program_id_index: 2, + accounts: vec![0, 1], + data: vec![], + }) + .build() + .unwrap(); + + let tx = Transaction::try_sign(message, &[&keypair0, &keypair1]).unwrap(); + + assert_eq!(tx.signatures.len(), 2); + assert!(tx.verify().unwrap()); + } + + #[cfg(all(feature = "bincode", feature = "verify"))] + #[test] + fn try_sign_reorders_keypairs_correctly() { + use {solana_keypair::Keypair, solana_signer::Signer}; + + let keypair0 = Keypair::new(); + let keypair1 = Keypair::new(); + let program_id = Address::new_unique(); + + // Message expects keypair0 first, then keypair1 + let message = Message::builder() + .num_required_signatures(2) + .lifetime_specifier(Hash::new_unique()) + .account_keys(vec![keypair0.pubkey(), keypair1.pubkey(), program_id]) + .instruction(CompiledInstruction { + program_id_index: 2, + accounts: vec![0, 1], + data: vec![], + }) + .build() + .unwrap(); + + // Provide keypairs in REVERSE order + let tx = Transaction::try_sign(message, &[&keypair1, &keypair0]).unwrap(); + + // Should still verify because signatures are reordered to match account_keys + assert!(tx.verify().unwrap()); + assert_eq!(tx.signatures.len(), 2); + } + + #[test] + fn serialize_produces_correct_wire_format() { + let message = create_test_message(); + let sig = test_signature(0xAA); + let tx = Transaction { + message: message.clone(), + signatures: vec![sig], + }; + + let bytes = tx.serialize().unwrap(); + + // Verify format: [message bytes][signatures] + let message_bytes = message.to_bytes().unwrap(); + assert_eq!(&bytes[..message_bytes.len()], &message_bytes[..]); + assert_eq!(&bytes[message_bytes.len()..], sig.as_ref()); + + // No length prefix before signatures + assert_eq!(bytes.len(), message_bytes.len() + 64); + } + + #[test] + fn from_bytes_rejects_invalid_message() { + // Empty bytes - fails at message parsing + let result = Transaction::from_bytes(&[]); + assert!(matches!(result, Err(TransactionError::MessageError(_)))); + + // Invalid version byte - fails at message parsing + let result = Transaction::from_bytes(&[0x00]); + assert!(matches!(result, Err(TransactionError::MessageError(_)))); + } + + #[test] + fn from_bytes_rejects_truncated_signatures() { + let message = create_test_message(); + let sig = test_signature(0xCC); + let tx = Transaction { + message, + signatures: vec![sig], + }; + + let mut bytes = tx.serialize().unwrap(); + bytes.truncate(bytes.len() - 10); // Remove part of signature + + assert_eq!( + Transaction::from_bytes(&bytes), + Err(TransactionError::NotEnoughSignatureBytes) + ); + } + + #[test] + fn from_bytes_rejects_trailing_data() { + let message = create_test_message(); + let sig = test_signature(0xDD); + let tx = Transaction { + message, + signatures: vec![sig], + }; + + let mut bytes = tx.serialize().unwrap(); + bytes.push(0xFF); // Extra byte + + assert_eq!( + Transaction::from_bytes(&bytes), + Err(TransactionError::TrailingData) + ); + } + + #[test] + fn from_bytes_roundtrip_single_signature() { + let message = create_test_message(); + let sig = test_signature(0xBB); + let tx = Transaction { + message, + signatures: vec![sig], + }; + + let bytes = tx.serialize().unwrap(); + let parsed = Transaction::from_bytes(&bytes).unwrap(); + + assert_eq!(tx.message, parsed.message); + assert_eq!(tx.signatures, parsed.signatures); + } + + #[test] + fn from_bytes_roundtrip_multiple_signatures() { + let message = Message::builder() + .num_required_signatures(3) + .lifetime_specifier(Hash::new_unique()) + .account_keys(vec![ + Address::new_unique(), + Address::new_unique(), + Address::new_unique(), + Address::new_unique(), + ]) + .instruction(CompiledInstruction { + program_id_index: 3, + accounts: vec![0, 1, 2], + data: vec![], + }) + .build() + .unwrap(); + + // Use distinct signatures to verify order is preserved + let signatures = vec![ + test_signature(0x11), + test_signature(0x22), + test_signature(0x33), + ]; + let tx = Transaction { + message, + signatures: signatures.clone(), + }; + + let bytes = tx.serialize().unwrap(); + let parsed = Transaction::from_bytes(&bytes).unwrap(); + + assert_eq!(parsed.signatures.len(), 3); + assert_eq!(parsed.signatures[0], signatures[0]); + assert_eq!(parsed.signatures[1], signatures[1]); + assert_eq!(parsed.signatures[2], signatures[2]); + } + + #[cfg(feature = "verify")] + #[test] + fn verify_returns_false_for_invalid_signature() { + let message = create_test_message(); + // Transaction with a bogus signature (not signed by the actual key) + let tx = Transaction { + message, + signatures: vec![test_signature(0x00)], + }; + + assert!(!tx.verify().unwrap()); + } + + #[cfg(feature = "verify")] + #[test] + fn verify_rejects_zero_signatures() { + // Create a malformed transaction by bypassing the constructor + // (message requires 1 signature, but we provide 0) + let tx = Transaction { + message: create_test_message(), + signatures: vec![], + }; + + assert_eq!( + tx.verify_with_results(), + Err(TransactionError::SignatureCountMismatch { + expected: 1, + actual: 0 + }) + ); + } + + #[cfg(feature = "verify")] + #[test] + fn verify_rejects_too_many_signatures() { + // Create a malformed transaction with extra signatures + let tx = Transaction { + message: create_test_message(), // requires 1 signature + signatures: vec![test_signature(0x01), test_signature(0x02)], // but has 2 + }; + + let result = tx.verify_with_results(); + assert_eq!( + result, + Err(TransactionError::SignatureCountMismatch { + expected: 1, + actual: 2 + }) + ); + } + + #[cfg(feature = "verify")] + #[test] + fn verify_rejects_not_enough_account_keys() { + use solana_message::{v1::TransactionConfig, MessageHeader}; + + // Create a malformed message where header claims more signers than account_keys + let malformed_message = Message { + header: MessageHeader { + num_required_signatures: 3, // Claims 3 signers + num_readonly_signed_accounts: 0, + num_readonly_unsigned_accounts: 0, + }, + config: TransactionConfig::default(), + lifetime_specifier: Hash::new_unique(), + account_keys: vec![Address::new_unique()], // But only 1 account + instructions: vec![], + }; + + // Transaction has matching signature count (3) but not enough account_keys + let tx = Transaction { + message: malformed_message, + signatures: vec![ + test_signature(0x01), + test_signature(0x02), + test_signature(0x03), + ], + }; + + let result = tx.verify_with_results(); + assert_eq!(result, Err(TransactionError::NotEnoughAccountKeys)); + } + + #[cfg(feature = "verify")] + #[test] + fn verify_with_results_returns_per_signature_status() { + let message = create_two_signer_message(); + + // Both signatures are bogus + let tx = Transaction { + message, + signatures: vec![test_signature(0x01), test_signature(0x02)], + }; + + let results = tx.verify_with_results().unwrap(); + assert_eq!(results.len(), 2); + // Both should be false since we didn't actually sign + assert!(!results[0]); + assert!(!results[1]); + } + + #[test] + fn conversion_to_versioned_transaction() { + let message = create_test_message(); + let sig = test_signature(0x77); + let tx = Transaction { + message: message.clone(), + signatures: vec![sig], + }; + + let versioned: VersionedTransaction = tx.into(); + + assert_eq!(versioned.signatures, vec![sig]); + match versioned.message { + VersionedMessage::V1(m) => assert_eq!(m, message), + _ => panic!("Expected V1 message"), + } + } + + #[test] + fn conversion_from_versioned_v1_rejects_signature_count_mismatch() { + // Create a message requiring 2 signatures + let message = create_two_signer_message(); + + // But only provide 1 signature + let versioned = VersionedTransaction { + signatures: vec![test_signature(0x01)], + message: VersionedMessage::V1(message), + }; + + // TryFrom should reject this + let result = Transaction::try_from(versioned); + assert!(result.is_err()); + + // The error should return the original transaction + let returned_tx = result.unwrap_err(); + assert_eq!(returned_tx.signatures.len(), 1); + } + + #[test] + fn conversion_from_versioned_v1_succeeds() { + let message = create_test_message(); + let sig = test_signature(0x88); + + let versioned = VersionedTransaction { + signatures: vec![sig], + message: VersionedMessage::V1(message.clone()), + }; + + let tx = Transaction::try_from(versioned).unwrap(); + assert_eq!(tx.message, message); + assert_eq!(tx.signatures, vec![sig]); + } + + #[test] + fn conversion_from_versioned_legacy_fails() { + let versioned = VersionedTransaction::default(); + + let result = Transaction::try_from(versioned); + assert!(result.is_err()); + } + + #[test] + fn conversion_from_versioned_v0_fails() { + let v0_message = solana_message::v0::Message { + header: solana_message::MessageHeader { + num_required_signatures: 1, + num_readonly_signed_accounts: 0, + num_readonly_unsigned_accounts: 1, + }, + account_keys: vec![Address::new_unique(), Address::new_unique()], + recent_blockhash: Hash::new_unique(), + instructions: vec![], + address_table_lookups: vec![], + }; + + let versioned = VersionedTransaction { + signatures: vec![test_signature(0x01)], + message: VersionedMessage::V0(v0_message), + }; + + let result = Transaction::try_from(versioned); + assert!(result.is_err()); + } + + #[test] + fn serialized_size_matches_serialized_bytes() { + let message = create_test_message(); + let tx = Transaction { + message, + signatures: vec![test_signature(0xAA)], + }; + + let bytes = tx.serialize().unwrap(); + assert_eq!(tx.serialized_size(), bytes.len()); + } + + #[test] + fn serialized_size_includes_all_signatures() { + // Create message with max signatures (12) + let mut account_keys: Vec<_> = (0..MAX_SIGNATURES).map(|_| Address::new_unique()).collect(); + account_keys.push(Address::new_unique()); // program + + let message = Message::builder() + .num_required_signatures(MAX_SIGNATURES) + .lifetime_specifier(Hash::new_unique()) + .account_keys(account_keys) + .instruction(CompiledInstruction { + program_id_index: MAX_SIGNATURES, + accounts: (0..MAX_SIGNATURES).collect(), + data: vec![], + }) + .build() + .unwrap(); + + let signatures: Vec<_> = (0..MAX_SIGNATURES).map(test_signature).collect(); + let tx = Transaction { + message, + signatures, + }; + + let bytes = tx.serialize().unwrap(); + assert_eq!(tx.serialized_size(), bytes.len()); + + // Verify signatures are at the expected position + let message_size = tx.message.size(); + assert_eq!(bytes.len(), message_size + (MAX_SIGNATURES as usize * 64)); + } + + #[test] + fn max_size_transaction_exactly_4096_bytes() { + // Build a transaction that's exactly at the 4096 byte limit + // Fixed header: 1 + 3 + 4 + 32 + 1 + 1 = 42 bytes + // With 1 signer: 1 signature = 64 bytes + // With 2 addresses: 64 bytes + // Total fixed: 42 + 64 + 64 = 170 bytes + // Remaining for instruction: 4096 - 170 = 3926 bytes + // Instruction header: 4 bytes + // Instruction accounts: 1 byte + // Instruction data: 3926 - 4 - 1 = 3921 bytes + + let message = Message::builder() + .num_required_signatures(1) + .lifetime_specifier(Hash::new_unique()) + .account_keys(vec![Address::new_unique(), Address::new_unique()]) + .instruction(CompiledInstruction { + program_id_index: 1, + accounts: vec![0], + data: vec![0u8; 3921], + }) + .build() + .unwrap(); + + let tx = Transaction { + message: message.clone(), + signatures: vec![test_signature(0x01)], + }; + + // Verify we're at exactly 4096 bytes + assert_eq!(tx.serialized_size(), MAX_TRANSACTION_SIZE); + + // Should serialize and deserialize successfully + let bytes = tx.serialize().unwrap(); + assert_eq!(bytes.len(), MAX_TRANSACTION_SIZE); + + let parsed = Transaction::from_bytes(&bytes).unwrap(); + assert_eq!(parsed.message, tx.message); + assert_eq!(parsed.signatures, tx.signatures); + + // Sanitize should pass + assert!(tx.sanitize().is_ok()); + } + + #[test] + fn transaction_exceeding_4096_bytes_fails_sanitize() { + // Construct message directly to bypass builder size validation. + // This tests that sanitize() catches oversized transactions. + let message = Message { + header: MessageHeader { + num_required_signatures: 1, + num_readonly_signed_accounts: 0, + num_readonly_unsigned_accounts: 0, + }, + config: TransactionConfig::default(), + lifetime_specifier: Hash::new_unique(), + account_keys: vec![Address::new_unique(), Address::new_unique()], + instructions: vec![CompiledInstruction { + program_id_index: 1, + accounts: vec![0], + data: vec![0u8; 3922], // One more byte than max + }], + }; + + let tx = Transaction { + message, + signatures: vec![test_signature(0x01)], + }; + + assert_eq!(tx.serialized_size(), MAX_TRANSACTION_SIZE + 1); + + // Sanitize should fail + assert!(tx.sanitize().is_err()); + } + + #[test] + fn max_signatures_with_max_addresses_fits() { + // Transaction with 12 signers and 64 addresses + let account_keys: Vec<_> = (0..MAX_ADDRESSES).map(|_| Address::new_unique()).collect(); + + let message = Message::builder() + .num_required_signatures(MAX_SIGNATURES) + .lifetime_specifier(Hash::new_unique()) + .account_keys(account_keys) + .instruction(CompiledInstruction { + program_id_index: MAX_SIGNATURES, // First non-signer as program + accounts: (0..MAX_SIGNATURES).collect(), + data: vec![], + }) + .build() + .unwrap(); + + let signatures: Vec<_> = (0..MAX_SIGNATURES).map(test_signature).collect(); + let tx = Transaction { + message, + signatures, + }; + + // This configuration should fit within 4096 bytes + // Fixed header: 42 bytes + // 64 addresses: 2048 bytes + // 12 signatures: 768 bytes + // 1 instruction header: 4 bytes + // 12 account indices: 12 bytes + // Total: 42 + 2048 + 768 + 4 + 12 = 2874 bytes + assert!(tx.serialized_size() <= MAX_TRANSACTION_SIZE); + assert!(tx.sanitize().is_ok()); + + // Verify roundtrip + let bytes = tx.serialize().unwrap(); + let parsed = Transaction::from_bytes(&bytes).unwrap(); + assert_eq!(parsed.message, tx.message); + } + + #[test] + fn sanitize_rejects_too_few_signatures() { + // Create a malformed transaction by bypassing the constructor + let tx = Transaction { + message: create_test_message(), // requires 1 signature + signatures: vec![], // but has 0 + }; + + assert_eq!(tx.sanitize(), Err(SanitizeError::ValueOutOfBounds)); + } + + #[test] + fn sanitize_rejects_too_many_signatures() { + let tx = Transaction { + message: create_test_message(), // requires 1 signature + signatures: vec![test_signature(0x01), test_signature(0x02)], // but has 2 + }; + + assert_eq!(tx.sanitize(), Err(SanitizeError::ValueOutOfBounds)); + } + + #[test] + fn sanitize_accepts_valid_transaction() { + let message = create_test_message(); + let tx = Transaction { + message, + signatures: vec![test_signature(0xFF)], + }; + assert!(tx.sanitize().is_ok()); + } + + #[test] + fn error_display_formats_correctly() { + assert_eq!( + TransactionError::MessageError(MessageError::BufferTooSmall).to_string(), + "message error: buffer too small" + ); + assert_eq!( + TransactionError::NotEnoughAccountKeys.to_string(), + "not enough account keys for required signatures" + ); + assert_eq!( + TransactionError::NotEnoughSignatureBytes.to_string(), + "not enough bytes for signatures" + ); + assert_eq!( + TransactionError::Overflow.to_string(), + "size calculation overflow" + ); + assert_eq!( + TransactionError::SignatureCountMismatch { + expected: 2, + actual: 1 + } + .to_string(), + "signature count mismatch: expected 2, got 1" + ); + assert_eq!( + TransactionError::TrailingData.to_string(), + "unexpected trailing data after transaction" + ); + } + + #[test] + fn clone_and_eq_work() { + let message = create_test_message(); + let tx = Transaction { + message, + signatures: vec![test_signature(0x01)], + }; + + let cloned = tx.clone(); + assert_eq!(tx, cloned); + + // Different signature should not be equal + let message2 = create_test_message(); + let tx2 = Transaction { + message: message2, + signatures: vec![test_signature(0x02)], + }; + assert_ne!(tx, tx2); + } + + #[test] + fn destructuring_works() { + let message = create_test_message(); + let sig = test_signature(0x99); + let tx = Transaction { + message: message.clone(), + signatures: vec![sig], + }; + + let Transaction { + message: returned_message, + signatures: returned_sigs, + } = tx; + assert_eq!(returned_message, message); + assert_eq!(returned_sigs, vec![sig]); + } +} diff --git a/transaction/src/versioned/mod.rs b/transaction/src/versioned/mod.rs index 31146a8e7..a0148d96c 100644 --- a/transaction/src/versioned/mod.rs +++ b/transaction/src/versioned/mod.rs @@ -160,6 +160,7 @@ impl VersionedTransaction { match self.message { VersionedMessage::Legacy(_) => TransactionVersion::LEGACY, VersionedMessage::V0(_) => TransactionVersion::Number(0), + VersionedMessage::V1(_) => TransactionVersion::Number(1), } } @@ -281,6 +282,57 @@ mod tests { } } + #[test] + fn test_v1_transaction_sign_verify_and_roundtrip() { + use solana_message::{compiled_instruction::CompiledInstruction, v1}; + + let keypair0 = Keypair::new(); + let keypair1 = Keypair::new(); + let program_id = Pubkey::new_unique(); + + // Create V1 message with two signers + let message = v1::Message::builder() + .num_required_signatures(2) + .lifetime_specifier(Hash::new_unique()) + .account_keys(vec![keypair0.pubkey(), keypair1.pubkey(), program_id]) + .priority_fee(1000) + .compute_unit_limit(200_000) + .instruction(CompiledInstruction { + program_id_index: 2, + accounts: vec![0, 1], + data: vec![1, 2, 3, 4], + }) + .build() + .unwrap(); + + let versioned_message = VersionedMessage::V1(message); + + // Sign the transaction + let tx = VersionedTransaction::try_new(versioned_message.clone(), &[&keypair0, &keypair1]) + .unwrap(); + + // Verify signatures + assert_eq!(tx.signatures.len(), 2); + assert!(tx.verify_with_results().iter().all(|&r| r)); + + // V1 messages don't support serde/bincode serialization. + // Test raw bytes roundtrip using message.serialize() instead. + let message_bytes = versioned_message.serialize(); + let parsed_message = + VersionedMessage::V1(solana_message::v1::Message::from_bytes(&message_bytes).unwrap()); + + // Verify message content preserved + match &parsed_message { + VersionedMessage::V1(msg) => { + assert_eq!(msg.header.num_required_signatures, 2); + assert_eq!(msg.config.priority_fee, Some(1000)); + assert_eq!(msg.config.compute_unit_limit, Some(200_000)); + assert_eq!(msg.instructions.len(), 1); + } + _ => panic!("Expected V1 message"), + } + } + fn nonced_transfer_tx() -> (Pubkey, Pubkey, VersionedTransaction) { let from_keypair = Keypair::new(); let from_pubkey = from_keypair.pubkey(); @@ -313,7 +365,7 @@ mod tests { VersionedMessage::Legacy(message) => { message.instructions.get_mut(0).unwrap().program_id_index = 255u8; } - VersionedMessage::V0(_) => unreachable!(), + VersionedMessage::V0(_) | VersionedMessage::V1(_) => unreachable!(), }; assert!(!tx.uses_durable_nonce()); }