From 2ae9e4da7fb2deab8ce013665ce8f31b60cc914d Mon Sep 17 00:00:00 2001 From: Sergio Chouhy Date: Fri, 15 May 2026 00:43:45 -0300 Subject: [PATCH] add tests and fix mechanism --- Cargo.lock | 1 + common/src/transaction.rs | 27 +-- program_methods/guest/src/bin/vault.rs | 2 +- sequencer/core/src/lib.rs | 179 ++++++++++++++++++ test_program_methods/guest/Cargo.toml | 1 + .../guest/src/bin/faucet_chain_caller.rs | 50 +++++ 6 files changed, 246 insertions(+), 14 deletions(-) create mode 100644 test_program_methods/guest/src/bin/faucet_chain_caller.rs diff --git a/Cargo.lock b/Cargo.lock index 195a5087..cf1943f0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -9181,6 +9181,7 @@ version = "0.1.0" dependencies = [ "authenticated_transfer_core", "clock_core", + "faucet_core", "nssa_core", "risc0-zkvm", "serde", diff --git a/common/src/transaction.rs b/common/src/transaction.rs index 6175f1a1..21cbfd75 100644 --- a/common/src/transaction.rs +++ b/common/src/transaction.rs @@ -67,26 +67,17 @@ impl NSSATransaction { } /// Validates the transaction against the current state and returns the resulting diff - /// without applying it. Rejects transactions that modify clock system accounts and - /// rejects unsafe modifications of the system faucet account. Also rejects direct - /// invocation of the faucet program for user-submitted transactions. + /// without applying it. Rejects transactions that modify clock or faucet system accounts, + /// whether directly or indirectly via chain calls. /// - /// This check is required for all user transactions. Only sequencer transaction may bypass this - /// check. + /// This check is required for all user transactions. Only sequencer transactions may bypass + /// this check. pub fn validate_on_state( &self, state: &V03State, block_id: BlockId, timestamp: Timestamp, ) -> Result { - if let Self::Public(tx) = self - && tx.message().program_id == nssa::program::Program::faucet().id() - { - return Err(nssa::error::NssaError::InvalidInput( - "Transaction invokes restricted faucet program".into(), - )); - } - let diff = match self { Self::Public(tx) => { ValidatedStateDiff::from_public_transaction(tx, state, block_id, timestamp) @@ -111,6 +102,16 @@ impl NSSATransaction { )); } + let faucet_id = nssa::system_faucet_account_id(); + if public_diff + .get(&faucet_id) + .is_some_and(|post| *post != state.get_account_by_id(faucet_id)) + { + return Err(nssa::error::NssaError::InvalidInput( + "Transaction modifies system faucet account".into(), + )); + } + Ok(diff) } diff --git a/program_methods/guest/src/bin/vault.rs b/program_methods/guest/src/bin/vault.rs index c691e8f6..c56c1a7f 100644 --- a/program_methods/guest/src/bin/vault.rs +++ b/program_methods/guest/src/bin/vault.rs @@ -40,7 +40,7 @@ fn main() { } => { let [sender, recipient_vault] = pre_states .try_into() - .expect("Transfer requires exactly 3 accounts"); + .expect("Transfer requires exactly 2 accounts"); let seed = vault_core::compute_vault_seed(recipient_id); diff --git a/sequencer/core/src/lib.rs b/sequencer/core/src/lib.rs index c6606145..054c8731 100644 --- a/sequencer/core/src/lib.rs +++ b/sequencer/core/src/lib.rs @@ -1060,4 +1060,183 @@ mod tests { "Block production should abort when clock account data is corrupted" ); } + + #[tokio::test] + async fn user_tx_that_chain_calls_faucet_is_dropped() { + let (mut sequencer, mempool_handle) = common_setup().await; + + // Deploy the faucet_chain_caller test program. + let deploy_tx = + NSSATransaction::ProgramDeployment(nssa::ProgramDeploymentTransaction::new( + nssa::program_deployment_transaction::Message::new( + test_program_methods::FAUCET_CHAIN_CALLER_ELF.to_vec(), + ), + )); + mempool_handle.push(deploy_tx).await.unwrap(); + sequencer.produce_new_block().await.unwrap(); + + // The attacker chain-calls the faucet through their own program: + // faucet_chain_caller → faucet → vault → authenticated_transfer. + // Funds from the system faucet would land in the attacker's vault PDA. + let faucet_account_id = nssa::system_faucet_account_id(); + let attacker_id = initial_accounts()[0].account_id; + let faucet_program_id = nssa::program::Program::faucet().id(); + let vault_program_id = nssa::program::Program::vault().id(); + let attacker_vault_id = + vault_core::compute_vault_account_id(vault_program_id, attacker_id); + let amount: u128 = 1_000; + + let faucet_chain_caller_id = + nssa::program::Program::new(test_program_methods::FAUCET_CHAIN_CALLER_ELF.to_vec()) + .unwrap() + .id(); + + let message = nssa::public_transaction::Message::try_new( + faucet_chain_caller_id, + vec![faucet_account_id, attacker_vault_id], + vec![], // no signers — faucet PDA authorization is handled internally + (faucet_program_id, vault_program_id, attacker_id, amount), + ) + .unwrap(); + let attack_tx = NSSATransaction::Public(nssa::PublicTransaction::new( + message, + nssa::public_transaction::WitnessSet::from_raw_parts(vec![]), + )); + + let faucet_balance_before = sequencer.state.get_account_by_id(faucet_account_id).balance; + let vault_balance_before = sequencer.state.get_account_by_id(attacker_vault_id).balance; + + mempool_handle.push(attack_tx).await.unwrap(); + sequencer.produce_new_block().await.unwrap(); + + let block = sequencer + .store + .get_block_at_id(sequencer.chain_height) + .unwrap() + .unwrap(); + let faucet_balance_after = sequencer.state.get_account_by_id(faucet_account_id).balance; + let vault_balance_after = sequencer.state.get_account_by_id(attacker_vault_id).balance; + + // The attack tx must be dropped; only the mandatory clock invocation remains. + assert_eq!( + block.body.transactions, + vec![NSSATransaction::Public(clock_invocation( + block.header.timestamp + ))] + ); + assert_eq!(faucet_balance_after, faucet_balance_before); + assert_eq!(vault_balance_after, vault_balance_before); + } + + #[tokio::test] + async fn ppt_that_chain_calls_faucet_is_dropped() { + use nssa::privacy_preserving_transaction::circuit::ProgramWithDependencies; + use nssa_core::{InputAccountIdentity, account::AccountWithMetadata}; + + let (mut sequencer, mempool_handle) = common_setup().await; + + // Deploy the faucet_chain_caller test program. + let deploy_tx = + NSSATransaction::ProgramDeployment(nssa::ProgramDeploymentTransaction::new( + nssa::program_deployment_transaction::Message::new( + test_program_methods::FAUCET_CHAIN_CALLER_ELF.to_vec(), + ), + )); + mempool_handle.push(deploy_tx).await.unwrap(); + sequencer.produce_new_block().await.unwrap(); + + // The attacker runs faucet_chain_caller inside a PPT circuit, producing a valid proof + // that the faucet was drained into their vault PDA. + let faucet_account_id = nssa::system_faucet_account_id(); + let attacker_id = initial_accounts()[0].account_id; + let faucet_program_id = nssa::program::Program::faucet().id(); + let vault_program_id = nssa::program::Program::vault().id(); + let auth_transfer_program_id = nssa::program::Program::authenticated_transfer_program().id(); + let attacker_vault_id = + vault_core::compute_vault_account_id(vault_program_id, attacker_id); + let amount: u128 = 1_000; + + let faucet_pre = AccountWithMetadata::new( + sequencer.state.get_account_by_id(faucet_account_id), + false, + faucet_account_id, + ); + let vault_pda_pre = AccountWithMetadata::new( + sequencer.state.get_account_by_id(attacker_vault_id), + false, + attacker_vault_id, + ); + + let faucet_chain_caller = + nssa::program::Program::new(test_program_methods::FAUCET_CHAIN_CALLER_ELF.to_vec()) + .unwrap(); + let program_with_deps = ProgramWithDependencies::new( + faucet_chain_caller, + [ + (faucet_program_id, nssa::program::Program::faucet()), + (vault_program_id, nssa::program::Program::vault()), + (auth_transfer_program_id, nssa::program::Program::authenticated_transfer_program()), + ] + .into(), + ); + + let instruction = nssa::program::Program::serialize_instruction(( + faucet_program_id, + vault_program_id, + attacker_id, + amount, + )) + .unwrap(); + + let (output, proof) = nssa::execute_and_prove( + vec![faucet_pre, vault_pda_pre], + instruction, + vec![ + InputAccountIdentity::Public, + InputAccountIdentity::Public, + ], + &program_with_deps, + ) + .unwrap(); + + let message = nssa::privacy_preserving_transaction::Message::try_from_circuit_output( + vec![faucet_account_id, attacker_vault_id], + vec![], // no public signers + vec![], + output, + ) + .unwrap(); + let witness_set = nssa::privacy_preserving_transaction::WitnessSet::for_message( + &message, + proof, + &[], // no signatures + ); + let attack_ppt = NSSATransaction::PrivacyPreserving( + nssa::PrivacyPreservingTransaction::new(message, witness_set), + ); + + let faucet_balance_before = sequencer.state.get_account_by_id(faucet_account_id).balance; + let vault_balance_before = sequencer.state.get_account_by_id(attacker_vault_id).balance; + + mempool_handle.push(attack_ppt).await.unwrap(); + sequencer.produce_new_block().await.unwrap(); + + let block = sequencer + .store + .get_block_at_id(sequencer.chain_height) + .unwrap() + .unwrap(); + let faucet_balance_after = sequencer.state.get_account_by_id(faucet_account_id).balance; + let vault_balance_after = sequencer.state.get_account_by_id(attacker_vault_id).balance; + + // The attack PPT must be dropped; only the mandatory clock invocation remains. + assert_eq!( + block.body.transactions, + vec![NSSATransaction::Public(clock_invocation( + block.header.timestamp + ))] + ); + assert_eq!(faucet_balance_after, faucet_balance_before); + assert_eq!(vault_balance_after, vault_balance_before); + } } diff --git a/test_program_methods/guest/Cargo.toml b/test_program_methods/guest/Cargo.toml index ca8cdc1d..47ea10e1 100644 --- a/test_program_methods/guest/Cargo.toml +++ b/test_program_methods/guest/Cargo.toml @@ -11,6 +11,7 @@ workspace = true nssa_core.workspace = true authenticated_transfer_core.workspace = true clock_core.workspace = true +faucet_core.workspace = true risc0-zkvm.workspace = true serde = { workspace = true, default-features = false } diff --git a/test_program_methods/guest/src/bin/faucet_chain_caller.rs b/test_program_methods/guest/src/bin/faucet_chain_caller.rs new file mode 100644 index 00000000..ca2e851b --- /dev/null +++ b/test_program_methods/guest/src/bin/faucet_chain_caller.rs @@ -0,0 +1,50 @@ +use nssa_core::{ + account::AccountId, + program::{AccountPostState, ChainedCall, ProgramId, ProgramInput, ProgramOutput, read_nssa_inputs}, +}; +use risc0_zkvm::serde::to_vec; + +type Instruction = (ProgramId, ProgramId, AccountId, u128); +// (faucet_program_id, vault_program_id, recipient_id, amount) + +fn main() { + let ( + ProgramInput { + self_program_id, + caller_program_id, + pre_states, + instruction: (faucet_program_id, vault_program_id, recipient_id, amount), + }, + instruction_words, + ) = read_nssa_inputs::(); + + let post_states: Vec<_> = pre_states + .iter() + .map(|pre| AccountPostState::new(pre.account.clone())) + .collect(); + + assert_eq!(pre_states.len(), 2); + let [faucet_pre, vault_pda_pre] = [pre_states[0].clone(), pre_states[1].clone()]; + + let chained_calls = vec![ChainedCall { + program_id: faucet_program_id, + instruction_data: to_vec(&faucet_core::Instruction::Transfer { + vault_program_id, + recipient_id, + amount, + }) + .unwrap(), + pre_states: vec![faucet_pre, vault_pda_pre], + pda_seeds: vec![], + }]; + + ProgramOutput::new( + self_program_id, + caller_program_id, + instruction_words, + pre_states, + post_states, + ) + .with_chained_calls(chained_calls) + .write(); +}