add validity window checks on privacy preserving transactions

This commit is contained in:
Sergio Chouhy 2026-03-19 16:07:20 -03:00
parent 7bbd2dd5d7
commit a069004451
10 changed files with 157 additions and 20 deletions

Binary file not shown.

View File

@ -3,11 +3,7 @@
use nssa_core::account::Nonce;
use crate::{
Account, AccountId, BedrockStatus, Block, BlockBody, BlockHeader, Ciphertext, Commitment,
CommitmentSetDigest, Data, EncryptedAccountData, EphemeralPublicKey, HashType, MantleMsgId,
Nullifier, PrivacyPreservingMessage, PrivacyPreservingTransaction, ProgramDeploymentMessage,
ProgramDeploymentTransaction, ProgramId, Proof, PublicKey, PublicMessage, PublicTransaction,
Signature, Transaction, WitnessSet,
Account, AccountId, BedrockStatus, Block, BlockBody, BlockHeader, Ciphertext, Commitment, CommitmentSetDigest, Data, EncryptedAccountData, EphemeralPublicKey, HashType, MantleMsgId, Nullifier, PrivacyPreservingMessage, PrivacyPreservingTransaction, ProgramDeploymentMessage, ProgramDeploymentTransaction, ProgramId, Proof, PublicKey, PublicMessage, PublicTransaction, Signature, Transaction, ValidityWindow, WitnessSet
};
// ============================================================================
@ -287,6 +283,7 @@ impl From<nssa::privacy_preserving_transaction::message::Message> for PrivacyPre
encrypted_private_post_states,
new_commitments,
new_nullifiers,
validity_window,
} = value;
Self {
public_account_ids: public_account_ids.into_iter().map(Into::into).collect(),
@ -301,6 +298,7 @@ impl From<nssa::privacy_preserving_transaction::message::Message> for PrivacyPre
.into_iter()
.map(|(n, d)| (n.into(), d.into()))
.collect(),
validity_window: ValidityWindow(validity_window),
}
}
}
@ -316,6 +314,7 @@ impl TryFrom<PrivacyPreservingMessage> for nssa::privacy_preserving_transaction:
encrypted_private_post_states,
new_commitments,
new_nullifiers,
validity_window,
} = value;
Ok(Self {
public_account_ids: public_account_ids.into_iter().map(Into::into).collect(),
@ -336,6 +335,7 @@ impl TryFrom<PrivacyPreservingMessage> for nssa::privacy_preserving_transaction:
.into_iter()
.map(|(n, d)| (n.into(), d.into()))
.collect(),
validity_window: validity_window.0,
})
}
}

View File

@ -235,6 +235,7 @@ pub struct PrivacyPreservingMessage {
pub encrypted_private_post_states: Vec<EncryptedAccountData>,
pub new_commitments: Vec<Commitment>,
pub new_nullifiers: Vec<(Nullifier, CommitmentSetDigest)>,
pub validity_window: ValidityWindow,
}
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema)]
@ -300,6 +301,9 @@ pub struct Nullifier(
pub [u8; 32],
);
#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema)]
pub struct ValidityWindow(pub (Option<BlockId>, Option<BlockId>));
#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema)]
pub struct CommitmentSetDigest(
#[serde(with = "base64::arr")]

View File

@ -1,11 +1,7 @@
use serde::{Deserialize, Serialize};
use crate::{
Commitment, CommitmentSetDigest, MembershipProof, Nullifier, NullifierPublicKey,
NullifierSecretKey, SharedSecretKey,
account::{Account, AccountWithMetadata},
encryption::Ciphertext,
program::{ProgramId, ProgramOutput},
account::{Account, AccountWithMetadata}, encryption::Ciphertext, program::{ProgramId, ProgramOutput, ValidityWindow}, Commitment, CommitmentSetDigest, MembershipProof, Nullifier, NullifierPublicKey, NullifierSecretKey, SharedSecretKey
};
#[derive(Serialize, Deserialize)]
@ -36,6 +32,7 @@ pub struct PrivacyPreservingCircuitOutput {
pub ciphertexts: Vec<Ciphertext>,
pub new_commitments: Vec<Commitment>,
pub new_nullifiers: Vec<(Nullifier, CommitmentSetDigest)>,
pub validity_window: ValidityWindow
}
#[cfg(feature = "host")]
@ -101,6 +98,7 @@ mod tests {
),
[0xab; 32],
)],
validity_window: (Some(1), None),
};
let bytes = output.to_bytes();
let output_from_slice: PrivacyPreservingCircuitOutput = from_slice(&bytes).unwrap();

View File

