Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
221 changes: 202 additions & 19 deletions crates/solana-client-tools/src/transaction.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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],
Expand All @@ -32,22 +45,7 @@ pub fn try_batch_instructions_with_common_signers(
address_lookup_table_accounts: &[AddressLookupTableAccount],
allow_compute_price_instruction: bool,
) -> Result<Vec<Vec<Instruction>>> {
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();

Expand All @@ -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;

Expand All @@ -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,
)?;

Expand All @@ -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,
)?;

Expand Down Expand Up @@ -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<u8> {
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<u32> {
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<u8> = 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<u8> = (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 */ }
}
}
}
71 changes: 71 additions & 0 deletions crates/validator-debt/src/transaction.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>, 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),
}
}
}
}