diff --git a/Cargo.lock b/Cargo.lock index 658e31f30..52a5774af 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3873,6 +3873,7 @@ dependencies = [ "solana-feature-set", "solana-fee", "solana-fee-structure", + "solana-keypair", "solana-loader-v4-program", "solana-program", "solana-program-runtime", diff --git a/magicblock-chainlink/src/chainlink/mod.rs b/magicblock-chainlink/src/chainlink/mod.rs index 78cdfacc4..4b46fe768 100644 --- a/magicblock-chainlink/src/chainlink/mod.rs +++ b/magicblock-chainlink/src/chainlink/mod.rs @@ -247,14 +247,16 @@ Kept: {} delegated, {} blacklisted", .is_none_or(|a| !a.delegated()) }; - let mark_empty_if_not_found = if clone_escrow { + // Always allow the fee payer to be treated as empty-if-not-found so that + // transactions can still be processed in gasless mode + let mut mark_empty_if_not_found = vec![*feepayer]; + + if clone_escrow { let balance_pda = ephemeral_balance_pda_from_payer(feepayer, 0); trace!("Adding balance PDA {balance_pda} for feepayer {feepayer}"); pubkeys.push(balance_pda); - vec![balance_pda] - } else { - vec![] - }; + mark_empty_if_not_found.push(balance_pda); + } let mark_empty_if_not_found = (!mark_empty_if_not_found.is_empty()) .then(|| &mark_empty_if_not_found[..]); self.ensure_accounts(&pubkeys, mark_empty_if_not_found) diff --git a/magicblock-processor/Cargo.toml b/magicblock-processor/Cargo.toml index 8aa007057..3323c8036 100644 --- a/magicblock-processor/Cargo.toml +++ b/magicblock-processor/Cargo.toml @@ -41,6 +41,7 @@ solana-transaction-error = { workspace = true } [dev-dependencies] guinea = { workspace = true } +solana-keypair = {workspace = true} solana-signature = { workspace = true } solana-signer = { workspace = true } test-kit = { workspace = true } diff --git a/magicblock-processor/src/executor/processing.rs b/magicblock-processor/src/executor/processing.rs index f5ed10746..c249c97c0 100644 --- a/magicblock-processor/src/executor/processing.rs +++ b/magicblock-processor/src/executor/processing.rs @@ -161,14 +161,23 @@ impl super::TransactionExecutor { .as_ref() .ok() .and_then(|r| r.executed_transaction()) - .and_then(|txn| txn.loaded_transaction.accounts.first()) - .map(|acc| { + .and_then(|txn| { + let first_acc = txn.loaded_transaction.accounts.first(); + let rollback_lamports = rollback_feepayer_lamports( + &txn.loaded_transaction.rollback_accounts, + ); + first_acc.map(|acc| (acc, rollback_lamports)) + }) + .map(|(acc, rollback_lamports)| { // The check logic: if we have an undelegated feepayer, then // it cannot have been mutated. The only exception is the // privileged feepayer (internal validator operations), for // which we do allow the mutations, since it can be used to // fund other accounts. - acc.1.is_dirty() && !acc.1.delegated() && !acc.1.privileged() + (acc.1.is_dirty() + && (acc.1.lamports() != 0 || rollback_lamports != 0)) + && !acc.1.delegated() + && !acc.1.privileged() }) .unwrap_or_default(); let gasless = self.environment.fee_lamports_per_signature == 0; @@ -178,7 +187,8 @@ impl super::TransactionExecutor { // from undelegated feepayers to delegated accounts, which would // result in validator loosing funds upon balance settling. if gasless && undelegated_feepayer_was_modified { - result = Err(TransactionError::UnbalancedTransaction); + println!("{:?}", result); + result = Err(TransactionError::InvalidAccountForFee); }; (result, output.balances) } @@ -340,3 +350,19 @@ impl super::TransactionExecutor { } } } + +// A utils to extract the rollback lamports of the feepayer +fn rollback_feepayer_lamports(rollback: &RollbackAccounts) -> u64 { + match rollback { + RollbackAccounts::FeePayerOnly { fee_payer_account } => { + fee_payer_account.lamports() + } + RollbackAccounts::SameNonceAndFeePayer { nonce } => { + nonce.account().lamports() + } + RollbackAccounts::SeparateNonceAndFeePayer { + fee_payer_account, + .. + } => fee_payer_account.lamports(), + } +} diff --git a/magicblock-processor/tests/fees.rs b/magicblock-processor/tests/fees.rs index ca559dfd1..3c1898313 100644 --- a/magicblock-processor/tests/fees.rs +++ b/magicblock-processor/tests/fees.rs @@ -1,7 +1,9 @@ use std::{collections::HashSet, time::Duration}; use guinea::GuineaInstruction; +use magicblock_core::traits::AccountsBank; use solana_account::{ReadableAccount, WritableAccount}; +use solana_keypair::Keypair; use solana_program::{ instruction::{AccountMeta, Instruction}, native_token::LAMPORTS_PER_SOL, @@ -307,3 +309,100 @@ async fn test_transaction_gasless_mode() { "payer balance should not change in gasless mode" ); } + +/// Verifies that in zero-fee ("gasless") mode, transactions are processed +/// successfully when using a not existing accounts (not the feepayer). +#[tokio::test] +async fn test_transaction_gasless_mode_with_not_existing_account() { + // Initialize the environment with a base fee of 0. + let env = ExecutionTestEnv::new_with_fee(0); + let mut payer = env.get_payer(); + payer.set_lamports(1); // Not enough to cover standard fee + payer.set_delegated(false); // Explicitly set the payer as NON-delegated. + let initial_balance = payer.lamports(); + payer.commmit(); + + let ix = Instruction::new_with_bincode( + guinea::ID, + &GuineaInstruction::PrintSizes, + vec![AccountMeta { + pubkey: Keypair::new().pubkey(), + is_signer: false, + is_writable: false, + }], + ); + let txn = env.build_transaction(&[ix]); + let signature = txn.signatures[0]; + + // In a normal fee-paying mode, this execution would fail. + env.execute_transaction(txn) + .await + .expect("transaction should succeed in gasless mode"); + + // Verify the transaction was fully processed and broadcast successfully. + let status = env + .dispatch + .transaction_status + .recv_timeout(Duration::from_millis(100)) + .expect("should receive a transaction status update"); + + assert_eq!(status.signature, signature); + assert!( + status.result.result.is_ok(), + "Transaction execution should be successful" + ); + + // Verify that absolutely no fee was charged. + let final_balance = env.get_payer().lamports(); + assert_eq!( + initial_balance, final_balance, + "payer balance should not change in gasless mode" + ); +} + +/// Verifies that in zero-fee ("gasless") mode, transactions are processed +/// successfully even when the fee payer does not exists. +#[tokio::test] +async fn test_transaction_gasless_mode_not_existing_feepayer() { + // Initialize the environment with a base fee of 0. + let payer = Keypair::new(); + let env = ExecutionTestEnv::new_with_payer_and_fees(&payer, 0); + + // Simple noop instruction that does not touch the fee payer account + let ix = Instruction::new_with_bincode( + guinea::ID, + &GuineaInstruction::PrintSizes, + vec![], + ); + let txn = env.build_transaction(&[ix]); + let signature = txn.signatures[0]; + + // In a normal fee-paying mode, this execution would fail. + env.execute_transaction(txn) + .await + .expect("transaction should succeed in gasless mode"); + + // Verify the transaction was fully processed and broadcast successfully. + let status = env + .dispatch + .transaction_status + .recv_timeout(Duration::from_millis(100)) + .expect("should receive a transaction status update"); + + assert_eq!(status.signature, signature); + assert!( + status.result.result.is_ok(), + "Transaction execution should be successful" + ); + + // Verify that the payer balance is zero (or doesn't exist) + let final_balance = env + .accountsdb + .get_account(&payer.pubkey()) + .unwrap_or_default() + .lamports(); + assert_eq!( + final_balance, 0, + "payer balance of a not existing feepayer should be 0 in gasless mode" + ); +} diff --git a/test-integration/Cargo.lock b/test-integration/Cargo.lock index 79bc9d6fa..313624625 100644 --- a/test-integration/Cargo.lock +++ b/test-integration/Cargo.lock @@ -3601,7 +3601,7 @@ dependencies = [ "solana-rpc", "solana-rpc-client", "solana-sdk", - "solana-svm 2.2.1 (git+https://github.com/magicblock-labs/magicblock-svm.git?rev=4d27862)", + "solana-svm 2.2.1 (git+https://github.com/magicblock-labs/magicblock-svm.git?rev=11bbaf2)", "solana-transaction", "tempfile", "thiserror 1.0.69", @@ -3789,7 +3789,7 @@ dependencies = [ "solana-metrics", "solana-sdk", "solana-storage-proto 0.2.3", - "solana-svm 2.2.1 (git+https://github.com/magicblock-labs/magicblock-svm.git?rev=4d27862)", + "solana-svm 2.2.1 (git+https://github.com/magicblock-labs/magicblock-svm.git?rev=11bbaf2)", "solana-timings", "solana-transaction-status", "thiserror 1.0.69", @@ -3856,7 +3856,7 @@ dependencies = [ "solana-pubkey", "solana-rent-collector", "solana-sdk-ids", - "solana-svm 2.2.1 (git+https://github.com/magicblock-labs/magicblock-svm.git?rev=4d27862)", + "solana-svm 2.2.1 (git+https://github.com/magicblock-labs/magicblock-svm.git?rev=11bbaf2)", "solana-svm-transaction", "solana-system-program", "solana-transaction", @@ -3932,7 +3932,7 @@ dependencies = [ "solana-program", "solana-pubsub-client", "solana-sdk", - "solana-svm 2.2.1 (git+https://github.com/magicblock-labs/magicblock-svm.git?rev=4d27862)", + "solana-svm 2.2.1 (git+https://github.com/magicblock-labs/magicblock-svm.git?rev=11bbaf2)", "solana-timings", "thiserror 1.0.69", "tokio", @@ -6248,7 +6248,7 @@ dependencies = [ [[package]] name = "solana-account" version = "2.2.1" -source = "git+https://github.com/magicblock-labs/solana-account.git?rev=f454d4a#f454d4a67a1ca64b87002025868f5369428e1c54" +source = "git+https://github.com/magicblock-labs/solana-account.git?rev=8f7050a#8f7050ad949465d2f94e7d798e2f9633a7c407f5" dependencies = [ "bincode", "qualifier_attr", @@ -9108,7 +9108,7 @@ dependencies = [ [[package]] name = "solana-svm" version = "2.2.1" -source = "git+https://github.com/magicblock-labs/magicblock-svm.git?rev=4d27862#4d278626742352432e5a6a856e73be7ca4bbd727" +source = "git+https://github.com/magicblock-labs/magicblock-svm.git?rev=11bbaf2#11bbaf2249aeb16cec4111e86f2e18a0c45ff1f2" dependencies = [ "ahash 0.8.12", "log", diff --git a/test-kit/src/lib.rs b/test-kit/src/lib.rs index a69b204d8..cf99fe1cd 100644 --- a/test-kit/src/lib.rs +++ b/test-kit/src/lib.rs @@ -81,6 +81,12 @@ impl ExecutionTestEnv { Self::new_with_fee(Self::BASE_FEE) } + pub fn new_with_payer_and_fees(payer: &Keypair, fee: u64) -> Self { + let mut ctx = Self::new_with_fee(fee); + ctx.payer = payer.insecure_clone(); + ctx + } + /// Creates a new, fully initialized validator test environment with given base fee /// /// This function sets up a complete validator stack: