diff --git a/crates/solana-client-tools/src/transaction.rs b/crates/solana-client-tools/src/transaction.rs index 1fbcf9e8..b820bc66 100644 --- a/crates/solana-client-tools/src/transaction.rs +++ b/crates/solana-client-tools/src/transaction.rs @@ -4,11 +4,24 @@ use solana_sdk::{ hash::Hash, instruction::Instruction, message::{AddressLookupTableAccount, VersionedMessage, v0::Message}, + packet::PACKET_DATA_SIZE, signature::Keypair, signer::Signer, transaction::VersionedTransaction, }; +pub(crate) const TRANSACTION_CU_BUFFER: u32 = 5_000; + +/// Maximum serialized transaction size after accounting for ComputeBudget overhead. +/// When `allow_compute_price_instruction` is true, an extra 9 bytes are reserved. +pub(crate) fn transaction_size_limit(allow_compute_price_instruction: bool) -> usize { + if allow_compute_price_instruction { + PACKET_DATA_SIZE - 32 - 5 - 9 + } else { + PACKET_DATA_SIZE - 32 - 5 + } +} + pub fn try_new_transaction( instructions: &[Instruction], signers: &[&Keypair], @@ -32,22 +45,7 @@ pub fn try_batch_instructions_with_common_signers( address_lookup_table_accounts: &[AddressLookupTableAccount], allow_compute_price_instruction: bool, ) -> Result>> { - const TRANSACTION_CU_BUFFER: u32 = 5_000; - - // These adjustments may be too conservative. But we want to err on the side - // of caution to avoid an accidental RPC revert with a transaction being - // too large. - let transaction_size_limit = if allow_compute_price_instruction { - // Only account for the instruction data size, which is 9 bytes. - 1_232 - - 32 // Compute Budget program ID. - - 5 // Compute Budget limit instruction (1 + 4). - - 9 // Compute Budget price instruction (1 + 8). - } else { - 1_232 - - 32 // Compute Budget program ID. - - 5 // Compute Budget limit instruction (1 + 4). - }; + let size_limit = transaction_size_limit(allow_compute_price_instruction); instructions_and_compute_units.reverse(); @@ -68,7 +66,7 @@ pub fn try_batch_instructions_with_common_signers( ) .unwrap(); - if bincode::serialize(&transaction).unwrap().len() > transaction_size_limit { + if bincode::serialize(&transaction).unwrap().len() > size_limit { let instruction = last_batch.pop().unwrap(); let batch_compute_units = last_compute_units - compute_units; @@ -77,7 +75,7 @@ pub fn try_batch_instructions_with_common_signers( &mut batch, signers, address_lookup_table_accounts, - transaction_size_limit, + size_limit, batch_compute_units, )?; @@ -91,7 +89,7 @@ pub fn try_batch_instructions_with_common_signers( &mut last_batch, signers, address_lookup_table_accounts, - transaction_size_limit, + size_limit, last_compute_units, )?; @@ -126,3 +124,188 @@ fn try_complete_instructions_batch( Ok(()) } + +#[cfg(test)] +mod tests { + use solana_sdk::{instruction::AccountMeta, pubkey::Pubkey}; + + use super::*; + + /// Synthetic instruction with ID in first data byte; accounts sized for batch testing. + fn synthetic_instruction(id: u8, data_size: usize, num_accounts: usize) -> Instruction { + Instruction { + program_id: Pubkey::new_from_array([id; 32]), + accounts: (0..num_accounts) + .map(|i| { + AccountMeta::new( + Pubkey::new_from_array([id.wrapping_add(i as u8 + 100); 32]), + false, + ) + }) + .collect(), + data: vec![id; data_size], + } + } + + fn instruction_id(ix: &Instruction) -> Option { + if ix.program_id == solana_sdk::compute_budget::ID { + None + } else { + ix.data.first().copied() + } + } + + fn is_compute_budget_instruction(ix: &Instruction) -> bool { + ix.program_id == solana_sdk::compute_budget::ID + } + + /// Decode CU-limit from a ComputeBudget instruction. + fn decode_cu_limit(ix: &Instruction) -> Option { + if ix.data.len() == 5 && ix.data[0] == 2 { + Some(u32::from_le_bytes([ + ix.data[1], ix.data[2], ix.data[3], ix.data[4], + ])) + } else { + None + } + } + + #[test] + fn batching_preserves_order_and_respects_size_limit() { + // Random signer OK: pubkey only for Message::try_compile; assertions don't depend on it. + let signer = Keypair::new(); + let signers = vec![&signer]; + + // 30 instructions (~100 bytes data + 3 accounts each) guarantee multiple batches + let instructions: Vec<(Instruction, u32)> = (0u8..30) + .map(|id| (synthetic_instruction(id, 100, 3), 10_000)) + .collect(); + + let batches = + try_batch_instructions_with_common_signers(instructions, &signers, &[], false) + .expect("batching should succeed"); + + assert!( + batches.len() > 1, + "Expected >1 batch, got {}", + batches.len() + ); + + let limit = transaction_size_limit(false); + let mut observed_ids: Vec = Vec::new(); + + for (i, batch) in batches.iter().enumerate() { + // (b) size check + let tx = try_new_transaction(batch, &signers, &[], Default::default()).unwrap(); + let size = bincode::serialize(&tx).unwrap().len(); + assert!(size <= limit, "Batch {} size {} > limit {}", i, size, limit); + + // (c) exactly one CU-limit instruction + let cu_limit_count = batch + .iter() + .filter(|ix| decode_cu_limit(ix).is_some()) + .count(); + assert_eq!( + cu_limit_count, 1, + "Batch {} should have exactly 1 CU-limit ix", + i + ); + + // (a) collect user instruction ids + for ix in batch { + if let Some(id) = instruction_id(ix) { + observed_ids.push(id); + } + } + } + + // (a) completeness check: all expected ids present + let expected_ids: Vec = (0u8..30).collect(); + assert_eq!( + observed_ids, expected_ids, + "Not all user instructions present or order violated" + ); + } + + /// Asserts ComputeBudget CU value == TRANSACTION_CU_BUFFER + sum(batch CUs). + #[test] + fn compute_budget_cu_equals_buffer_plus_sum() { + let signer = Keypair::new(); + let signers = vec![&signer]; + + // 3 small instructions (guaranteed single batch) each with 100_000 CU + let instructions: Vec<(Instruction, u32)> = (0u8..3) + .map(|id| (synthetic_instruction(id, 20, 2), 100_000)) + .collect(); + + let batches = + try_batch_instructions_with_common_signers(instructions, &signers, &[], false) + .expect("batching should succeed"); + + assert_eq!( + batches.len(), + 1, + "Expected single batch for small instructions" + ); + + let cu = batches[0] + .iter() + .find_map(decode_cu_limit) + .expect("batch must have CU-limit ix"); + let expected = TRANSACTION_CU_BUFFER + 3 * 100_000; + assert_eq!(cu, expected, "CU limit should be buffer + sum"); + } + + /// With allow_compute_price_instruction=true, size limit is smaller and all batches respect it. + #[test] + fn compute_price_flag_uses_smaller_limit() { + assert!( + transaction_size_limit(true) < transaction_size_limit(false), + "price flag should reduce size limit" + ); + + let signer = Keypair::new(); + let signers = vec![&signer]; + + let instructions: Vec<(Instruction, u32)> = (0u8..20) + .map(|id| (synthetic_instruction(id, 80, 4), 5_000)) + .collect(); + + let batches = try_batch_instructions_with_common_signers(instructions, &signers, &[], true) + .expect("batching with price should succeed"); + + let limit = transaction_size_limit(true); + for (i, batch) in batches.iter().enumerate() { + let tx = try_new_transaction(batch, &signers, &[], Default::default()).unwrap(); + let size = bincode::serialize(&tx).unwrap().len(); + assert!( + size <= limit, + "Batch {} size {} > limit {} (with price)", + i, + size, + limit + ); + } + } + + /// Oversized single instruction either errors or produces valid batch (no silent truncation). + #[test] + fn oversized_instruction_not_silently_truncated() { + let signer = Keypair::new(); + let signers = vec![&signer]; + + let large_ix = synthetic_instruction(1, 800, 10); + let instructions = vec![(large_ix, 100_000)]; + + match try_batch_instructions_with_common_signers(instructions, &signers, &[], false) { + Ok(batches) => { + assert!(!batches.is_empty()); + let intact = batches[0] + .iter() + .any(|ix| !is_compute_budget_instruction(ix) && ix.data.len() == 800); + assert!(intact, "Large instruction must be untruncated"); + } + Err(_) => { /* acceptable for truly oversized */ } + } + } +} diff --git a/crates/validator-debt/src/transaction.rs b/crates/validator-debt/src/transaction.rs index 13fc4225..9a0bc53e 100644 --- a/crates/validator-debt/src/transaction.rs +++ b/crates/validator-debt/src/transaction.rs @@ -537,3 +537,74 @@ fn parse_program_logs( success, } } + +#[cfg(test)] +mod tests { + use super::*; + + /// Build 5-line logs with `marker` at line 5 (index 4). + fn logs_with_line5(marker: &str) -> String { + format!("L0\nL1\nL2\nL3\n{}", marker) + } + + /// REQUIRED CASES (table-driven, minimal, high-signal): + /// 1. "Merkle leaf" at line 5 → success=false, result contains marker + /// 2. "Insufficient funds" at line 5 → success=false, result contains marker + /// 3. Normal success message at line 5 → success=true, result contains message + /// 4. None or < 5 lines → success=true, result=None + #[test] + fn classification_table() { + let node = Pubkey::new_from_array([1u8; 32]); + let amt = 1_000_000u64; + + let cases: &[(&str, Option, bool, Option<&str>)] = &[ + // (name, logs, expected_success, expected_result_contains) + ( + "merkle_leaf", + Some(logs_with_line5("Merkle leaf already processed")), + false, + Some("Merkle leaf"), + ), + ( + "insufficient_funds", + Some(logs_with_line5("Insufficient funds in source")), + false, + Some("Insufficient funds"), + ), + ( + "success_message", + Some(logs_with_line5("Transfer successful")), + true, + Some("Transfer successful"), + ), + ("none_logs", None, true, None), + ("short_logs", Some("L0\nL1\nL2".to_string()), true, None), + ]; + + for (name, logs, want_success, want_contains) in cases { + let r = parse_program_logs(amt, node, logs.clone()); + assert_eq!( + r.success, *want_success, + "[{}] success mismatch: got {}, want {}", + name, r.success, want_success + ); + assert_eq!(r.amount, amt, "[{}] amount", name); + assert_eq!(r.validator_id, node.to_string(), "[{}] validator_id", name); + + match (want_contains, &r.result) { + (Some(substr), Some(res)) => { + assert!( + res.contains(substr), + "[{}] result '{}' missing '{}'", + name, + res, + substr + ); + } + (Some(_), None) => panic!("[{}] expected Some result", name), + (None, None) => {} + (None, Some(res)) => panic!("[{}] expected None result, got {:?}", name, res), + } + } + } +}