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
204 changes: 203 additions & 1 deletion crates/core/src/rpc/surfnet_cheatcodes.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<u8> = (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."
);
}
}
28 changes: 26 additions & 2 deletions crates/core/src/surfnet/locker.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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(())
}

Expand Down Expand Up @@ -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();
Expand Down
1 change: 1 addition & 0 deletions crates/core/src/surfnet/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ use crate::{
};

pub mod locker;
pub mod noop_program;
pub mod remote;
pub mod surfnet_lite_svm;
pub mod svm;
Expand Down
99 changes: 99 additions & 0 deletions crates/core/src/surfnet/noop_program.rs
Original file line number Diff line number Diff line change
@@ -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);
}
}
4 changes: 2 additions & 2 deletions crates/core/src/surfnet/svm.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 {
Expand Down
Binary file removed crates/core/src/tests/assets/minimum_program.so
Binary file not shown.
Loading