From f729072fae5642b15129985eced07cd89d3aee4d Mon Sep 17 00:00:00 2001 From: Daniil Polyakov Date: Fri, 12 Dec 2025 16:53:30 +0300 Subject: [PATCH] feat: allow private authorized uninitialized accounts --- integration_tests/src/test_suite_map.rs | 43 ++++ integration_tests/src/tps_test_utils.rs | 2 +- nssa/core/src/circuit_io.rs | 13 +- .../src/bin/privacy_preserving_circuit.rs | 43 +++- .../privacy_preserving_transaction/circuit.rs | 6 +- nssa/src/program.rs | 9 + nssa/src/state.rs | 215 ++++++++++++++++-- .../guest/src/bin/noop.rs | 12 + wallet/src/privacy_preserving_tx.rs | 16 +- 9 files changed, 317 insertions(+), 42 deletions(-) create mode 100644 nssa/test_program_methods/guest/src/bin/noop.rs diff --git a/integration_tests/src/test_suite_map.rs b/integration_tests/src/test_suite_map.rs index 8012e17..8e42640 100644 --- a/integration_tests/src/test_suite_map.rs +++ b/integration_tests/src/test_suite_map.rs @@ -1681,6 +1681,49 @@ pub fn prepare_function_map() -> HashMap { info!("Success!"); } + #[nssa_integration_test] + pub async fn test_authenticated_transfer_initialize_function_private() { + info!("########## test initialize private account for authenticated transfer ##########"); + let command = + Command::Account(AccountSubcommand::New(NewSubcommand::Private { cci: None })); + let SubcommandReturnValue::RegisterAccount { account_id } = + wallet::cli::execute_subcommand(command).await.unwrap() + else { + panic!("Error creating account"); + }; + + let command = Command::AuthTransfer(AuthTransferSubcommand::Init { + account_id: make_private_account_input_from_str(&account_id.to_string()), + }); + wallet::cli::execute_subcommand(command).await.unwrap(); + + tokio::time::sleep(Duration::from_secs(TIME_TO_WAIT_FOR_BLOCK_SECONDS)).await; + + info!("Checking correct execution"); + let command = Command::Account(AccountSubcommand::SyncPrivate {}); + wallet::cli::execute_subcommand(command).await.unwrap(); + + let wallet_config = fetch_config().await.unwrap(); + let seq_client = SequencerClient::new(wallet_config.sequencer_addr.clone()).unwrap(); + let wallet_storage = WalletCore::start_from_config_update_chain(wallet_config) + .await + .unwrap(); + + let new_commitment1 = wallet_storage + .get_private_account_commitment(&account_id) + .unwrap(); + assert!(verify_commitment_is_in_state(new_commitment1, &seq_client).await); + + let account = wallet_storage.get_account_private(&account_id).unwrap(); + + let expected_program_owner = Program::authenticated_transfer_program().id(); + let expected_balance = 0; + + assert_eq!(account.program_owner, expected_program_owner); + assert_eq!(account.balance, expected_balance); + assert!(account.data.is_empty()); + } + #[nssa_integration_test] pub async fn test_pinata_private_receiver() { info!("########## test_pinata_private_receiver ##########"); diff --git a/integration_tests/src/tps_test_utils.rs b/integration_tests/src/tps_test_utils.rs index e9e7a82..154253c 100644 --- a/integration_tests/src/tps_test_utils.rs +++ b/integration_tests/src/tps_test_utils.rs @@ -168,7 +168,7 @@ fn build_privacy_transaction() -> PrivacyPreservingTransaction { (recipient_npk.clone(), recipient_ss), ], &[sender_nsk], - &[proof], + &[Some(proof)], &program.into(), ) .unwrap(); diff --git a/nssa/core/src/circuit_io.rs b/nssa/core/src/circuit_io.rs index 2abf7b5..dedcf78 100644 --- a/nssa/core/src/circuit_io.rs +++ b/nssa/core/src/circuit_io.rs @@ -10,12 +10,23 @@ use crate::{ #[derive(Serialize, Deserialize)] pub struct PrivacyPreservingCircuitInput { + /// Outputs of the program execution. pub program_outputs: Vec, + /// Visibility mask for accounts. + /// + /// - `0` - public account + /// - `1` - private account with authentication + /// - `2` - private account without authentication pub visibility_mask: Vec, + /// Nonces of private accounts. pub private_account_nonces: Vec, + /// Public keys of private accounts. pub private_account_keys: Vec<(NullifierPublicKey, SharedSecretKey)>, + /// Nullifier secret keys for authorized private accounts. pub private_account_nsks: Vec, - pub private_account_membership_proofs: Vec, + /// Membership proofs for private accounts. Can be [`None`] for uninitialized accounts. + pub private_account_membership_proofs: Vec>, + /// Program ID. pub program_id: ProgramId, } diff --git a/nssa/program_methods/guest/src/bin/privacy_preserving_circuit.rs b/nssa/program_methods/guest/src/bin/privacy_preserving_circuit.rs index 289d271..8d1688a 100644 --- a/nssa/program_methods/guest/src/bin/privacy_preserving_circuit.rs +++ b/nssa/program_methods/guest/src/bin/privacy_preserving_circuit.rs @@ -163,29 +163,44 @@ fn main() { // Private account with authentication let nsk = private_nsks_iter.next().expect("Missing nsk"); - let membership_proof = private_membership_proofs_iter - .next() - .expect("Missing membership proof"); - // Verify the nullifier public key let expected_npk = NullifierPublicKey::from(nsk); if &expected_npk != npk { panic!("Nullifier public key mismatch"); } - // Compute commitment set digest associated with provided auth path - let commitment_pre = Commitment::new(npk, &pre_states[i].account); - let set_digest = compute_digest_for_path(&commitment_pre, membership_proof); - // Check pre_state authorization if !pre_states[i].is_authorized { panic!("Pre-state not authorized"); } - // Compute update nullifier - let nullifier = Nullifier::for_account_update(&commitment_pre, nsk); + let membership_proof_opt = private_membership_proofs_iter + .next() + .expect("Missing membership proof"); + let (nullifier, set_digest) = membership_proof_opt + .as_ref() + .map(|membership_proof| { + // Compute commitment set digest associated with provided auth path + let commitment_pre = Commitment::new(npk, &pre_states[i].account); + let set_digest = + compute_digest_for_path(&commitment_pre, membership_proof); + + // Compute update nullifier + let nullifier = Nullifier::for_account_update(&commitment_pre, nsk); + (nullifier, set_digest) + }) + .unwrap_or_else(|| { + if pre_states[i].account != Account::default() { + panic!("Found new private account with non default values."); + } + + // Compute initialization nullifier + let nullifier = Nullifier::for_account_initialization(npk); + (nullifier, DUMMY_COMMITMENT_HASH) + }); new_nullifiers.push((nullifier, set_digest)); } else { + // Private account without authentication if pre_states[i].account != Account::default() { panic!("Found new private account with non default values."); } @@ -194,7 +209,13 @@ fn main() { panic!("Found new private account marked as authorized."); } - // Compute initialization nullifier + let membership_proof_opt = private_membership_proofs_iter + .next() + .expect("Missing membership proof"); + assert!( + membership_proof_opt.is_none(), + "Membership proof must be None for unauthorized accounts" + ); let nullifier = Nullifier::for_account_initialization(npk); new_nullifiers.push((nullifier, DUMMY_COMMITMENT_HASH)); } diff --git a/nssa/src/privacy_preserving_transaction/circuit.rs b/nssa/src/privacy_preserving_transaction/circuit.rs index b45f17c..c698b1b 100644 --- a/nssa/src/privacy_preserving_transaction/circuit.rs +++ b/nssa/src/privacy_preserving_transaction/circuit.rs @@ -52,7 +52,7 @@ pub fn execute_and_prove( private_account_nonces: &[u128], private_account_keys: &[(NullifierPublicKey, SharedSecretKey)], private_account_nsks: &[NullifierSecretKey], - private_account_membership_proofs: &[MembershipProof], + private_account_membership_proofs: &[Option], program_with_dependencies: &ProgramWithDependencies, ) -> Result<(PrivacyPreservingCircuitOutput, Proof), NssaError> { let mut program = &program_with_dependencies.program; @@ -221,7 +221,7 @@ mod tests { &[0xdeadbeef], &[(recipient_keys.npk(), shared_secret.clone())], &[], - &[], + &[None], &Program::authenticated_transfer_program().into(), ) .unwrap(); @@ -320,7 +320,7 @@ mod tests { (recipient_keys.npk(), shared_secret_2.clone()), ], &[sender_keys.nsk], - &[commitment_set.get_proof_for(&commitment_sender).unwrap()], + &[commitment_set.get_proof_for(&commitment_sender), None], &program.into(), ) .unwrap(); diff --git a/nssa/src/program.rs b/nssa/src/program.rs index 1865248..335bcb5 100644 --- a/nssa/src/program.rs +++ b/nssa/src/program.rs @@ -222,6 +222,15 @@ mod tests { } } + pub fn noop() -> Self { + use test_program_methods::{NOOP_ELF, NOOP_ID}; + + Program { + id: NOOP_ID, + elf: NOOP_ELF.to_vec(), + } + } + pub fn modified_transfer_program() -> Self { use test_program_methods::MODIFIED_TRANSFER_ELF; // This unwrap won't panic since the `MODIFIED_TRANSFER_ELF` comes from risc0 build of diff --git a/nssa/src/state.rs b/nssa/src/state.rs index 408b7e9..d5c138d 100644 --- a/nssa/src/state.rs +++ b/nssa/src/state.rs @@ -868,7 +868,7 @@ pub mod tests { &[0xdeadbeef], &[(recipient_keys.npk(), shared_secret)], &[], - &[], + &[None], &Program::authenticated_transfer_program().into(), ) .unwrap(); @@ -918,7 +918,7 @@ pub mod tests { (recipient_keys.npk(), shared_secret_2), ], &[sender_keys.nsk], - &[state.get_proof_for_commitment(&sender_commitment).unwrap()], + &[state.get_proof_for_commitment(&sender_commitment), None], &program.into(), ) .unwrap(); @@ -968,7 +968,7 @@ pub mod tests { &[new_nonce], &[(sender_keys.npk(), shared_secret)], &[sender_keys.nsk], - &[state.get_proof_for_commitment(&sender_commitment).unwrap()], + &[state.get_proof_for_commitment(&sender_commitment)], &program.into(), ) .unwrap(); @@ -1498,7 +1498,7 @@ pub mod tests { ), ], &[sender_keys.nsk], - &[(0, vec![])], + &[Some((0, vec![]))], &program.into(), ); @@ -1533,7 +1533,49 @@ pub mod tests { &[0xdeadbeef1, 0xdeadbeef2], &private_account_keys, &[sender_keys.nsk], - &[(0, vec![])], + &[Some((0, vec![]))], + &program.into(), + ); + + assert!(matches!(result, Err(NssaError::CircuitProvingError(_)))); + } + + #[test] + fn test_circuit_fails_if_insufficient_commitment_proofs_are_provided() { + let program = Program::simple_balance_transfer(); + let sender_keys = test_private_account_keys_1(); + let recipient_keys = test_private_account_keys_2(); + let private_account_1 = AccountWithMetadata::new( + Account { + program_owner: program.id(), + balance: 100, + ..Account::default() + }, + true, + &sender_keys.npk(), + ); + let private_account_2 = + AccountWithMetadata::new(Account::default(), false, &recipient_keys.npk()); + + // Setting no second commitment proof. + let private_account_membership_proofs = [Some((0, vec![]))]; + let result = execute_and_prove( + &[private_account_1, private_account_2], + &Program::serialize_instruction(10u128).unwrap(), + &[1, 2], + &[0xdeadbeef1, 0xdeadbeef2], + &[ + ( + sender_keys.npk(), + SharedSecretKey::new(&[55; 32], &sender_keys.ivk()), + ), + ( + recipient_keys.npk(), + SharedSecretKey::new(&[56; 32], &recipient_keys.ivk()), + ), + ], + &[sender_keys.nsk], + &private_account_membership_proofs, &program.into(), ); @@ -1616,7 +1658,7 @@ pub mod tests { // This should be set to the sender private account in // a normal circumstance. The recipient can't authorize this. let private_account_nsks = [recipient_keys.nsk]; - let private_account_membership_proofs = [(0, vec![])]; + let private_account_membership_proofs = [Some((0, vec![]))]; let result = execute_and_prove( &[private_account_1, private_account_2], &Program::serialize_instruction(10u128).unwrap(), @@ -1671,7 +1713,7 @@ pub mod tests { ), ], &[sender_keys.nsk], - &[(0, vec![])], + &[Some((0, vec![]))], &program.into(), ); @@ -1719,7 +1761,7 @@ pub mod tests { ), ], &[sender_keys.nsk], - &[(0, vec![])], + &[Some((0, vec![]))], &program.into(), ); @@ -1766,7 +1808,7 @@ pub mod tests { ), ], &[sender_keys.nsk], - &[(0, vec![])], + &[Some((0, vec![]))], &program.into(), ); @@ -1813,7 +1855,7 @@ pub mod tests { ), ], &[sender_keys.nsk], - &[(0, vec![])], + &[Some((0, vec![]))], &program.into(), ); @@ -1858,7 +1900,7 @@ pub mod tests { ), ], &[sender_keys.nsk], - &[(0, vec![])], + &[Some((0, vec![]))], &program.into(), ); @@ -1931,7 +1973,7 @@ pub mod tests { ), ], &[sender_keys.nsk], - &[(0, vec![])], + &[Some((0, vec![]))], &program.into(), ); @@ -1978,7 +2020,7 @@ pub mod tests { &[0xdeadbeef1, 0xdeadbeef2], &private_account_keys, &[sender_keys.nsk], - &[(0, vec![])], + &[Some((0, vec![]))], &program.into(), ); @@ -2006,7 +2048,7 @@ pub mod tests { // private account (visibility mask equal to 1 means that auth keys are expected). let visibility_mask = [1, 2]; let private_account_nsks = [sender_keys.nsk, recipient_keys.nsk]; - let private_account_membership_proofs = [(0, vec![]), (1, vec![])]; + let private_account_membership_proofs = [Some((0, vec![])), Some((1, vec![]))]; let result = execute_and_prove( &[private_account_1, private_account_2], &Program::serialize_instruction(10u128).unwrap(), @@ -2101,7 +2143,7 @@ pub mod tests { let visibility_mask = [1, 1]; let private_account_nsks = [sender_keys.nsk, sender_keys.nsk]; - let private_account_membership_proofs = [(1, vec![]), (1, vec![])]; + let private_account_membership_proofs = [Some((1, vec![])), Some((1, vec![]))]; let shared_secret = SharedSecretKey::new(&[55; 32], &sender_keys.ivk()); let result = execute_and_prove( &[private_account_1.clone(), private_account_1], @@ -2416,8 +2458,8 @@ pub mod tests { &[(from_keys.npk(), to_ss), (to_keys.npk(), from_ss)], &[from_keys.nsk, to_keys.nsk], &[ - state.get_proof_for_commitment(&from_commitment).unwrap(), - state.get_proof_for_commitment(&to_commitment).unwrap(), + state.get_proof_for_commitment(&from_commitment), + state.get_proof_for_commitment(&to_commitment), ], &program_with_deps, ) @@ -2624,4 +2666,143 @@ pub mod tests { assert!(expected_sender_post == sender_post); assert!(expected_recipient_post == recipient_post); } + + #[test] + fn test_private_authorized_uninitialized_account() { + let mut state = V02State::new_with_genesis_accounts(&[], &[]); + + // Set up keys for the authorized private account + let private_keys = test_private_account_keys_1(); + + // Create an authorized private account with default values (new account being initialized) + let authorized_account = + AccountWithMetadata::new(Account::default(), true, &private_keys.npk()); + + let program = Program::authenticated_transfer_program(); + + // Set up parameters for the new account + let esk = [3; 32]; + let shared_secret = SharedSecretKey::new(&esk, &private_keys.ivk()); + let epk = EphemeralPublicKey::from_scalar(esk); + + // Balance to initialize the account with (0 for a new account) + let balance: u128 = 0; + + let nonce = 0xdeadbeef1; + + // Execute and prove the circuit with the authorized account but no commitment proof + let (output, proof) = execute_and_prove( + std::slice::from_ref(&authorized_account), + &Program::serialize_instruction(balance).unwrap(), + &[1], + &[nonce], + &[(private_keys.npk(), shared_secret)], + &[private_keys.nsk], + &[None], + &program.into(), + ) + .unwrap(); + + // Create message from circuit output + let message = Message::try_from_circuit_output( + vec![], + vec![], + vec![(private_keys.npk(), private_keys.ivk(), epk)], + output, + ) + .unwrap(); + + let witness_set = WitnessSet::for_message(&message, proof, &[]); + + let tx = PrivacyPreservingTransaction::new(message, witness_set); + let result = state.transition_from_privacy_preserving_transaction(&tx); + assert!(result.is_ok()); + + let nullifier = Nullifier::for_account_initialization(&private_keys.npk()); + assert!(state.private_state.1.contains(&nullifier)); + } + + #[test] + fn test_private_account_claimed_then_used_without_init_flag_should_fail() { + let mut state = V02State::new_with_genesis_accounts(&[], &[]).with_test_programs(); + + // Set up keys for the private account + let private_keys = test_private_account_keys_1(); + + // Step 1: Create a new private account with authorization + let authorized_account = + AccountWithMetadata::new(Account::default(), true, &private_keys.npk()); + + let claimer_program = Program::claimer(); + + // Set up parameters for claiming the new account + let esk = [3; 32]; + let shared_secret = SharedSecretKey::new(&esk, &private_keys.ivk()); + let epk = EphemeralPublicKey::from_scalar(esk); + + let balance: u128 = 0; + let nonce = 0xdeadbeef1; + + // Step 2: Execute claimer program to claim the account with authentication + let (output, proof) = execute_and_prove( + std::slice::from_ref(&authorized_account), + &Program::serialize_instruction(balance).unwrap(), + &[1], + &[nonce], + &[(private_keys.npk(), shared_secret)], + &[private_keys.nsk], + &[None], + &claimer_program.into(), + ) + .unwrap(); + + let message = Message::try_from_circuit_output( + vec![], + vec![], + vec![(private_keys.npk(), private_keys.ivk(), epk)], + output, + ) + .unwrap(); + + let witness_set = WitnessSet::for_message(&message, proof, &[]); + let tx = PrivacyPreservingTransaction::new(message, witness_set); + + // Claim should succeed + assert!( + state + .transition_from_privacy_preserving_transaction(&tx) + .is_ok() + ); + + // Verify the account is now initialized (nullifier exists) + let nullifier = Nullifier::for_account_initialization(&private_keys.npk()); + assert!(state.private_state.1.contains(&nullifier)); + + // Prepare new state of account + let account_metadata = { + let mut acc = authorized_account.clone(); + acc.account.program_owner = Program::claimer().id(); + acc + }; + + let noop_program = Program::noop(); + let esk2 = [4; 32]; + let shared_secret2 = SharedSecretKey::new(&esk2, &private_keys.ivk()); + + let nonce2 = 0xdeadbeef2; + + // Step 3: Try to execute noop program with authentication but without initialization + let res = execute_and_prove( + std::slice::from_ref(&account_metadata), + &Program::serialize_instruction(()).unwrap(), + &[1], + &[nonce2], + &[(private_keys.npk(), shared_secret2)], + &[private_keys.nsk], + &[None], + &noop_program.into(), + ); + + assert!(matches!(res, Err(NssaError::CircuitProvingError(_)))); + } } diff --git a/nssa/test_program_methods/guest/src/bin/noop.rs b/nssa/test_program_methods/guest/src/bin/noop.rs new file mode 100644 index 0000000..fb02389 --- /dev/null +++ b/nssa/test_program_methods/guest/src/bin/noop.rs @@ -0,0 +1,12 @@ +use nssa_core::program::{read_nssa_inputs, write_nssa_outputs, ProgramInput, AccountPostState}; + +type Instruction = (); + +fn main() { + let (ProgramInput { pre_states, .. }, instruction_words) = read_nssa_inputs::(); + + let post_states = pre_states.iter().map(|account| { + AccountPostState::new(account.account.clone()) + }).collect(); + write_nssa_outputs(instruction_words, pre_states, post_states); +} diff --git a/wallet/src/privacy_preserving_tx.rs b/wallet/src/privacy_preserving_tx.rs index 4ddf5e6..3bd0c4c 100644 --- a/wallet/src/privacy_preserving_tx.rs +++ b/wallet/src/privacy_preserving_tx.rs @@ -143,11 +143,11 @@ impl AccountManager { .collect() } - pub fn private_account_membership_proofs(&self) -> Vec { + pub fn private_account_membership_proofs(&self) -> Vec> { self.states .iter() .filter_map(|state| match state { - State::Private(pre) => pre.proof.clone(), + State::Private(pre) => Some(pre.proof.clone()), _ => None, }) .collect() @@ -195,7 +195,7 @@ async fn private_acc_preparation( return Err(ExecutionFailureKind::KeyNotFoundError); }; - let mut nsk = Some(from_keys.private_key_holder.nullifier_secret_key); + let nsk = from_keys.private_key_holder.nullifier_secret_key; let from_npk = from_keys.nullifer_public_key; let from_ipk = from_keys.incoming_viewing_public_key; @@ -206,14 +206,12 @@ async fn private_acc_preparation( .await .unwrap(); - if proof.is_none() { - nsk = None; - } - - let sender_pre = AccountWithMetadata::new(from_acc.clone(), proof.is_some(), &from_npk); + // TODO: Technically we could allow unauthorized owned accounts, but currently we don't have + // support from that in the wallet. + let sender_pre = AccountWithMetadata::new(from_acc.clone(), true, &from_npk); Ok(AccountPreparedData { - nsk, + nsk: Some(nsk), npk: from_npk, ipk: from_ipk, pre_state: sender_pre,