Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

svm: improve integration test framework for SIMD83 #3181

Merged
merged 3 commits into from
Oct 17, 2024
Merged
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
12 changes: 12 additions & 0 deletions svm/tests/example-programs/write-to-account/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
[package]
name = "write-to-account"
version = "2.1.0"
edition = "2021"

[dependencies]
solana-program = { path = "../../../../sdk/program", version = "=2.1.0" }

[lib]
crate-type = ["cdylib", "rlib"]

[workspace]
62 changes: 62 additions & 0 deletions svm/tests/example-programs/write-to-account/src/lib.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
use solana_program::{
account_info::{next_account_info, AccountInfo},
entrypoint,
entrypoint::ProgramResult,
incinerator, msg,
program_error::ProgramError,
pubkey::Pubkey,
};

entrypoint!(process_instruction);

fn process_instruction(
_program_id: &Pubkey,
accounts: &[AccountInfo],
data: &[u8],

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does data contains some random number to prevent duplicate txs? This is not the only way but the simplest

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

no check on length - if need that functionality easy to just append random bytes at the end; program has no need to read them

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

no, duplicates arent a concern because all the svm integration tests are structured like "here is a specific list of transactions to execute, heres what the execution results, final account states, and log messages should be"

) -> ProgramResult {
let accounts_iter = &mut accounts.iter();
let target_account_info = next_account_info(accounts_iter)?;
match data[0] {
// print account size
0 => {

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why don't we use enum here with meaningful names for variants?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

like the other example programs that already exist, i just wanted to do the simplest thing possible. theyre only used for one set of tests and arent expected to change, and are trivial 20-40 line single-file programs that arent part of the toplevel monorepo Cargo.toml workspace and arent imported by anything. i have helpers in svm/tests/integration_test.rs tho

msg!(

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do you print here for debug purpose or to imitate reading the data without writing back? In case of the second, I believe msg cost is not negligible and instead I would call std::ptr::read_volatile which prevents compiler to optimize unused code (which doesn't happen with our compiler anyways).

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i use this to check which specific transactions cause accounts to be deallocated or reallocated in a multi-transaction batch. svm integration tests can directly read transaction logs off the execution result

"account size {}",
target_account_info.try_borrow_data()?.len()
);
}
// set account data
1 => {
let mut account_data = target_account_info.try_borrow_mut_data()?;
account_data[0] = 100;
}
// deallocate account
2 => {
let incinerator_info = next_account_info(accounts_iter)?;
if !incinerator::check_id(incinerator_info.key) {
return Err(ProgramError::InvalidAccountData);
}

let mut target_lamports = target_account_info.try_borrow_mut_lamports()?;
let mut incinerator_lamports = incinerator_info.try_borrow_mut_lamports()?;

**incinerator_lamports = incinerator_lamports
.checked_add(**target_lamports)
.ok_or(ProgramError::ArithmeticOverflow)?;

**target_lamports = target_lamports
.checked_sub(**target_lamports)
.ok_or(ProgramError::InsufficientFunds)?;
}
// reallocate account
3 => {
let new_size = usize::from_le_bytes(data[1..9].try_into().unwrap());
Copy link

@KirillLykov KirillLykov Oct 16, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do you avoid using borsh to avoid associated deserialization cost?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

cost isnt a concern, as above its just doing the simplest thing possible

target_account_info.realloc(new_size, false)?;
}
// bad ixn
_ => {
return Err(ProgramError::InvalidArgument);
}
}

Ok(())
}
Binary file not shown.
176 changes: 156 additions & 20 deletions svm/tests/integration_test.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
#![cfg(test)]
#![allow(clippy::arithmetic_side_effects)]

use {
crate::mock_bank::{
Expand All @@ -9,6 +10,7 @@ use {
solana_sdk::{
account::{AccountSharedData, ReadableAccount, WritableAccount},
clock::Slot,
compute_budget::ComputeBudgetInstruction,
feature_set::{self, FeatureSet},
hash::Hash,
instruction::{AccountMeta, Instruction},
Expand All @@ -27,7 +29,7 @@ use {
nonce_info::NonceInfo,
rollback_accounts::RollbackAccounts,
transaction_execution_result::TransactionExecutionDetails,
transaction_processing_result::ProcessedTransaction,
transaction_processing_result::{ProcessedTransaction, TransactionProcessingResult},
transaction_processor::{
ExecutionRecordingConfig, TransactionBatchProcessor, TransactionProcessingConfig,
TransactionProcessingEnvironment,
Expand All @@ -49,7 +51,7 @@ const LAST_BLOCKHASH: Hash = Hash::new_from_array([7; 32]); // Arbitrary constan
pub type AccountsMap = HashMap<Pubkey, AccountSharedData>;

// container for a transaction batch and all data needed to run and verify it against svm
#[derive(Debug, Default)]
#[derive(Clone, Debug, Default)]
pub struct SvmTestEntry {
// features are disabled by default; these will be enabled
pub enabled_features: Vec<Pubkey>,
Expand Down Expand Up @@ -156,12 +158,14 @@ impl SvmTestEntry {
mut nonce_info: NonceInfo,
status: ExecutionStatus,
) {
nonce_info
.try_advance_nonce(
DurableNonce::from_blockhash(&LAST_BLOCKHASH),
LAMPORTS_PER_SIGNATURE,
)
.unwrap();
if status != ExecutionStatus::Discarded {
nonce_info
.try_advance_nonce(
DurableNonce::from_blockhash(&LAST_BLOCKHASH),
LAMPORTS_PER_SIGNATURE,
)
.unwrap();
}

self.transaction_batch.push(TransactionBatchItem {
transaction,
Expand Down Expand Up @@ -321,6 +325,22 @@ impl ExecutionStatus {
}
}

impl From<&TransactionProcessingResult> for ExecutionStatus {
fn from(processing_result: &TransactionProcessingResult) -> Self {
match processing_result {
Ok(ProcessedTransaction::Executed(executed_transaction)) => {
if executed_transaction.execution_details.status.is_ok() {
ExecutionStatus::Succeeded
} else {
ExecutionStatus::ExecutedFailed
}
}
Ok(ProcessedTransaction::FeesOnly(_)) => ExecutionStatus::ProcessedFailed,
Err(_) => ExecutionStatus::Discarded,
}
}
}

#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub enum ReturnDataAssert {
Some(TransactionReturnData),
Expand Down Expand Up @@ -666,7 +686,11 @@ fn simple_nonce(enable_fee_only_transactions: bool, fee_paying_nonce: bool) -> V
// * true/false: normal nonce account used to pay fees with rent minimum plus 1sol
// * false/true: normal nonce account with rent minimum, fee payer doesnt exist
// * true/true: same account for both which does not exist
let mk_nonce_transaction = |test_entry: &mut SvmTestEntry, program_id, fake_fee_payer: bool| {
// we also provide a side door to bring a fee-paying nonce account below rent-exemption
let mk_nonce_transaction = |test_entry: &mut SvmTestEntry,
program_id,
fake_fee_payer: bool,
rent_paying_nonce: bool| {
let fee_payer_keypair = Keypair::new();
let fee_payer = fee_payer_keypair.pubkey();
let nonce_pubkey = if fee_paying_nonce {
Expand All @@ -682,8 +706,12 @@ fn simple_nonce(enable_fee_only_transactions: bool, fee_paying_nonce: bool) -> V
let mut fee_payer_data = AccountSharedData::default();
fee_payer_data.set_lamports(LAMPORTS_PER_SOL);
test_entry.add_initial_account(fee_payer, &fee_payer_data);
} else if rent_paying_nonce {
assert!(fee_paying_nonce);

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

not sure I understand the connection between fee paying and rent paying for the nonce here.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

a nonce account used as a transaction fee-payer that would be brought one lamport below rent exemption if it was allowed to pay for the transaction

Copy link
Member Author

@2501babe 2501babe Oct 16, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

oh you mean the assert, its just to make sure the annoyingly complex lambda wasnt misused. if you wanted a rent-paying non-fee-paying nonce you wouldnt add LAMPORTS_PER_SIGNATURE, or else youd still have a rent-exempt nonce, and might write a test that doesnt test what it means to test. but no tests need an account like that so i didnt add a branch for it

nonce_balance += LAMPORTS_PER_SIGNATURE;
nonce_balance -= 1;
} else if fee_paying_nonce {
nonce_balance = nonce_balance.saturating_add(LAMPORTS_PER_SOL);
nonce_balance += LAMPORTS_PER_SOL;
}

let nonce_initial_hash = DurableNonce::from_blockhash(&Hash::new_unique());
Expand Down Expand Up @@ -716,10 +744,11 @@ fn simple_nonce(enable_fee_only_transactions: bool, fee_paying_nonce: bool) -> V
(transaction, fee_payer, nonce_info)
};

// successful nonce transaction, regardless of features
// 0: successful nonce transaction, regardless of features
{
let (transaction, fee_payer, mut nonce_info) =
mk_nonce_transaction(&mut test_entry, real_program_id, false);
mk_nonce_transaction(&mut test_entry, real_program_id, false, false);

test_entry.push_nonce_transaction(transaction, nonce_info.clone());

test_entry.decrease_expected_lamports(&fee_payer, LAMPORTS_PER_SIGNATURE);
Expand All @@ -739,10 +768,10 @@ fn simple_nonce(enable_fee_only_transactions: bool, fee_paying_nonce: bool) -> V
.copy_from_slice(nonce_info.account().data());
}

// non-executing nonce transaction (fee payer doesnt exist) regardless of features
// 1: non-executing nonce transaction (fee payer doesnt exist) regardless of features
{
let (transaction, _fee_payer, nonce_info) =
mk_nonce_transaction(&mut test_entry, real_program_id, true);
mk_nonce_transaction(&mut test_entry, real_program_id, true, false);

test_entry
.final_accounts
Expand All @@ -756,10 +785,11 @@ fn simple_nonce(enable_fee_only_transactions: bool, fee_paying_nonce: bool) -> V
);
}

// failing nonce transaction (bad system instruction) regardless of features
// 2: failing nonce transaction (bad system instruction) regardless of features
{
let (transaction, fee_payer, mut nonce_info) =
mk_nonce_transaction(&mut test_entry, system_program::id(), false);
mk_nonce_transaction(&mut test_entry, system_program::id(), false, false);

test_entry.push_nonce_transaction_with_status(
transaction,
nonce_info.clone(),
Expand All @@ -783,11 +813,10 @@ fn simple_nonce(enable_fee_only_transactions: bool, fee_paying_nonce: bool) -> V
.copy_from_slice(nonce_info.account().data());
}

// and this (program doesnt exist) will be a non-executing transaction without the feature
// or a fee-only transaction with it. which is identical to failed *except* rent is not updated
// 3: processable non-executable nonce transaction with fee-only enabled, otherwise discarded
{
let (transaction, fee_payer, mut nonce_info) =
mk_nonce_transaction(&mut test_entry, Pubkey::new_unique(), false);
mk_nonce_transaction(&mut test_entry, Pubkey::new_unique(), false, false);

if enable_fee_only_transactions {
test_entry.push_nonce_transaction_with_status(
Expand Down Expand Up @@ -841,9 +870,102 @@ fn simple_nonce(enable_fee_only_transactions: bool, fee_paying_nonce: bool) -> V
}
}

// 4: safety check that nonce fee-payers are required to be rent-exempt (blockhash fee-payers may be below rent-exemption)
// if this situation is ever allowed in the future, the nonce account MUST be hidden for fee-only transactions
// as an aside, nonce accounts closed by WithdrawNonceAccount are safe because they are ordinary executed transactions
// we also dont care whether a non-fee nonce (or any account) pays rent because rent is charged on executed transactions
if fee_paying_nonce {
let (transaction, _, nonce_info) =
mk_nonce_transaction(&mut test_entry, real_program_id, false, true);

test_entry
.final_accounts
.get_mut(nonce_info.address())
.unwrap()
.set_rent_epoch(0);

test_entry.push_nonce_transaction_with_status(
transaction,
nonce_info.clone(),
ExecutionStatus::Discarded,
);
}

// 5: rent-paying nonce fee-payers are also not charged for fee-only transactions
if enable_fee_only_transactions && fee_paying_nonce {
Comment on lines +894 to +895

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this seems exactly the same as above, or am I missing something?

Copy link
Member Author

@2501babe 2501babe Oct 16, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

maybe im overly paranoid but case 4 uses a real program id, so account loading can succeed, but the transaction is discarded because charging for it would make the valid nonce fee-payer become invalid. case 5 uses a fake program id, which aborts account loading, so we test that with fee-only transactions enabled there isnt any kind of short-circuit code path that would cause it to pay fees

i wanted to be very thorough about this because there are a number of edge cases to cover if a) a regular fee-payer has to pay rent, and b) nonce accounts are mutated in-batch, so we want to be absolutely sure the overlap of those two things is impossible

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

thanks. I just didn't notice the difference in 2nd arge of mk_nonce_transaction

let (transaction, _, nonce_info) =
mk_nonce_transaction(&mut test_entry, Pubkey::new_unique(), false, true);

test_entry
.final_accounts
.get_mut(nonce_info.address())
.unwrap()
.set_rent_epoch(0);

test_entry.push_nonce_transaction_with_status(
transaction,
nonce_info.clone(),
ExecutionStatus::Discarded,
);
}

vec![test_entry]
}

#[allow(unused)]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum WriteProgramInstruction {
Print,
Set,
Dealloc,
Realloc(usize),
}
impl WriteProgramInstruction {
fn _create_transaction(
self,
program_id: Pubkey,
fee_payer: &Keypair,
target: Pubkey,
clamp_data_size: Option<u32>,
) -> Transaction {
let (instruction_data, account_metas) = match self {
Self::Print => (vec![0], vec![AccountMeta::new_readonly(target, false)]),
Self::Set => (vec![1], vec![AccountMeta::new(target, false)]),
Self::Dealloc => (
vec![2],
vec![
AccountMeta::new(target, false),
AccountMeta::new(solana_sdk::incinerator::id(), false),
],
),
Self::Realloc(new_size) => {
let mut instruction_data = vec![3];
instruction_data.extend_from_slice(&new_size.to_le_bytes());
(instruction_data, vec![AccountMeta::new(target, false)])
}
};

let mut instructions = vec![];

if let Some(size) = clamp_data_size {
instructions.push(ComputeBudgetInstruction::set_loaded_accounts_data_size_limit(size));
}

instructions.push(Instruction::new_with_bytes(
program_id,
&instruction_data,
account_metas,
));

Transaction::new_signed_with_payer(
&instructions,
Some(&fee_payer.pubkey()),
&[fee_payer],
Hash::default(),
)
}
}

#[test_case(program_medley())]
#[test_case(simple_transfer(false))]
#[test_case(simple_transfer(true))]
Expand Down Expand Up @@ -922,7 +1044,7 @@ fn execute_test_entry(test_entry: SvmTestEntry) {
);

// build a hashmap of final account states incrementally, starting with all initial states, updating to all final states
// NOTE with SIMD-83 an account may appear multiple times in the same batch
// with SIMD83, an account might change multiple times in the same batch, but it might not exist on all transactions
let mut final_accounts_actual = test_entry.initial_accounts.clone();

for (index, processed_transaction) in batch_output.processing_results.iter().enumerate() {
Expand Down Expand Up @@ -957,6 +1079,20 @@ fn execute_test_entry(test_entry: SvmTestEntry) {
}
}

// first assert all transaction states together, it makes test-driven development much less of a headache
let (expected_statuses, actual_statuses): (Vec<_>, Vec<_>) = batch_output
.processing_results
.iter()
.zip(test_entry.asserts())
.map(|(processing_result, test_item_assert)| {
(
ExecutionStatus::from(processing_result),
test_item_assert.status,
)
})
.unzip();
assert_eq!(expected_statuses, actual_statuses);

// check that all the account states we care about are present and correct
for (pubkey, expected_account_data) in test_entry.final_accounts.iter() {
let actual_account_data = final_accounts_actual.get(pubkey);
Expand Down
Loading
Loading