diff --git a/crates/core/src/rpc/surfnet_cheatcodes.rs b/crates/core/src/rpc/surfnet_cheatcodes.rs index 930c83c2..41d50178 100644 --- a/crates/core/src/rpc/surfnet_cheatcodes.rs +++ b/crates/core/src/rpc/surfnet_cheatcodes.rs @@ -1860,7 +1860,7 @@ mod tests { use solana_pubkey::Pubkey; use solana_signer::Signer; use solana_system_interface::instruction::create_account; - use solana_transaction::Transaction; + use solana_transaction::{Transaction, versioned::VersionedTransaction}; use spl_associated_token_account_interface::{ address::get_associated_token_address_with_program_id, instruction::create_associated_token_account, @@ -3984,4 +3984,206 @@ mod tests { println!("✅ Response context is valid"); } + + #[tokio::test(flavor = "multi_thread")] + async fn test_write_program_small_no_minimum_program_artifacts() { + // Regression test: writing a program smaller than minimum_program.so (3312 bytes) + // should not leave leftover minimum_program.so bytes in the account. + let client = TestSetup::new(SurfnetCheatcodesRpc); + let program_id = Keypair::new(); + + let program_data = vec![0xAB; 100]; + let result = client + .rpc + .write_program( + Some(client.context.clone()), + program_id.pubkey().to_string(), + hex::encode(&program_data), + 0, + None, + ) + .await; + + assert!( + result.is_ok(), + "Failed to write program: {:?}", + result.err() + ); + + let program_data_address = + solana_loader_v3_interface::get_program_data_address(&program_id.pubkey()); + let account = client.context.svm_locker.with_svm_reader(|svm_reader| { + svm_reader + .inner + .get_account(&program_data_address) + .unwrap() + .unwrap() + }); + + let metadata_size = + solana_loader_v3_interface::state::UpgradeableLoaderState::size_of_programdata_metadata( + ); + + // Account data length should be exactly metadata + program data, not metadata + 3312 + assert_eq!( + account.data.len(), + metadata_size + 100, + "Account data length should be metadata_size ({}) + 100 = {}, but was {}", + metadata_size, + metadata_size + 100, + account.data.len() + ); + + // Verify written content matches exactly + let written_data = &account.data[metadata_size..]; + assert_eq!( + written_data, &program_data, + "Written data should match exactly" + ); + + // Verify no trailing non-zero bytes beyond written data + assert!( + account.data[metadata_size + 100..].is_empty(), + "There should be no trailing bytes beyond the written data" + ); + + println!("✅ Small program has no minimum_program.so artifacts"); + } + + #[tokio::test(flavor = "multi_thread")] + async fn test_write_program_exact_account_size() { + // Regression test: verify account size is exactly correct for various data sizes, + // including sizes around the minimum_program.so boundary (3312 bytes). + let client = TestSetup::new(SurfnetCheatcodesRpc); + + let metadata_size = + solana_loader_v3_interface::state::UpgradeableLoaderState::size_of_programdata_metadata( + ); + + for data_len in [1usize, 100, 3311, 3312, 3313, 5000] { + let program_id = Keypair::new(); + let program_data: Vec = (0..data_len).map(|i| (i % 256) as u8).collect(); + + let result = client + .rpc + .write_program( + Some(client.context.clone()), + program_id.pubkey().to_string(), + hex::encode(&program_data), + 0, + None, + ) + .await; + + assert!( + result.is_ok(), + "Failed to write program of size {}: {:?}", + data_len, + result.err() + ); + + let program_data_address = + solana_loader_v3_interface::get_program_data_address(&program_id.pubkey()); + let account = client.context.svm_locker.with_svm_reader(|svm_reader| { + svm_reader + .inner + .get_account(&program_data_address) + .unwrap() + .unwrap() + }); + + assert_eq!( + account.data.len(), + metadata_size + data_len, + "For data_len={}, account data length should be {} but was {}", + data_len, + metadata_size + data_len, + account.data.len() + ); + + let written_data = &account.data[metadata_size..metadata_size + data_len]; + assert_eq!( + written_data, &program_data, + "For data_len={}, written content should match exactly", + data_len + ); + } + + println!("✅ All program sizes produce exact account sizes"); + } + + #[tokio::test(flavor = "multi_thread")] + async fn test_write_program_execution_uses_written_bytes_not_noop() { + // Regression test: after write_program, executing the program should use + // the written bytes, not the noop placeholder from init_programdata_account. + let client = TestSetup::new(SurfnetCheatcodesRpc); + let program_id = Keypair::new(); + + // Create an "error program" ELF: identical to noop but returns r0=1 (error) + // instead of r0=0 (success). This lets us distinguish noop vs real execution. + let mut error_program_elf = crate::surfnet::noop_program::NOOP_PROGRAM_ELF.to_vec(); + // Byte 124 is the first byte of the imm field in `mov64 r0, 0` at .text offset. + // Changing it to 1 makes the instruction `mov64 r0, 1` (program returns error). + error_program_elf[124] = 0x01; + + // Write the error program + let result = client + .rpc + .write_program( + Some(client.context.clone()), + program_id.pubkey().to_string(), + hex::encode(&error_program_elf), + 0, + None, + ) + .await; + assert!( + result.is_ok(), + "Failed to write program: {:?}", + result.err() + ); + + // Create a payer and fund it + let payer = Keypair::new(); + client + .context + .svm_locker + .airdrop(&payer.pubkey(), 1_000_000_000) + .unwrap() + .unwrap(); + + // Build a transaction invoking the written program + let recent_blockhash = client + .context + .svm_locker + .with_svm_reader(|svm_reader| svm_reader.latest_blockhash()); + // Build a minimal instruction that invokes the written program + let invoke_ix = solana_instruction::Instruction { + program_id: program_id.pubkey(), + accounts: vec![], + data: vec![], + }; + let message = solana_message::Message::new_with_blockhash( + &[invoke_ix], + Some(&payer.pubkey()), + &recent_blockhash, + ); + let tx = VersionedTransaction::try_new( + solana_message::VersionedMessage::Legacy(message), + &[&payer], + ) + .unwrap(); + + // Simulate the transaction + let sim_result = client.context.svm_locker.simulate_transaction(tx, false); + + // The error program returns r0=1, which should cause an InstructionError. + // If the noop (r0=0) is still cached, this would incorrectly succeed. + assert!( + sim_result.is_err(), + "Transaction should fail because the written program returns error (r0=1). \ + If it succeeded, the noop placeholder is still being executed instead of \ + the written program bytes." + ); + } } diff --git a/crates/core/src/surfnet/locker.rs b/crates/core/src/surfnet/locker.rs index 138631c0..6ba2ba0f 100644 --- a/crates/core/src/surfnet/locker.rs +++ b/crates/core/src/surfnet/locker.rs @@ -3410,11 +3410,10 @@ impl SurfnetSvmLocker { ) -> SurfpoolResult<()> { let program_data_address = get_program_data_address(&program_id); - let _ = self + let program_account = self .get_or_create_program_account(program_id, program_data_address, remote_ctx) .await?; - // Get or create program data account let _ = self .write_program_data_account_with_offset( program_id, @@ -3426,6 +3425,21 @@ impl SurfnetSvmLocker { ) .await?; + // Re-set the program account to force LiteSVM to recompile the program + // from the updated programdata. Without this, the program cache retains + // the noop placeholder compiled during initial program account creation. + // Errors are expected for incomplete ELF (multi-chunk writes) and are + // logged but not propagated. + let set_result = self.with_svm_writer(|svm_writer| { + svm_writer.set_account(&program_id, program_account.clone()) + }); + if let Err(e) = set_result { + let _ = self.simnet_events_tx().send(SimnetEvent::info(format!( + "Program cache update deferred for {}: {}", + program_id, e + ))); + } + Ok(()) } @@ -3630,6 +3644,16 @@ impl SurfnetSvmLocker { SurfpoolError::internal(format!("Failed to serialize program data metadata: {}", e)) })?; + // Strip the minimum_program.so placeholder if it was pre-filled by + // init_programdata_account during program account creation. This prevents + // leftover placeholder bytes when the actual program is smaller than 3312 bytes. + let minimum_program_bytes = crate::surfnet::noop_program::NOOP_PROGRAM_ELF; + if program_data_account.data.len() == metadata_size + minimum_program_bytes.len() + && program_data_account.data[metadata_size..] == *minimum_program_bytes + { + program_data_account.data.truncate(metadata_size); + } + // Calculate absolute offset in account data (metadata + offset) let absolute_offset = metadata_size + offset; let end_offset = absolute_offset + data.len(); diff --git a/crates/core/src/surfnet/mod.rs b/crates/core/src/surfnet/mod.rs index 08646137..87ab3a70 100644 --- a/crates/core/src/surfnet/mod.rs +++ b/crates/core/src/surfnet/mod.rs @@ -26,6 +26,7 @@ use crate::{ }; pub mod locker; +pub mod noop_program; pub mod remote; pub mod surfnet_lite_svm; pub mod svm; diff --git a/crates/core/src/surfnet/noop_program.rs b/crates/core/src/surfnet/noop_program.rs new file mode 100644 index 00000000..ef3c40a7 --- /dev/null +++ b/crates/core/src/surfnet/noop_program.rs @@ -0,0 +1,99 @@ +/// Minimal valid SBF ELF (352 bytes) — just returns SUCCESS (r0=0). +/// +/// Used as a placeholder in `init_programdata_account` to satisfy LiteSVM's +/// ELF validation when bootstrapping program accounts. This gets stripped +/// by `write_program_data_account_with_offset` before actual program data +/// is written. +/// +/// Layout (352 bytes): +/// 0x000..0x040 ELF64 header (ET_DYN, EM_SBPF=0x107, SBPFv0, entry=0x78) +/// 0x040..0x078 1× PT_LOAD program header (text at 0x78, 16 bytes, RX) +/// 0x078..0x088 .text section: mov64 r0,0 ; exit +/// 0x088..0x099 .shstrtab contents: "\0.text\0.shstrtab\0" +/// 0x099..0x0A0 padding (7 bytes, align section headers to 8) +/// 0x0A0..0x0E0 Section header [0]: SHT_NULL +/// 0x0E0..0x120 Section header [1]: .text +/// 0x120..0x160 Section header [2]: .shstrtab +pub const NOOP_PROGRAM_ELF: &[u8] = &[ + // ===== ELF64 Header (64 bytes) ===== + // e_ident: EI_MAG0..3, EI_CLASS=2(64-bit), EI_DATA=1(LE), EI_VERSION=1, + // EI_OSABI=0, padding + 0x7F, b'E', b'L', b'F', 0x02, 0x01, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + // e_type=ET_DYN(3), e_machine=EM_SBPF(0x107) + 0x03, 0x00, 0x07, 0x01, // e_version=1 + 0x01, 0x00, 0x00, 0x00, // e_entry=0x78 (start of .text) + 0x78, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + // e_phoff=0x40 (program headers right after ELF header) + 0x40, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + // e_shoff=0xA0 (section headers at offset 160) + 0xA0, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // e_flags=0 (SBPFv0) + 0x00, 0x00, 0x00, 0x00, // e_ehsize=64 + 0x40, 0x00, // e_phentsize=56 + 0x38, 0x00, // e_phnum=1 + 0x01, 0x00, // e_shentsize=64 + 0x40, 0x00, // e_shnum=3 + 0x03, 0x00, // e_shstrndx=2 + 0x02, 0x00, // ===== Program Header (56 bytes) ===== + // p_type=PT_LOAD(1) + 0x01, 0x00, 0x00, 0x00, // p_flags=PF_R|PF_X(5) + 0x05, 0x00, 0x00, 0x00, // p_offset=0x78 + 0x78, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // p_vaddr=0x78 + 0x78, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // p_paddr=0x78 + 0x78, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // p_filesz=16 + 0x10, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // p_memsz=16 + 0x10, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // p_align=8 + 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + // ===== .text section (16 bytes at 0x78) ===== + // mov64 r0, 0 (opcode=0xb7, dst=r0, src=0, off=0, imm=0) + 0xB7, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + // exit (opcode=0x95, dst=0, src=0, off=0, imm=0) + 0x95, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + // ===== .shstrtab contents (17 bytes at 0x88) ===== + // "\0.text\0.shstrtab\0" + 0x00, b'.', b't', b'e', b'x', b't', 0x00, b'.', b's', b'h', b's', b't', b'r', b't', b'a', b'b', + 0x00, // ===== Padding to align section headers to 8 bytes (7 bytes) ===== + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + // ===== Section Header [0]: SHT_NULL (64 bytes at 0xA0) ===== + 0x00, 0x00, 0x00, 0x00, // sh_name=0 + 0x00, 0x00, 0x00, 0x00, // sh_type=SHT_NULL(0) + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // sh_flags=0 + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // sh_addr=0 + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // sh_offset=0 + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // sh_size=0 + 0x00, 0x00, 0x00, 0x00, // sh_link=0 + 0x00, 0x00, 0x00, 0x00, // sh_info=0 + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // sh_addralign=0 + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // sh_entsize=0 + // ===== Section Header [1]: .text (64 bytes at 0xE0) ===== + 0x01, 0x00, 0x00, 0x00, // sh_name=1 (index into .shstrtab: ".text") + 0x01, 0x00, 0x00, 0x00, // sh_type=SHT_PROGBITS(1) + 0x06, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // sh_flags=SHF_ALLOC|SHF_EXECINSTR(6) + 0x78, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // sh_addr=0x78 + 0x78, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // sh_offset=0x78 + 0x10, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // sh_size=16 + 0x00, 0x00, 0x00, 0x00, // sh_link=0 + 0x00, 0x00, 0x00, 0x00, // sh_info=0 + 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // sh_addralign=8 + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // sh_entsize=0 + // ===== Section Header [2]: .shstrtab (64 bytes at 0x120) ===== + 0x07, 0x00, 0x00, 0x00, // sh_name=7 (index into .shstrtab: ".shstrtab") + 0x03, 0x00, 0x00, 0x00, // sh_type=SHT_STRTAB(3) + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // sh_flags=0 + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // sh_addr=0 + 0x88, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // sh_offset=0x88 + 0x11, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // sh_size=17 + 0x00, 0x00, 0x00, 0x00, // sh_link=0 + 0x00, 0x00, 0x00, 0x00, // sh_info=0 + 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // sh_addralign=1 + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // sh_entsize=0 +]; + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_noop_program_elf_size() { + assert_eq!(NOOP_PROGRAM_ELF.len(), 352); + } +} diff --git a/crates/core/src/surfnet/svm.rs b/crates/core/src/surfnet/svm.rs index 556c92e4..8cec6643 100644 --- a/crates/core/src/surfnet/svm.rs +++ b/crates/core/src/surfnet/svm.rs @@ -1779,7 +1779,7 @@ impl SurfnetSvm { }; let mut data = bincode::serialize(&programdata_state).unwrap(); - data.extend_from_slice(&include_bytes!("../tests/assets/minimum_program.so").to_vec()); + data.extend_from_slice(crate::surfnet::noop_program::NOOP_PROGRAM_ELF); let lamports = self.inner.minimum_balance_for_rent_exemption(data.len()); Some(( programdata_address, @@ -3830,7 +3830,7 @@ mod tests { ) .unwrap(); - let mut bin = include_bytes!("../tests/assets/minimum_program.so").to_vec(); + let mut bin = crate::surfnet::noop_program::NOOP_PROGRAM_ELF.to_vec(); data.append(&mut bin); // push our binary after the state data let lamports = svm.inner.minimum_balance_for_rent_exemption(data.len()); let default_program_data_account = Account { diff --git a/crates/core/src/tests/assets/minimum_program.so b/crates/core/src/tests/assets/minimum_program.so deleted file mode 100755 index 8d3ef457..00000000 Binary files a/crates/core/src/tests/assets/minimum_program.so and /dev/null differ