@ -3,6 +3,7 @@ use nssa_core::{
Commitment, CommitmentSetDigest, Nullifier, NullifierPublicKey, PrivacyPreservingCircuitOutput,
account::{Account, Nonce},
encryption::{Ciphertext, EphemeralPublicKey, ViewingPublicKey},
program::ValidityWindow,
};
use sha2::{Digest as _, Sha256};
@ -52,6 +53,7 @@ pub struct Message {
pub encrypted_private_post_states: Vec<EncryptedAccountData>,
pub new_commitments: Vec<Commitment>,
pub new_nullifiers: Vec<(Nullifier, CommitmentSetDigest)>,
pub validity_window: ValidityWindow,
}
impl std::fmt::Debug for Message {
@ -77,6 +79,7 @@ impl std::fmt::Debug for Message {
)
.field("new_commitments", &self.new_commitments)
.field("new_nullifiers", &nullifiers)
.field("validity_window", &self.validity_window)
.finish()
}
}
@ -109,6 +112,7 @@ impl Message {
encrypted_private_post_states,
new_commitments: output.new_commitments,
new_nullifiers: output.new_nullifiers,
validity_window: output.validity_window,
})
}
}
@ -161,6 +165,7 @@ pub mod tests {
encrypted_private_post_states,
new_commitments,
new_nullifiers,
validity_window: (None, None),
}
}

View File

