feat: update commitment mechanism for new private accounts

Allow init accounts to optionally use a real membership proof for
DUMMY_COMMITMENT instead of hardcoding DUMMY_COMMITMENT_HASH as the
CommitmentSetDigest. The wallet fetches the proof from the sequencer
and passes it through the circuit.
This commit is contained in:
Marvin Jones 2026-06-19 12:45:24 -04:00
parent 67c832ae0f
commit dd0dc9f1b5
8 changed files with 210 additions and 14 deletions

View File

@ -11,8 +11,9 @@ use lee::{
privacy_preserving_transaction::circuit::ProgramWithDependencies, program::Program,
};
use lee_core::{
EncryptedAccountData, InputAccountIdentity, NullifierPublicKey,
account::AccountWithMetadata,
Commitment, DUMMY_COMMITMENT, DUMMY_COMMITMENT_HASH, EncryptedAccountData,
InputAccountIdentity, Nullifier, NullifierPublicKey, compute_digest_for_path,
account::{Account, AccountWithMetadata},
encryption::{EphemeralPublicKey, ViewingPublicKey},
};
use log::info;
@ -710,6 +711,7 @@ async fn ppt_cant_chain_call_faucet() -> Result<()> {
npk,
ssk,
identifier: 1337,
membership_proof: None,
seed: None,
},
],
@ -720,3 +722,115 @@ async fn ppt_cant_chain_call_faucet() -> Result<()> {
Ok(())
}
#[test]
async fn init_with_dummy_commitment_membership_proof_produces_valid_root() -> Result<()> {
let ctx = TestContext::new().await?;
let program = Program::authenticated_transfer_program();
let sender_id = ctx.existing_public_accounts()[0];
let sender_pre = AccountWithMetadata::new(
ctx.sequencer_client().get_account(sender_id).await?,
true,
sender_id,
);
let nsk: lee_core::NullifierSecretKey = [7; 32];
let npk = NullifierPublicKey::from(&nsk);
let vpk = ViewingPublicKey::from_bytes(vec![4_u8; 1184]).unwrap();
let ssk = SharedSecretKey([55_u8; 32]);
let recipient_account_id = AccountId::for_regular_private_account(&npk, 0);
let recipient = AccountWithMetadata::new(Account::default(), false, recipient_account_id);
let dummy_proof = ctx
.sequencer_client()
.get_proof_for_commitment(DUMMY_COMMITMENT)
.await?
.expect("DUMMY_COMMITMENT must be in genesis commitment set");
let expected_digest = compute_digest_for_path(&DUMMY_COMMITMENT, &dummy_proof);
let (output, proof) = execute_and_prove(
vec![sender_pre, recipient],
Program::serialize_instruction(authenticated_transfer_core::Instruction::Transfer {
amount: 1,
})?,
vec![
InputAccountIdentity::Public,
InputAccountIdentity::PrivateUnauthorized {
epk: EphemeralPublicKey(Vec::new()),
view_tag: EncryptedAccountData::compute_view_tag(&npk, &vpk),
npk,
ssk,
identifier: 0,
membership_proof: Some(dummy_proof),
},
],
&program.into(),
)?;
assert!(proof.is_valid_for(&output));
assert_eq!(output.new_nullifiers.len(), 1);
let (nullifier, digest) = &output.new_nullifiers[0];
assert_eq!(
*nullifier,
Nullifier::for_account_initialization(&recipient_account_id)
);
assert_eq!(*digest, expected_digest);
assert_ne!(*digest, DUMMY_COMMITMENT_HASH);
Ok(())
}
#[test]
async fn init_proof_is_invalid_when_nullifier_digest_is_swapped() -> Result<()> {
let ctx = TestContext::new().await?;
let program = Program::authenticated_transfer_program();
let sender_id = ctx.existing_public_accounts()[0];
let sender_pre = AccountWithMetadata::new(
ctx.sequencer_client().get_account(sender_id).await?,
true,
sender_id,
);
let nsk: lee_core::NullifierSecretKey = [7; 32];
let npk = NullifierPublicKey::from(&nsk);
let vpk = ViewingPublicKey::from_bytes(vec![4_u8; 1184]).unwrap();
let ssk = SharedSecretKey([55_u8; 32]);
let recipient_account_id = AccountId::for_regular_private_account(&npk, 0);
let recipient = AccountWithMetadata::new(Account::default(), false, recipient_account_id);
let dummy_proof = ctx
.sequencer_client()
.get_proof_for_commitment(DUMMY_COMMITMENT)
.await?
.expect("DUMMY_COMMITMENT must be in genesis commitment set");
let (output, proof) = execute_and_prove(
vec![sender_pre, recipient],
Program::serialize_instruction(authenticated_transfer_core::Instruction::Transfer {
amount: 1,
})?,
vec![
InputAccountIdentity::Public,
InputAccountIdentity::PrivateUnauthorized {
epk: EphemeralPublicKey(Vec::new()),
view_tag: EncryptedAccountData::compute_view_tag(&npk, &vpk),
npk,
ssk,
identifier: 0,
membership_proof: Some(dummy_proof),
},
],
&program.into(),
)?;
assert!(proof.is_valid_for(&output));
let mut tampered_output = output;
tampered_output.new_nullifiers[0].1 = DUMMY_COMMITMENT_HASH;
assert!(!proof.is_valid_for(&tampered_output));
Ok(())
}

