2026-06-03 14:51:15 +03:00
|
|
|
use core::fmt;
|
|
|
|
|
|
2025-12-16 14:05:34 +02:00
|
|
|
use anyhow::Result;
|
refactor(keycard_wallet): replace keycard-py with keycard-rs (#595)
* refactor(keycard_wallet): replace keycard-py (pyo3) with native keycard-rs
Rewrites the Keycard integration to talk to the LEE-flavored applet
directly from Rust via keycard-rs (pinned git dependency), dropping the
pyo3 shim, python_path.rs, and the vendored keycard-py Python library
entirely. Verified end-to-end against real hardware: pairing, PIN
verification, mnemonic loading, BIP340 Schnorr signing, and transfers
between keycard and public/private accounts.
Also cleans up the pyo3 usage that had leaked into wallet's signing
path (signing.rs, account_manager.rs) beyond the keycard CLI itself,
and trims wallet_with_keycard.sh's Python setup down to just pyscard,
which is only needed by the force_unpower.py test helper now.
* Updated to v2 secure communication
* ci: install libpcsclite-dev for pcsc crate builds
* docs(keycard): clarify personalization is mandatory and document default CA
Reinstalling the applet via `./gradlew install` wipes any existing
personalization regardless of firmware source, which wasn't previously
called out and is a common source of "card not available" confusion.
Also state plainly that personalization is required before any command
works, and document keycard-rs's actual baked-in default CA public key
instead of only describing the override mechanism.
Export KEYCARD_CA_PUBLIC_KEY in all keycard test scripts so they work
against the dev/test-CA personalization flow they rely on.
* fix(keycard): address PR #595 review comments
- Propagate the load_mnemonic error instead of swallowing it behind
is_ok(), matching every other command handler in this file.
- Add deny.toml allowing the keycard-rs git source, fixing the
source-not-allowed failure in `cargo deny check`. Licenses and
advisories checks still have pre-existing, separate failures not
addressed here.
- Drop the pinned rev for the keycard-rs git dependency so it tracks
the upstream default branch instead.
* chore(deny): trim deny.toml to essentials and allow all in-use licenses
Replace the full cargo-deny init template with just the two sections
that matter: the keycard-rs git-source allowance, and an explicit
license allow-list covering every license currently in the dependency
tree. cargo deny check now passes on sources, bans, and licenses;
advisories still fails on a pre-existing, unrelated RUSTSEC advisory.
* fix(deny): remove second config and add source to the original one
* chore: pin keycard-rs dependency and fix factory-reset debug-gate doc
* chore: regenerate test fixture and lockfile after rebase onto dev
---------
Co-authored-by: Daniil Polyakov <arjentix@gmail.com>
2026-07-23 15:50:30 -04:00
|
|
|
use keycard_wallet::KeycardWallet;
|
2026-06-05 17:35:10 -04:00
|
|
|
use lee::{AccountId, PrivateKey, PublicKey, Signature};
|
2026-06-01 17:10:46 -03:00
|
|
|
use lee_core::{
|
2026-07-03 16:53:39 +00:00
|
|
|
Commitment, CommitmentSetDigest, DummyInput, Identifier, InputAccountIdentity, MembershipProof,
|
|
|
|
|
NullifierPublicKey, NullifierSecretKey, PrivateAccountKind, SharedSecretKey,
|
2026-07-03 15:04:52 +00:00
|
|
|
account::{Account, AccountWithMetadata, Nonce},
|
2026-07-10 12:31:26 +00:00
|
|
|
compute_digest_for_path,
|
2026-07-03 15:04:52 +00:00
|
|
|
encryption::{
|
2026-07-06 10:41:15 +00:00
|
|
|
Ciphertext, EncryptedAccountData, MlKem768EncapsulationKey, ViewTag, ViewingPublicKey,
|
2026-07-03 15:04:52 +00:00
|
|
|
},
|
2025-11-30 01:19:06 +03:00
|
|
|
};
|
2026-06-19 22:37:36 +04:00
|
|
|
use rand::{RngCore as _, rngs::OsRng};
|
2025-11-30 01:19:06 +03:00
|
|
|
|
2026-03-13 22:38:23 +03:00
|
|
|
use crate::{ExecutionFailureKind, WalletCore};
|
2025-11-30 01:19:06 +03:00
|
|
|
|
2026-06-03 14:51:15 +03:00
|
|
|
#[derive(Clone, PartialEq, Eq)]
|
2026-05-20 18:55:39 +03:00
|
|
|
pub enum AccountIdentity {
|
2025-11-30 01:19:06 +03:00
|
|
|
Public(AccountId),
|
2026-05-19 17:54:25 +03:00
|
|
|
/// A public account without signing. Would not try to sign, even if account is owned.
|
|
|
|
|
PublicNoSign(AccountId),
|
2026-06-05 17:35:10 -04:00
|
|
|
/// A public account from keycard. Mandatory signing.
|
|
|
|
|
PublicKeycard {
|
|
|
|
|
account_id: AccountId,
|
|
|
|
|
key_path: String,
|
|
|
|
|
},
|
2025-11-30 01:57:59 +03:00
|
|
|
PrivateOwned(AccountId),
|
2025-11-30 01:19:06 +03:00
|
|
|
PrivateForeign {
|
|
|
|
|
npk: NullifierPublicKey,
|
2026-01-21 17:27:23 -05:00
|
|
|
vpk: ViewingPublicKey,
|
2026-04-15 16:51:20 -03:00
|
|
|
identifier: Identifier,
|
2025-11-30 01:19:06 +03:00
|
|
|
},
|
2026-05-07 13:21:05 -03:00
|
|
|
/// An owned private PDA: wallet holds the nsk/npk; `account_id` was derived via
|
2026-05-08 21:41:48 -03:00
|
|
|
/// [`AccountId::for_private_pda`].
|
2026-05-04 21:40:30 -03:00
|
|
|
PrivatePdaOwned(AccountId),
|
|
|
|
|
/// A foreign private PDA: wallet knows the recipient's npk/vpk but not their nsk.
|
2026-05-08 21:41:48 -03:00
|
|
|
/// Uses a default (uninitialised) account.
|
2026-05-04 21:40:30 -03:00
|
|
|
PrivatePdaForeign {
|
|
|
|
|
account_id: AccountId,
|
|
|
|
|
npk: NullifierPublicKey,
|
|
|
|
|
vpk: ViewingPublicKey,
|
|
|
|
|
identifier: Identifier,
|
|
|
|
|
},
|
2026-05-05 15:38:18 +02:00
|
|
|
/// A shared regular private account with externally-provided keys (e.g. from GMS).
|
2026-05-07 22:48:32 +02:00
|
|
|
/// Uses standard `AccountId = from((&npk, identifier))` with authorized/unauthorized private
|
|
|
|
|
/// paths. Works with `authenticated_transfer` and all existing programs out of the box.
|
2026-05-05 15:38:18 +02:00
|
|
|
PrivateShared {
|
|
|
|
|
nsk: NullifierSecretKey,
|
|
|
|
|
npk: NullifierPublicKey,
|
|
|
|
|
vpk: ViewingPublicKey,
|
|
|
|
|
identifier: Identifier,
|
|
|
|
|
},
|
2026-05-11 20:26:03 -03:00
|
|
|
/// 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,
|
|
|
|
|
},
|
2025-11-30 01:19:06 +03:00
|
|
|
}
|
|
|
|
|
|
2026-06-03 14:51:15 +03:00
|
|
|
impl fmt::Debug for AccountIdentity {
|
|
|
|
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
|
|
|
|
match self {
|
|
|
|
|
Self::Public(id) => f.debug_tuple("Public").field(id).finish(),
|
|
|
|
|
Self::PublicNoSign(id) => f.debug_tuple("PublicNoSign").field(id).finish(),
|
2026-06-15 14:14:45 +03:00
|
|
|
Self::PublicKeycard {
|
|
|
|
|
account_id,
|
|
|
|
|
key_path: _,
|
|
|
|
|
} => f
|
|
|
|
|
.debug_struct("PublicKeycard")
|
|
|
|
|
.field("account_id", account_id)
|
|
|
|
|
.field("key_path", &"<redacted>")
|
|
|
|
|
.finish(),
|
2026-06-03 14:51:15 +03:00
|
|
|
Self::PrivateOwned(id) => f.debug_tuple("PrivateOwned").field(id).finish(),
|
|
|
|
|
Self::PrivateForeign {
|
|
|
|
|
npk,
|
|
|
|
|
vpk,
|
|
|
|
|
identifier,
|
|
|
|
|
} => f
|
|
|
|
|
.debug_struct("PrivateForeign")
|
|
|
|
|
.field("npk", npk)
|
|
|
|
|
.field("vpk", vpk)
|
|
|
|
|
.field("identifier", identifier)
|
|
|
|
|
.finish(),
|
|
|
|
|
Self::PrivatePdaOwned(id) => f.debug_tuple("PrivatePdaOwned").field(id).finish(),
|
|
|
|
|
Self::PrivatePdaForeign {
|
|
|
|
|
account_id,
|
|
|
|
|
npk,
|
|
|
|
|
vpk,
|
|
|
|
|
identifier,
|
|
|
|
|
} => f
|
|
|
|
|
.debug_struct("PrivatePdaForeign")
|
|
|
|
|
.field("account_id", account_id)
|
|
|
|
|
.field("npk", npk)
|
|
|
|
|
.field("vpk", vpk)
|
|
|
|
|
.field("identifier", identifier)
|
|
|
|
|
.finish(),
|
|
|
|
|
Self::PrivateShared {
|
|
|
|
|
npk,
|
|
|
|
|
vpk,
|
|
|
|
|
identifier,
|
|
|
|
|
..
|
|
|
|
|
} => f
|
|
|
|
|
.debug_struct("PrivateShared")
|
|
|
|
|
.field("nsk", &"<redacted>")
|
|
|
|
|
.field("npk", npk)
|
|
|
|
|
.field("vpk", vpk)
|
|
|
|
|
.field("identifier", identifier)
|
|
|
|
|
.finish(),
|
|
|
|
|
Self::PrivatePdaShared {
|
|
|
|
|
account_id,
|
|
|
|
|
npk,
|
|
|
|
|
vpk,
|
|
|
|
|
identifier,
|
|
|
|
|
..
|
|
|
|
|
} => f
|
|
|
|
|
.debug_struct("PrivatePdaShared")
|
|
|
|
|
.field("account_id", account_id)
|
|
|
|
|
.field("nsk", &"<redacted>")
|
|
|
|
|
.field("npk", npk)
|
|
|
|
|
.field("vpk", vpk)
|
|
|
|
|
.field("identifier", identifier)
|
|
|
|
|
.finish(),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-20 18:55:39 +03:00
|
|
|
impl AccountIdentity {
|
2026-03-03 23:21:08 +03:00
|
|
|
#[must_use]
|
2026-05-21 13:02:18 +03:00
|
|
|
/// Note: `PublicNoSign` still counts as public, the variant just suppresses the signing-key
|
|
|
|
|
/// lookup.
|
2026-03-09 18:27:56 +03:00
|
|
|
pub const fn is_public(&self) -> bool {
|
2026-06-05 17:35:10 -04:00
|
|
|
matches!(
|
|
|
|
|
&self,
|
|
|
|
|
Self::Public(_) | Self::PublicNoSign(_) | Self::PublicKeycard { .. }
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Returns the `AccountId` for public variants. Used by facades that need the raw ID
|
|
|
|
|
/// for derived-address computation alongside the identity.
|
|
|
|
|
#[must_use]
|
|
|
|
|
pub const fn public_account_id(&self) -> Option<lee::AccountId> {
|
|
|
|
|
match self {
|
|
|
|
|
Self::Public(id) | Self::PublicNoSign(id) => Some(*id),
|
|
|
|
|
Self::PublicKeycard { account_id, .. } => Some(*account_id),
|
|
|
|
|
Self::PrivateOwned(_)
|
|
|
|
|
| Self::PrivateForeign { .. }
|
|
|
|
|
| Self::PrivatePdaOwned(_)
|
|
|
|
|
| Self::PrivatePdaForeign { .. }
|
|
|
|
|
| Self::PrivateShared { .. }
|
|
|
|
|
| Self::PrivatePdaShared { .. } => None,
|
|
|
|
|
}
|
2025-12-16 14:05:34 +02:00
|
|
|
}
|
|
|
|
|
|
2026-03-03 23:21:08 +03:00
|
|
|
#[must_use]
|
2026-03-09 18:27:56 +03:00
|
|
|
pub const fn is_private(&self) -> bool {
|
2025-12-16 14:05:34 +02:00
|
|
|
matches!(
|
|
|
|
|
&self,
|
2026-04-19 23:13:51 -03:00
|
|
|
Self::PrivateOwned(_)
|
2026-05-04 21:40:30 -03:00
|
|
|
| Self::PrivateForeign { .. }
|
|
|
|
|
| Self::PrivatePdaOwned(_)
|
|
|
|
|
| Self::PrivatePdaForeign { .. }
|
2026-05-05 15:38:18 +02:00
|
|
|
| Self::PrivateShared { .. }
|
2026-05-11 20:26:03 -03:00
|
|
|
| Self::PrivatePdaShared { .. }
|
2025-12-16 14:05:34 +02:00
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2025-11-30 01:19:06 +03:00
|
|
|
pub struct PrivateAccountKeys {
|
|
|
|
|
pub ssk: SharedSecretKey,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
enum State {
|
|
|
|
|
Public {
|
|
|
|
|
account: AccountWithMetadata,
|
|
|
|
|
sk: Option<PrivateKey>,
|
|
|
|
|
},
|
2026-06-05 17:35:10 -04:00
|
|
|
PublicKeycard {
|
|
|
|
|
account: AccountWithMetadata,
|
|
|
|
|
key_path: String,
|
|
|
|
|
},
|
2025-11-30 01:19:06 +03:00
|
|
|
Private(AccountPreparedData),
|
|
|
|
|
}
|
|
|
|
|
|
2025-11-30 02:18:38 +03:00
|
|
|
pub struct AccountManager {
|
2025-11-30 01:19:06 +03:00
|
|
|
states: Vec<State>,
|
2026-06-05 17:35:10 -04:00
|
|
|
pin: Option<String>,
|
2026-07-01 10:14:32 -04:00
|
|
|
dummy_commitment_root: CommitmentSetDigest,
|
2025-11-30 01:19:06 +03:00
|
|
|
}
|
|
|
|
|
|
2025-11-30 02:18:38 +03:00
|
|
|
impl AccountManager {
|
2026-07-07 11:52:58 +00:00
|
|
|
/// The private-account count that every privacy-preserving transaction is padded up to with
|
|
|
|
|
/// dummy inputs via the default interface.
|
|
|
|
|
///
|
|
|
|
|
/// The value is selected based on the largest account number per-tx currently supported
|
|
|
|
|
/// (it is 7 for AMM). It is recommended to reassess this value per new actively supported
|
|
|
|
|
/// application and that all users share the value for a larger anonymity set.
|
|
|
|
|
const MAX_PRIVATE_ACCOUNTS: usize = 7;
|
|
|
|
|
|
2025-11-30 01:19:06 +03:00
|
|
|
pub async fn new(
|
|
|
|
|
wallet: &WalletCore,
|
2026-05-20 18:55:39 +03:00
|
|
|
accounts: Vec<AccountIdentity>,
|
2025-11-30 01:19:06 +03:00
|
|
|
) -> Result<Self, ExecutionFailureKind> {
|
2026-04-28 17:50:28 +02:00
|
|
|
let mut states = Vec::with_capacity(accounts.len());
|
2026-06-05 17:35:10 -04:00
|
|
|
let mut pin = None;
|
2025-11-30 01:19:06 +03:00
|
|
|
|
|
|
|
|
for account in accounts {
|
2026-04-28 17:50:28 +02:00
|
|
|
let state = match account {
|
2026-05-20 18:55:39 +03:00
|
|
|
AccountIdentity::Public(account_id) => {
|
2025-11-30 01:19:06 +03:00
|
|
|
let acc = wallet
|
|
|
|
|
.get_account_public(account_id)
|
|
|
|
|
.await
|
2026-03-04 18:42:33 +03:00
|
|
|
.map_err(ExecutionFailureKind::SequencerError)?;
|
2025-11-30 01:19:06 +03:00
|
|
|
|
2026-01-29 22:20:42 +03:00
|
|
|
let sk = wallet.get_account_public_signing_key(account_id).cloned();
|
2025-11-30 01:19:06 +03:00
|
|
|
let account = AccountWithMetadata::new(acc.clone(), sk.is_some(), account_id);
|
2026-05-19 17:54:25 +03:00
|
|
|
|
|
|
|
|
State::Public { account, sk }
|
|
|
|
|
}
|
2026-05-20 18:55:39 +03:00
|
|
|
AccountIdentity::PublicNoSign(account_id) => {
|
2026-05-19 17:54:25 +03:00
|
|
|
let acc = wallet
|
|
|
|
|
.get_account_public(account_id)
|
|
|
|
|
.await
|
|
|
|
|
.map_err(ExecutionFailureKind::SequencerError)?;
|
|
|
|
|
|
|
|
|
|
let sk = None;
|
|
|
|
|
let account = AccountWithMetadata::new(acc.clone(), sk.is_some(), account_id);
|
2025-11-30 01:19:06 +03:00
|
|
|
|
2026-04-28 17:50:28 +02:00
|
|
|
State::Public { account, sk }
|
2025-11-30 01:19:06 +03:00
|
|
|
}
|
2026-06-05 17:35:10 -04:00
|
|
|
AccountIdentity::PublicKeycard {
|
|
|
|
|
account_id,
|
|
|
|
|
key_path,
|
|
|
|
|
} => {
|
|
|
|
|
let acc = wallet
|
|
|
|
|
.get_account_public(account_id)
|
|
|
|
|
.await
|
|
|
|
|
.map_err(ExecutionFailureKind::SequencerError)?;
|
|
|
|
|
|
|
|
|
|
let account = AccountWithMetadata::new(acc.clone(), true, account_id);
|
|
|
|
|
|
|
|
|
|
if pin.is_none() {
|
|
|
|
|
pin = Some(
|
|
|
|
|
crate::helperfunctions::read_pin()
|
refactor(keycard_wallet): replace keycard-py with keycard-rs (#595)
* refactor(keycard_wallet): replace keycard-py (pyo3) with native keycard-rs
Rewrites the Keycard integration to talk to the LEE-flavored applet
directly from Rust via keycard-rs (pinned git dependency), dropping the
pyo3 shim, python_path.rs, and the vendored keycard-py Python library
entirely. Verified end-to-end against real hardware: pairing, PIN
verification, mnemonic loading, BIP340 Schnorr signing, and transfers
between keycard and public/private accounts.
Also cleans up the pyo3 usage that had leaked into wallet's signing
path (signing.rs, account_manager.rs) beyond the keycard CLI itself,
and trims wallet_with_keycard.sh's Python setup down to just pyscard,
which is only needed by the force_unpower.py test helper now.
* Updated to v2 secure communication
* ci: install libpcsclite-dev for pcsc crate builds
* docs(keycard): clarify personalization is mandatory and document default CA
Reinstalling the applet via `./gradlew install` wipes any existing
personalization regardless of firmware source, which wasn't previously
called out and is a common source of "card not available" confusion.
Also state plainly that personalization is required before any command
works, and document keycard-rs's actual baked-in default CA public key
instead of only describing the override mechanism.
Export KEYCARD_CA_PUBLIC_KEY in all keycard test scripts so they work
against the dev/test-CA personalization flow they rely on.
* fix(keycard): address PR #595 review comments
- Propagate the load_mnemonic error instead of swallowing it behind
is_ok(), matching every other command handler in this file.
- Add deny.toml allowing the keycard-rs git source, fixing the
source-not-allowed failure in `cargo deny check`. Licenses and
advisories checks still have pre-existing, separate failures not
addressed here.
- Drop the pinned rev for the keycard-rs git dependency so it tracks
the upstream default branch instead.
* chore(deny): trim deny.toml to essentials and allow all in-use licenses
Replace the full cargo-deny init template with just the two sections
that matter: the keycard-rs git-source allowance, and an explicit
license allow-list covering every license currently in the dependency
tree. cargo deny check now passes on sources, bans, and licenses;
advisories still fails on a pre-existing, unrelated RUSTSEC advisory.
* fix(deny): remove second config and add source to the original one
* chore: pin keycard-rs dependency and fix factory-reset debug-gate doc
* chore: regenerate test fixture and lockfile after rebase onto dev
---------
Co-authored-by: Daniil Polyakov <arjentix@gmail.com>
2026-07-23 15:50:30 -04:00
|
|
|
.map_err(ExecutionFailureKind::SignError)?
|
2026-06-05 17:35:10 -04:00
|
|
|
.as_str()
|
|
|
|
|
.to_owned(),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
State::PublicKeycard { account, key_path }
|
|
|
|
|
}
|
2026-05-20 18:55:39 +03:00
|
|
|
AccountIdentity::PrivateOwned(account_id) => {
|
2026-07-10 11:58:05 +00:00
|
|
|
let pre = private_key_tree_acc_preparation(wallet, account_id, false)?;
|
2025-11-30 01:19:06 +03:00
|
|
|
|
2026-04-28 17:50:28 +02:00
|
|
|
State::Private(pre)
|
2025-11-30 01:19:06 +03:00
|
|
|
}
|
2026-05-20 18:55:39 +03:00
|
|
|
AccountIdentity::PrivateForeign {
|
2026-04-19 23:13:51 -03:00
|
|
|
npk,
|
|
|
|
|
vpk,
|
|
|
|
|
identifier,
|
|
|
|
|
} => {
|
2026-06-01 17:10:46 -03:00
|
|
|
let acc = lee_core::account::Account::default();
|
refactor: `PrivateUnauthorized` authorization changed to true (#621)
* refactor: rename PrivateUnauthorized to PrivateForeignInit
The account_identity's is_authorized flag no longer determines
authorization for this variant, so keep the name tied to what
actually distinguishes it: no nsk, only npk (a foreign account init).
* chore: rebuild guest artifacts and bump spin to clear yanked advisory
Regenerate ELF artifacts after the PrivateForeignInit rename in lee_core
(compiled into every guest program), and update spin 0.9.8 -> 0.9.9 since
0.9.8 was yanked from crates.io, per cargo deny check advisories.
* test: align is_authorized with PrivateForeignInit's flipped semantics
Recipient pre-states built for PrivateForeignInit now need is_authorized:
true to match the assertion in output.rs. Also rewrites the boundary test
that checked the old invalid case to check the new one, and updates
stale "unauthorized" wording left over from the PrivateUnauthorized name.
* chore: rebuild guest artifacts
Reproducible across repeated local builds; likely toolchain drift since
the prior artifact commit rather than a source change, since no
guest-relevant source or Cargo.lock changed in between.
* fix(tests): align integration tests with PrivateForeignInit and regenerate fixture
prove_init_with_commitment_root (private.rs) and build_privacy_transaction
(tps.rs) still built PrivateForeignInit recipients with is_authorized: false,
same stale-semantics bug fixed earlier in the lee crate's own tests.
The prebuilt sequencer DB dump embeds program IDs derived from guest ELF
bytes, which shifted once the PrivateForeignInit rename changed lee_core
(compiled into every guest program). The stale dump caused widespread
"Unknown program" failures across integration test suites that exercise
deployed programs (wallet_ffi, auth_transfer, bridge, amm, token, pinata,
ata, indexer state-consistency checks). Regenerated via
`just regenerate-test-fixture`.
* fix(tests): rename leftover PrivateUnauthorized to PrivateForeignInit and regenerate fixture
* test: align is_authorized with PrivateForeignInit's flipped semantics
* chore: regenerate test fixture after rebase onto dev
* chore: regenerate test fixture after rebase onto dev
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-23 17:32:01 -04:00
|
|
|
let auth_acc = AccountWithMetadata::new(acc, true, (&npk, &vpk, identifier));
|
2026-07-03 16:53:39 +00:00
|
|
|
let random_seed = random_bytes();
|
2025-11-30 01:19:06 +03:00
|
|
|
let pre = AccountPreparedData {
|
|
|
|
|
nsk: None,
|
|
|
|
|
npk,
|
2026-04-15 16:51:20 -03:00
|
|
|
identifier,
|
2026-01-21 17:27:23 -05:00
|
|
|
vpk,
|
2025-12-11 20:59:37 -03:00
|
|
|
pre_state: auth_acc,
|
2025-11-30 01:19:06 +03:00
|
|
|
proof: None,
|
2026-06-26 14:21:40 -04:00
|
|
|
random_seed,
|
2026-05-06 00:22:58 -03:00
|
|
|
is_pda: false,
|
2025-11-30 01:19:06 +03:00
|
|
|
};
|
|
|
|
|
|
2026-05-05 12:37:54 +02:00
|
|
|
State::Private(pre)
|
2025-11-30 01:19:06 +03:00
|
|
|
}
|
2026-05-20 18:55:39 +03:00
|
|
|
AccountIdentity::PrivatePdaOwned(account_id) => {
|
2026-07-10 11:58:05 +00:00
|
|
|
let pre = private_key_tree_acc_preparation(wallet, account_id, true)?;
|
2026-05-06 00:22:58 -03:00
|
|
|
State::Private(pre)
|
2026-05-04 21:40:30 -03:00
|
|
|
}
|
2026-05-20 18:55:39 +03:00
|
|
|
AccountIdentity::PrivatePdaForeign {
|
2026-05-04 21:40:30 -03:00
|
|
|
account_id,
|
|
|
|
|
npk,
|
|
|
|
|
vpk,
|
|
|
|
|
identifier,
|
|
|
|
|
} => {
|
2026-06-01 17:10:46 -03:00
|
|
|
let acc = lee_core::account::Account::default();
|
2026-05-04 21:40:30 -03:00
|
|
|
let auth_acc = AccountWithMetadata::new(acc, false, account_id);
|
2026-07-03 16:53:39 +00:00
|
|
|
let random_seed = random_bytes();
|
2026-05-04 21:40:30 -03:00
|
|
|
let pre = AccountPreparedData {
|
|
|
|
|
nsk: None,
|
|
|
|
|
npk,
|
|
|
|
|
identifier,
|
|
|
|
|
vpk,
|
|
|
|
|
pre_state: auth_acc,
|
|
|
|
|
proof: None,
|
2026-06-26 14:21:40 -04:00
|
|
|
random_seed,
|
2026-05-06 00:22:58 -03:00
|
|
|
is_pda: true,
|
2026-05-04 21:40:30 -03:00
|
|
|
};
|
2026-04-28 17:50:28 +02:00
|
|
|
State::Private(pre)
|
2026-05-04 21:40:30 -03:00
|
|
|
}
|
2026-05-20 18:55:39 +03:00
|
|
|
AccountIdentity::PrivateShared {
|
2026-05-05 15:38:18 +02:00
|
|
|
nsk,
|
|
|
|
|
npk,
|
|
|
|
|
vpk,
|
|
|
|
|
identifier,
|
|
|
|
|
} => {
|
2026-06-19 22:37:36 +04:00
|
|
|
let account_id = lee::AccountId::from((&npk, &vpk, identifier));
|
2026-05-11 20:26:03 -03:00
|
|
|
let pre = private_shared_acc_preparation(
|
|
|
|
|
wallet, account_id, nsk, npk, vpk, identifier, false,
|
2026-07-10 11:58:05 +00:00
|
|
|
);
|
2026-05-11 20:26:03 -03:00
|
|
|
|
|
|
|
|
State::Private(pre)
|
|
|
|
|
}
|
2026-05-20 18:55:39 +03:00
|
|
|
AccountIdentity::PrivatePdaShared {
|
2026-05-11 20:26:03 -03:00
|
|
|
account_id,
|
|
|
|
|
nsk,
|
|
|
|
|
npk,
|
|
|
|
|
vpk,
|
|
|
|
|
identifier,
|
|
|
|
|
} => {
|
|
|
|
|
let pre = private_shared_acc_preparation(
|
|
|
|
|
wallet, account_id, nsk, npk, vpk, identifier, true,
|
2026-07-10 11:58:05 +00:00
|
|
|
);
|
2026-05-05 15:38:18 +02:00
|
|
|
|
2026-04-28 17:50:28 +02:00
|
|
|
State::Private(pre)
|
2026-04-27 02:43:51 +02:00
|
|
|
}
|
2025-11-30 01:19:06 +03:00
|
|
|
};
|
|
|
|
|
|
2026-04-28 17:50:28 +02:00
|
|
|
states.push(state);
|
2025-11-30 01:19:06 +03:00
|
|
|
}
|
|
|
|
|
|
2026-07-10 11:58:05 +00:00
|
|
|
let dummy_commitment_root = fetch_private_proofs_and_root(wallet, &mut states).await?;
|
2026-07-01 10:14:32 -04:00
|
|
|
|
|
|
|
|
Ok(Self {
|
|
|
|
|
states,
|
|
|
|
|
pin,
|
|
|
|
|
dummy_commitment_root,
|
|
|
|
|
})
|
2025-11-30 01:19:06 +03:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub fn pre_states(&self) -> Vec<AccountWithMetadata> {
|
|
|
|
|
self.states
|
|
|
|
|
.iter()
|
|
|
|
|
.map(|state| match state {
|
2026-06-05 17:35:10 -04:00
|
|
|
State::Public { account, .. } | State::PublicKeycard { account, .. } => {
|
|
|
|
|
account.clone()
|
|
|
|
|
}
|
2025-12-11 20:59:37 -03:00
|
|
|
State::Private(pre) => pre.pre_state.clone(),
|
2025-11-30 01:19:06 +03:00
|
|
|
})
|
|
|
|
|
.collect()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub fn public_account_nonces(&self) -> Vec<Nonce> {
|
2026-06-05 17:35:10 -04:00
|
|
|
// Must match the signature order produced by sign_message(): local accounts first,
|
|
|
|
|
// keycard accounts second.
|
|
|
|
|
let local = self.states.iter().filter_map(|state| match state {
|
|
|
|
|
State::Public { account, sk } => sk.as_ref().map(|_| account.account.nonce),
|
|
|
|
|
State::PublicKeycard { .. } | State::Private(_) => None,
|
|
|
|
|
});
|
|
|
|
|
let keycard = self.states.iter().filter_map(|state| match state {
|
|
|
|
|
State::PublicKeycard { account, .. } => Some(account.account.nonce),
|
|
|
|
|
State::Public { .. } | State::Private(_) => None,
|
|
|
|
|
});
|
|
|
|
|
local.chain(keycard).collect()
|
2025-11-30 01:19:06 +03:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub fn private_account_keys(&self) -> Vec<PrivateAccountKeys> {
|
|
|
|
|
self.states
|
|
|
|
|
.iter()
|
|
|
|
|
.filter_map(|state| match state {
|
2026-06-19 22:37:36 +04:00
|
|
|
State::Private(pre) => Some(pre),
|
2026-06-05 17:35:10 -04:00
|
|
|
State::Public { .. } | State::PublicKeycard { .. } => None,
|
2025-11-30 01:19:06 +03:00
|
|
|
})
|
2026-06-25 17:58:57 -04:00
|
|
|
.map(|pre| {
|
|
|
|
|
let nonce = if pre.proof.is_some() {
|
|
|
|
|
pre.pre_state.account.nonce.private_account_nonce_increment(
|
|
|
|
|
pre.nsk.as_ref().expect("update variant must have nsk"),
|
|
|
|
|
)
|
|
|
|
|
} else {
|
|
|
|
|
lee_core::account::Nonce::private_account_nonce_init(&pre.pre_state.account_id)
|
|
|
|
|
};
|
|
|
|
|
let esk = lee_core::EphemeralSecretKey::new(
|
|
|
|
|
&pre.pre_state.account_id,
|
2026-06-26 14:21:40 -04:00
|
|
|
&pre.random_seed,
|
2026-06-25 17:58:57 -04:00
|
|
|
&nonce,
|
|
|
|
|
);
|
|
|
|
|
PrivateAccountKeys {
|
|
|
|
|
ssk: SharedSecretKey::encapsulate_deterministic(&pre.vpk, &esk).0,
|
|
|
|
|
}
|
2026-06-19 22:37:36 +04:00
|
|
|
})
|
2025-11-30 01:19:06 +03:00
|
|
|
.collect()
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-03 15:04:52 +00:00
|
|
|
/// Given a count, generate that many dummy inputs with randomized seeds and notes.
|
|
|
|
|
/// Uses the given commitment root from the account.
|
|
|
|
|
pub fn dummy_inputs(&self, count: usize) -> Vec<DummyInput> {
|
|
|
|
|
std::iter::repeat_with(|| DummyInput {
|
|
|
|
|
nullifier_seed: random_bytes(),
|
|
|
|
|
commitment_seed: random_bytes(),
|
|
|
|
|
note: random_dummy_note(),
|
|
|
|
|
commitment_root: self.dummy_commitment_root,
|
|
|
|
|
})
|
|
|
|
|
.take(count)
|
|
|
|
|
.collect()
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-07 11:52:58 +00:00
|
|
|
/// Generate the dummy inputs that pad this transaction's private-account count up to
|
|
|
|
|
/// `MAX_PRIVATE_ACCOUNTS`.
|
|
|
|
|
pub fn dummy_inputs_default(&self) -> Vec<DummyInput> {
|
|
|
|
|
let private_count = self
|
|
|
|
|
.states
|
|
|
|
|
.iter()
|
|
|
|
|
.filter(|state| matches!(state, State::Private(_)))
|
|
|
|
|
.count();
|
|
|
|
|
self.dummy_inputs(Self::MAX_PRIVATE_ACCOUNTS.saturating_sub(private_count))
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-28 17:50:28 +02:00
|
|
|
/// 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.
|
2026-04-30 15:44:51 +02:00
|
|
|
pub fn account_identities(&self) -> Vec<InputAccountIdentity> {
|
2025-11-30 01:19:06 +03:00
|
|
|
self.states
|
|
|
|
|
.iter()
|
2026-04-28 17:50:28 +02:00
|
|
|
.map(|state| match state {
|
2026-06-05 17:35:10 -04:00
|
|
|
State::Public { .. } | State::PublicKeycard { .. } => InputAccountIdentity::Public,
|
2026-05-07 01:41:35 -03:00
|
|
|
State::Private(pre) if pre.is_pda => match (pre.nsk, pre.proof.clone()) {
|
|
|
|
|
(Some(nsk), Some(membership_proof)) => InputAccountIdentity::PrivatePdaUpdate {
|
2026-06-19 22:37:36 +04:00
|
|
|
vpk: pre.vpk.clone(),
|
2026-06-26 14:21:40 -04:00
|
|
|
random_seed: pre.random_seed,
|
2026-07-10 17:17:31 +04:00
|
|
|
view_tag: random_view_tag(),
|
2026-05-07 01:41:35 -03:00
|
|
|
nsk,
|
|
|
|
|
membership_proof,
|
|
|
|
|
identifier: pre.identifier,
|
2026-05-21 01:45:10 -03:00
|
|
|
seed: None,
|
2026-05-07 01:41:35 -03:00
|
|
|
},
|
|
|
|
|
_ => InputAccountIdentity::PrivatePdaInit {
|
2026-06-19 22:37:36 +04:00
|
|
|
vpk: pre.vpk.clone(),
|
2026-06-26 14:21:40 -04:00
|
|
|
random_seed: pre.random_seed,
|
2026-05-07 01:41:35 -03:00
|
|
|
npk: pre.npk,
|
|
|
|
|
identifier: pre.identifier,
|
2026-07-01 10:14:32 -04:00
|
|
|
commitment_root: self.dummy_commitment_root,
|
2026-05-21 01:45:10 -03:00
|
|
|
seed: None,
|
2026-05-07 01:41:35 -03:00
|
|
|
},
|
|
|
|
|
},
|
2026-04-28 17:50:28 +02:00
|
|
|
State::Private(pre) => match (pre.nsk, pre.proof.clone()) {
|
|
|
|
|
(Some(nsk), Some(membership_proof)) => {
|
2026-04-30 15:44:51 +02:00
|
|
|
InputAccountIdentity::PrivateAuthorizedUpdate {
|
2026-06-19 22:37:36 +04:00
|
|
|
vpk: pre.vpk.clone(),
|
2026-06-26 14:21:40 -04:00
|
|
|
random_seed: pre.random_seed,
|
2026-07-10 17:17:31 +04:00
|
|
|
view_tag: random_view_tag(),
|
2026-04-28 17:50:28 +02:00
|
|
|
nsk,
|
|
|
|
|
membership_proof,
|
2026-04-30 20:17:47 +02:00
|
|
|
identifier: pre.identifier,
|
2026-04-28 17:50:28 +02:00
|
|
|
}
|
|
|
|
|
}
|
2026-04-30 20:17:47 +02:00
|
|
|
(Some(nsk), None) => InputAccountIdentity::PrivateAuthorizedInit {
|
2026-06-19 22:37:36 +04:00
|
|
|
vpk: pre.vpk.clone(),
|
2026-06-26 14:21:40 -04:00
|
|
|
random_seed: pre.random_seed,
|
2026-04-30 20:17:47 +02:00
|
|
|
nsk,
|
|
|
|
|
identifier: pre.identifier,
|
2026-07-01 10:14:32 -04:00
|
|
|
commitment_root: self.dummy_commitment_root,
|
2026-04-30 20:17:47 +02:00
|
|
|
},
|
refactor: `PrivateUnauthorized` authorization changed to true (#621)
* refactor: rename PrivateUnauthorized to PrivateForeignInit
The account_identity's is_authorized flag no longer determines
authorization for this variant, so keep the name tied to what
actually distinguishes it: no nsk, only npk (a foreign account init).
* chore: rebuild guest artifacts and bump spin to clear yanked advisory
Regenerate ELF artifacts after the PrivateForeignInit rename in lee_core
(compiled into every guest program), and update spin 0.9.8 -> 0.9.9 since
0.9.8 was yanked from crates.io, per cargo deny check advisories.
* test: align is_authorized with PrivateForeignInit's flipped semantics
Recipient pre-states built for PrivateForeignInit now need is_authorized:
true to match the assertion in output.rs. Also rewrites the boundary test
that checked the old invalid case to check the new one, and updates
stale "unauthorized" wording left over from the PrivateUnauthorized name.
* chore: rebuild guest artifacts
Reproducible across repeated local builds; likely toolchain drift since
the prior artifact commit rather than a source change, since no
guest-relevant source or Cargo.lock changed in between.
* fix(tests): align integration tests with PrivateForeignInit and regenerate fixture
prove_init_with_commitment_root (private.rs) and build_privacy_transaction
(tps.rs) still built PrivateForeignInit recipients with is_authorized: false,
same stale-semantics bug fixed earlier in the lee crate's own tests.
The prebuilt sequencer DB dump embeds program IDs derived from guest ELF
bytes, which shifted once the PrivateForeignInit rename changed lee_core
(compiled into every guest program). The stale dump caused widespread
"Unknown program" failures across integration test suites that exercise
deployed programs (wallet_ffi, auth_transfer, bridge, amm, token, pinata,
ata, indexer state-consistency checks). Regenerated via
`just regenerate-test-fixture`.
* fix(tests): rename leftover PrivateUnauthorized to PrivateForeignInit and regenerate fixture
* test: align is_authorized with PrivateForeignInit's flipped semantics
* chore: regenerate test fixture after rebase onto dev
* chore: regenerate test fixture after rebase onto dev
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-23 17:32:01 -04:00
|
|
|
(None, _) => InputAccountIdentity::PrivateForeignInit {
|
2026-06-19 22:37:36 +04:00
|
|
|
vpk: pre.vpk.clone(),
|
2026-06-26 14:21:40 -04:00
|
|
|
random_seed: pre.random_seed,
|
2026-04-28 17:50:28 +02:00
|
|
|
npk: pre.npk,
|
2026-04-30 20:17:47 +02:00
|
|
|
identifier: pre.identifier,
|
2026-07-01 10:14:32 -04:00
|
|
|
commitment_root: self.dummy_commitment_root,
|
2026-04-28 17:50:28 +02:00
|
|
|
},
|
|
|
|
|
},
|
2025-11-30 01:19:06 +03:00
|
|
|
})
|
|
|
|
|
.collect()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub fn public_account_ids(&self) -> Vec<AccountId> {
|
|
|
|
|
self.states
|
|
|
|
|
.iter()
|
|
|
|
|
.filter_map(|state| match state {
|
2026-06-05 17:35:10 -04:00
|
|
|
State::Public { account, .. } | State::PublicKeycard { account, .. } => {
|
|
|
|
|
Some(account.account_id)
|
|
|
|
|
}
|
2026-03-03 23:21:08 +03:00
|
|
|
State::Private(_) => None,
|
2025-11-30 01:19:06 +03:00
|
|
|
})
|
|
|
|
|
.collect()
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-05 17:35:10 -04:00
|
|
|
pub fn public_non_keycard_account_auth(&self) -> Vec<&PrivateKey> {
|
2025-11-30 01:19:06 +03:00
|
|
|
self.states
|
|
|
|
|
.iter()
|
|
|
|
|
.filter_map(|state| match state {
|
|
|
|
|
State::Public { sk, .. } => sk.as_ref(),
|
2026-06-05 17:35:10 -04:00
|
|
|
State::PublicKeycard { .. } | State::Private(_) => None,
|
2025-11-30 01:19:06 +03:00
|
|
|
})
|
|
|
|
|
.collect()
|
|
|
|
|
}
|
2026-06-05 17:35:10 -04:00
|
|
|
|
|
|
|
|
pub fn sign_message(&self, message_hash: [u8; 32]) -> Result<Vec<(Signature, PublicKey)>> {
|
|
|
|
|
let mut sigs: Vec<(Signature, PublicKey)> = self
|
|
|
|
|
.public_non_keycard_account_auth()
|
|
|
|
|
.into_iter()
|
|
|
|
|
.map(|key| {
|
|
|
|
|
(
|
|
|
|
|
Signature::new(key, &message_hash),
|
|
|
|
|
PublicKey::new_from_private_key(key),
|
|
|
|
|
)
|
|
|
|
|
})
|
|
|
|
|
.collect();
|
|
|
|
|
|
|
|
|
|
let keycard_paths: Vec<&str> = self
|
|
|
|
|
.states
|
|
|
|
|
.iter()
|
|
|
|
|
.filter_map(|state| match state {
|
|
|
|
|
State::PublicKeycard { key_path, .. } => Some(key_path.as_str()),
|
|
|
|
|
State::Private(_) | State::Public { .. } => None,
|
|
|
|
|
})
|
|
|
|
|
.collect();
|
|
|
|
|
|
|
|
|
|
if let Some(pin) = self.pin.clone() {
|
refactor(keycard_wallet): replace keycard-py with keycard-rs (#595)
* refactor(keycard_wallet): replace keycard-py (pyo3) with native keycard-rs
Rewrites the Keycard integration to talk to the LEE-flavored applet
directly from Rust via keycard-rs (pinned git dependency), dropping the
pyo3 shim, python_path.rs, and the vendored keycard-py Python library
entirely. Verified end-to-end against real hardware: pairing, PIN
verification, mnemonic loading, BIP340 Schnorr signing, and transfers
between keycard and public/private accounts.
Also cleans up the pyo3 usage that had leaked into wallet's signing
path (signing.rs, account_manager.rs) beyond the keycard CLI itself,
and trims wallet_with_keycard.sh's Python setup down to just pyscard,
which is only needed by the force_unpower.py test helper now.
* Updated to v2 secure communication
* ci: install libpcsclite-dev for pcsc crate builds
* docs(keycard): clarify personalization is mandatory and document default CA
Reinstalling the applet via `./gradlew install` wipes any existing
personalization regardless of firmware source, which wasn't previously
called out and is a common source of "card not available" confusion.
Also state plainly that personalization is required before any command
works, and document keycard-rs's actual baked-in default CA public key
instead of only describing the override mechanism.
Export KEYCARD_CA_PUBLIC_KEY in all keycard test scripts so they work
against the dev/test-CA personalization flow they rely on.
* fix(keycard): address PR #595 review comments
- Propagate the load_mnemonic error instead of swallowing it behind
is_ok(), matching every other command handler in this file.
- Add deny.toml allowing the keycard-rs git source, fixing the
source-not-allowed failure in `cargo deny check`. Licenses and
advisories checks still have pre-existing, separate failures not
addressed here.
- Drop the pinned rev for the keycard-rs git dependency so it tracks
the upstream default branch instead.
* chore(deny): trim deny.toml to essentials and allow all in-use licenses
Replace the full cargo-deny init template with just the two sections
that matter: the keycard-rs git-source allowance, and an explicit
license allow-list covering every license currently in the dependency
tree. cargo deny check now passes on sources, bans, and licenses;
advisories still fails on a pre-existing, unrelated RUSTSEC advisory.
* fix(deny): remove second config and add source to the original one
* chore: pin keycard-rs dependency and fix factory-reset debug-gate doc
* chore: regenerate test fixture and lockfile after rebase onto dev
---------
Co-authored-by: Daniil Polyakov <arjentix@gmail.com>
2026-07-23 15:50:30 -04:00
|
|
|
let mut wallet = KeycardWallet::new()?;
|
|
|
|
|
wallet.connect(&pin)?;
|
|
|
|
|
for path in keycard_paths {
|
|
|
|
|
sigs.push(wallet.sign_message_for_path(path, &message_hash)?);
|
|
|
|
|
}
|
2026-06-05 17:35:10 -04:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Ok(sigs)
|
|
|
|
|
}
|
2025-11-30 01:19:06 +03:00
|
|
|
}
|
2025-11-30 02:18:38 +03:00
|
|
|
|
2025-12-11 21:15:57 -03:00
|
|
|
struct AccountPreparedData {
|
|
|
|
|
nsk: Option<NullifierSecretKey>,
|
|
|
|
|
npk: NullifierPublicKey,
|
2026-04-15 01:09:17 -03:00
|
|
|
identifier: Identifier,
|
2026-01-21 17:27:23 -05:00
|
|
|
vpk: ViewingPublicKey,
|
2025-12-11 21:15:57 -03:00
|
|
|
pre_state: AccountWithMetadata,
|
|
|
|
|
proof: Option<MembershipProof>,
|
2026-06-26 14:21:40 -04:00
|
|
|
random_seed: [u8; 32],
|
2026-05-06 00:22:58 -03:00
|
|
|
/// 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,
|
2025-11-30 02:18:38 +03:00
|
|
|
}
|
|
|
|
|
|
2026-07-10 11:58:05 +00:00
|
|
|
fn private_key_tree_acc_preparation(
|
2025-11-30 02:18:38 +03:00
|
|
|
wallet: &WalletCore,
|
|
|
|
|
account_id: AccountId,
|
2026-05-06 00:22:58 -03:00
|
|
|
is_pda: bool,
|
2025-11-30 02:18:38 +03:00
|
|
|
) -> Result<AccountPreparedData, ExecutionFailureKind> {
|
2026-04-10 20:23:25 +03:00
|
|
|
let Some(from_acc) = wallet.storage.key_chain().private_account(account_id) else {
|
|
|
|
|
return Err(ExecutionFailureKind::KeyNotFoundError);
|
|
|
|
|
};
|
2026-04-27 02:43:51 +02:00
|
|
|
|
2026-04-10 20:23:25 +03:00
|
|
|
let from_identifier = from_acc.kind.identifier();
|
|
|
|
|
let from_keys = &from_acc.key_chain;
|
2026-05-11 20:26:03 -03:00
|
|
|
let nsk = from_keys.private_key_holder.nullifier_secret_key;
|
|
|
|
|
let from_npk = from_keys.nullifier_public_key;
|
2026-04-10 20:23:25 +03:00
|
|
|
let from_vpk = from_keys.viewing_public_key.clone();
|
2026-04-27 02:43:51 +02:00
|
|
|
|
2026-05-11 20:26:03 -03:00
|
|
|
// TODO: Technically we could allow unauthorized owned accounts, but currently we don't have
|
|
|
|
|
// support from that in the wallet.
|
2026-04-10 20:23:25 +03:00
|
|
|
let sender_pre = AccountWithMetadata::new(from_acc.account.clone(), true, account_id);
|
2025-11-30 02:18:38 +03:00
|
|
|
|
2026-07-03 16:53:39 +00:00
|
|
|
let random_seed = random_bytes();
|
2026-05-05 12:37:54 +02:00
|
|
|
|
2026-04-27 02:43:51 +02:00
|
|
|
Ok(AccountPreparedData {
|
2026-05-11 20:26:03 -03:00
|
|
|
nsk: Some(nsk),
|
|
|
|
|
npk: from_npk,
|
|
|
|
|
identifier: from_identifier,
|
|
|
|
|
vpk: from_vpk,
|
|
|
|
|
pre_state: sender_pre,
|
2026-07-10 11:58:05 +00:00
|
|
|
proof: None,
|
2026-06-26 14:21:40 -04:00
|
|
|
random_seed,
|
2026-05-11 20:26:03 -03:00
|
|
|
is_pda,
|
2026-04-27 02:43:51 +02:00
|
|
|
})
|
|
|
|
|
}
|
2026-05-05 15:38:18 +02:00
|
|
|
|
2026-07-10 11:58:05 +00:00
|
|
|
fn private_shared_acc_preparation(
|
2026-05-05 15:38:18 +02:00
|
|
|
wallet: &WalletCore,
|
2026-05-11 20:26:03 -03:00
|
|
|
account_id: AccountId,
|
2026-05-05 15:38:18 +02:00
|
|
|
nsk: NullifierSecretKey,
|
|
|
|
|
npk: NullifierPublicKey,
|
|
|
|
|
vpk: ViewingPublicKey,
|
|
|
|
|
identifier: Identifier,
|
2026-05-11 20:26:03 -03:00
|
|
|
is_pda: bool,
|
2026-07-10 11:58:05 +00:00
|
|
|
) -> AccountPreparedData {
|
2026-05-05 15:38:18 +02:00
|
|
|
let acc = wallet
|
2026-04-10 20:23:25 +03:00
|
|
|
.storage()
|
|
|
|
|
.key_chain()
|
|
|
|
|
.shared_private_account(account_id)
|
2026-05-05 18:55:51 +02:00
|
|
|
.map(|e| e.account.clone())
|
2026-05-05 15:38:18 +02:00
|
|
|
.unwrap_or_default();
|
2025-11-30 02:18:38 +03:00
|
|
|
|
2026-05-12 17:17:58 -03:00
|
|
|
let pre_state = AccountWithMetadata::new(acc, true, account_id);
|
|
|
|
|
|
2026-07-03 16:53:39 +00:00
|
|
|
let random_seed = random_bytes();
|
2026-06-03 14:40:06 -04:00
|
|
|
|
2026-07-10 11:58:05 +00:00
|
|
|
AccountPreparedData {
|
2026-05-12 17:17:58 -03:00
|
|
|
nsk: Some(nsk),
|
2026-05-05 15:38:18 +02:00
|
|
|
npk,
|
|
|
|
|
identifier,
|
|
|
|
|
vpk,
|
|
|
|
|
pre_state,
|
2026-07-10 11:58:05 +00:00
|
|
|
proof: None,
|
2026-06-26 14:21:40 -04:00
|
|
|
random_seed,
|
2026-05-11 20:26:03 -03:00
|
|
|
is_pda,
|
2026-07-10 11:58:05 +00:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async fn fetch_private_proofs_and_root(
|
|
|
|
|
wallet: &WalletCore,
|
|
|
|
|
states: &mut [State],
|
|
|
|
|
) -> Result<CommitmentSetDigest, ExecutionFailureKind> {
|
|
|
|
|
let (mut private, commitments): (Vec<&mut AccountPreparedData>, Vec<Commitment>) = states
|
|
|
|
|
.iter_mut()
|
|
|
|
|
.filter_map(|state| match state {
|
|
|
|
|
State::Private(pre) => {
|
|
|
|
|
let commitment = wallet.get_private_account_commitment(pre.pre_state.account_id)?;
|
|
|
|
|
Some((pre, commitment))
|
|
|
|
|
}
|
|
|
|
|
State::Public { .. } | State::PublicKeycard { .. } => None,
|
|
|
|
|
})
|
|
|
|
|
.unzip();
|
|
|
|
|
|
|
|
|
|
let (proofs, root) = wallet
|
2026-07-21 18:01:04 +03:00
|
|
|
.get_proofs_and_root(&commitments)
|
2026-07-10 11:58:05 +00:00
|
|
|
.await
|
|
|
|
|
.map_err(ExecutionFailureKind::SequencerError)?;
|
|
|
|
|
|
2026-07-10 12:31:26 +00:00
|
|
|
validate_proofs_against_root(&commitments, &proofs, root)?;
|
|
|
|
|
|
2026-07-10 11:58:05 +00:00
|
|
|
for (pre, proof) in private.iter_mut().zip(proofs) {
|
|
|
|
|
pre.proof = proof;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Ok(root)
|
2025-11-30 02:18:38 +03:00
|
|
|
}
|
2026-05-05 15:38:18 +02:00
|
|
|
|
2026-07-10 12:31:26 +00:00
|
|
|
fn validate_proofs_against_root(
|
|
|
|
|
commitments: &[Commitment],
|
|
|
|
|
proofs: &[Option<MembershipProof>],
|
|
|
|
|
root: CommitmentSetDigest,
|
|
|
|
|
) -> Result<(), ExecutionFailureKind> {
|
|
|
|
|
if proofs.len() != commitments.len() {
|
|
|
|
|
return Err(ExecutionFailureKind::SequencerError(anyhow::anyhow!(
|
|
|
|
|
"Sequencer returned {} proofs for {} commitments.",
|
|
|
|
|
proofs.len(),
|
|
|
|
|
commitments.len(),
|
|
|
|
|
)));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
for (commitment, proof) in commitments.iter().zip(proofs) {
|
|
|
|
|
if let Some(proof) = proof
|
|
|
|
|
&& compute_digest_for_path(commitment, proof) != root
|
|
|
|
|
{
|
|
|
|
|
return Err(ExecutionFailureKind::SequencerError(anyhow::anyhow!(
|
|
|
|
|
"Membership proof for {commitment:?} does not reproduce the appropriate root {root:?}.",
|
|
|
|
|
)));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Ok(())
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-10 17:17:31 +04:00
|
|
|
/// 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]
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-03 15:04:52 +00:00
|
|
|
fn random_bytes() -> [u8; 32] {
|
|
|
|
|
let mut bytes = [0; 32];
|
|
|
|
|
OsRng.fill_bytes(&mut bytes);
|
|
|
|
|
bytes
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-03 16:53:39 +00:00
|
|
|
fn random_vec(len: usize) -> Vec<u8> {
|
|
|
|
|
let mut bytes = vec![0; len];
|
|
|
|
|
OsRng.fill_bytes(&mut bytes);
|
|
|
|
|
bytes
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-06 10:41:15 +00:00
|
|
|
/// Generates a dummy note: random bytes sized to a default-account ciphertext, a real
|
|
|
|
|
/// ML-KEM ciphertext epk toward a throwaway key, and a random view tag.
|
2026-07-03 15:04:52 +00:00
|
|
|
fn random_dummy_note() -> EncryptedAccountData {
|
2026-07-03 16:53:39 +00:00
|
|
|
// Sized to a default-account ciphertext; matching real data sizes is a separate issue.
|
|
|
|
|
let ciphertext_len = PrivateAccountKind::HEADER_LEN
|
|
|
|
|
.checked_add(Account::default().to_bytes().len())
|
|
|
|
|
.expect("dummy ciphertext length fits in usize");
|
2026-07-06 10:41:15 +00:00
|
|
|
let throwaway_ek = MlKem768EncapsulationKey::from_seed(&random_bytes(), &random_bytes());
|
|
|
|
|
let (_, epk) = SharedSecretKey::encapsulate(&throwaway_ek);
|
2026-07-03 15:04:52 +00:00
|
|
|
EncryptedAccountData {
|
2026-07-03 16:53:39 +00:00
|
|
|
ciphertext: Ciphertext::from_inner(random_vec(ciphertext_len)),
|
2026-07-06 10:41:15 +00:00
|
|
|
epk,
|
2026-07-03 15:04:52 +00:00
|
|
|
view_tag: random_view_tag(),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-05 15:38:18 +02:00
|
|
|
#[cfg(test)]
|
|
|
|
|
mod tests {
|
|
|
|
|
use super::*;
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn private_shared_is_private() {
|
2026-05-20 18:55:39 +03:00
|
|
|
let acc = AccountIdentity::PrivateShared {
|
2026-05-05 15:38:18 +02:00
|
|
|
nsk: [0; 32],
|
|
|
|
|
npk: NullifierPublicKey([1; 32]),
|
2026-06-03 14:40:06 -04:00
|
|
|
vpk: ViewingPublicKey::from_seed(&[2_u8; 32], &[3_u8; 32]),
|
2026-05-05 15:38:18 +02:00
|
|
|
identifier: 42,
|
|
|
|
|
};
|
|
|
|
|
assert!(acc.is_private());
|
|
|
|
|
assert!(!acc.is_public());
|
|
|
|
|
}
|
2026-07-07 11:52:58 +00:00
|
|
|
|
|
|
|
|
fn private_state() -> State {
|
|
|
|
|
let npk = NullifierPublicKey([0; 32]);
|
|
|
|
|
let vpk = ViewingPublicKey::from_seed(&[0; 32], &[0; 32]);
|
|
|
|
|
let pre_state = AccountWithMetadata::new(Account::default(), false, (&npk, &vpk, 0));
|
|
|
|
|
State::Private(AccountPreparedData {
|
|
|
|
|
nsk: None,
|
|
|
|
|
npk,
|
|
|
|
|
identifier: 0,
|
|
|
|
|
vpk,
|
|
|
|
|
pre_state,
|
|
|
|
|
proof: None,
|
|
|
|
|
random_seed: [0; 32],
|
|
|
|
|
is_pda: false,
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn public_state() -> State {
|
|
|
|
|
let npk = NullifierPublicKey([0; 32]);
|
|
|
|
|
let vpk = ViewingPublicKey::from_seed(&[0; 32], &[0; 32]);
|
|
|
|
|
let account = AccountWithMetadata::new(Account::default(), false, (&npk, &vpk, 0));
|
|
|
|
|
State::Public { account, sk: None }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn manager(states: Vec<State>) -> AccountManager {
|
|
|
|
|
AccountManager {
|
|
|
|
|
states,
|
|
|
|
|
pin: None,
|
|
|
|
|
dummy_commitment_root: [0; 32],
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn dummy_inputs_default_pads_private_count_to_max() {
|
|
|
|
|
let max = AccountManager::MAX_PRIVATE_ACCOUNTS;
|
|
|
|
|
|
|
|
|
|
// Empty txs get padded to the max.
|
|
|
|
|
assert_eq!(manager(vec![]).dummy_inputs_default().len(), max);
|
|
|
|
|
// In a padded transaction, the padding amount depends on
|
|
|
|
|
// the amount of private accounts used.
|
|
|
|
|
assert_eq!(
|
|
|
|
|
manager(vec![private_state(), private_state()])
|
|
|
|
|
.dummy_inputs_default()
|
|
|
|
|
.len(),
|
|
|
|
|
max - 2
|
|
|
|
|
);
|
|
|
|
|
assert_eq!(
|
|
|
|
|
manager(vec![private_state(), public_state(), private_state()])
|
|
|
|
|
.dummy_inputs_default()
|
|
|
|
|
.len(),
|
|
|
|
|
max - 2
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
// If the private accounts in the transaction exceed the max, no padding
|
|
|
|
|
// is done.
|
|
|
|
|
let full: Vec<State> = std::iter::repeat_with(private_state).take(max).collect();
|
|
|
|
|
assert_eq!(manager(full).dummy_inputs_default().len(), 0);
|
|
|
|
|
let over: Vec<State> = std::iter::repeat_with(private_state)
|
|
|
|
|
.take(max + 2)
|
|
|
|
|
.collect();
|
|
|
|
|
assert_eq!(manager(over).dummy_inputs_default().len(), 0);
|
|
|
|
|
}
|
2026-05-05 15:38:18 +02:00
|
|
|
}
|