@ -7,6 +7,7 @@ use borsh::{BorshDeserialize, BorshSerialize};
use nssa_core::{
Commitment, CommitmentSetDigest, Nullifier, PrivacyPreservingCircuitOutput,
account::{Account, AccountWithMetadata},
program::{BlockId, ValidityWindow},
};
use sha2::{Digest as _, digest::FixedOutput as _};
@ -35,6 +36,7 @@ impl PrivacyPreservingTransaction {
pub(crate) fn validate_and_produce_public_state_diff(
&self,
state: &V02State,
block_id: BlockId,
) -> Result<HashMap<AccountId, Account>, NssaError> {
let message = &self.message;
let witness_set = &self.witness_set;
@ -112,6 +114,7 @@ impl PrivacyPreservingTransaction {
&message.encrypted_private_post_states,
&message.new_commitments,
&message.new_nullifiers,
&message.validity_window,
)?;
// 5. Commitment freshness
@ -120,6 +123,18 @@ impl PrivacyPreservingTransaction {
// 6. Nullifier uniqueness
state.check_nullifiers_are_valid(&message.new_nullifiers)?;
// 7. Verify validity window
if let Some(from_id) = message.validity_window.0
&& block_id < from_id
{
return Err(NssaError::OutOfValidityWindow);
}
if let Some(until_id) = message.validity_window.1
&& until_id < block_id
{
return Err(NssaError::OutOfValidityWindow);
}
Ok(message
.public_account_ids
.iter()
@ -173,6 +188,7 @@ fn check_privacy_preserving_circuit_proof_is_valid(
encrypted_private_post_states: &[EncryptedAccountData],
new_commitments: &[Commitment],
new_nullifiers: &[(Nullifier, CommitmentSetDigest)],
validity_window: &ValidityWindow,
) -> Result<(), NssaError> {
let output = PrivacyPreservingCircuitOutput {
public_pre_states: public_pre_states.to_vec(),
@ -184,6 +200,7 @@ fn check_privacy_preserving_circuit_proof_is_valid(
.collect(),
new_commitments: new_commitments.to_vec(),
new_nullifiers: new_nullifiers.to_vec(),
validity_window: validity_window.to_owned(),
};
proof
.is_valid_for(&output)

View File

@ -182,10 +182,10 @@ impl V02State {
pub fn transition_from_privacy_preserving_transaction(
&mut self,
tx: &PrivacyPreservingTransaction,
_block_id: BlockId,
block_id: BlockId,
) -> Result<(), NssaError> {
// 1. Verify the transaction satisfies acceptance criteria
let public_state_diff = tx.validate_and_produce_public_state_diff(self)?;
let public_state_diff = tx.validate_and_produce_public_state_diff(self, block_id)?;
let message = tx.message();
@ -3012,12 +3012,14 @@ pub mod tests {
#[test_case::test_case((None, Some(3)), 4; "upper bound only - above")]
#[test_case::test_case((None, None), 0; "no bounds - always valid")]
#[test_case::test_case((None, None), 100; "no bounds - always valid 2")]
fn validity_window_works(validity_window: ValidityWindow, block_id: BlockId) {
fn validity_window_works_in_public_transactions(
validity_window: ValidityWindow,
block_id: BlockId,
) {
let validity_window_program = Program::validity_window();
let account_keys = test_public_account_keys_1();
let pre = AccountWithMetadata::new(Account::default(), false, account_keys.account_id());
let mut state =
V02State::new_with_genesis_accounts(&[], &[]).with_test_programs();
let mut state = V02State::new_with_genesis_accounts(&[], &[]).with_test_programs();
let tx = {
let account_ids = vec![pre.account_id];
let nonces = vec![];
@ -3046,6 +3048,68 @@ pub mod tests {
}
}
#[test_case::test_case((Some(1), Some(3)), 3; "at upper bound")]
#[test_case::test_case((Some(1), Some(3)), 2; "inside range")]
#[test_case::test_case((Some(1), Some(3)), 0; "below range")]
#[test_case::test_case((Some(1), Some(3)), 1; "at lower bound")]
#[test_case::test_case((Some(1), Some(3)), 4; "above range")]
#[test_case::test_case((Some(1), None), 1; "lower bound only - at bound")]
#[test_case::test_case((Some(1), None), 10; "lower bound only - above")]
#[test_case::test_case((Some(1), None), 0; "lower bound only - below")]
#[test_case::test_case((None, Some(3)), 3; "upper bound only - at bound")]
#[test_case::test_case((None, Some(3)), 0; "upper bound only - below")]
#[test_case::test_case((None, Some(3)), 4; "upper bound only - above")]
#[test_case::test_case((None, None), 0; "no bounds - always valid")]
#[test_case::test_case((None, None), 100; "no bounds - always valid 2")]
fn validity_window_works_in_privacy_preserving_transactions(
validity_window: ValidityWindow,
block_id: BlockId,
) {
let validity_window_program = Program::validity_window();
let account_keys = test_private_account_keys_1();
let pre = AccountWithMetadata::new(Account::default(), false, &account_keys.npk());
let mut state = V02State::new_with_genesis_accounts(&[], &[]).with_test_programs();
let tx = {
let esk = [3; 32];
let shared_secret = SharedSecretKey::new(&esk, &account_keys.vpk());
let epk = EphemeralPublicKey::from_scalar(esk);
let (output, proof) = circuit::execute_and_prove(
vec![pre],
Program::serialize_instruction(validity_window).unwrap(),
vec![2],
vec![(account_keys.npk(), shared_secret)],
vec![],
vec![None],
&validity_window_program.into(),
)
.unwrap();
let message = Message::try_from_circuit_output(
vec![],
vec![],
vec![(account_keys.npk(), account_keys.vpk(), epk)],
output,
)
.unwrap();
let witness_set = WitnessSet::for_message(&message, proof, &[]);
PrivacyPreservingTransaction::new(message, witness_set)
};
let result = state.transition_from_privacy_preserving_transaction(&tx, block_id);
let is_inside_validity_window = match (validity_window.0, validity_window.1) {
(Some(s), Some(e)) => s <= block_id && block_id <= e,
(Some(s), None) => s <= block_id,
(None, Some(e)) => block_id <= e,
(None, None) => true,
};
if is_inside_validity_window {
assert!(result.is_ok());
} else {
assert!(matches!(result, Err(NssaError::OutOfValidityWindow)))
}
}
#[test]
fn state_serialization_roundtrip() {
let account_id_1 = AccountId::new([1; 32]);

View File

@ -11,7 +11,7 @@ use nssa_core::{
compute_digest_for_path,
program::{
AccountPostState, ChainedCall, DEFAULT_PROGRAM_ID, MAX_NUMBER_CHAINED_CALLS, ProgramId,
ProgramOutput, validate_execution,
ProgramOutput, ValidityWindow, validate_execution,
},
};
use risc0_zkvm::{guest::env, serde::to_vec};
@ -20,11 +20,27 @@ use risc0_zkvm::{guest::env, serde::to_vec};
struct ExecutionState {
pre_states: Vec<AccountWithMetadata>,
post_states: HashMap<AccountId, Account>,
validity_window: ValidityWindow,
}
impl ExecutionState {
/// Validate program outputs and derive the overall execution state.
pub fn derive_from_outputs(program_id: ProgramId, program_outputs: Vec<ProgramOutput>) -> Self {
let valid_from_id = program_outputs
.iter()
.filter_map(|output| output.validity_window.0)
.max();
let valid_until_id = program_outputs
.iter()
.filter_map(|output| output.validity_window.1)
.min();
let mut execution_state = Self {
pre_states: Vec::new(),
post_states: HashMap::new(),
validity_window: (valid_from_id, valid_until_id),
};
let Some(first_output) = program_outputs.first() else {
panic!("No program outputs provided");
};
@ -37,10 +53,6 @@ impl ExecutionState {
};
let mut chained_calls = VecDeque::from_iter([(initial_call, None)]);
let mut execution_state = Self {
pre_states: Vec::new(),
post_states: HashMap::new(),
};
let mut program_outputs_iter = program_outputs.into_iter();
let mut chain_calls_counter = 0;
@ -210,6 +222,7 @@ fn compute_circuit_output(
ciphertexts: Vec::new(),
new_commitments: Vec::new(),
new_nullifiers: Vec::new(),
validity_window: execution_state.validity_window,
};
let states_iter = execution_state.into_states_iter();

View File

@ -0,0 +1,36 @@
use nssa_core::program::{
AccountPostState, BlockId, ProgramInput, ProgramOutput, read_nssa_inputs,
};
type Instruction = (Option<BlockId>, Option<BlockId>);
fn main() {
let (
ProgramInput {
pre_states,
instruction: (from_id, until_id),
},
instruction_words,
) = read_nssa_inputs::<Instruction>();
let Ok([pre]) = <[_; 1]>::try_from(pre_states) else {
return;
};
let post = pre.account.clone();
let mut output = ProgramOutput::new(
instruction_words,
vec![pre],
vec![AccountPostState::new(post)],
);
if let Some(id) = from_id {
output = output.valid_from_id(id);
}
if let Some(id) = until_id {
output = output.valid_until_id(id);
}
output.write();
}