View File

@ -78,6 +78,7 @@ async fn fund_private_pda(
npk,
ssk,
identifier,
membership_proof: None,
seed: Some((seed, authority_program_id)),
},
];

View File

@ -314,6 +314,7 @@ fn build_privacy_transaction() -> PrivacyPreservingTransaction {
npk: recipient_npk,
ssk: recipient_ss,
identifier: 0,
membership_proof: None,
},
],
&program.into(),

View File

@ -38,6 +38,7 @@ pub enum InputAccountIdentity {
ssk: SharedSecretKey,
nsk: NullifierSecretKey,
identifier: Identifier,
membership_proof: Option<MembershipProof>,
},
/// Update of an authorized standalone private account: existing on-chain commitment, with
/// membership proof.
@ -57,6 +58,7 @@ pub enum InputAccountIdentity {
npk: NullifierPublicKey,
ssk: SharedSecretKey,
identifier: Identifier,
membership_proof: Option<MembershipProof>,
},
/// Init of a private PDA, unauthorized. The npk-to-account_id binding is proven upstream
/// via `Claim::Pda(seed)` or a caller's `pda_seeds` match. The identifier diversifies the
@ -68,6 +70,7 @@ pub enum InputAccountIdentity {
npk: NullifierPublicKey,
ssk: SharedSecretKey,
identifier: Identifier,
membership_proof: Option<MembershipProof>,
/// When `Some((seed, authority_program_id))`, the circuit binds this position via the
/// external derivation check
/// `AccountId::for_private_pda(authority_program_id, seed, npk, identifier) ==

View File

@ -273,6 +273,7 @@ mod tests {
npk: recipient_keys.npk(),
ssk: shared_secret,
identifier: 0,
membership_proof: None,
},
],
&crate::test_methods::simple_balance_transfer().into(),
@ -387,6 +388,7 @@ mod tests {
npk: recipient_keys.npk(),
ssk: shared_secret_2,
identifier: 0,
membership_proof: None,
},
],
&program.into(),
@ -460,6 +462,7 @@ mod tests {
npk: account_keys.npk(),
ssk: shared_secret,
identifier: 0,
membership_proof: None,
}],
&program_with_deps,
);
@ -491,6 +494,7 @@ mod tests {
npk,
ssk: shared_secret,
identifier,
membership_proof: None,
seed: None,
}],
&program.clone().into(),
@ -540,6 +544,7 @@ mod tests {
npk,
ssk: shared_secret_pda,
identifier: 0,
membership_proof: None,
seed: None,
}],
&program_with_deps,
@ -595,6 +600,7 @@ mod tests {
npk,
ssk: shared_secret_pda,
identifier: 0,
membership_proof: None,
seed: None,
},
InputAccountIdentity::Public,
@ -653,6 +659,7 @@ mod tests {
npk: shared_npk,
ssk: shared_secret,
identifier: shared_identifier,
membership_proof: None,
},
],
&program.into(),
@ -683,6 +690,7 @@ mod tests {
ssk,
nsk: keys.nsk,
identifier,
membership_proof: None,
}],
&program.into(),
)
@ -698,23 +706,40 @@ mod tests {
/// to `PrivateAccountKind::Regular` carrying the correct identifier.
#[test]
fn private_unauthorized_init_encrypts_regular_kind_with_identifier() {
let program = crate::test_methods::claimer();
let program = Program::authenticated_transfer_program();
let keys = test_private_account_keys_1();
let identifier: u128 = 99;
let ssk = SharedSecretKey::encapsulate_deterministic(&keys.vpk(), &[0_u8; 32], 0).0;
let sender = AccountWithMetadata::new(
Account {
program_owner: program.id(),
balance: 1,
..Account::default()
},
true,
AccountId::new([0; 32]),
);
let recipient_id = AccountId::for_regular_private_account(&keys.npk(), identifier);
let recipient = AccountWithMetadata::new(Account::default(), false, recipient_id);
let (output, _) = execute_and_prove(
vec![recipient],
Program::serialize_instruction(()).unwrap(),
vec![InputAccountIdentity::PrivateUnauthorized {
epk: EphemeralPublicKey(Vec::new()),
view_tag: EncryptedAccountData::compute_view_tag(&keys.npk(), &keys.vpk()),
npk: keys.npk(),
ssk,
identifier,
}],
vec![sender, recipient],
Program::serialize_instruction(authenticated_transfer_core::Instruction::Transfer {
amount: 1,
})
.unwrap(),
vec![
InputAccountIdentity::Public,
InputAccountIdentity::PrivateUnauthorized {
epk: EphemeralPublicKey(Vec::new()),
view_tag: EncryptedAccountData::compute_view_tag(&keys.npk(), &keys.vpk()),
npk: keys.npk(),
ssk,
identifier,
membership_proof: None,
},
],
&program.into(),
)
.unwrap();
@ -848,6 +873,7 @@ mod tests {
npk,
ssk: shared_secret,
identifier: 99,
membership_proof: None,
seed: None,
}],
&program.into(),
@ -903,4 +929,5 @@ mod tests {
assert!(matches!(result, Err(LeeError::CircuitProvingError(_))));
}
}

