From 3b479a4ad27f0a0458678936779ab303a3f3a6c6 Mon Sep 17 00:00:00 2001 From: Pravdyvy Date: Fri, 15 May 2026 17:58:20 +0300 Subject: [PATCH] feat: lez core base --- Cargo.lock | 19 ++ Cargo.toml | 4 +- lez_core/Cargo.toml | 23 ++ lez_core/src/lib.rs | 21 ++ lez_core/src/privacy_preserving_tx.rs | 315 ++++++++++++++++++++++++++ wallet/Cargo.toml | 3 +- wallet/src/lib.rs | 19 +- 7 files changed, 384 insertions(+), 20 deletions(-) create mode 100644 lez_core/Cargo.toml create mode 100644 lez_core/src/lib.rs create mode 100644 lez_core/src/privacy_preserving_tx.rs diff --git a/Cargo.lock b/Cargo.lock index 195a5087..6f107c59 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4633,6 +4633,24 @@ dependencies = [ "tachys", ] +[[package]] +name = "lez_core" +version = "0.1.0" +dependencies = [ + "anyhow", + "common", + "env_logger", + "hex", + "key_protocol", + "log", + "nssa", + "nssa_core", + "sequencer_service_rpc", + "serde", + "serde_json", + "thiserror 2.0.18", +] + [[package]] name = "libc" version = "0.2.183" @@ -10143,6 +10161,7 @@ dependencies = [ "indicatif", "itertools 0.14.0", "key_protocol", + "lez_core", "log", "nssa", "nssa_core", diff --git a/Cargo.toml b/Cargo.toml index 724f53e0..ce0efb6d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -40,7 +40,8 @@ members = [ "examples/program_deployment/methods", "examples/program_deployment/methods/guest", "testnet_initial_state", - "indexer/ffi", + "indexer/ffi", + "lez_core", ] [workspace.dependencies] @@ -73,6 +74,7 @@ faucet_core = { path = "programs/faucet/core" } vault_core = { path = "programs/vault/core" } test_program_methods = { path = "test_program_methods" } testnet_initial_state = { path = "testnet_initial_state" } +lez_core = { path = "lez_core" } tokio = { version = "1.50", features = [ "net", diff --git a/lez_core/Cargo.toml b/lez_core/Cargo.toml new file mode 100644 index 00000000..b3a20e94 --- /dev/null +++ b/lez_core/Cargo.toml @@ -0,0 +1,23 @@ +[package] +name = "lez_core" +version = "0.1.0" +edition = "2024" +license.workspace = true + +[dependencies] +nssa_core.workspace = true +nssa.workspace = true +common.workspace = true +key_protocol.workspace = true +sequencer_service_rpc = { workspace = true, features = ["client"] } + +anyhow.workspace = true +thiserror.workspace = true +serde_json.workspace = true +env_logger.workspace = true +log.workspace = true +serde.workspace = true +hex.workspace = true + +[lints] +workspace = true diff --git a/lez_core/src/lib.rs b/lez_core/src/lib.rs new file mode 100644 index 00000000..9a2b1d16 --- /dev/null +++ b/lez_core/src/lib.rs @@ -0,0 +1,21 @@ +use nssa::AccountId; + +pub mod privacy_preserving_tx; + +#[derive(Debug, thiserror::Error)] +pub enum ExecutionFailureKind { + #[error("Failed to get data from sequencer")] + SequencerError(#[source] anyhow::Error), + #[error("Inputs amounts does not match outputs")] + AmountMismatchError, + #[error("Accounts key not found")] + KeyNotFoundError, + #[error("Sequencer client error")] + SequencerClientError(#[from] sequencer_service_rpc::ClientError), + #[error("Can not pay for operation")] + InsufficientFundsError, + #[error("Account {0} data is invalid")] + AccountDataError(AccountId), + #[error("Failed to build transaction: {0}")] + TransactionBuildError(#[from] nssa::error::NssaError), +} \ No newline at end of file diff --git a/lez_core/src/privacy_preserving_tx.rs b/lez_core/src/privacy_preserving_tx.rs new file mode 100644 index 00000000..5df4c56a --- /dev/null +++ b/lez_core/src/privacy_preserving_tx.rs @@ -0,0 +1,315 @@ +use anyhow::Result; +use key_protocol::key_management::ephemeral_key_holder::EphemeralKeyHolder; +use nssa::{AccountId, PrivateKey}; +use nssa_core::{ + Identifier, InputAccountIdentity, MembershipProof, NullifierPublicKey, NullifierSecretKey, + SharedSecretKey, + account::{AccountWithMetadata, Nonce}, + encryption::{EphemeralPublicKey, ViewingPublicKey}, +}; + +#[derive(Clone)] +pub enum PrivacyPreservingAccount { + Public(AccountId), + PrivateOwned(AccountId), + PrivateForeign { + npk: NullifierPublicKey, + vpk: ViewingPublicKey, + identifier: Identifier, + }, + /// An owned private PDA: wallet holds the nsk/npk; `account_id` was derived via + /// [`AccountId::for_private_pda`]. + PrivatePdaOwned(AccountId), + /// A foreign private PDA: wallet knows the recipient's npk/vpk but not their nsk. + /// Uses a default (uninitialised) account. + PrivatePdaForeign { + account_id: AccountId, + npk: NullifierPublicKey, + vpk: ViewingPublicKey, + identifier: Identifier, + }, + /// A shared regular private account with externally-provided keys (e.g. from GMS). + /// Uses standard `AccountId = from((&npk, identifier))` with authorized/unauthorized private + /// paths. Works with `authenticated_transfer` and all existing programs out of the box. + PrivateShared { + nsk: NullifierSecretKey, + npk: NullifierPublicKey, + vpk: ViewingPublicKey, + identifier: Identifier, + }, + /// A shared private PDA with externally-provided keys (e.g. from GMS). + /// `account_id` was derived via [`AccountId::for_private_pda`]. + PrivatePdaShared { + account_id: AccountId, + nsk: NullifierSecretKey, + npk: NullifierPublicKey, + vpk: ViewingPublicKey, + identifier: Identifier, + }, +} + +impl PrivacyPreservingAccount { + #[must_use] + pub const fn is_public(&self) -> bool { + matches!(&self, Self::Public(_)) + } + + #[must_use] + pub const fn is_private(&self) -> bool { + matches!( + &self, + Self::PrivateOwned(_) + | Self::PrivateForeign { .. } + | Self::PrivatePdaOwned(_) + | Self::PrivatePdaForeign { .. } + | Self::PrivateShared { .. } + | Self::PrivatePdaShared { .. } + ) + } +} + +pub struct PrivateAccountKeys { + pub npk: NullifierPublicKey, + pub ssk: SharedSecretKey, + pub vpk: ViewingPublicKey, + pub epk: EphemeralPublicKey, +} + +enum State { + Public { + account: AccountWithMetadata, + sk: Option, + }, + Private(AccountPreparedData), +} + +pub struct AccountManager { + states: Vec, +} + +pub trait AccountManager { + pub async fn new( + account_source: &T, + accounts: Vec, + ) -> Result; + + pub fn states(&self) -> &[State]; + + pub fn pre_states(&self) -> Vec { + self.states() + .iter() + .map(|state| match state { + State::Public { account, .. } => account.clone(), + State::Private(pre) => pre.pre_state.clone(), + }) + .collect() + } + + pub fn public_account_nonces(&self) -> Vec { + self.states() + .iter() + .filter_map(|state| match state { + State::Public { account, sk } => sk.as_ref().map(|_| account.account.nonce), + State::Private(_) => None, + }) + .collect() + } + + pub fn private_account_keys(&self) -> Vec { + self.states() + .iter() + .filter_map(|state| match state { + State::Private(pre) => Some(PrivateAccountKeys { + npk: pre.npk, + ssk: pre.ssk, + vpk: pre.vpk.clone(), + epk: pre.epk.clone(), + }), + State::Public { .. } => None, + }) + .collect() + } + + /// Build the per-account input vec for the privacy-preserving circuit. Each variant carries + /// exactly the fields the circuit's code path for that account needs, with the ephemeral + /// keys (`ssk`) drawn from the cached values that `private_account_keys` and the message + /// construction also use, so all three views agree on the same ephemeral key. + pub fn account_identities(&self) -> Vec { + self.states() + .iter() + .map(|state| match state { + State::Public { .. } => InputAccountIdentity::Public, + State::Private(pre) if pre.is_pda => match (pre.nsk, pre.proof.clone()) { + (Some(nsk), Some(membership_proof)) => InputAccountIdentity::PrivatePdaUpdate { + ssk: pre.ssk, + nsk, + membership_proof, + identifier: pre.identifier, + }, + _ => InputAccountIdentity::PrivatePdaInit { + npk: pre.npk, + ssk: pre.ssk, + identifier: pre.identifier, + }, + }, + State::Private(pre) => match (pre.nsk, pre.proof.clone()) { + (Some(nsk), Some(membership_proof)) => { + InputAccountIdentity::PrivateAuthorizedUpdate { + ssk: pre.ssk, + nsk, + membership_proof, + identifier: pre.identifier, + } + } + (Some(nsk), None) => InputAccountIdentity::PrivateAuthorizedInit { + ssk: pre.ssk, + nsk, + identifier: pre.identifier, + }, + (None, _) => InputAccountIdentity::PrivateUnauthorized { + npk: pre.npk, + ssk: pre.ssk, + identifier: pre.identifier, + }, + }, + }) + .collect() + } + + pub fn public_account_ids(&self) -> Vec { + self.states() + .iter() + .filter_map(|state| match state { + State::Public { account, .. } => Some(account.account_id), + State::Private(_) => None, + }) + .collect() + } + + pub fn public_account_auth(&self) -> Vec<&PrivateKey> { + self.states() + .iter() + .filter_map(|state| match state { + State::Public { sk, .. } => sk.as_ref(), + State::Private(_) => None, + }) + .collect() + } +} + +struct AccountPreparedData { + nsk: Option, + npk: NullifierPublicKey, + identifier: Identifier, + vpk: ViewingPublicKey, + pre_state: AccountWithMetadata, + proof: Option, + /// Cached shared-secret key derived once at `AccountManager::new`. Reused for both the + /// circuit input variant (`account_identities()`) and the message ephemeral-key tuples + /// (`private_account_keys()`), so all consumers see the same key. The corresponding + /// `EphemeralKeyHolder` uses `OsRng` and would produce a different value on a second call. + ssk: SharedSecretKey, + /// Cached ephemeral public key, paired with `ssk`. + epk: EphemeralPublicKey, + /// True when this account is a private PDA (owned or foreign). Used by `account_identities()` + /// to select `PrivatePdaInit`/`PrivatePdaUpdate` rather than the standalone private variants. + is_pda: bool, +} + +async fn private_key_tree_acc_preparation( + wallet: &WalletCore, + account_id: AccountId, + is_pda: bool, +) -> Result { + let Some(from_acc) = wallet.storage.key_chain().private_account(account_id) else { + return Err(ExecutionFailureKind::KeyNotFoundError); + }; + + let from_identifier = from_acc.kind.identifier(); + let from_keys = &from_acc.key_chain; + let nsk = from_keys.private_key_holder.nullifier_secret_key; + let from_npk = from_keys.nullifier_public_key; + let from_vpk = from_keys.viewing_public_key.clone(); + + // TODO: Remove this unwrap, error types must be compatible + let proof = wallet + .check_private_account_initialized(account_id) + .await + .unwrap(); + + // 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.account.clone(), true, account_id); + + let eph_holder = EphemeralKeyHolder::new(&from_npk); + let ssk = eph_holder.calculate_shared_secret_sender(&from_vpk); + let epk = eph_holder.generate_ephemeral_public_key(); + + Ok(AccountPreparedData { + nsk: Some(nsk), + npk: from_npk, + identifier: from_identifier, + vpk: from_vpk, + pre_state: sender_pre, + proof, + ssk, + epk, + is_pda, + }) +} + +async fn private_shared_acc_preparation( + wallet: &WalletCore, + account_id: AccountId, + nsk: NullifierSecretKey, + npk: NullifierPublicKey, + vpk: ViewingPublicKey, + identifier: Identifier, + is_pda: bool, +) -> Result { + let acc = wallet + .storage() + .key_chain() + .shared_private_account(account_id) + .map(|e| e.account.clone()) + .unwrap_or_default(); + + let pre_state = AccountWithMetadata::new(acc, true, account_id); + + let proof = wallet + .check_private_account_initialized(account_id) + .await + .unwrap_or(None); + + let eph_holder = EphemeralKeyHolder::new(&npk); + let ssk = eph_holder.calculate_shared_secret_sender(&vpk); + let epk = eph_holder.generate_ephemeral_public_key(); + Ok(AccountPreparedData { + nsk: Some(nsk), + npk, + identifier, + vpk, + pre_state, + proof, + ssk, + epk, + is_pda, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn private_shared_is_private() { + let acc = PrivacyPreservingAccount::PrivateShared { + nsk: [0; 32], + npk: NullifierPublicKey([1; 32]), + vpk: ViewingPublicKey::from_scalar([2; 32]), + identifier: 42, + }; + assert!(acc.is_private()); + assert!(!acc.is_public()); + } +} diff --git a/wallet/Cargo.toml b/wallet/Cargo.toml index ed6fc1c5..1546bfbe 100644 --- a/wallet/Cargo.toml +++ b/wallet/Cargo.toml @@ -18,8 +18,9 @@ token_core.workspace = true amm_core.workspace = true testnet_initial_state.workspace = true ata_core.workspace = true -bip39.workspace = true +lez_core.workspace = true +bip39.workspace = true anyhow.workspace = true thiserror.workspace = true serde_json.workspace = true diff --git a/wallet/src/lib.rs b/wallet/src/lib.rs index bfbe7145..e860f7a7 100644 --- a/wallet/src/lib.rs +++ b/wallet/src/lib.rs @@ -14,6 +14,7 @@ use bip39::Mnemonic; use common::{HashType, transaction::NSSATransaction}; use config::WalletConfig; use key_protocol::key_management::key_tree::chain_index::ChainIndex; +pub use lez_core::ExecutionFailureKind; use log::info; use nssa::{ Account, AccountId, PrivacyPreservingTransaction, @@ -59,24 +60,6 @@ pub struct SharedAccountInfo { pub vpk: nssa_core::encryption::ViewingPublicKey, } -#[derive(Debug, thiserror::Error)] -pub enum ExecutionFailureKind { - #[error("Failed to get data from sequencer")] - SequencerError(#[source] anyhow::Error), - #[error("Inputs amounts does not match outputs")] - AmountMismatchError, - #[error("Accounts key not found")] - KeyNotFoundError, - #[error("Sequencer client error")] - SequencerClientError(#[from] sequencer_service_rpc::ClientError), - #[error("Can not pay for operation")] - InsufficientFundsError, - #[error("Account {0} data is invalid")] - AccountDataError(AccountId), - #[error("Failed to build transaction: {0}")] - TransactionBuildError(#[from] nssa::error::NssaError), -} - #[expect(clippy::partial_pub_fields, reason = "TODO: make all fields private")] pub struct WalletCore { config_path: PathBuf,