add tests and fix mechanism

This commit is contained in:
Sergio Chouhy 2026-05-15 00:43:45 -03:00
parent 4079b0c9c8
commit 2ae9e4da7f
6 changed files with 246 additions and 14 deletions

1
Cargo.lock generated
View File

@ -9181,6 +9181,7 @@ version = "0.1.0"
dependencies = [
"authenticated_transfer_core",
"clock_core",
"faucet_core",
"nssa_core",
"risc0-zkvm",
"serde",

View File

@ -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<ValidatedStateDiff, nssa::error::NssaError> {
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)
}

View File

@ -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);

View File

@ -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);
}
}

View File

@ -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 }

View File

@ -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::<Instruction>();
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();
}