View File

@ -1192,6 +1192,7 @@ pub mod tests {
npk: recipient_keys.npk(),
ssk: shared_secret,
identifier: 0,
membership_proof: None,
},
],
&crate::test_methods::simple_balance_transfer().into(),
@ -1259,6 +1260,7 @@ pub mod tests {
npk: recipient_keys.npk(),
ssk: shared_secret_2,
identifier: 0,
membership_proof: None,
},
],
&program.into(),
@ -1908,6 +1910,7 @@ pub mod tests {
0,
)
.0,
membership_proof: None,
identifier: 0,
},
],
@ -1974,6 +1977,7 @@ pub mod tests {
0,
)
.0,
membership_proof: None,
identifier: 0,
},
],
@ -2040,6 +2044,7 @@ pub mod tests {
0,
)
.0,
membership_proof: None,
identifier: 0,
},
],
@ -2106,6 +2111,7 @@ pub mod tests {
0,
)
.0,
membership_proof: None,
identifier: 0,
},
],
@ -2172,6 +2178,7 @@ pub mod tests {
0,
)
.0,
membership_proof: None,
identifier: 0,
},
],
@ -2236,6 +2243,7 @@ pub mod tests {
0,
)
.0,
membership_proof: None,
identifier: 0,
},
],
@ -2279,6 +2287,7 @@ pub mod tests {
npk,
ssk: shared_secret,
identifier: u128::MAX,
membership_proof: None,
seed: None,
},
],
@ -2314,6 +2323,7 @@ pub mod tests {
npk,
ssk: shared_secret,
identifier: u128::MAX,
membership_proof: None,
seed: None,
}],
&program.into(),
@ -2357,6 +2367,7 @@ pub mod tests {
npk: npk_b,
ssk: shared_secret,
identifier: u128::MAX,
membership_proof: None,
seed: None,
}],
&program.into(),
@ -2396,6 +2407,7 @@ pub mod tests {
npk,
ssk: shared_secret,
identifier: u128::MAX,
membership_proof: None,
seed: None,
}],
&program_with_deps,
@ -2438,6 +2450,7 @@ pub mod tests {
npk,
ssk: shared_secret,
identifier: u128::MAX,
membership_proof: None,
seed: None,
}],
&program_with_deps,
@ -2479,6 +2492,7 @@ pub mod tests {
npk: keys_a.npk(),
ssk: shared_a,
identifier: u128::MAX,
membership_proof: None,
seed: None,
},
InputAccountIdentity::PrivatePdaInit {
@ -2487,6 +2501,7 @@ pub mod tests {
npk: keys_b.npk(),
ssk: shared_b,
identifier: u128::MAX,
membership_proof: None,
seed: None,
},
],
@ -2531,6 +2546,7 @@ pub mod tests {
npk,
ssk: shared_secret,
identifier: u128::MAX,
membership_proof: None,
seed: None,
}],
&program.into(),
@ -3302,6 +3318,7 @@ pub mod tests {
ssk: shared_secret,
nsk: private_keys.nsk,
identifier: 0,
membership_proof: None,
}],
&program.into(),
)
@ -3349,6 +3366,7 @@ pub mod tests {
npk: private_keys.npk(),
ssk: shared_secret,
identifier: 0,
membership_proof: None,
}],
&program.into(),
)
@ -3400,6 +3418,7 @@ pub mod tests {
ssk: shared_secret,
nsk: private_keys.nsk,
identifier: 0,
membership_proof: None,
}],
&claimer_program.into(),
)
@ -3446,6 +3465,7 @@ pub mod tests {
ssk: shared_secret2,
nsk: private_keys.nsk,
identifier: 0,
membership_proof: None,
}],
&noop_program.into(),
);
@ -3788,6 +3808,7 @@ pub mod tests {
npk: account_keys.npk(),
ssk: shared_secret,
identifier: 0,
membership_proof: None,
}],
&validity_window_program.into(),
)
@ -3856,6 +3877,7 @@ pub mod tests {
npk: account_keys.npk(),
ssk: shared_secret,
identifier: 0,
membership_proof: None,
}],
&validity_window_program.into(),
)
@ -4200,6 +4222,7 @@ pub mod tests {
npk: alice_npk,
ssk: alice_shared_0,
identifier: 0,
membership_proof: None,
seed: Some((seed, proxy_id)),
},
],
@ -4240,6 +4263,7 @@ pub mod tests {
npk: alice_npk,
ssk: alice_shared_1,
identifier: 1,
membership_proof: None,
seed: Some((seed, proxy_id)),
},
],

