From 1fde547697c3ac2a31308245e5265963a851803d Mon Sep 17 00:00:00 2001 From: Gabriele Picco Date: Sun, 16 Nov 2025 13:34:57 +0500 Subject: [PATCH 1/7] fix: allow not existing feepayer in gasless mode --- Cargo.lock | 1 + magicblock-chainlink/src/chainlink/mod.rs | 14 ++-- magicblock-processor/Cargo.toml | 1 + magicblock-processor/tests/fees.rs | 95 +++++++++++++++++++++++ test-integration/Cargo.lock | 12 +-- test-kit/src/lib.rs | 6 ++ 6 files changed, 118 insertions(+), 11 deletions(-) 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..b719fe55c 100644 --- a/magicblock-chainlink/src/chainlink/mod.rs +++ b/magicblock-chainlink/src/chainlink/mod.rs @@ -247,14 +247,18 @@ 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 scenarios like gasless mode or + // when the payer hasn't been created yet. This mirrors Solana loader + // behavior which provides an empty placeholder AccountInfo at load time. + 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/tests/fees.rs b/magicblock-processor/tests/fees.rs index ca559dfd1..97d252da5 100644 --- a/magicblock-processor/tests/fees.rs +++ b/magicblock-processor/tests/fees.rs @@ -2,6 +2,7 @@ use std::{collections::HashSet, time::Duration}; use guinea::GuineaInstruction; use solana_account::{ReadableAccount, WritableAccount}; +use solana_keypair::Keypair; use solana_program::{ instruction::{AccountMeta, Instruction}, native_token::LAMPORTS_PER_SOL, @@ -307,3 +308,97 @@ 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 with 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); + let initial_balance = 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 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" + ); +} 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: From 21db84641b1ab184463585931bd4e64b01348335 Mon Sep 17 00:00:00 2001 From: Gabriele Picco Date: Sun, 16 Nov 2025 13:37:27 +0500 Subject: [PATCH 2/7] chore: simplify comment --- magicblock-chainlink/src/chainlink/mod.rs | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/magicblock-chainlink/src/chainlink/mod.rs b/magicblock-chainlink/src/chainlink/mod.rs index b719fe55c..4b46fe768 100644 --- a/magicblock-chainlink/src/chainlink/mod.rs +++ b/magicblock-chainlink/src/chainlink/mod.rs @@ -248,9 +248,7 @@ Kept: {} delegated, {} blacklisted", }; // Always allow the fee payer to be treated as empty-if-not-found so that - // transactions can still be processed in scenarios like gasless mode or - // when the payer hasn't been created yet. This mirrors Solana loader - // behavior which provides an empty placeholder AccountInfo at load time. + // transactions can still be processed in gasless mode let mut mark_empty_if_not_found = vec![*feepayer]; if clone_escrow { From 1ce0fd24595ec3effc7d812160351201c5351e52 Mon Sep 17 00:00:00 2001 From: Gabriele Picco Date: Sun, 16 Nov 2025 20:56:34 +0400 Subject: [PATCH 3/7] chore: fix test --- magicblock-chainlink/src/chainlink/mod.rs | 1 + .../src/executor/processing.rs | 29 ++++++++++++++++--- magicblock-processor/tests/fees.rs | 16 ++++++---- 3 files changed, 36 insertions(+), 10 deletions(-) diff --git a/magicblock-chainlink/src/chainlink/mod.rs b/magicblock-chainlink/src/chainlink/mod.rs index 4b46fe768..47a9c8302 100644 --- a/magicblock-chainlink/src/chainlink/mod.rs +++ b/magicblock-chainlink/src/chainlink/mod.rs @@ -32,6 +32,7 @@ pub mod errors; pub mod fetch_cloner; pub use blacklisted_accounts::*; +use magicblock_core::link::transactions::SanitizeableTransaction; // ----------------- // Chainlink diff --git a/magicblock-processor/src/executor/processing.rs b/magicblock-processor/src/executor/processing.rs index f5ed10746..6fb1610db 100644 --- a/magicblock-processor/src/executor/processing.rs +++ b/magicblock-processor/src/executor/processing.rs @@ -161,14 +161,34 @@ 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(); + // Extract the fee-payer account lamports from rollback snapshot + let rollback_lamports = + match &txn.loaded_transaction.rollback_accounts { + RollbackAccounts::FeePayerOnly { + fee_payer_account, + } => fee_payer_account.lamports(), + RollbackAccounts::SameNonceAndFeePayer { nonce } => { + nonce.account().lamports() + } + RollbackAccounts::SeparateNonceAndFeePayer { + fee_payer_account, + .. + } => fee_payer_account.lamports(), + }; + 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 +198,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) } diff --git a/magicblock-processor/tests/fees.rs b/magicblock-processor/tests/fees.rs index 97d252da5..3c1898313 100644 --- a/magicblock-processor/tests/fees.rs +++ b/magicblock-processor/tests/fees.rs @@ -1,6 +1,7 @@ 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::{ @@ -310,7 +311,7 @@ async fn test_transaction_gasless_mode() { } /// Verifies that in zero-fee ("gasless") mode, transactions are processed -/// successfully with not existing accounts (not the feepayer). +/// 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. @@ -366,7 +367,6 @@ 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); - let initial_balance = 0; // Simple noop instruction that does not touch the fee payer account let ix = Instruction::new_with_bincode( @@ -395,10 +395,14 @@ async fn test_transaction_gasless_mode_not_existing_feepayer() { "Transaction execution should be successful" ); - // Verify that absolutely no fee was charged. - let final_balance = env.get_payer().lamports(); + // 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!( - initial_balance, final_balance, - "payer balance should not change in gasless mode" + final_balance, 0, + "payer balance of a not existing feepayer should be 0 in gasless mode" ); } From ec7f53a4d773efec67dd8c485ff816c139744da8 Mon Sep 17 00:00:00 2001 From: Gabriele Picco Date: Sun, 16 Nov 2025 21:09:21 +0400 Subject: [PATCH 4/7] chore: lint --- magicblock-chainlink/src/chainlink/mod.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/magicblock-chainlink/src/chainlink/mod.rs b/magicblock-chainlink/src/chainlink/mod.rs index 47a9c8302..4b46fe768 100644 --- a/magicblock-chainlink/src/chainlink/mod.rs +++ b/magicblock-chainlink/src/chainlink/mod.rs @@ -32,7 +32,6 @@ pub mod errors; pub mod fetch_cloner; pub use blacklisted_accounts::*; -use magicblock_core::link::transactions::SanitizeableTransaction; // ----------------- // Chainlink From 7c01a32c541015b3a3be7200a710de2a1e45ecbf Mon Sep 17 00:00:00 2001 From: Gabriele Picco Date: Sun, 16 Nov 2025 21:37:23 +0400 Subject: [PATCH 5/7] chore: lint --- .../src/executor/processing.rs | 41 +++++++++++-------- 1 file changed, 23 insertions(+), 18 deletions(-) diff --git a/magicblock-processor/src/executor/processing.rs b/magicblock-processor/src/executor/processing.rs index 6fb1610db..1d5a3ddcd 100644 --- a/magicblock-processor/src/executor/processing.rs +++ b/magicblock-processor/src/executor/processing.rs @@ -163,20 +163,9 @@ impl super::TransactionExecutor { .and_then(|r| r.executed_transaction()) .and_then(|txn| { let first_acc = txn.loaded_transaction.accounts.first(); - // Extract the fee-payer account lamports from rollback snapshot - let rollback_lamports = - match &txn.loaded_transaction.rollback_accounts { - RollbackAccounts::FeePayerOnly { - fee_payer_account, - } => fee_payer_account.lamports(), - RollbackAccounts::SameNonceAndFeePayer { nonce } => { - nonce.account().lamports() - } - RollbackAccounts::SeparateNonceAndFeePayer { - fee_payer_account, - .. - } => fee_payer_account.lamports(), - }; + let rollback_lamports = rollback_feepayer_lamports( + &txn.loaded_transaction.rollback_accounts, + ); first_acc.map(|acc| (acc, rollback_lamports)) }) .map(|(acc, rollback_lamports)| { @@ -185,10 +174,10 @@ impl super::TransactionExecutor { // 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.lamports() == 0 && rollback_lamports == 0)) - && !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; @@ -361,3 +350,19 @@ impl super::TransactionExecutor { } } } + +// A utils +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(), + } +} From 15fbf4b4f30895b11ed5a42b107736d70b31799a Mon Sep 17 00:00:00 2001 From: Gabriele Picco Date: Sun, 16 Nov 2025 21:39:41 +0400 Subject: [PATCH 6/7] chore: fmt --- magicblock-processor/src/executor/processing.rs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/magicblock-processor/src/executor/processing.rs b/magicblock-processor/src/executor/processing.rs index 1d5a3ddcd..547cc4553 100644 --- a/magicblock-processor/src/executor/processing.rs +++ b/magicblock-processor/src/executor/processing.rs @@ -174,10 +174,10 @@ impl super::TransactionExecutor { // 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.lamports() != 0 || rollback_lamports != 0)) - && !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; From bc931c43a4dbb77132aaeaba30d960ee103b4ec1 Mon Sep 17 00:00:00 2001 From: Gabriele Picco Date: Sun, 16 Nov 2025 22:04:47 +0400 Subject: [PATCH 7/7] chore: doc --- magicblock-processor/src/executor/processing.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/magicblock-processor/src/executor/processing.rs b/magicblock-processor/src/executor/processing.rs index 547cc4553..c249c97c0 100644 --- a/magicblock-processor/src/executor/processing.rs +++ b/magicblock-processor/src/executor/processing.rs @@ -351,7 +351,7 @@ impl super::TransactionExecutor { } } -// A utils +// A utils to extract the rollback lamports of the feepayer fn rollback_feepayer_lamports(rollback: &RollbackAccounts) -> u64 { match rollback { RollbackAccounts::FeePayerOnly { fee_payer_account } => {