Skip to content

Commit e4e19b4

Browse files
authored
feat(executor): validate no confined account is modified (#740)
1 parent 67c1324 commit e4e19b4

File tree

8 files changed

+178
-94
lines changed

8 files changed

+178
-94
lines changed

Cargo.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -131,7 +131,7 @@ serde_json = "1.0"
131131
serde_with = "3.16"
132132
serial_test = "3.2"
133133
sha3 = "0.10.8"
134-
solana-account = { git = "https://github.com/magicblock-labs/solana-account.git", rev = "1beed4c" }
134+
solana-account = { git = "https://github.com/magicblock-labs/solana-account.git", rev = "57158728" }
135135
solana-account-decoder = { version = "2.2" }
136136
solana-account-decoder-client-types = { version = "2.2" }
137137
solana-account-info = { version = "2.2" }
@@ -216,11 +216,10 @@ features = ["lz4"]
216216
# some solana dependencies have solana-storage-proto as dependency
217217
# we need to patch them with our version, because they use protobuf-src v1.1.0
218218
# and we use protobuf-src v2.1.1. Otherwise compilation fails
219-
solana-account = { git = "https://github.com/magicblock-labs/solana-account.git", rev = "1beed4c" }
219+
solana-account = { git = "https://github.com/magicblock-labs/solana-account.git", rev = "57158728" }
220220
solana-storage-proto = { path = "./storage-proto" }
221221
solana-svm = { git = "https://github.com/magicblock-labs/magicblock-svm.git", rev = "3e9456ec4" }
222222
# Fork is used to enable `disable_manual_compaction` usage
223223
# Fork is based on commit d4e9e16 of rocksdb (parent commit of 0.23.0 release)
224224
# without patching update isn't possible due to conflict with solana deps
225-
# TODO(edwin): remove once solana deps upgraded and are using rust-rocksdb 0.25.0(likely)
226-
rocksdb = { git = "https://github.com/magicblock-labs/rust-rocksdb.git", rev = "6d975197" }
225+
rocksdb = { git = "https://github.com/magicblock-labs/rust-rocksdb.git", rev = "6d975197" }

magicblock-accounts-db/src/lib.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -101,10 +101,10 @@ impl AccountsDb {
101101
// atomic counter. New readers will see the latest update.
102102
acc.commit();
103103
// check whether the account's owner has changed
104-
if !acc.owner_changed {
104+
if !acc.owner_changed() {
105105
return;
106106
}
107-
// and perform some index bookkeeping to ensure correct owner
107+
// and perform some index bookkeeping to ensure a correct owner
108108
let _ = self
109109
.index
110110
.ensure_correct_owner(pubkey, account.owner())

magicblock-processor/src/executor/processing.rs

Lines changed: 40 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -341,16 +341,18 @@ impl super::TransactionExecutor {
341341

342342
/// Ensure that no post execution account state violations occurred:
343343
/// 1. No modification of the non-delegated feepayer in gasless mode
344-
/// 2. No illegal account resizing when the balance is zero
344+
/// 2. No lamport modification of confined accounts
345345
fn verify_account_states(&self, processed: &mut ProcessedTransaction) {
346346
let ProcessedTransaction::Executed(executed) = processed else {
347347
return;
348348
};
349349
let txn = &executed.loaded_transaction;
350350
let feepayer = txn.accounts.first();
351-
let rollback_lamports =
352-
rollback_feepayer_lamports(&txn.rollback_accounts);
353351

352+
let logs = executed
353+
.execution_details
354+
.log_messages
355+
.get_or_insert_default();
354356
let gasless = self.environment.fee_lamports_per_signature == 0;
355357
if gasless {
356358
// If we are running in the gasless mode, we should not allow
@@ -359,40 +361,50 @@ impl super::TransactionExecutor {
359361
// from undelegated feepayers to delegated accounts, which would
360362
// result in validator loosing funds upon balance settling.
361363
let undelegated_feepayer_was_modified = feepayer
362-
.map(|acc| {
363-
(acc.1.is_dirty()
364-
&& !self.is_auto_airdrop_lamports_enabled
365-
&& (acc.1.lamports() != 0 || rollback_lamports != 0))
366-
&& !acc.1.delegated()
367-
&& !acc.1.privileged()
364+
.map(|(_, acc)| {
365+
let mutated = acc
366+
.as_borrowed()
367+
.map(|a| a.lamports_changed())
368+
// NOTE: this branch can be taken only, if the account
369+
// has been upgraded to the Owned variant, which indicates
370+
// that it has been resized, which in turn is a clear
371+
// violation of the feepayer immutability rule in this mode
372+
.unwrap_or(true);
373+
!self.is_auto_airdrop_lamports_enabled
374+
&& mutated
375+
&& !acc.delegated()
376+
&& !acc.privileged()
368377
})
369378
.unwrap_or_default();
370379
if undelegated_feepayer_was_modified {
371380
executed.execution_details.status =
372381
Err(TransactionError::InvalidAccountForFee);
373-
let logs = executed
374-
.execution_details
375-
.log_messages
376-
.get_or_insert_default();
377-
let msg = "Feepayer balance has been modified illegally".into();
382+
let msg = "Feepayer balance has been illegally modified".into();
378383
logs.push(msg);
384+
return;
379385
}
380386
}
381-
}
382-
}
387+
for (pubkey, acc) in &txn.accounts {
388+
if !acc.confined() {
389+
continue;
390+
}
391+
// If the confined account was modified in any way that affected its lamport
392+
// balance, then an corresponding marker must have been set, in which case we
393+
// fail the transaction, since this is regarded as a validator draining attack
394+
let balance_changed = acc
395+
.as_borrowed()
396+
.map(|a| a.lamports_changed())
397+
.unwrap_or(true);
383398

384-
// A utility to extract the rollback lamports of the feepayer
385-
fn rollback_feepayer_lamports(rollback: &RollbackAccounts) -> u64 {
386-
match rollback {
387-
RollbackAccounts::FeePayerOnly { fee_payer_account } => {
388-
fee_payer_account.lamports()
389-
}
390-
RollbackAccounts::SameNonceAndFeePayer { nonce } => {
391-
nonce.account().lamports()
399+
if balance_changed {
400+
executed.execution_details.status =
401+
Err(TransactionError::UnbalancedTransaction);
402+
let msg = format!(
403+
"Confined account {pubkey} has been illegally modified"
404+
);
405+
logs.push(msg);
406+
break;
407+
}
392408
}
393-
RollbackAccounts::SeparateNonceAndFeePayer {
394-
fee_payer_account,
395-
..
396-
} => fee_payer_account.lamports(),
397409
}
398410
}

magicblock-processor/tests/fees.rs

Lines changed: 6 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
use std::{collections::HashSet, time::Duration};
22

33
use guinea::GuineaInstruction;
4-
use magicblock_core::traits::AccountsBank;
54
use solana_account::{ReadableAccount, WritableAccount};
65
use solana_keypair::Keypair;
76
use solana_program::{
@@ -51,7 +50,7 @@ async fn test_insufficient_fee() {
5150
let env = ExecutionTestEnv::new();
5251
let mut payer = env.get_payer();
5352
payer.set_lamports(ExecutionTestEnv::BASE_FEE - 1);
54-
payer.commmit();
53+
payer.commit();
5554

5655
let (ix, _) =
5756
setup_guinea_instruction(&env, &GuineaInstruction::PrintSizes, false);
@@ -105,7 +104,7 @@ async fn test_non_delegated_payer_rejection() {
105104
let mut payer = env.get_payer();
106105
payer.set_delegated(false); // Mark the payer as not delegated
107106
let fee_payer_initial_balance = payer.lamports();
108-
payer.commmit();
107+
payer.commit();
109108

110109
let (ix, _) =
111110
setup_guinea_instruction(&env, &GuineaInstruction::PrintSizes, false);
@@ -133,7 +132,7 @@ async fn test_escrowed_payer_success() {
133132
payer.set_lamports(ExecutionTestEnv::BASE_FEE - 1);
134133
payer.set_delegated(false);
135134
let escrow = ephemeral_balance_pda_from_payer(&payer.pubkey);
136-
payer.commmit();
135+
payer.commit();
137136

138137
env.fund_account(escrow, LAMPORTS_PER_SOL); // Fund the escrow PDA
139138

@@ -220,7 +219,7 @@ async fn test_escrow_charged_for_failed_transaction() {
220219
payer.set_lamports(0);
221220
payer.set_delegated(false);
222221
let escrow = ephemeral_balance_pda_from_payer(&payer.pubkey);
223-
payer.commmit();
222+
payer.commit();
224223
let account = env
225224
.create_account_with_config(LAMPORTS_PER_SOL, 0, guinea::ID) // Account with no data
226225
.pubkey();
@@ -266,7 +265,7 @@ async fn test_transaction_gasless_mode() {
266265
payer.set_lamports(1); // Not enough to cover standard fee
267266
payer.set_delegated(false); // Explicitly set the payer as NON-delegated.
268267
let initial_balance = payer.lamports();
269-
payer.commmit();
268+
payer.commit();
270269

271270
let ix = Instruction::new_with_bincode(
272271
guinea::ID,
@@ -312,7 +311,7 @@ async fn test_transaction_gasless_mode_with_not_existing_account() {
312311
payer.set_lamports(1); // Not enough to cover standard fee
313312
payer.set_delegated(false); // Explicitly set the payer as NON-delegated.
314313
let initial_balance = payer.lamports();
315-
payer.commmit();
314+
payer.commit();
316315

317316
let ix = Instruction::new_with_bincode(
318317
guinea::ID,
@@ -351,51 +350,3 @@ async fn test_transaction_gasless_mode_with_not_existing_account() {
351350
"payer balance should not change in gasless mode"
352351
);
353352
}
354-
355-
/// Verifies that in zero-fee ("gasless") mode, transactions are processed
356-
/// successfully even when the fee payer does not exists.
357-
#[tokio::test]
358-
async fn test_transaction_gasless_mode_not_existing_feepayer() {
359-
// Initialize the environment with a base fee of 0.
360-
let env = ExecutionTestEnv::new_with_config(0);
361-
let payer = env.get_payer().pubkey;
362-
env.accountsdb.remove_account(&payer);
363-
364-
// Simple noop instruction that does not touch the fee payer account
365-
let ix = Instruction::new_with_bincode(
366-
guinea::ID,
367-
&GuineaInstruction::PrintSizes,
368-
vec![],
369-
);
370-
let txn = env.build_transaction(&[ix]);
371-
let signature = txn.signatures[0];
372-
373-
// In a normal fee-paying mode, this execution would fail.
374-
env.execute_transaction(txn)
375-
.await
376-
.expect("transaction should succeed in gasless mode");
377-
378-
// Verify the transaction was fully processed and broadcast successfully.
379-
let status = env
380-
.dispatch
381-
.transaction_status
382-
.recv_timeout(Duration::from_millis(100))
383-
.expect("should receive a transaction status update");
384-
385-
assert_eq!(status.signature, signature);
386-
assert!(
387-
status.result.result.is_ok(),
388-
"Transaction execution should be successful"
389-
);
390-
391-
// Verify that the payer balance is zero (or doesn't exist)
392-
let final_balance = env
393-
.accountsdb
394-
.get_account(&payer)
395-
.unwrap_or_default()
396-
.lamports();
397-
assert_eq!(
398-
final_balance, 0,
399-
"payer balance of a not existing feepayer should be 0 in gasless mode"
400-
);
401-
}
Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
use guinea::GuineaInstruction;
2+
use solana_account::{ReadableAccount, WritableAccount};
3+
use solana_program::{
4+
instruction::{AccountMeta, Instruction},
5+
native_token::LAMPORTS_PER_SOL,
6+
};
7+
use solana_signer::Signer;
8+
use solana_transaction_error::TransactionError;
9+
use test_kit::ExecutionTestEnv;
10+
11+
/// Verifies that in gasless mode (0 fees), a transaction fails
12+
/// if it modifies an undelegated fee payer.
13+
#[tokio::test]
14+
async fn test_gasless_undelegated_feepayer_modification_fails() {
15+
let env = ExecutionTestEnv::new_with_config(0);
16+
17+
{
18+
let mut account = env.get_payer();
19+
account.set_owner(guinea::ID);
20+
account.set_delegated(false);
21+
account.commit();
22+
}
23+
env.advance_slot();
24+
25+
// 3. Create a recipient (also owned by Guinea for the Transfer instruction compatibility)
26+
let recipient = env.create_account_with_config(100, 42, guinea::ID);
27+
28+
// 4. Create Transfer instruction: Payer -> Recipient
29+
// This modifies the payer's lamports.
30+
let ix = Instruction::new_with_bincode(
31+
guinea::ID,
32+
&GuineaInstruction::Transfer(1000),
33+
vec![
34+
AccountMeta::new(env.payer.pubkey(), true),
35+
AccountMeta::new(recipient.pubkey(), false),
36+
],
37+
);
38+
39+
// 5. Build transaction using the default payer
40+
let txn = env.build_transaction(&[ix]);
41+
42+
// 6. Execute and Assert Failure
43+
let result = env.execute_transaction(txn).await;
44+
assert!(
45+
result.is_err(),
46+
"Modification of undelegated fee payer in gasless mode should fail"
47+
);
48+
assert_eq!(
49+
result.unwrap_err(),
50+
TransactionError::InvalidAccountForFee,
51+
"Expected InvalidAccountForFee error"
52+
);
53+
}
54+
55+
/// Verifies that modifying the lamports of a confined
56+
/// account causes the transaction to fail.
57+
#[tokio::test]
58+
async fn test_confined_account_lamport_modification_fails() {
59+
let env = ExecutionTestEnv::new(); // Standard fees
60+
61+
let confined_sender = env.create_account_with_config(100, 42, guinea::ID);
62+
{
63+
let mut account = env.get_account(confined_sender.pubkey());
64+
account.set_confined(true);
65+
account.commit();
66+
}
67+
env.advance_slot();
68+
69+
let recipient = env.create_account_with_config(100, 42, guinea::ID);
70+
71+
let ix = Instruction::new_with_bincode(
72+
guinea::ID,
73+
&GuineaInstruction::Transfer(100),
74+
vec![
75+
AccountMeta::new(confined_sender.pubkey(), false), // Writable, Not Signer
76+
AccountMeta::new(recipient.pubkey(), false), // Writable
77+
],
78+
);
79+
80+
let txn = env.build_transaction(&[ix]);
81+
82+
let result = env.execute_transaction(txn).await;
83+
84+
assert!(
85+
result.is_err(),
86+
"Modifying lamports of a confined account should fail"
87+
);
88+
assert_eq!(result.unwrap_err(), TransactionError::UnbalancedTransaction);
89+
}
90+
91+
/// Verifies that modifying ONLY the DATA of a confined account SUCCEEDS.
92+
#[tokio::test]
93+
async fn test_confined_account_data_modification_succeeds() {
94+
let env = ExecutionTestEnv::new();
95+
96+
let confined_account =
97+
env.create_account_with_config(LAMPORTS_PER_SOL, 42, guinea::ID);
98+
{
99+
let mut account = env.get_account(confined_account.pubkey());
100+
account.set_confined(true);
101+
account.commit();
102+
}
103+
env.advance_slot();
104+
105+
let ix = Instruction::new_with_bincode(
106+
guinea::ID,
107+
&GuineaInstruction::WriteByteToData(42),
108+
vec![AccountMeta::new(confined_account.pubkey(), false)],
109+
);
110+
111+
let txn = env.build_transaction(&[ix]);
112+
113+
let result = env.execute_transaction(txn).await;
114+
115+
assert!(
116+
result.is_ok(),
117+
"Data-only modification of confined account should succeed"
118+
);
119+
120+
let acc = env.get_account(confined_account.pubkey());
121+
assert_eq!(acc.data()[0], 42, "Data should be updated");
122+
}

test-integration/Cargo.toml

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,7 @@ rayon = "1.10.0"
7474
schedulecommit-client = { path = "schedulecommit/client" }
7575
serde = "1.0.217"
7676
serial_test = "3.2.0"
77-
solana-account = { git = "https://github.com/magicblock-labs/solana-account.git", rev = "1beed4c" }
77+
solana-account = { git = "https://github.com/magicblock-labs/solana-account.git", rev = "57158728" }
7878
solana-loader-v2-interface = "2.2"
7979
solana-loader-v3-interface = "4.0"
8080
solana-loader-v4-interface = "2.1"
@@ -106,5 +106,5 @@ url = "2.5.0"
106106
# and we use protobuf-src v2.1.1. Otherwise compilation fails
107107
solana-storage-proto = { path = "../storage-proto" }
108108
# same reason as above
109-
solana-account = { git = "https://github.com/magicblock-labs/solana-account.git", rev = "1beed4c" }
110-
rocksdb = { git = "https://github.com/magicblock-labs/rust-rocksdb.git", rev = "6d975197" }
109+
rocksdb = { git = "https://github.com/magicblock-labs/rust-rocksdb.git", rev = "6d975197" }
110+
solana-account = { git = "https://github.com/magicblock-labs/solana-account.git", rev = "57158728" }

0 commit comments

Comments
 (0)