View File

@ -187,6 +187,7 @@ enum State {
pub struct AccountManager {
states: Vec<State>,
pin: Option<String>,
dummy_commitment_proof: Option<MembershipProof>,
}
impl AccountManager {
@ -340,7 +341,21 @@ impl AccountManager {
states.push(state);
}
Ok(Self { states, pin })
let has_init_account = states.iter().any(|s| matches!(s, State::Private(pre) if pre.proof.is_none()));
let dummy_commitment_proof = if has_init_account {
wallet
.get_dummy_commitment_proof()
.await
.map_err(ExecutionFailureKind::SequencerError)?
} else {
None
};
Ok(Self {
states,
pin,
dummy_commitment_proof,
})
}
pub fn pre_states(&self) -> Vec<AccountWithMetadata> {
@ -404,6 +419,7 @@ impl AccountManager {
npk: pre.npk,
ssk: pre.ssk,
identifier: pre.identifier,
membership_proof: self.dummy_commitment_proof.clone(),
seed: None,
},
},
@ -424,6 +440,7 @@ impl AccountManager {
ssk: pre.ssk,
nsk,
identifier: pre.identifier,
membership_proof: self.dummy_commitment_proof.clone(),
},
(None, _) => InputAccountIdentity::PrivateUnauthorized {
epk: pre.epk.clone(),
@ -431,6 +448,7 @@ impl AccountManager {
npk: pre.npk,
ssk: pre.ssk,
identifier: pre.identifier,
membership_proof: self.dummy_commitment_proof.clone(),
},
},
})

View File

@ -22,7 +22,8 @@ use lee::{
},
};
use lee_core::{
Commitment, MembershipProof, SharedSecretKey, account::Nonce, program::InstructionData,
Commitment, DUMMY_COMMITMENT, MembershipProof, SharedSecretKey, account::Nonce,
program::InstructionData,
};
use log::info;
use sequencer_service_rpc::{RpcClient as _, SequencerClient, SequencerClientBuilder};
@ -508,6 +509,13 @@ impl WalletCore {
}
}
pub async fn get_dummy_commitment_proof(&self) -> Result<Option<MembershipProof>> {
self.sequencer_client
.get_proof_for_commitment(DUMMY_COMMITMENT)
.await
.map_err(Into::into)
}
pub fn decode_insert_privacy_preserving_transaction_results(
&mut self,
tx: &lee::privacy_preserving_transaction::PrivacyPreservingTransaction,