Merge ce68b4dc2717f6781beba69223cd530285bd6b67 into 041cf68cd63acd9c4c2d57492a0a0590396c27de

This commit is contained in:
Artem Gureev 2026-07-17 14:43:37 +04:00 committed by GitHub
commit 23edc97a5d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
32 changed files with 316 additions and 45 deletions

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -294,6 +294,7 @@ fn build_privacy_transaction() -> PrivacyPreservingTransaction {
InputAccountIdentity::PrivateAuthorizedUpdate {
vpk: sender_vpk,
random_seed: [0; 32],
view_tag: 0,
nsk: sender_nsk,
membership_proof: proof,
identifier: 0,

View File

@ -4,7 +4,7 @@ use lee_core::{
PrivacyPreservingCircuitOutput, PrivateAccountKind, SharedSecretKey,
account::{Account, AccountId, Nonce},
compute_digest_for_path,
encryption::ViewingPublicKey,
encryption::{ViewTag, ViewingPublicKey},
};
use crate::execution_state::ExecutionState;
@ -66,6 +66,7 @@ pub fn compute_circuit_output(
*commitment_root,
);
let new_nonce = Nonce::private_account_nonce_init(&account_id);
let view_tag = EncryptedAccountData::compute_view_tag(&npk, vpk);
emit_private_output(
&mut output,
@ -73,7 +74,7 @@ pub fn compute_circuit_output(
post_state,
&account_id,
&PrivateAccountKind::Regular(*identifier),
&npk,
view_tag,
vpk,
random_seed,
new_nullifier,
@ -83,6 +84,7 @@ pub fn compute_circuit_output(
InputAccountIdentity::PrivateAuthorizedUpdate {
vpk,
random_seed,
view_tag,
nsk,
membership_proof,
identifier,
@ -110,7 +112,7 @@ pub fn compute_circuit_output(
post_state,
&account_id,
&PrivateAccountKind::Regular(*identifier),
&npk,
*view_tag,
vpk,
random_seed,
new_nullifier,
@ -142,6 +144,7 @@ pub fn compute_circuit_output(
*commitment_root,
);
let new_nonce = Nonce::private_account_nonce_init(&account_id);
let view_tag = EncryptedAccountData::compute_view_tag(npk, vpk);
emit_private_output(
&mut output,
@ -149,7 +152,7 @@ pub fn compute_circuit_output(
post_state,
&account_id,
&PrivateAccountKind::Regular(*identifier),
npk,
view_tag,
vpk,
random_seed,
new_nullifier,
@ -190,6 +193,7 @@ pub fn compute_circuit_output(
let (authority_program_id, seed) = pda_seed_by_position
.get(&pos)
.expect("PrivatePdaInit position must be in pda_seed_by_position");
let view_tag = EncryptedAccountData::compute_view_tag(npk, vpk);
emit_private_output(
&mut output,
&mut output_index,
@ -200,7 +204,7 @@ pub fn compute_circuit_output(
seed: *seed,
identifier: *identifier,
},
npk,
view_tag,
vpk,
random_seed,
new_nullifier,
@ -210,6 +214,7 @@ pub fn compute_circuit_output(
InputAccountIdentity::PrivatePdaUpdate {
vpk,
random_seed,
view_tag,
nsk,
membership_proof,
identifier,
@ -234,7 +239,6 @@ pub fn compute_circuit_output(
let new_nonce = pre_state.account.nonce.private_account_nonce_increment(nsk);
let account_id = pre_state.account_id;
let npk = NullifierPublicKey::from(nsk);
let (authority_program_id, seed) = pda_seed_by_position
.get(&pos)
.expect("PrivatePdaUpdate position must be in pda_seed_by_position");
@ -248,7 +252,7 @@ pub fn compute_circuit_output(
seed: *seed,
identifier: *identifier,
},
&npk,
*view_tag,
vpk,
random_seed,
new_nullifier,
@ -271,7 +275,7 @@ fn emit_private_output(
post_state: Account,
account_id: &AccountId,
kind: &PrivateAccountKind,
npk: &NullifierPublicKey,
view_tag: ViewTag,
vpk: &ViewingPublicKey,
random_seed: &[u8; 32],
new_nullifier: (Nullifier, CommitmentSetDigest),
@ -287,15 +291,6 @@ fn emit_private_output(
let esk = EphemeralSecretKey::new(account_id, random_seed, &new_nonce);
let (shared_secret, epk) = SharedSecretKey::encapsulate_deterministic(vpk, &esk);
// Currently the view tag is properlty generated for all accounts.
// To increase privacy, this will be changed in the later version
// to only be generated explicitly for initialized accounts and
// fed by the prover directly for updated accounts.
//
// See issue 573:
// https://github.com/logos-blockchain/logos-execution-zone/issues/573
let view_tag = EncryptedAccountData::compute_view_tag(npk, vpk);
let encrypted_account = EncryptionScheme::encrypt(
&post_with_updated_nonce,
kind,

View File

@ -4,7 +4,7 @@ use crate::{
Commitment, CommitmentSetDigest, Identifier, MembershipProof, Nullifier, NullifierPublicKey,
NullifierSecretKey,
account::{Account, AccountWithMetadata},
encryption::{EncryptedAccountData, ViewingPublicKey},
encryption::{EncryptedAccountData, ViewTag, ViewingPublicKey},
program::{BlockValidityWindow, PdaSeed, ProgramId, ProgramOutput, TimestampValidityWindow},
};
@ -42,6 +42,7 @@ pub enum InputAccountIdentity {
PrivateAuthorizedUpdate {
vpk: ViewingPublicKey,
random_seed: [u8; 32],
view_tag: ViewTag,
nsk: NullifierSecretKey,
membership_proof: MembershipProof,
identifier: Identifier,
@ -79,6 +80,7 @@ pub enum InputAccountIdentity {
PrivatePdaUpdate {
vpk: ViewingPublicKey,
random_seed: [u8; 32],
view_tag: ViewTag,
nsk: NullifierSecretKey,
membership_proof: MembershipProof,
identifier: Identifier,

View File

@ -1,8 +1,8 @@
#![expect(clippy::shadow_unrelated, reason = "We don't care about it in tests")]
use lee_core::{
Commitment, DUMMY_COMMITMENT_HASH, EncryptionScheme, EphemeralSecretKey, Nullifier,
PrivacyPreservingCircuitOutput, SharedSecretKey,
Commitment, DUMMY_COMMITMENT_HASH, EncryptedAccountData, EncryptionScheme, EphemeralSecretKey,
Nullifier, PrivacyPreservingCircuitOutput, SharedSecretKey,
account::{Account, AccountId, AccountWithMetadata, Nonce, data::Data},
program::{PdaSeed, PrivateAccountKind},
};
@ -198,6 +198,7 @@ fn prove_privacy_preserving_execution_circuit_fully_private() {
InputAccountIdentity::PrivateAuthorizedUpdate {
vpk: sender_keys.vpk(),
random_seed: [0; 32],
view_tag: 0,
nsk: sender_keys.nsk,
membership_proof: commitment_set
.get_proof_for(&commitment_sender)
@ -242,6 +243,76 @@ fn prove_privacy_preserving_execution_circuit_fully_private() {
assert_eq!(recipient_post, expected_private_account_2);
}
#[test]
fn init_note_view_tag_is_derived_from_account_keys() {
let program = crate::test_methods::noop();
let keys = test_private_account_keys_1();
let identifier: u128 = 0;
let account_id = AccountId::for_regular_private_account(&keys.npk(), &keys.vpk(), identifier);
let account = AccountWithMetadata::new(Account::default(), false, account_id);
let (output, proof) = execute_and_prove(
vec![account],
Program::serialize_instruction(()).unwrap(),
vec![InputAccountIdentity::PrivateUnauthorized {
vpk: keys.vpk(),
random_seed: [0; 32],
npk: keys.npk(),
identifier,
commitment_root: DUMMY_COMMITMENT_HASH,
}],
&program.into(),
)
.unwrap();
assert!(proof.is_valid_for(&output));
assert_eq!(output.encrypted_private_post_states.len(), 1);
assert_eq!(
output.encrypted_private_post_states[0].view_tag,
EncryptedAccountData::compute_view_tag(&keys.npk(), &keys.vpk()),
);
}
#[test]
fn update_note_view_tag_is_the_supplied_value() {
let program = crate::test_methods::noop();
let keys = test_private_account_keys_1();
let identifier: u128 = 99;
let account_id = AccountId::for_regular_private_account(&keys.npk(), &keys.vpk(), identifier);
let account = Account {
program_owner: program.id(),
balance: 1,
..Account::default()
};
let commitment = Commitment::new(&account_id, &account);
let mut commitment_set = CommitmentSet::with_capacity(1);
commitment_set.extend(std::slice::from_ref(&commitment));
let sender = AccountWithMetadata::new(account, true, account_id);
// A tag deliberately different from the address-derived one, so a passthrough is
// distinguishable from re-derivation.
let fed_tag = EncryptedAccountData::compute_view_tag(&keys.npk(), &keys.vpk()).wrapping_add(1);
let (output, proof) = execute_and_prove(
vec![sender],
Program::serialize_instruction(()).unwrap(),
vec![InputAccountIdentity::PrivateAuthorizedUpdate {
vpk: keys.vpk(),
random_seed: [0; 32],
view_tag: fed_tag,
nsk: keys.nsk,
membership_proof: commitment_set.get_proof_for(&commitment).unwrap(),
identifier,
}],
&program.into(),
)
.unwrap();
assert!(proof.is_valid_for(&output));
assert_eq!(output.encrypted_private_post_states.len(), 1);
assert_eq!(output.encrypted_private_post_states[0].view_tag, fed_tag);
}
#[test]
fn circuit_fails_when_chained_validity_windows_have_empty_intersection() {
let account_keys = test_private_account_keys_1();
@ -574,6 +645,7 @@ fn private_authorized_update_encrypts_regular_kind_with_identifier() {
vec![InputAccountIdentity::PrivateAuthorizedUpdate {
vpk: keys.vpk(),
random_seed: [0; 32],
view_tag: 0,
nsk: keys.nsk,
membership_proof: commitment_set.get_proof_for(&commitment).unwrap(),
identifier,
@ -630,6 +702,7 @@ fn private_pda_update_encrypts_pda_kind_with_identifier() {
InputAccountIdentity::PrivatePdaUpdate {
vpk: keys.vpk(),
random_seed: [0; 32],
view_tag: 0,
nsk: keys.nsk,
membership_proof: commitment_set.get_proof_for(&pda_commitment).unwrap(),
identifier,
@ -708,6 +781,7 @@ fn private_pda_update_identifier_mismatch_fails() {
InputAccountIdentity::PrivatePdaUpdate {
vpk: keys.vpk(),
random_seed: [0; 32],
view_tag: 0,
nsk: keys.nsk,
membership_proof: commitment_set.get_proof_for(&pda_commitment).unwrap(),
identifier: 99,

View File

@ -74,6 +74,7 @@ fn private_changer_claimer_no_data_change_no_claim_succeeds() {
vec![InputAccountIdentity::PrivateAuthorizedUpdate {
vpk: sender_keys.vpk(),
random_seed: [0; 32],
view_tag: 0,
nsk: sender_keys.nsk,
membership_proof: (0, vec![]),
identifier: 0,
@ -104,6 +105,7 @@ fn private_changer_claimer_data_change_no_claim_fails() {
vec![InputAccountIdentity::PrivateAuthorizedUpdate {
vpk: sender_keys.vpk(),
random_seed: [0; 32],
view_tag: 0,
nsk: sender_keys.nsk,
membership_proof: (0, vec![]),
identifier: 0,

View File

@ -64,6 +64,7 @@ fn circuit_fails_if_invalid_auth_keys_are_provided() {
InputAccountIdentity::PrivateAuthorizedUpdate {
vpk: sender_keys.vpk(),
random_seed: [0; 32],
view_tag: 0,
nsk: recipient_keys.nsk,
membership_proof: (0, vec![]),
identifier: 0,
@ -113,6 +114,7 @@ fn circuit_should_fail_if_new_private_account_with_non_default_balance_is_provid
InputAccountIdentity::PrivateAuthorizedUpdate {
vpk: sender_keys.vpk(),
random_seed: [0; 32],
view_tag: 0,
nsk: sender_keys.nsk,
membership_proof: (0, vec![]),
identifier: 0,
@ -162,6 +164,7 @@ fn circuit_should_fail_if_new_private_account_with_non_default_program_owner_is_
InputAccountIdentity::PrivateAuthorizedUpdate {
vpk: sender_keys.vpk(),
random_seed: [0; 32],
view_tag: 0,
nsk: sender_keys.nsk,
membership_proof: (0, vec![]),
identifier: 0,
@ -211,6 +214,7 @@ fn circuit_should_fail_if_new_private_account_with_non_default_data_is_provided(
InputAccountIdentity::PrivateAuthorizedUpdate {
vpk: sender_keys.vpk(),
random_seed: [0; 32],
view_tag: 0,
nsk: sender_keys.nsk,
membership_proof: (0, vec![]),
identifier: 0,
@ -260,6 +264,7 @@ fn circuit_should_fail_if_new_private_account_with_non_default_nonce_is_provided
InputAccountIdentity::PrivateAuthorizedUpdate {
vpk: sender_keys.vpk(),
random_seed: [0; 32],
view_tag: 0,
nsk: sender_keys.nsk,
membership_proof: (0, vec![]),
identifier: 0,
@ -307,6 +312,7 @@ fn circuit_should_fail_if_new_private_account_is_provided_with_default_values_bu
InputAccountIdentity::PrivateAuthorizedUpdate {
vpk: sender_keys.vpk(),
random_seed: [0; 32],
view_tag: 0,
nsk: sender_keys.nsk,
membership_proof: (0, vec![]),
identifier: 0,
@ -694,6 +700,7 @@ fn circuit_should_fail_if_there_are_repeated_ids() {
InputAccountIdentity::PrivateAuthorizedUpdate {
vpk: sender_keys.vpk(),
random_seed: [0; 32],
view_tag: 0,
nsk: sender_keys.nsk,
membership_proof: (1, vec![]),
identifier: 0,
@ -701,6 +708,7 @@ fn circuit_should_fail_if_there_are_repeated_ids() {
InputAccountIdentity::PrivateAuthorizedUpdate {
vpk: sender_keys.vpk(),
random_seed: [0; 32],
view_tag: 0,
nsk: sender_keys.nsk,
membership_proof: (1, vec![]),
identifier: 0,
@ -1020,6 +1028,7 @@ fn two_private_pda_family_members_receive_and_spend() {
InputAccountIdentity::PrivatePdaUpdate {
vpk: alice_keys.vpk(),
random_seed: [0; 32],
view_tag: 0,
nsk: alice_keys.nsk,
membership_proof: state
.get_proof_for_commitment(&commitment_pda_0)
@ -1057,6 +1066,7 @@ fn two_private_pda_family_members_receive_and_spend() {
InputAccountIdentity::PrivatePdaUpdate {
vpk: alice_keys.vpk(),
random_seed: [0; 32],
view_tag: 0,
nsk: alice_keys.nsk,
membership_proof: state
.get_proof_for_commitment(&commitment_pda_1)
@ -1108,6 +1118,7 @@ fn two_private_pda_family_members_receive_and_spend() {
InputAccountIdentity::PrivatePdaUpdate {
vpk: alice_keys.vpk(),
random_seed: [0; 32],
view_tag: 0,
nsk: alice_keys.nsk,
membership_proof: state
.get_proof_for_commitment(&commitment_pda_1_after_spend)

View File

@ -328,6 +328,7 @@ fn authorized_public_account_claiming_succeeds_when_executed_privately() {
InputAccountIdentity::PrivateAuthorizedUpdate {
vpk: sender_keys.vpk(),
random_seed: [0; 32],
view_tag: 0,
nsk: sender_keys.nsk,
membership_proof: state
.get_proof_for_commitment(&sender_commitment)
@ -443,6 +444,7 @@ fn private_chained_call(number_of_calls: u32) {
InputAccountIdentity::PrivateAuthorizedUpdate {
vpk: from_keys.vpk(),
random_seed: [0; 32],
view_tag: 0,
nsk: from_keys.nsk,
membership_proof: state
.get_proof_for_commitment(&from_commitment)
@ -452,6 +454,7 @@ fn private_chained_call(number_of_calls: u32) {
InputAccountIdentity::PrivateAuthorizedUpdate {
vpk: to_keys.vpk(),
random_seed: [0; 32],
view_tag: 0,
nsk: to_keys.nsk,
membership_proof: state
.get_proof_for_commitment(&to_commitment)

View File

@ -330,6 +330,7 @@ fn private_balance_transfer_for_tests(
InputAccountIdentity::PrivateAuthorizedUpdate {
vpk: sender_keys.vpk(),
random_seed: [0; 32],
view_tag: 0,
nsk: sender_keys.nsk,
membership_proof: state
.get_proof_for_commitment(&sender_commitment)
@ -384,6 +385,7 @@ fn deshielded_balance_transfer_for_tests(
InputAccountIdentity::PrivateAuthorizedUpdate {
vpk: sender_keys.vpk(),
random_seed: [0; 32],
view_tag: 0,
nsk: sender_keys.nsk,
membership_proof: state
.get_proof_for_commitment(&sender_commitment)

View File

@ -524,6 +524,7 @@ fn malicious_authorization_changer_should_fail_in_privacy_preserving_circuit() {
InputAccountIdentity::PrivateAuthorizedUpdate {
vpk: recipient_keys.vpk(),
random_seed: [0; 32],
view_tag: 0,
nsk: recipient_keys.nsk,
membership_proof: state
.get_proof_for_commitment(&recipient_commitment)

View File

@ -167,6 +167,7 @@ fn privacy_malicious_programs_cannot_drain_public_victim() {
InputAccountIdentity::PrivateAuthorizedUpdate {
vpk: attacker_keys.vpk(),
random_seed: [0; 32],
view_tag: 0,
nsk: attacker_keys.nsk,
membership_proof,
identifier: 0,
@ -327,6 +328,7 @@ fn privacy_malicious_programs_cannot_drain_private_victim() {
InputAccountIdentity::PrivateAuthorizedUpdate {
vpk: attacker_keys.vpk(),
random_seed: [0; 32],
view_tag: 0,
nsk: attacker_keys.nsk,
membership_proof,
identifier: 0,

View File

@ -862,6 +862,7 @@ fn private_bridge_withdraw_invocation_is_dropped() {
InputAccountIdentity::PrivateAuthorizedUpdate {
vpk: sender_keys.viewing_public_key.clone(),
random_seed: [0; 32],
view_tag: 0,
nsk: sender_keys.private_key_holder.nullifier_secret_key,
membership_proof: state
.get_proof_for_commitment(&sender_commitment)

View File

@ -8,7 +8,7 @@ use lee_core::{
NullifierPublicKey, NullifierSecretKey, SharedSecretKey,
account::{AccountWithMetadata, Nonce},
compute_digest_for_path,
encryption::ViewingPublicKey,
encryption::{ViewTag, ViewingPublicKey},
};
use rand::{RngCore as _, rngs::OsRng};
@ -411,6 +411,7 @@ impl AccountManager {
(Some(nsk), Some(membership_proof)) => InputAccountIdentity::PrivatePdaUpdate {
vpk: pre.vpk.clone(),
random_seed: pre.random_seed,
view_tag: random_view_tag(),
nsk,
membership_proof,
identifier: pre.identifier,
@ -430,6 +431,7 @@ impl AccountManager {
InputAccountIdentity::PrivateAuthorizedUpdate {
vpk: pre.vpk.clone(),
random_seed: pre.random_seed,
view_tag: random_view_tag(),
nsk,
membership_proof,
identifier: pre.identifier,
@ -650,6 +652,13 @@ fn validate_proofs_against_root(
Ok(())
}
/// Generate random byte using OS randomness.
fn random_view_tag() -> ViewTag {
let mut byte: [u8; 1] = [0; 1];
OsRng.fill_bytes(&mut byte);
byte[0]
}
#[cfg(test)]
mod tests {
use super::*;

View File

@ -111,8 +111,9 @@ pub enum NewSubcommand {
#[arg(long, requires = "pda")]
/// Program ID as hex string.
program_id: Option<String>,
#[arg(long, requires = "pda")]
/// Identifier that diversifies this PDA within the (`program_id`, seed, npk) family.
#[arg(long)]
/// Identifier selecting the shared account.
/// Co-owners must supply the same value to derive the same account.
/// Defaults to a random value if not specified.
identifier: Option<u128>,
},
@ -197,7 +198,7 @@ impl NewSubcommand {
Ok(SubcommandReturnValue::RegisterAccount { account_id })
}
fn handle_private_gms(
async fn handle_private_gms(
group: &Label,
label: Option<Label>,
pda: bool,
@ -229,14 +230,22 @@ impl NewSubcommand {
pid[i] = u32::from_le_bytes(chunk.try_into().unwrap());
}
wallet_core.create_shared_pda_account(
group.clone(),
pda_seed,
pid,
identifier.unwrap_or_else(rand::random),
)?
wallet_core
.create_shared_pda_account(
group.clone(),
pda_seed,
pid,
identifier.unwrap_or_else(rand::random),
)
.await?
} else if let Some(id) = identifier {
wallet_core
.create_shared_regular_account_with_identifier(group.clone(), id)
.await?
} else {
wallet_core.create_shared_regular_account(group.clone())?
wallet_core
.create_shared_regular_account(group.clone())
.await?
};
if let Some(label) = label {
@ -295,15 +304,18 @@ impl WalletSubcommand for NewSubcommand {
seed,
program_id,
identifier,
} => Self::handle_private_gms(
&group,
label,
pda,
seed,
program_id,
identifier,
wallet_core,
),
} => {
Self::handle_private_gms(
&group,
label,
pda,
seed,
program_id,
identifier,
wallet_core,
)
.await
}
Self::PrivateAccountsKey { cci } => Self::handle_private_accounts_key(cci, wallet_core),
}
}

View File

@ -352,8 +352,60 @@ impl WalletCore {
);
}
/// Re-derive a shared account's state by scanning its keypair from genesis to the current
/// synced block. The init note's nullifier is deterministic on ID, so we await it and let
/// the nullifier pass decode the init and every subsequent update.
///
/// If no initialization is found, will return `Ok` and default to usual hot-sync.
async fn catch_up_shared_account(&mut self, account_id: AccountId) -> Result<()> {
use futures::TryStreamExt as _;
let cursor = self.storage.last_synced_block();
if cursor == 0
|| self
.storage
.key_chain()
.shared_private_account(account_id)
.is_none()
{
return Ok(());
}
info!("Scanning shared account {account_id:#?} from genesis to block {cursor}");
let mut index = NullifierIndex::default();
index.track_initialization(account_id);
let poller = self.poller.clone();
let mut blocks = std::pin::pin!(poller.poll_block_range(1..=cursor));
while let Some(block) = blocks.try_next().await? {
for tx in block.body.transactions {
let LeeTransaction::PrivacyPreserving(pp_tx) = &tx else {
continue;
};
pp_tx.message.validate_note_lengths()?;
// Sync updates while watching only the init nullifier.
self.storage
.key_chain_mut()
.sync_updates_via_nullifiers(&pp_tx.message, &mut index);
}
}
let now = self.storage.last_synced_block();
// This is a defence-in-depth. Currently during the async update the cursor
// cannot advance. However, de-sync can be possible later. This hard error
// will signal this.
if now != cursor {
return Err(anyhow::anyhow!(
"Shared-account catched-up to {cursor} with a cursor de-sync advancing to {now}"
));
}
Ok(())
}
/// Create a shared PDA account from a group's GMS. Returns the `AccountId` and derived keys.
pub fn create_shared_pda_account(
pub async fn create_shared_pda_account(
&mut self,
group_name: Label,
pda_seed: lee_core::program::PdaSeed,
@ -378,6 +430,7 @@ impl WalletCore {
Some(pda_seed),
Some(program_id),
);
self.catch_up_shared_account(account_id).await?;
Ok(SharedAccountInfo {
account_id,
@ -386,14 +439,21 @@ impl WalletCore {
})
}
/// Create a shared regular private account from a group's GMS. Returns the `AccountId` and
/// derived keys. The derivation seed is computed deterministically from a random identifier.
pub fn create_shared_regular_account(
/// Create a shared regular private account from a group's GMS with a random identifier.
pub async fn create_shared_regular_account(
&mut self,
group_name: Label,
) -> Result<SharedAccountInfo> {
let identifier: lee_core::Identifier = rand::random();
self.create_shared_regular_account_with_identifier(group_name, rand::random())
.await
}
/// Create a shared regular private account from a group's GMS under the given `identifier`.
pub async fn create_shared_regular_account_with_identifier(
&mut self,
group_name: Label,
identifier: lee_core::Identifier,
) -> Result<SharedAccountInfo> {
let holder = self
.storage
.key_chain()
@ -406,6 +466,7 @@ impl WalletCore {
let account_id = AccountId::from((&npk, &vpk, identifier));
self.register_shared_account(account_id, group_name, identifier, None, None);
self.catch_up_shared_account(account_id).await?;
Ok(SharedAccountInfo {
account_id,

View File

@ -90,6 +90,14 @@ impl NullifierIndex {
);
}
/// Indexes `account_id` by the nullifier its initialization publishes.
pub fn track_initialization(&mut self, account_id: AccountId) {
self.0.insert(
Nullifier::for_account_initialization(&account_id),
account_id,
);
}
/// Replaces a spent nullifier with the account's `next` one.
pub fn update(&mut self, spent: &Nullifier, next: Nullifier, account_id: AccountId) {
self.0.remove(spent);
@ -987,6 +995,93 @@ mod tests {
assert!(index.account_for(&old_nullifier).is_none());
}
// The genesis catch-up seeds only the init nullifier and lets the nullifier pass decode the
// init note and every subsequent (randomly-tagged) update. Verify a shared account rolls from
// default through its init to a later update purely by nullifier — the path the catch-up runs.
#[test]
fn nullifier_sync_catches_up_shared_account_from_init() {
let mut kc = UserKeyChain::default();
let label = Label::new("group");
let holder = GroupKeyHolder::new();
let identifier = 0;
let keys = holder.derive_regular_shared_account_keys_from_identifier(identifier);
let npk = keys.generate_nullifier_public_key();
let vpk = keys.generate_viewing_public_key();
let nsk = keys.nullifier_secret_key;
let account_id = AccountId::from((&npk, &vpk, identifier));
kc.insert_group_key_holder(label.clone(), holder);
kc.insert_shared_private_account(
account_id,
SharedAccountEntry {
group_label: label,
identifier,
pda_seed: None,
authority_program_id: None,
account: Account::default(),
},
);
let mut index = NullifierIndex::default();
index.track_initialization(account_id);
// A note publishing `spent` and carrying the state `next`.
let make_message = |spent: Nullifier, next: &Account| {
let commitment = Commitment::new(&account_id, next);
let (sender_ss, epk) = SharedSecretKey::encapsulate(&vpk);
let ciphertext = EncryptionScheme::encrypt(
next,
&PrivateAccountKind::Regular(identifier),
&sender_ss,
&commitment,
0,
);
let note = EncryptedAccountData::new(ciphertext, &npk, &vpk, epk);
Message {
encrypted_private_post_states: vec![note],
new_commitments: vec![commitment],
new_nullifiers: vec![(spent, [0; 32])],
..Default::default()
}
};
// Init: default -> initialized, discovered via the seeded init nullifier.
let initialized = Account {
balance: 250,
..Account::default()
};
let init_msg = make_message(
Nullifier::for_account_initialization(&account_id),
&initialized,
);
assert_eq!(
kc.sync_updates_via_nullifiers(&init_msg, &mut index),
HashSet::from([0])
);
assert_eq!(
kc.shared_private_account(account_id).unwrap().account,
initialized
);
// Update: initialized -> updated, discovered via the now-tracked update nullifier.
let updated = Account {
balance: 500,
..Account::default()
};
let update_spent =
Nullifier::for_account_update(&Commitment::new(&account_id, &initialized), &nsk);
let update_msg = make_message(update_spent, &updated);
assert_eq!(
kc.sync_updates_via_nullifiers(&update_msg, &mut index),
HashSet::from([0])
);
assert_eq!(
kc.shared_private_account(account_id).unwrap().account,
updated
);
}
#[test]
fn nullifier_sync_ignores_unindexed_nullifier() {
let mut kc = UserKeyChain::default();