Skip to content
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
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

12 changes: 7 additions & 5 deletions magicblock-chainlink/src/chainlink/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
1 change: 1 addition & 0 deletions magicblock-processor/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
34 changes: 30 additions & 4 deletions magicblock-processor/src/executor/processing.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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)
}
Expand Down Expand Up @@ -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(),
}
}
99 changes: 99 additions & 0 deletions magicblock-processor/tests/fees.rs
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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"
);
}
12 changes: 6 additions & 6 deletions test-integration/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 6 additions & 0 deletions test-kit/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
Loading