diff --git a/Cargo.lock b/Cargo.lock index d451bf0a..49afe513 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3099,9 +3099,9 @@ dependencies = [ [[package]] name = "solana-clock" -version = "3.0.1" +version = "3.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "95cf11109c3b6115cc510f1e31f06fdd52f504271bc24ef5f1249fbbcae5f9f3" +checksum = "5ea35d8f69b67daddb921a9da7f78ca591b533cf5e98833cd9ae62fdc2e4652c" dependencies = [ "serde", "serde_derive", diff --git a/crates/litesvm/src/lib.rs b/crates/litesvm/src/lib.rs index f28d8737..d9c05fbc 100644 --- a/crates/litesvm/src/lib.rs +++ b/crates/litesvm/src/lib.rs @@ -364,7 +364,8 @@ use { }, solana_rent::Rent, solana_sdk_ids::{ - bpf_loader, bpf_loader_deprecated, bpf_loader_upgradeable, native_loader, system_program, + bpf_loader, bpf_loader_deprecated, bpf_loader_upgradeable, config as config_program, + native_loader, system_program, }, solana_signature::Signature, solana_signer::Signer, @@ -582,7 +583,35 @@ impl LiteSVM { latest_blockhash, )])); self.set_sysvar(&SlotHistory::default()); - self.set_sysvar(&StakeHistory::default()); + + // StakeHistory::size_of() is hard-coded to 16 KiB (512 max entries). Using set_sysvar + // would allocate that padded buffer, and sol_get_sysvar reads beyond the actual data + // would return zeros. The Stake BPF program asserts entry_epoch == target_epoch after + // each partial read, so those zero bytes trigger a panic at epoch >= 1. Serialize only + // the actual data so reads beyond the end return an error instead. + { + let data = bincode::serialize(&StakeHistory::default()).unwrap(); + let mut account = AccountSharedData::new(1, data.len(), &solana_sdk_ids::sysvar::id()); + account.data_as_mut_slice().copy_from_slice(&data); + self.accounts + .add_account(StakeHistory::id(), account) + .unwrap(); + } + + // Initialize the deprecated StakeConfig account so it is available to programs + // that still pass it as a transaction account (e.g. older DelegateStake callers). + // Format: ConfigKeys header (8-byte u64 key count = 0) followed by + // bincode-serialised Config::default(). + #[allow(deprecated)] + { + use solana_stake_interface::config::Config; + let mut data = bincode::serialize(&0u64).unwrap(); // 0 authorized keys + data.extend(bincode::serialize(&Config::default()).unwrap()); + let mut account = AccountSharedData::new(1, data.len(), &config_program::id()); + account.data_as_mut_slice().copy_from_slice(&data); + self.accounts + .add_account_no_checks(solana_sdk_ids::stake::config::id(), account); + } } /// Includes the default sysvars. diff --git a/crates/litesvm/src/programs/elf/core_bpf_stake-5.0.0.so b/crates/litesvm/src/programs/elf/core_bpf_stake-5.0.0.so new file mode 100644 index 00000000..53e422b6 Binary files /dev/null and b/crates/litesvm/src/programs/elf/core_bpf_stake-5.0.0.so differ diff --git a/crates/litesvm/src/programs/mod.rs b/crates/litesvm/src/programs/mod.rs index 57367780..685b032b 100644 --- a/crates/litesvm/src/programs/mod.rs +++ b/crates/litesvm/src/programs/mod.rs @@ -61,7 +61,7 @@ pub fn load_default_programs(svm: &mut LiteSVM) { .unwrap(); svm.add_program_preverified( stake::ID, - include_bytes!("elf/core_bpf_stake-1.0.1.so"), + include_bytes!("elf/core_bpf_stake-5.0.0.so"), &bpf_loader_upgradeable::id(), ) .unwrap(); diff --git a/crates/litesvm/tests/stake_program.rs b/crates/litesvm/tests/stake_program.rs index b25bc5d5..8a196f65 100644 --- a/crates/litesvm/tests/stake_program.rs +++ b/crates/litesvm/tests/stake_program.rs @@ -23,6 +23,7 @@ use { solana_transaction::Transaction, solana_transaction_error::TransactionError, solana_vote_interface::{ + authorized_voters::AuthorizedVoters, instruction as vote_instruction, state::{VoteInit, VoteStateV4, VoteStateVersions}, }, @@ -712,7 +713,6 @@ fn test_authorize() { } #[test] -#[ignore] fn test_stake_delegate() { let mut svm = LiteSVM::new(); let accounts = Accounts::default(); @@ -841,3 +841,91 @@ fn test_stake_delegate() { process_instruction(&mut svm, &instruction, &vec![&staker_keypair], &payer).unwrap_err(); assert_eq!(e, ProgramError::InvalidAccountData); } + +/// Regression test for https://github.com/solana-foundation/surfpool/pull/605. +/// +/// Three bugs existed in LiteSVM that made it unusable with mainnet-forked state: +/// +/// 1. The bundled stake ELF (v1.0.1) was compiled against solana-vote-interface v2.x which only +/// knew VoteStateVersions discriminants 0–2. Delegating to a V4 vote account (discriminant 3, +/// common on mainnet) returned InvalidAccountData. +/// +/// 2. StakeHistory was initialised from `size_of()` (16 KiB of zeros). The Stake BPF program +/// uses `sol_get_sysvar` with byte offsets to read individual entries; the oversized zero- +/// padded account made those reads succeed but return zeros, triggering an +/// `assert_eq!(entry_epoch, target_epoch)` panic inside the program at epoch >= 2. +/// +/// 3. The StakeConfig account was absent, so DelegateStake instructions that pass it as an +/// account would fail with AccountNotFound. +#[test] +fn test_stake_surfpool_605() { + let mut svm = LiteSVM::new(); + let payer = Keypair::new(); + svm.airdrop(&payer.pubkey(), 10_000_000_000).unwrap(); + + // Bug 3: StakeConfig must be present at the well-known address after LiteSVM::new(). + #[allow(deprecated)] + let stake_config_id = solana_sdk_ids::stake::config::id(); + assert!( + svm.get_account(&stake_config_id).is_some(), + "StakeConfig account not found — was it removed from set_sysvars()?" + ); + + // Build a V4 vote account directly, bypassing the vote program. + // This simulates the way surfpool (and similar fork-from-mainnet tools) inject live accounts: + // they serialise the RPC state straight into LiteSVM without re-creating it via instructions. + let voter = Keypair::new(); + let vote_account_address = Address::new_unique(); + let rent = svm.get_sysvar::(); + let vote_state = VoteStateV4 { + node_pubkey: Keypair::new().pubkey(), + authorized_withdrawer: Keypair::new().pubkey(), + authorized_voters: AuthorizedVoters::new(0, voter.pubkey()), + ..VoteStateV4::default() + }; + let mut vote_account = Account { + lamports: rent.minimum_balance(VoteStateV4::size_of()), + data: vec![0u8; VoteStateV4::size_of()], + owner: solana_sdk_ids::vote::id(), + executable: false, + rent_epoch: u64::MAX, + }; + to(&VoteStateVersions::V4(Box::new(vote_state)), &mut vote_account).unwrap(); + svm.set_account(vote_account_address, vote_account).unwrap(); + + // Bug 1: DelegateStake to the V4 vote account. + // The old stake ELF (v1.0.1) returned InvalidAccountData for V4 vote accounts. + let staker = Keypair::new(); + let withdrawer = Keypair::new(); + let minimum_delegation = get_minimum_delegation(&mut svm, &payer); + let stake = create_independent_stake_account( + &mut svm, + &Authorized { + staker: staker.pubkey(), + withdrawer: withdrawer.pubkey(), + }, + minimum_delegation * 2, + &payer, + ); + process_instruction( + &mut svm, + &ixn::delegate_stake(&stake, &staker.pubkey(), &vote_account_address), + &[&staker], + &payer, + ) + .unwrap(); + + // Bug 2: Perform a stake operation after epoch 2. + // The old 16 KiB zero-padded StakeHistory caused the Stake BPF program to panic when it read + // an entry via sol_get_sysvar and the zero bytes made the epoch-index assertion fire. + advance_epoch(&mut svm); + advance_epoch(&mut svm); + + process_instruction( + &mut svm, + &ixn::deactivate_stake(&stake, &staker.pubkey()), + &[&staker], + &payer, + ) + .unwrap(); +}