diff --git a/.github/workflows/cargo-test.yml b/.github/workflows/cargo-test.yml new file mode 100644 index 0000000..74fc83a --- /dev/null +++ b/.github/workflows/cargo-test.yml @@ -0,0 +1,38 @@ +name: Cargo tests + +on: + push: + branches: + - main + pull_request: + +permissions: + contents: read + +jobs: + test: + name: Run cargo test + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - uses: dtolnay/rust-toolchain@stable + + - uses: Swatinem/rust-cache@v2 + with: + cache-on-failure: true + + - name: Show rustc and cargo versions + run: | + rustc -Vv + cargo -Vv + + - name: Install protoc (protobuf-compiler) + run: | + sudo apt-get update + sudo apt-get install -y protobuf-compiler + protoc --version + + - name: Run tests + run: cargo test --all --locked diff --git a/Cargo.lock b/Cargo.lock index e8721d5..492ad96 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2618,8 +2618,7 @@ dependencies = [ [[package]] name = "solana-account" version = "2.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0f949fe4edaeaea78c844023bfc1c898e0b1f5a100f8a8d2d0f85d0a7b090258" +source = "git+https://github.com/magicblock-labs/solana-account.git?rev=a892d2#a892d2aff374f260535a4499e00bbe5752a2d29c" dependencies = [ "bincode", "qualifier_attr", @@ -2631,7 +2630,6 @@ dependencies = [ "solana-frozen-abi", "solana-frozen-abi-macro", "solana-instruction", - "solana-logger", "solana-pubkey", "solana-sdk-ids", "solana-sysvar", diff --git a/Cargo.toml b/Cargo.toml index ae44125..13786cd 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -25,10 +25,10 @@ solana-compute-budget = { version = "=2.2.1" } solana-compute-budget-instruction = { version = "=2.2.1" } solana-fee-structure = { version = "=2.2.1" } solana-frozen-abi = { version = "=2.2.1", optional = true, features = [ - "frozen-abi", + "frozen-abi", ] } solana-frozen-abi-macro = { version = "=2.2.1", optional = true, features = [ - "frozen-abi", + "frozen-abi", ] } solana-hash = { version = "=2.2.1" } solana-instruction = { version = "=2.2.1", features = ["std"] } @@ -65,15 +65,18 @@ bincode = { version = "1.3.3" } ed25519-dalek = "=1.0.1" lazy_static = "1.5.0" libsecp256k1 = { version = "0.6.0", default-features = false, features = [ - "std", - "static-context", + "std", + "static-context", ] } openssl = "0.10" prost = "0.11.9" rand0-7 = { package = "rand", version = "0.7" } shuttle = "0.7.1" +solana-account = { version = "=2.2.1", features = ["dev-context-only-utils"] } solana-clock = { version = "=2.2.1" } -solana-compute-budget = { version = "=2.2.1", features = ["dev-context-only-utils"] } +solana-compute-budget = { version = "=2.2.1", features = [ + "dev-context-only-utils", +] } solana-compute-budget-interface = { version = "=2.2.1" } solana-compute-budget-program = { version = "=2.2.1" } solana-ed25519-program = { version = "=2.2.1" } @@ -87,18 +90,22 @@ solana-rent = { version = "=2.2.1" } solana-sbpf = "0.10" solana-sdk = { version = "=2.2.1", features = ["dev-context-only-utils"] } solana-secp256k1-program = { version = "=2.2.1" } -solana-secp256r1-program = { version = "=2.2.1", features = ["openssl-vendored"] } +solana-secp256r1-program = { version = "=2.2.1", features = [ + "openssl-vendored", +] } solana-signature = { version = "=2.2.1" } solana-signer = { version = "=2.2.1" } # See order-crates-for-publishing.py for using this unusual `path = "."` -solana-svm = { path = ".", features = ["dev-context-only-utils"] } solana-svm-conformance = { version = "=2.2.1" } solana-system-program = { version = "=2.2.1" } solana-system-transaction = { version = "=2.2.1" } solana-sysvar = { version = "=2.2.1" } solana-transaction = { version = "=2.2.1" } -solana-transaction-context = { version = "=2.2.1", features = ["dev-context-only-utils" ] } +solana-transaction-context = { version = "=2.2.1", features = [ + "dev-context-only-utils", +] } test-case = "3.3.1" +solana-svm = { path = ".", features = ["dev-context-only-utils"] } [package.metadata.docs.rs] targets = ["x86_64-unknown-linux-gnu"] @@ -106,15 +113,18 @@ targets = ["x86_64-unknown-linux-gnu"] [features] dev-context-only-utils = ["dep:qualifier_attr"] frozen-abi = [ - "dep:solana-frozen-abi", - "dep:solana-frozen-abi-macro", - "solana-compute-budget/frozen-abi", - "solana-program-runtime/frozen-abi", - "solana-sdk/frozen-abi", + "dep:solana-frozen-abi", + "dep:solana-frozen-abi-macro", + "solana-compute-budget/frozen-abi", + "solana-program-runtime/frozen-abi", + "solana-sdk/frozen-abi", ] shuttle-test = [ - "solana-type-overrides/shuttle-test", - "solana-program-runtime/shuttle-test", - "solana-bpf-loader-program/shuttle-test", - "solana-loader-v4-program/shuttle-test", + "solana-type-overrides/shuttle-test", + "solana-program-runtime/shuttle-test", + "solana-bpf-loader-program/shuttle-test", + "solana-loader-v4-program/shuttle-test", ] + +[patch.crates-io] +solana-account = { git = "https://github.com/magicblock-labs/solana-account.git", rev = "a892d2" } diff --git a/src/account_loader.rs b/src/account_loader.rs index eb3a023..6ae25d6 100644 --- a/src/account_loader.rs +++ b/src/account_loader.rs @@ -80,6 +80,7 @@ pub(crate) struct ValidatedTransactionDetails { pub(crate) compute_budget_limits: ComputeBudgetLimits, pub(crate) fee_details: FeeDetails, pub(crate) loaded_fee_payer_account: LoadedTransactionAccount, + pub(crate) fee_payer_address: Pubkey, } #[derive(PartialEq, Eq, Debug, Clone)] @@ -208,21 +209,48 @@ impl<'a, CB: TransactionProcessingCallback> AccountLoader<'a, CB> { &executed_transaction.loaded_transaction.accounts, ); } else { + let fee_payer_address = self.effective_fee_payer_address_for_failed_tx(message); self.update_accounts_for_failed_tx( - message, &executed_transaction.loaded_transaction.rollback_accounts, + &fee_payer_address, ); } } - pub(crate) fn update_accounts_for_failed_tx( + /// If the fee payer is delegated, use it. Otherwise, load the escrow + /// account if delegated + pub(crate) fn effective_fee_payer_address_for_failed_tx( &mut self, message: &impl SVMMessage, + ) -> Pubkey { + use crate::escrow::ephemeral_balance_pda_from_payer; + let fee_payer_address = *message.fee_payer(); + let mut is_delegated = |addr: &Pubkey| -> bool { + self.load_account(addr, true) + .map(|acc| acc.account.delegated()) + .unwrap_or(false) + }; + // If the fee payer is delegated, use it + if is_delegated(&fee_payer_address) { + return fee_payer_address; + } + // Otherwise, load the escrow account if delegated + let escrow_address = ephemeral_balance_pda_from_payer(&fee_payer_address); + if is_delegated(&escrow_address) { + return escrow_address; + } + fee_payer_address + } + + pub(crate) fn update_accounts_for_failed_tx( + &mut self, rollback_accounts: &RollbackAccounts, + fee_payer_address: &Pubkey, ) { - let fee_payer_address = message.fee_payer(); match rollback_accounts { - RollbackAccounts::FeePayerOnly { fee_payer_account } => { + RollbackAccounts::FeePayerOnly { + fee_payer_account, .. + } => { self.account_cache .insert(*fee_payer_address, fee_payer_account.clone()); } @@ -233,6 +261,7 @@ impl<'a, CB: TransactionProcessingCallback> AccountLoader<'a, CB> { RollbackAccounts::SeparateNonceAndFeePayer { nonce, fee_payer_account, + .. } => { self.account_cache .insert(*nonce.address(), nonce.account().clone()); @@ -360,6 +389,7 @@ pub(crate) fn load_transaction( let load_result = load_transaction_accounts( account_loader, message, + &tx_details.fee_payer_address, tx_details.loaded_fee_payer_account, &tx_details.compute_budget_limits, error_metrics, @@ -399,6 +429,7 @@ struct LoadedTransactionAccounts { fn load_transaction_accounts( account_loader: &mut AccountLoader, message: &impl SVMMessage, + fee_payer_address: &Pubkey, loaded_fee_payer_account: LoadedTransactionAccount, compute_budget_limits: &ComputeBudgetLimits, error_metrics: &mut TransactionErrorMetrics, @@ -434,7 +465,7 @@ fn load_transaction_accounts( // Since the fee payer is always the first account, collect it first. // We can use it directly because it was already loaded during validation. - collect_loaded_account(message.fee_payer(), loaded_fee_payer_account)?; + collect_loaded_account(fee_payer_address, loaded_fee_payer_account)?; // Attempt to load and collect remaining non-fee payer accounts for (account_index, account_key) in account_keys.iter().enumerate().skip(1) { @@ -1043,10 +1074,26 @@ mod tests { Arc::new(FeatureSet::all_enabled()), 0, ); + // Build proper ValidatedTransactionDetails with real fee payer + let fee_payer = *tx.message().fee_payer(); + // In some tests we don't pass actual accounts; default to a zeroed account for fee payer + let fee_payer_account = callbacks + .accounts_map + .get(&fee_payer) + .cloned() + .unwrap_or_else(|| AccountSharedData::default()); + let validation_details = ValidatedTransactionDetails { + fee_payer_address: fee_payer, + loaded_fee_payer_account: LoadedTransactionAccount { + account: fee_payer_account, + ..LoadedTransactionAccount::default() + }, + ..ValidatedTransactionDetails::default() + }; load_transaction( &mut account_loader, &tx, - Ok(ValidatedTransactionDetails::default()), + Ok(validation_details), &mut error_metrics, &RentCollector::default(), ) @@ -1205,8 +1252,11 @@ mod tests { } } - // If payer account has no balance, expected AccountNotFound Error + // If payer account has no balance, expected InsufficientFundsForFee Error // regardless feature gate status, or if payer is nonce account. + // NOTE: solana svm returns AccountNotFound, but since we support not existing (signer) + // accounts as fee payer (when validator fees = 0), we return InsufficientFundsForFee + // instead. { for is_nonce in [true, false] { validate_fee_payer_account( @@ -1214,7 +1264,7 @@ mod tests { is_nonce, payer_init_balance: 0, fee, - expected_result: Err(TransactionError::AccountNotFound), + expected_result: Err(TransactionError::InsufficientFundsForFee), payer_post_balance: 0, }, &rent_collector, @@ -1331,6 +1381,7 @@ mod tests { let result = load_transaction_accounts( &mut account_loader, sanitized_transaction.message(), + &fee_payer_address, LoadedTransactionAccount { loaded_size: fee_payer_account.data().len(), account: fee_payer_account.clone(), @@ -1394,6 +1445,7 @@ mod tests { let result = load_transaction_accounts( &mut account_loader, sanitized_transaction.message(), + &key1.pubkey(), LoadedTransactionAccount { account: fee_payer_account.clone(), ..LoadedTransactionAccount::default() @@ -1454,6 +1506,7 @@ mod tests { let result = load_transaction_accounts( &mut account_loader, sanitized_transaction.message(), + &key1.pubkey(), LoadedTransactionAccount::default(), &ComputeBudgetLimits::default(), &mut error_metrics, @@ -1496,6 +1549,7 @@ mod tests { let result = load_transaction_accounts( &mut account_loader, sanitized_transaction.message(), + &key1.pubkey(), LoadedTransactionAccount::default(), &ComputeBudgetLimits::default(), &mut error_metrics, @@ -1549,6 +1603,7 @@ mod tests { let result = load_transaction_accounts( &mut account_loader, sanitized_transaction.message(), + &key2.pubkey(), LoadedTransactionAccount { account: fee_payer_account.clone(), ..LoadedTransactionAccount::default() @@ -1613,6 +1668,7 @@ mod tests { let result = load_transaction_accounts( &mut account_loader, sanitized_transaction.message(), + &key2.pubkey(), LoadedTransactionAccount::default(), &ComputeBudgetLimits::default(), &mut error_metrics, @@ -1665,6 +1721,7 @@ mod tests { let result = load_transaction_accounts( &mut account_loader, sanitized_transaction.message(), + &key2.pubkey(), LoadedTransactionAccount::default(), &ComputeBudgetLimits::default(), &mut error_metrics, @@ -1725,6 +1782,7 @@ mod tests { let result = load_transaction_accounts( &mut account_loader, sanitized_transaction.message(), + &key2.pubkey(), LoadedTransactionAccount { account: fee_payer_account.clone(), ..LoadedTransactionAccount::default() @@ -1808,6 +1866,7 @@ mod tests { let result = load_transaction_accounts( &mut account_loader, sanitized_transaction.message(), + &key2.pubkey(), LoadedTransactionAccount { account: fee_payer_account.clone(), ..LoadedTransactionAccount::default() @@ -1960,6 +2019,7 @@ mod tests { account: fee_payer_account, ..LoadedTransactionAccount::default() }, + fee_payer_address: key2.pubkey(), ..ValidatedTransactionDetails::default() }); @@ -2321,6 +2381,7 @@ mod tests { let loaded_transaction_accounts = load_transaction_accounts( &mut account_loader, &transaction, + &fee_payer, LoadedTransactionAccount { account: fee_payer_account.clone(), loaded_size: fee_payer_size as usize, diff --git a/src/escrow.rs b/src/escrow.rs new file mode 100644 index 0000000..f51e7bd --- /dev/null +++ b/src/escrow.rs @@ -0,0 +1,10 @@ +use solana_pubkey::{pubkey, Pubkey}; + +// Delegation program ID used for deriving escrow-related PDAs +pub const DELEGATION_PROGRAM_ID: Pubkey = pubkey!("DELeGGvXpWV2fqJUhqcF5ZSYMS4JTLjteaAMARRSaeSh"); + +/// Derive the ephemeral balance PDA for a given payer and index, using the +/// delegation program ID. +pub fn ephemeral_balance_pda_from_payer(payer: &Pubkey) -> Pubkey { + Pubkey::find_program_address(&[b"balance", payer.as_ref(), &[0]], &DELEGATION_PROGRAM_ID).0 +} diff --git a/src/lib.rs b/src/lib.rs index 7a42e3c..9ba678e 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -3,6 +3,7 @@ pub mod account_loader; pub mod account_overrides; +pub mod escrow; pub mod message_processor; pub mod nonce_info; pub mod program_loader; diff --git a/src/rollback_accounts.rs b/src/rollback_accounts.rs index e25f507..d425c35 100644 --- a/src/rollback_accounts.rs +++ b/src/rollback_accounts.rs @@ -88,17 +88,28 @@ impl RollbackAccounts { /// cost of transaction processing in the cost model. pub fn data_size(&self) -> usize { match self { - Self::FeePayerOnly { fee_payer_account } => fee_payer_account.data().len(), + Self::FeePayerOnly { + fee_payer_account, .. + } => fee_payer_account.data().len(), Self::SameNonceAndFeePayer { nonce } => nonce.account().data().len(), Self::SeparateNonceAndFeePayer { nonce, fee_payer_account, + .. } => fee_payer_account .data() .len() .saturating_add(nonce.account().data().len()), } } + + /// Return the effective fee-payer address that should be written back for fees-only results. + pub fn effective_fee_payer_address(&self) -> Pubkey { + match self { + Self::SameNonceAndFeePayer { nonce } => *nonce.address(), + _ => Pubkey::default(), + } + } } #[cfg(test)] diff --git a/src/transaction_processor.rs b/src/transaction_processor.rs index 6568f1a..c3dcd55 100644 --- a/src/transaction_processor.rs +++ b/src/transaction_processor.rs @@ -1,4 +1,5 @@ use crate::account_loader::{AccountsBalances, LoadedTransactionAccount}; +use crate::escrow::ephemeral_balance_pda_from_payer; #[cfg(feature = "dev-context-only-utils")] use qualifier_attr::{field_qualifiers, qualifiers}; use { @@ -446,8 +447,14 @@ impl TransactionBatchProcessor { TransactionLoadResult::FeesOnly(fees_only_tx) => { if enable_transaction_loading_failure_fees { // Update loaded accounts cache with nonce and fee-payer - account_loader - .update_accounts_for_failed_tx(tx, &fees_only_tx.rollback_accounts); + { + let fee_payer_address = + account_loader.effective_fee_payer_address_for_failed_tx(tx); + account_loader.update_accounts_for_failed_tx( + &fees_only_tx.rollback_accounts, + &fee_payer_address, + ); + } Ok(ProcessedTransaction::FeesOnly(Box::new(fees_only_tx))) } else { @@ -581,7 +588,7 @@ impl TransactionBatchProcessor { // Loads transaction fee payer, collects rent if necessary, then calculates // transaction fees, and deducts them from the fee payer balance. If the - // account is not found or has insufficient funds, an error is returned. + // account is not found (and fee_lamports_per_signature > 0) or has insufficient funds, an error is returned. fn validate_transaction_fee_payer( account_loader: &mut AccountLoader, message: &impl SVMMessage, @@ -599,29 +606,44 @@ impl TransactionBatchProcessor { error_counters.invalid_compute_budget += 1; })?; - let fee_payer_address = message.fee_payer(); + let mut fee_payer_address = *message.fee_payer(); - let mut loaded_fee_payer = match account_loader.load_account(fee_payer_address, true) { - Some(account) => account, - None => { - if fee_lamports_per_signature > 0 { - error_counters.account_not_found += 1; - return Err(TransactionError::AccountNotFound); - } + let initial_loaded = account_loader.load_account(&fee_payer_address, true); - LoadedTransactionAccount { - account: AccountSharedData::default(), - loaded_size: 0, - rent_collected: 0, - } - } + let is_delegated_or_privileged = + |acc: &LoadedTransactionAccount| acc.account.delegated() || acc.account.privileged(); + + let mut loaded_fee_payer = if fee_lamports_per_signature == 0 { + // zero-fee: use provided account if any, otherwise an empty default + initial_loaded.unwrap_or_else(|| LoadedTransactionAccount { + account: AccountSharedData::default(), + loaded_size: 0, + rent_collected: 0, + }) + } else { + initial_loaded + .filter(is_delegated_or_privileged) + .or_else(|| { + let escrow_address = ephemeral_balance_pda_from_payer(&fee_payer_address); + account_loader + .load_account(&escrow_address, true) + .filter(is_delegated_or_privileged) + .map(|acc| { + fee_payer_address = escrow_address; + acc + }) + }) + .ok_or_else(|| { + error_counters.invalid_account_for_fee += 1; + TransactionError::InvalidAccountForFee + })? }; let fee_payer_loaded_rent_epoch = loaded_fee_payer.account.rent_epoch(); loaded_fee_payer.rent_collected = collect_rent_from_account( &account_loader.feature_set, rent_collector, - fee_payer_address, + &fee_payer_address, &mut loaded_fee_payer.account, ) .rent_amount; @@ -644,20 +666,26 @@ impl TransactionBatchProcessor { }; let fee_payer_index = 0; + // If the fee payer is privileged, it's exempt from fees. + let fees = if loaded_fee_payer.account.privileged() { + 0 + } else { + fee_details.total_fee() + }; validate_fee_payer( - fee_payer_address, + &fee_payer_address, &mut loaded_fee_payer.account, fee_payer_index, error_counters, rent_collector, - fee_details.total_fee(), + fees, )?; // Capture fee-subtracted fee payer account and next nonce account state // to commit if transaction execution fails. let rollback_accounts = RollbackAccounts::new( nonce, - *fee_payer_address, + fee_payer_address, loaded_fee_payer.account.clone(), loaded_fee_payer.rent_collected, fee_payer_loaded_rent_epoch, @@ -668,6 +696,7 @@ impl TransactionBatchProcessor { rollback_accounts, compute_budget_limits, loaded_fee_payer_account: loaded_fee_payer, + fee_payer_address, }) } @@ -2203,6 +2232,99 @@ mod tests { so ensure that the starting balance is more than the min balance" ); + let fee_payer_rent_epoch = current_epoch; + let fee_payer_rent_debit = 0; + let mut fee_payer_account = AccountSharedData::new_rent_epoch( + starting_balance, + 0, + &Pubkey::default(), + fee_payer_rent_epoch, + ); + fee_payer_account.set_delegated(true); + let mut mock_accounts = HashMap::new(); + mock_accounts.insert(*fee_payer_address, fee_payer_account.clone()); + let mock_bank = MockBankCallback { + account_shared_data: Arc::new(RwLock::new(mock_accounts)), + ..Default::default() + }; + let mut account_loader = (&mock_bank).into(); + + let mut error_counters = TransactionErrorMetrics::default(); + let result = + TransactionBatchProcessor::::validate_transaction_nonce_and_fee_payer( + &mut account_loader, + &message, + CheckedTransactionDetails::new(None, lamports_per_signature), + &Hash::default(), + FeeStructure::default().lamports_per_signature, + &rent_collector, + &mut error_counters, + &mock_bank, + ); + + let post_validation_fee_payer_account = { + let mut account = fee_payer_account.clone(); + account.set_rent_epoch(RENT_EXEMPT_RENT_EPOCH); + account.set_lamports(0); + account + }; + + assert_eq!( + result, + Ok(ValidatedTransactionDetails { + rollback_accounts: RollbackAccounts::new( + None, // nonce + *fee_payer_address, + post_validation_fee_payer_account.clone(), + fee_payer_rent_debit, + fee_payer_rent_epoch + ), + compute_budget_limits, + fee_details: FeeDetails::new(transaction_fee, priority_fee), + loaded_fee_payer_account: LoadedTransactionAccount { + loaded_size: fee_payer_account.data().len(), + account: post_validation_fee_payer_account, + rent_collected: fee_payer_rent_debit, + }, + fee_payer_address: *fee_payer_address, + }) + ); + } + + #[test] + fn test_validate_transaction_privileged_fee_payer_exact_balance() { + let lamports_per_signature = 5000; + let message = new_unchecked_sanitized_message(Message::new_with_blockhash( + &[ + ComputeBudgetInstruction::set_compute_unit_limit(2000u32), + ComputeBudgetInstruction::set_compute_unit_price(1_000_000_000), + ], + Some(&Pubkey::new_unique()), + &Hash::new_unique(), + )); + let compute_budget_limits = process_compute_budget_instructions( + SVMMessage::program_instructions_iter(&message), + &FeatureSet::default(), + ) + .unwrap(); + let fee_payer_address = message.fee_payer(); + let current_epoch = 42; + let rent_collector = RentCollector { + epoch: current_epoch, + ..RentCollector::default() + }; + let min_balance = rent_collector + .rent + .minimum_balance(nonce::state::State::size()); + let transaction_fee = lamports_per_signature; + let priority_fee = 2_000_000u64; + let starting_balance = transaction_fee + priority_fee; + assert!( + starting_balance > min_balance, + "we're testing that a rent exempt fee payer can be fully drained, \ + so ensure that the starting balance is more than the min balance" + ); + let fee_payer_rent_epoch = current_epoch; let fee_payer_rent_debit = 0; let fee_payer_account = AccountSharedData::new_rent_epoch( @@ -2211,6 +2333,10 @@ mod tests { &Pubkey::default(), fee_payer_rent_epoch, ); + let (_guard, mut borrowed_acc) = + solana_account::test_utils::create_borrowed_account_shared_data(&fee_payer_account, 0); + borrowed_acc.as_borrowed_mut().unwrap().set_privileged(true); + let fee_payer_account = borrowed_acc; let mut mock_accounts = HashMap::new(); mock_accounts.insert(*fee_payer_address, fee_payer_account.clone()); let mock_bank = MockBankCallback { @@ -2256,6 +2382,7 @@ mod tests { account: post_validation_fee_payer_account, rent_collected: fee_payer_rent_debit, }, + fee_payer_address: *fee_payer_address, }) ); } @@ -2279,7 +2406,8 @@ mod tests { let min_balance = rent_collector.rent.minimum_balance(0); let transaction_fee = lamports_per_signature; let starting_balance = min_balance - 1; - let fee_payer_account = AccountSharedData::new(starting_balance, 0, &Pubkey::default()); + let mut fee_payer_account = AccountSharedData::new(starting_balance, 0, &Pubkey::default()); + fee_payer_account.set_delegated(true); let fee_payer_rent_debit = rent_collector .get_rent_due( fee_payer_account.lamports(), @@ -2333,7 +2461,8 @@ mod tests { loaded_size: fee_payer_account.data().len(), account: post_validation_fee_payer_account, rent_collected: fee_payer_rent_debit, - } + }, + fee_payer_address: *fee_payer_address, }) ); } @@ -2359,8 +2488,8 @@ mod tests { &mock_bank, ); - assert_eq!(error_counters.account_not_found.0, 1); - assert_eq!(result, Err(TransactionError::AccountNotFound)); + assert_eq!(error_counters.invalid_account_for_fee.0, 1); + assert_eq!(result, Err(TransactionError::InvalidAccountForFee)); } #[test] @@ -2369,7 +2498,8 @@ mod tests { let message = new_unchecked_sanitized_message(Message::new(&[], Some(&Pubkey::new_unique()))); let fee_payer_address = message.fee_payer(); - let fee_payer_account = AccountSharedData::new(1, 0, &Pubkey::default()); + let mut fee_payer_account = AccountSharedData::new(1, 0, &Pubkey::default()); + fee_payer_account.set_delegated(true); let mut mock_accounts = HashMap::new(); mock_accounts.insert(*fee_payer_address, fee_payer_account.clone()); let mock_bank = MockBankCallback { @@ -2405,7 +2535,8 @@ mod tests { let rent_collector = RentCollector::default(); let min_balance = rent_collector.rent.minimum_balance(0); let starting_balance = min_balance + transaction_fee - 1; - let fee_payer_account = AccountSharedData::new(starting_balance, 0, &Pubkey::default()); + let mut fee_payer_account = AccountSharedData::new(starting_balance, 0, &Pubkey::default()); + fee_payer_account.set_delegated(true); let mut mock_accounts = HashMap::new(); mock_accounts.insert(*fee_payer_address, fee_payer_account.clone()); let mock_bank = MockBankCallback { @@ -2521,7 +2652,7 @@ mod tests { // Sufficient Fees { - let fee_payer_account = AccountSharedData::new_data( + let mut fee_payer_account = AccountSharedData::new_data( min_balance + transaction_fee + priority_fee, &nonce::versions::Versions::new(nonce::state::State::Initialized( nonce::state::Data::new( @@ -2533,6 +2664,7 @@ mod tests { &system_program::id(), ) .unwrap(); + fee_payer_account.set_delegated(true); let mut mock_accounts = HashMap::new(); mock_accounts.insert(*fee_payer_address, fee_payer_account.clone()); @@ -2588,7 +2720,8 @@ mod tests { loaded_size: fee_payer_account.data().len(), account: post_validation_fee_payer_account, rent_collected: 0, - } + }, + fee_payer_address: *fee_payer_address, }) ); } @@ -2624,8 +2757,8 @@ mod tests { &mock_bank, ); - assert_eq!(error_counters.insufficient_funds.0, 1); - assert_eq!(result, Err(TransactionError::InsufficientFundsForFee)); + assert_eq!(error_counters.invalid_account_for_fee.0, 1); + assert_eq!(result, Err(TransactionError::InvalidAccountForFee)); } } @@ -2634,12 +2767,13 @@ mod tests { #[test] fn test_inspect_account_fee_payer() { let fee_payer_address = Pubkey::new_unique(); - let fee_payer_account = AccountSharedData::new_rent_epoch( + let mut fee_payer_account = AccountSharedData::new_rent_epoch( 123_000_000_000, 0, &Pubkey::default(), RENT_EXEMPT_RENT_EPOCH, ); + fee_payer_account.set_delegated(true); let mock_bank = MockBankCallback::default(); mock_bank .account_shared_data diff --git a/tests/integration_test.rs b/tests/integration_test.rs index 3a4892b..c547d8a 100644 --- a/tests/integration_test.rs +++ b/tests/integration_test.rs @@ -165,22 +165,57 @@ impl SvmTestEnvironment<'_> { .enumerate() { if sanitized_transaction.is_writable(index) { + let effective_pubkey = if index == 0 { + // Compute effective fee payer address (may be escrow PDA) + let mut addr = *sanitized_transaction.fee_payer(); + let is_delegated = |key: &Pubkey| { + final_accounts_actual + .get(key) + .map(|a| a.delegated()) + .unwrap_or(false) + }; + if !is_delegated(&addr) { + let escrow = + solana_svm::escrow::ephemeral_balance_pda_from_payer(&addr); + if is_delegated(&escrow) { + addr = escrow; + } + } + addr + } else { + *pubkey + }; update_or_dealloc_account( &mut final_accounts_actual, - *pubkey, + effective_pubkey, account_data.clone(), ); } } } Ok(ProcessedTransaction::FeesOnly(fees_only_transaction)) => { - let fee_payer = sanitized_transaction.fee_payer(); - match fees_only_transaction.rollback_accounts.clone() { - RollbackAccounts::FeePayerOnly { fee_payer_account } => { + RollbackAccounts::FeePayerOnly { + fee_payer_account, .. + } => { + // Compute effective fee payer address (may be escrow PDA) + let mut addr = *sanitized_transaction.fee_payer(); + let is_delegated = |key: &Pubkey| { + final_accounts_actual + .get(key) + .map(|a| a.delegated()) + .unwrap_or(false) + }; + if !is_delegated(&addr) { + let escrow = + solana_svm::escrow::ephemeral_balance_pda_from_payer(&addr); + if is_delegated(&escrow) { + addr = escrow; + } + } update_or_dealloc_account( &mut final_accounts_actual, - *fee_payer, + addr, fee_payer_account, ); } @@ -195,9 +230,24 @@ impl SvmTestEnvironment<'_> { nonce, fee_payer_account, } => { + // Compute effective fee payer address (may be escrow PDA) + let mut addr = *sanitized_transaction.fee_payer(); + let is_delegated = |key: &Pubkey| { + final_accounts_actual + .get(key) + .map(|a| a.delegated()) + .unwrap_or(false) + }; + if !is_delegated(&addr) { + let escrow = + solana_svm::escrow::ephemeral_balance_pda_from_payer(&addr); + if is_delegated(&escrow) { + addr = escrow; + } + } update_or_dealloc_account( &mut final_accounts_actual, - *fee_payer, + addr, fee_payer_account, ); update_or_dealloc_account( @@ -774,10 +824,14 @@ fn simple_transfer(enable_fee_only_transactions: bool) -> Vec { let destination = Pubkey::new_unique(); let mut source_data = AccountSharedData::default(); + source_data.set_delegated(true); let mut destination_data = AccountSharedData::default(); + destination_data.set_delegated(true); + destination_data.set_rent_epoch(u64::MAX); source_data.set_lamports(LAMPORTS_PER_SOL * 10); test_entry.add_initial_account(source, &source_data); + test_entry.add_initial_account(destination, &destination_data); test_entry.push_transaction(system_transaction::transfer( &source_keypair, @@ -786,11 +840,7 @@ fn simple_transfer(enable_fee_only_transactions: bool) -> Vec { Hash::default(), )); - destination_data - .checked_add_lamports(transfer_amount) - .unwrap(); - test_entry.create_expected_account(destination, &destination_data); - + test_entry.increase_expected_lamports(&destination, transfer_amount); test_entry.decrease_expected_lamports(&source, transfer_amount + LAMPORTS_PER_SIGNATURE); } @@ -800,6 +850,7 @@ fn simple_transfer(enable_fee_only_transactions: bool) -> Vec { let source = source_keypair.pubkey(); let mut source_data = AccountSharedData::default(); + source_data.set_delegated(true); source_data.set_lamports(transfer_amount - 1); test_entry.add_initial_account(source, &source_data); @@ -850,6 +901,7 @@ fn simple_transfer(enable_fee_only_transactions: bool) -> Vec { let source = source_keypair.pubkey(); let mut source_data = AccountSharedData::default(); + source_data.set_delegated(true); source_data.set_lamports(transfer_amount * 10); test_entry @@ -920,6 +972,7 @@ fn simple_nonce(enable_fee_only_transactions: bool, fee_paying_nonce: bool) -> V if !fake_fee_payer && !fee_paying_nonce { let mut fee_payer_data = AccountSharedData::default(); fee_payer_data.set_lamports(LAMPORTS_PER_SOL); + fee_payer_data.set_delegated(true); test_entry.add_initial_account(fee_payer, &fee_payer_data); } else if rent_paying_nonce { assert!(fee_paying_nonce); @@ -932,12 +985,13 @@ fn simple_nonce(enable_fee_only_transactions: bool, fee_paying_nonce: bool) -> V let nonce_initial_hash = DurableNonce::from_blockhash(&Hash::new_unique()); let nonce_data = nonce::state::Data::new(fee_payer, nonce_initial_hash, LAMPORTS_PER_SIGNATURE); - let nonce_account = AccountSharedData::new_data( + let mut nonce_account = AccountSharedData::new_data( nonce_balance, &nonce::state::Versions::new(nonce::State::Initialized(nonce_data.clone())), &system_program::id(), ) .unwrap(); + nonce_account.set_delegated(true); let nonce_info = NonceInfo::new(nonce_pubkey, nonce_account.clone()); if !(fake_fee_payer && fee_paying_nonce) { @@ -1142,13 +1196,20 @@ fn simd83_intrabatch_account_reuse(enable_fee_only_transactions: bool) -> Vec Vec Vec Vec Vec Vec Vec Vec Vec Vec { let mut fee_payer_data = AccountSharedData::default(); fee_payer_data.set_lamports(LAMPORTS_PER_SOL); + fee_payer_data.set_delegated(true); test_entry.add_initial_account(fee_payer, &fee_payer_data); let target = Pubkey::new_unique(); @@ -2046,6 +2070,7 @@ fn simd83_fee_payer_deallocate(enable_fee_only_transactions: bool) -> Vec Vec Vec Vec Vec Vec does not exist + assert!(test_entry + .final_accounts + .get_mut(&fee_payer.pubkey()) + .is_none()); + // Assert that the escrow account will be charged for the transaction fee + test_entry.decrease_expected_lamports(&escrow_pubkey, LAMPORTS_PER_SIGNATURE); + + // Execute + let env = SvmTestEnvironment::create(test_entry); + env.execute(); +} + +#[test] +fn escrow_fee_rejected_when_not_delegated() { + // Create a fee payer that does not have an on-ledger account + let fee_payer = Keypair::new(); + + // Derive the escrow PDA for this fee payer and create it with some lamports + let escrow_pubkey = solana_svm::escrow::ephemeral_balance_pda_from_payer(&fee_payer.pubkey()); + let mut escrow_account = AccountSharedData::default(); + // Not delegated: should not be allowed to pay fees + escrow_account.set_delegated(false); + escrow_account.set_lamports(10 * LAMPORTS_PER_SIGNATURE); + escrow_account.set_rent_epoch(u64::MAX); + + let mut test_entry = SvmTestEntry::default(); + // Add the escrow account as an initial account in the bank + test_entry.add_initial_account(escrow_pubkey, &escrow_account); + + // Construct a no-op transaction (ComputeBudget instruction only) + let ix = ComputeBudgetInstruction::set_compute_unit_limit(200_000); + let tx = Transaction::new_signed_with_payer( + &[ix.into()], + Some(&fee_payer.pubkey()), + &[&fee_payer], + LAST_BLOCKHASH, + ); + + // Expect the transaction to be discarded due to InvalidAccountForFee + test_entry.push_transaction_with_status(tx, ExecutionStatus::Discarded); + + // Execute + let env = SvmTestEnvironment::create(test_entry); + env.execute(); +} + +#[test] +fn escrow_fee_charged_when_feepayer_exists_and_not_delegated() { + let fee_payer = Keypair::new(); + let mut fee_payer_account = AccountSharedData::default(); + fee_payer_account.set_lamports(1_000_000_000); + fee_payer_account.set_delegated(false); // explicitly not delegated + fee_payer_account.set_rent_epoch(u64::MAX); + + // Derive the escrow PDA for this fee payer and create it with some lamports, delegated + let escrow_pubkey = solana_svm::escrow::ephemeral_balance_pda_from_payer(&fee_payer.pubkey()); + let mut escrow_account = AccountSharedData::default(); + escrow_account.set_delegated(true); + escrow_account.set_lamports(10 * LAMPORTS_PER_SIGNATURE); + escrow_account.set_rent_epoch(u64::MAX); + + let mut test_entry = SvmTestEntry::default(); + // Add the fee payer and the escrow account as initial accounts in the bank + test_entry.add_initial_account(fee_payer.pubkey(), &fee_payer_account); + test_entry.add_initial_account(escrow_pubkey, &escrow_account); + + // Construct a no-op transaction (ComputeBudget instruction only) + let ix = ComputeBudgetInstruction::set_compute_unit_limit(200_000); + let tx = Transaction::new_signed_with_payer( + &[ix.into()], + Some(&fee_payer.pubkey()), + &[&fee_payer], + LAST_BLOCKHASH, + ); + + // Add the transaction to the test entry and set expected escrow deduction by one signature fee + test_entry.push_transaction(tx); + + // Assert that the fee payer exists and is not charged (balance remains 1_000_000_000) + assert_eq!( + test_entry + .final_accounts + .get(&fee_payer.pubkey()) + .unwrap() + .lamports(), + 1_000_000_000 + ); + + // Assert that the escrow account will be charged for the transaction fee + test_entry.decrease_expected_lamports(&escrow_pubkey, LAMPORTS_PER_SIGNATURE); + + // Execute + let env = SvmTestEnvironment::create(test_entry); + env.execute(); +}