Merge pull request #460 from logos-blockchain/moudy/feat-group-cli

feat(wallet)!: wallet group commands with shared account derivation
This commit is contained in:
Moudy 2026-05-09 00:28:02 +02:00 committed by GitHub
commit b44551225c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
22 changed files with 1312 additions and 480 deletions

Binary file not shown.

View File

@ -0,0 +1,229 @@
#![expect(
clippy::tests_outside_test_module,
reason = "Integration test file, not inside a #[cfg(test)] module"
)]
#![expect(
clippy::shadow_unrelated,
reason = "Sequential wallet commands naturally reuse the `command` binding"
)]
//! Shared account integration tests.
//!
//! Demonstrates:
//! 1. Group creation and GMS distribution via seal/unseal.
//! 2. Shared regular private account creation via `--for-gms`.
//! 3. Funding a shared account from a public account.
//! 4. Syncing discovers the funded shared account state.
use std::time::Duration;
use anyhow::{Context as _, Result};
use integration_tests::{TIME_TO_WAIT_FOR_BLOCK_SECONDS, TestContext, format_public_account_id};
use log::info;
use tokio::test;
use wallet::cli::{
Command, SubcommandReturnValue,
account::{AccountSubcommand, NewSubcommand},
group::GroupSubcommand,
programs::native_token_transfer::AuthTransferSubcommand,
};
/// Create a group, create a shared account from it, and verify registration.
#[test]
async fn group_create_and_shared_account_registration() -> Result<()> {
let mut ctx = TestContext::new().await?;
// Create a group
let command = Command::Group(GroupSubcommand::New {
name: "test-group".into(),
});
wallet::cli::execute_subcommand(ctx.wallet_mut(), command).await?;
// Verify group exists
assert!(
ctx.wallet()
.storage()
.user_data
.group_key_holder("test-group")
.is_some()
);
// Create a shared regular private account from the group
let command = Command::Account(AccountSubcommand::New(NewSubcommand::PrivateGms {
group: "test-group".into(),
label: Some("shared-acc".into()),
pda: false,
seed: None,
program_id: None,
}));
let result = wallet::cli::execute_subcommand(ctx.wallet_mut(), command).await?;
let SubcommandReturnValue::RegisterAccount {
account_id: shared_account_id,
} = result
else {
anyhow::bail!("Expected RegisterAccount return value");
};
// Verify shared account is registered in storage
let entry = ctx
.wallet()
.storage()
.user_data
.shared_private_account(&shared_account_id)
.context("Shared account not found in storage")?;
assert_eq!(entry.group_label, "test-group");
assert!(entry.pda_seed.is_none());
info!("Shared account registered: {shared_account_id}");
Ok(())
}
/// GMS seal/unseal round-trip via invite/join, verify key agreement.
#[test]
async fn group_invite_join_key_agreement() -> Result<()> {
let mut ctx = TestContext::new().await?;
// Generate a sealing key
let command = Command::Group(GroupSubcommand::NewSealingKey);
wallet::cli::execute_subcommand(ctx.wallet_mut(), command).await?;
// Create a group
let command = Command::Group(GroupSubcommand::New {
name: "alice-group".into(),
});
wallet::cli::execute_subcommand(ctx.wallet_mut(), command).await?;
// Seal GMS for ourselves (simulating invite to another wallet)
let sealing_sk = ctx
.wallet()
.storage()
.user_data
.sealing_secret_key
.context("Sealing key not found")?;
let sealing_pk =
key_protocol::key_management::group_key_holder::SealingPublicKey::from_scalar(sealing_sk);
let holder = ctx
.wallet()
.storage()
.user_data
.group_key_holder("alice-group")
.context("Group not found")?;
let sealed = holder.seal_for(&sealing_pk);
let sealed_hex = hex::encode(&sealed);
// Join under a different name (simulating Bob receiving the sealed GMS)
let command = Command::Group(GroupSubcommand::Join {
name: "bob-copy".into(),
sealed: sealed_hex,
});
wallet::cli::execute_subcommand(ctx.wallet_mut(), command).await?;
// Both derive the same keys for the same derivation seed
let alice_holder = ctx
.wallet()
.storage()
.user_data
.group_key_holder("alice-group")
.unwrap();
let bob_holder = ctx
.wallet()
.storage()
.user_data
.group_key_holder("bob-copy")
.unwrap();
let seed = [42_u8; 32];
let alice_npk = alice_holder
.derive_keys_for_shared_account(&seed)
.generate_nullifier_public_key();
let bob_npk = bob_holder
.derive_keys_for_shared_account(&seed)
.generate_nullifier_public_key();
assert_eq!(
alice_npk, bob_npk,
"Key agreement: same GMS produces same keys"
);
info!("Key agreement verified via invite/join");
Ok(())
}
/// Fund a shared account from a public account via auth-transfer, then sync.
/// TODO: Requires auth-transfer init to work with shared accounts (authorization flow).
#[test]
#[ignore = "Requires auth-transfer init to work with shared accounts (authorization flow)"]
async fn fund_shared_account_from_public() -> Result<()> {
let mut ctx = TestContext::new().await?;
// Create group and shared account
let command = Command::Group(GroupSubcommand::New {
name: "fund-group".into(),
});
wallet::cli::execute_subcommand(ctx.wallet_mut(), command).await?;
let command = Command::Account(AccountSubcommand::New(NewSubcommand::PrivateGms {
group: "fund-group".into(),
label: None,
pda: false,
seed: None,
program_id: None,
}));
let result = wallet::cli::execute_subcommand(ctx.wallet_mut(), command).await?;
let SubcommandReturnValue::RegisterAccount {
account_id: shared_id,
} = result
else {
anyhow::bail!("Expected RegisterAccount return value");
};
// Initialize the shared account under auth-transfer
let command = Command::AuthTransfer(AuthTransferSubcommand::Init {
account_id: Some(format!("Private/{shared_id}")),
account_label: None,
});
wallet::cli::execute_subcommand(ctx.wallet_mut(), command).await?;
tokio::time::sleep(Duration::from_secs(TIME_TO_WAIT_FOR_BLOCK_SECONDS)).await;
// Fund from a public account
let from_public = ctx.existing_public_accounts()[0];
let command = Command::AuthTransfer(AuthTransferSubcommand::Send {
from: Some(format_public_account_id(from_public)),
from_label: None,
to: Some(format!("Private/{shared_id}")),
to_label: None,
to_npk: None,
to_vpk: None,
to_identifier: None,
amount: 100,
});
wallet::cli::execute_subcommand(ctx.wallet_mut(), command).await?;
tokio::time::sleep(Duration::from_secs(TIME_TO_WAIT_FOR_BLOCK_SECONDS)).await;
// Sync private accounts
let command = Command::Account(AccountSubcommand::SyncPrivate);
wallet::cli::execute_subcommand(ctx.wallet_mut(), command).await?;
// Verify the shared account was updated
let entry = ctx
.wallet()
.storage()
.user_data
.shared_private_account(&shared_id)
.context("Shared account not found after sync")?;
info!(
"Shared account balance after funding: {}",
entry.account.balance
);
assert_eq!(
entry.account.balance, 100,
"Shared account should have received 100"
);
Ok(())
}

View File

@ -2,7 +2,7 @@ use aes_gcm::{Aes256Gcm, KeyInit as _, aead::Aead as _};
use nssa_core::{
SharedSecretKey,
encryption::{Scalar, shared_key_derivation::Secp256k1Point},
program::PdaSeed,
program::{PdaSeed, ProgramId},
};
use rand::{RngCore as _, rngs::OsRng};
use serde::{Deserialize, Serialize};
@ -12,10 +12,30 @@ use super::secret_holders::{PrivateKeyHolder, SecretSpendingKey};
/// Public key used to seal a `GroupKeyHolder` for distribution to a recipient.
///
/// Structurally identical to `ViewingPublicKey` (both are secp256k1 points), but given
/// a distinct alias to clarify intent: viewing keys encrypt account state, sealing keys
/// encrypt the GMS for off-chain distribution.
pub type SealingPublicKey = Secp256k1Point;
/// Wraps a secp256k1 point but is a distinct type from `ViewingPublicKey` to enforce
/// key separation: viewing keys encrypt account state, sealing keys encrypt the GMS
/// for off-chain distribution.
pub struct SealingPublicKey(Secp256k1Point);
impl SealingPublicKey {
/// Derive the sealing public key from a secret scalar.
#[must_use]
pub fn from_scalar(scalar: Scalar) -> Self {
Self(Secp256k1Point::from_scalar(scalar))
}
/// Construct from raw serialized bytes (e.g. received from another wallet).
#[must_use]
pub const fn from_bytes(bytes: Vec<u8>) -> Self {
Self(Secp256k1Point(bytes))
}
/// Returns the raw bytes for display or transmission.
#[must_use]
pub fn to_bytes(&self) -> &[u8] {
&self.0.0
}
}
/// Secret key used to unseal a `GroupKeyHolder` received from another member.
pub type SealingSecretKey = Scalar;
@ -83,28 +103,54 @@ impl GroupKeyHolder {
/// Derive a per-PDA [`SecretSpendingKey`] by mixing the seed into the SHA-256 input.
///
/// Each distinct `pda_seed` produces a distinct SSK in the full 256-bit space, so
/// adversarial seed-grinding cannot collide two PDAs' derived keys under the same
/// Each distinct `(program_id, pda_seed)` pair produces a distinct SSK in the full 256-bit
/// space, so adversarial seed-grinding cannot collide two PDAs' derived keys under the same
/// group. Uses the codebase's 32-byte protocol-versioned domain-separation convention.
fn secret_spending_key_for_pda(&self, pda_seed: &PdaSeed) -> SecretSpendingKey {
fn secret_spending_key_for_pda(
&self,
program_id: &ProgramId,
pda_seed: &PdaSeed,
) -> SecretSpendingKey {
const PREFIX: &[u8; 32] = b"/LEE/v0.3/GroupKeyDerivation/SSK";
let mut hasher = sha2::Sha256::new();
hasher.update(PREFIX);
hasher.update(self.gms);
for word in program_id {
hasher.update(word.to_le_bytes());
}
hasher.update(pda_seed.as_ref());
SecretSpendingKey(hasher.finalize_fixed().into())
}
/// Derive keys for a specific PDA.
/// Derive keys for a specific PDA under a given program.
///
/// All controllers holding the same GMS independently derive the same keys for the
/// same PDA because the derivation is deterministic in (GMS, seed).
/// same `(program_id, seed)` because the derivation is deterministic.
#[must_use]
pub fn derive_keys_for_pda(&self, pda_seed: &PdaSeed) -> PrivateKeyHolder {
self.secret_spending_key_for_pda(pda_seed)
pub fn derive_keys_for_pda(
&self,
program_id: &ProgramId,
pda_seed: &PdaSeed,
) -> PrivateKeyHolder {
self.secret_spending_key_for_pda(program_id, pda_seed)
.produce_private_key_holder(None)
}
/// Derive keys for a shared regular (non-PDA) private account.
///
/// Uses a distinct domain separator from `derive_keys_for_pda` to prevent cross-domain
/// key collisions. The `derivation_seed` should be a stable, unique 32-byte value
/// (e.g. derived deterministically from the account's identifier).
#[must_use]
pub fn derive_keys_for_shared_account(&self, derivation_seed: &[u8; 32]) -> PrivateKeyHolder {
const PREFIX: &[u8; 32] = b"/LEE/v0.3/GroupKeyDerivation/SHA";
let mut hasher = sha2::Sha256::new();
hasher.update(PREFIX);
hasher.update(self.gms);
hasher.update(derivation_seed);
SecretSpendingKey(hasher.finalize_fixed().into()).produce_private_key_holder(None)
}
/// Encrypts this holder's GMS under the recipient's [`SealingPublicKey`].
///
/// Uses an ephemeral ECDH key exchange to derive a shared secret, then AES-256-GCM
@ -118,7 +164,7 @@ impl GroupKeyHolder {
let mut ephemeral_scalar: Scalar = [0_u8; 32];
OsRng.fill_bytes(&mut ephemeral_scalar);
let ephemeral_pubkey = Secp256k1Point::from_scalar(ephemeral_scalar);
let shared = SharedSecretKey::new(&ephemeral_scalar, recipient_key);
let shared = SharedSecretKey::new(&ephemeral_scalar, &recipient_key.0);
let aes_key = Self::seal_kdf(&shared);
let cipher = Aes256Gcm::new(&aes_key.into());
@ -195,6 +241,8 @@ mod tests {
use super::*;
const TEST_PROGRAM_ID: ProgramId = [9; 8];
/// Two holders from the same GMS derive identical keys for the same PDA seed.
#[test]
fn same_gms_same_seed_produces_same_keys() {
@ -203,8 +251,8 @@ mod tests {
let holder_b = GroupKeyHolder::from_gms(gms);
let seed = PdaSeed::new([1; 32]);
let keys_a = holder_a.derive_keys_for_pda(&seed);
let keys_b = holder_b.derive_keys_for_pda(&seed);
let keys_a = holder_a.derive_keys_for_pda(&TEST_PROGRAM_ID, &seed);
let keys_b = holder_b.derive_keys_for_pda(&TEST_PROGRAM_ID, &seed);
assert_eq!(
keys_a.generate_nullifier_public_key().to_byte_array(),
@ -220,10 +268,10 @@ mod tests {
let seed_b = PdaSeed::new([2; 32]);
let npk_a = holder
.derive_keys_for_pda(&seed_a)
.derive_keys_for_pda(&TEST_PROGRAM_ID, &seed_a)
.generate_nullifier_public_key();
let npk_b = holder
.derive_keys_for_pda(&seed_b)
.derive_keys_for_pda(&TEST_PROGRAM_ID, &seed_b)
.generate_nullifier_public_key();
assert_ne!(npk_a.to_byte_array(), npk_b.to_byte_array());
@ -237,10 +285,10 @@ mod tests {
let seed = PdaSeed::new([1; 32]);
let npk_a = holder_a
.derive_keys_for_pda(&seed)
.derive_keys_for_pda(&TEST_PROGRAM_ID, &seed)
.generate_nullifier_public_key();
let npk_b = holder_b
.derive_keys_for_pda(&seed)
.derive_keys_for_pda(&TEST_PROGRAM_ID, &seed)
.generate_nullifier_public_key();
assert_ne!(npk_a.to_byte_array(), npk_b.to_byte_array());
@ -254,10 +302,10 @@ mod tests {
let seed = PdaSeed::new([1; 32]);
let npk_original = original
.derive_keys_for_pda(&seed)
.derive_keys_for_pda(&TEST_PROGRAM_ID, &seed)
.generate_nullifier_public_key();
let npk_restored = restored
.derive_keys_for_pda(&seed)
.derive_keys_for_pda(&TEST_PROGRAM_ID, &seed)
.generate_nullifier_public_key();
assert_eq!(npk_original.to_byte_array(), npk_restored.to_byte_array());
@ -269,7 +317,7 @@ mod tests {
let holder = GroupKeyHolder::from_gms([42_u8; 32]);
let seed = PdaSeed::new([1; 32]);
let npk = holder
.derive_keys_for_pda(&seed)
.derive_keys_for_pda(&TEST_PROGRAM_ID, &seed)
.generate_nullifier_public_key();
assert_ne!(npk, NullifierPublicKey([0; 32]));
@ -289,18 +337,17 @@ mod tests {
let holder = GroupKeyHolder::from_gms(gms);
let npk = holder
.derive_keys_for_pda(&seed)
.derive_keys_for_pda(&TEST_PROGRAM_ID, &seed)
.generate_nullifier_public_key();
let account_id = AccountId::for_private_pda(&program_id, &seed, &npk);
let expected_npk = NullifierPublicKey([
185, 161, 225, 224, 20, 156, 173, 0, 6, 173, 74, 136, 16, 88, 71, 154, 101, 160, 224,
162, 247, 98, 183, 210, 118, 130, 143, 237, 20, 112, 111, 114,
]);
let expected_account_id = AccountId::new([
236, 138, 175, 184, 194, 233, 144, 109, 157, 51, 193, 120, 83, 110, 147, 90, 154, 57,
148, 236, 12, 92, 135, 38, 253, 79, 88, 143, 161, 175, 46, 144,
136, 176, 234, 71, 208, 8, 143, 142, 126, 155, 132, 18, 71, 27, 88, 56, 100, 90, 79,
215, 76, 92, 60, 166, 104, 35, 51, 91, 16, 114, 188, 112,
]);
// AccountId is derived from (program_id, seed, npk), so it changes when npk changes.
// We verify npk is pinned, and AccountId is deterministically derived from it.
let expected_account_id = AccountId::for_private_pda(&program_id, &seed, &expected_npk);
assert_eq!(npk, expected_npk);
assert_eq!(account_id, expected_account_id);
@ -318,10 +365,10 @@ mod tests {
let seed = PdaSeed::new([1; 32]);
let npk_original = original
.derive_keys_for_pda(&seed)
.derive_keys_for_pda(&TEST_PROGRAM_ID, &seed)
.generate_nullifier_public_key();
let npk_restored = restored
.derive_keys_for_pda(&seed)
.derive_keys_for_pda(&TEST_PROGRAM_ID, &seed)
.generate_nullifier_public_key();
assert_eq!(npk_original, npk_restored);
@ -339,7 +386,7 @@ mod tests {
let seed = PdaSeed::new([5; 32]);
let group_npk = GroupKeyHolder::from_gms(shared_bytes)
.derive_keys_for_pda(&seed)
.derive_keys_for_pda(&TEST_PROGRAM_ID, &seed)
.generate_nullifier_public_key();
let personal_npk = SecretSpendingKey(shared_bytes)
@ -359,7 +406,7 @@ mod tests {
let recipient_vpk = recipient_keys.generate_viewing_public_key();
let recipient_vsk = recipient_keys.viewing_secret_key;
let sealed = holder.seal_for(&recipient_vpk);
let sealed = holder.seal_for(&SealingPublicKey::from_bytes(recipient_vpk.0));
let restored = GroupKeyHolder::unseal(&sealed, &recipient_vsk).expect("unseal");
assert_eq!(restored.dangerous_raw_gms(), holder.dangerous_raw_gms());
@ -367,10 +414,10 @@ mod tests {
let seed = PdaSeed::new([1; 32]);
assert_eq!(
holder
.derive_keys_for_pda(&seed)
.derive_keys_for_pda(&TEST_PROGRAM_ID, &seed)
.generate_nullifier_public_key(),
restored
.derive_keys_for_pda(&seed)
.derive_keys_for_pda(&TEST_PROGRAM_ID, &seed)
.generate_nullifier_public_key(),
);
}
@ -390,7 +437,7 @@ mod tests {
.produce_private_key_holder(None)
.viewing_secret_key;
let sealed = holder.seal_for(&recipient_vpk);
let sealed = holder.seal_for(&SealingPublicKey::from_bytes(recipient_vpk.0));
let result = GroupKeyHolder::unseal(&sealed, &wrong_vsk);
assert!(matches!(result, Err(super::SealError::DecryptionFailed)));
}
@ -405,7 +452,7 @@ mod tests {
let recipient_vpk = recipient_keys.generate_viewing_public_key();
let recipient_vsk = recipient_keys.viewing_secret_key;
let mut sealed = holder.seal_for(&recipient_vpk);
let mut sealed = holder.seal_for(&SealingPublicKey::from_bytes(recipient_vpk.0));
// Flip a byte in the ciphertext portion (after ephemeral_pubkey + nonce)
let last = sealed.len() - 1;
sealed[last] ^= 0xFF;
@ -424,8 +471,9 @@ mod tests {
.produce_private_key_holder(None)
.generate_viewing_public_key();
let sealed_a = holder.seal_for(&recipient_vpk);
let sealed_b = holder.seal_for(&recipient_vpk);
let sealing_key = SealingPublicKey::from_bytes(recipient_vpk.0);
let sealed_a = holder.seal_for(&sealing_key);
let sealed_b = holder.seal_for(&sealing_key);
assert_ne!(sealed_a, sealed_b);
}
@ -453,7 +501,7 @@ mod tests {
.iter()
.map(|gms| {
GroupKeyHolder::from_gms(*gms)
.derive_keys_for_pda(&seed)
.derive_keys_for_pda(&TEST_PROGRAM_ID, &seed)
.generate_nullifier_public_key()
})
.collect();
@ -478,7 +526,7 @@ mod tests {
let program_id: nssa_core::program::ProgramId = [1; 8];
// Derive Alice's keys
let alice_keys = alice_holder.derive_keys_for_pda(&pda_seed);
let alice_keys = alice_holder.derive_keys_for_pda(&TEST_PROGRAM_ID, &pda_seed);
let alice_npk = alice_keys.generate_nullifier_public_key();
// Seal GMS for Bob using Bob's viewing key, Bob unseals
@ -487,13 +535,13 @@ mod tests {
let bob_vpk = bob_keys.generate_viewing_public_key();
let bob_vsk = bob_keys.viewing_secret_key;
let sealed = alice_holder.seal_for(&bob_vpk);
let sealed = alice_holder.seal_for(&SealingPublicKey::from_bytes(bob_vpk.0));
let bob_holder =
GroupKeyHolder::unseal(&sealed, &bob_vsk).expect("Bob should unseal the GMS");
// Key agreement: both derive identical NPK and AccountId
let bob_npk = bob_holder
.derive_keys_for_pda(&pda_seed)
.derive_keys_for_pda(&TEST_PROGRAM_ID, &pda_seed)
.generate_nullifier_public_key();
assert_eq!(alice_npk, bob_npk);
@ -501,4 +549,52 @@ mod tests {
let bob_account_id = AccountId::for_private_pda(&program_id, &pda_seed, &bob_npk);
assert_eq!(alice_account_id, bob_account_id);
}
/// Same GMS + same derivation seed produces same keys for shared accounts.
#[test]
fn shared_account_same_gms_same_seed_produces_same_keys() {
let gms = [42_u8; 32];
let derivation_seed = [1_u8; 32];
let holder_a = GroupKeyHolder::from_gms(gms);
let holder_b = GroupKeyHolder::from_gms(gms);
let npk_a = holder_a
.derive_keys_for_shared_account(&derivation_seed)
.generate_nullifier_public_key();
let npk_b = holder_b
.derive_keys_for_shared_account(&derivation_seed)
.generate_nullifier_public_key();
assert_eq!(npk_a, npk_b);
}
/// Different derivation seeds produce different keys for shared accounts.
#[test]
fn shared_account_different_seeds_produce_different_keys() {
let holder = GroupKeyHolder::from_gms([42_u8; 32]);
let npk_a = holder
.derive_keys_for_shared_account(&[1_u8; 32])
.generate_nullifier_public_key();
let npk_b = holder
.derive_keys_for_shared_account(&[2_u8; 32])
.generate_nullifier_public_key();
assert_ne!(npk_a, npk_b);
}
/// PDA and shared account derivations from the same GMS + same bytes never collide.
#[test]
fn pda_and_shared_derivations_do_not_collide() {
let holder = GroupKeyHolder::from_gms([42_u8; 32]);
let bytes = [1_u8; 32];
let pda_npk = holder
.derive_keys_for_pda(&TEST_PROGRAM_ID, &PdaSeed::new(bytes))
.generate_nullifier_public_key();
let shared_npk = holder
.derive_keys_for_shared_account(&bytes)
.generate_nullifier_public_key();
assert_ne!(pda_npk, shared_npk);
}
}

View File

@ -21,7 +21,22 @@ pub struct UserPrivateAccountData {
pub accounts: Vec<(Identifier, Account)>,
}
/// Metadata for a shared account (GMS-derived), stored alongside the cached plaintext state.
/// The group label and identifier (or PDA seed) are needed to re-derive keys during sync.
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct SharedAccountEntry {
pub group_label: String,
pub identifier: Identifier,
/// For PDA accounts, the seed and program ID used to derive keys via `derive_keys_for_pda`.
/// `None` for regular shared accounts (keys derived from identifier via derivation seed).
#[serde(default)]
pub pda_seed: Option<nssa_core::program::PdaSeed>,
#[serde(default)]
pub pda_program_id: Option<nssa_core::program::ProgramId>,
pub account: Account,
}
#[derive(Clone, Debug)]
pub struct NSSAUserData {
/// Default public accounts.
pub default_pub_account_signing_keys: BTreeMap<nssa::AccountId, nssa::PrivateKey>,
@ -31,17 +46,16 @@ pub struct NSSAUserData {
pub public_key_tree: KeyTreePublic,
/// Tree of private keys.
pub private_key_tree: KeyTreePrivate,
/// Group key holders for private PDA groups, keyed by a human-readable label.
/// Defaults to empty for backward compatibility with wallets that predate group PDAs.
/// An older wallet binary that re-serializes this struct will drop the field.
#[serde(default)]
/// Group key holders for shared account management, keyed by a human-readable label.
pub group_key_holders: BTreeMap<String, GroupKeyHolder>,
/// Cached plaintext state of private PDA accounts, keyed by `AccountId`.
/// Updated after each private PDA transaction by decrypting the circuit output.
/// The sequencer only stores encrypted commitments, so this local cache is the
/// only source of plaintext state for private PDAs.
#[serde(default, alias = "group_pda_accounts")]
pub pda_accounts: BTreeMap<nssa::AccountId, nssa_core::account::Account>,
/// Cached plaintext state of shared private accounts (PDAs and regular shared accounts),
/// keyed by `AccountId`. Each entry stores the group label and identifier needed
/// to re-derive keys during sync.
pub shared_private_accounts: BTreeMap<nssa::AccountId, SharedAccountEntry>,
/// Dedicated sealing secret key for GMS distribution. Generated once via
/// `wallet group new-sealing-key`. The corresponding public key is shared with
/// group members so they can seal GMS for this wallet.
pub sealing_secret_key: Option<nssa_core::encryption::Scalar>,
}
impl NSSAUserData {
@ -101,7 +115,8 @@ impl NSSAUserData {
public_key_tree,
private_key_tree,
group_key_holders: BTreeMap::new(),
pda_accounts: BTreeMap::new(),
shared_private_accounts: BTreeMap::new(),
sealing_secret_key: None,
})
}
@ -223,6 +238,42 @@ impl NSSAUserData {
pub fn insert_group_key_holder(&mut self, label: String, holder: GroupKeyHolder) {
self.group_key_holders.insert(label, holder);
}
/// Returns the cached account for a shared private account, if it exists.
#[must_use]
pub fn shared_private_account(
&self,
account_id: &nssa::AccountId,
) -> Option<&SharedAccountEntry> {
self.shared_private_accounts.get(account_id)
}
/// Inserts or replaces a shared private account entry.
pub fn insert_shared_private_account(
&mut self,
account_id: nssa::AccountId,
entry: SharedAccountEntry,
) {
self.shared_private_accounts.insert(account_id, entry);
}
/// Updates the cached account state for a shared private account.
pub fn update_shared_private_account_state(
&mut self,
account_id: &nssa::AccountId,
account: nssa_core::account::Account,
) {
if let Some(entry) = self.shared_private_accounts.get_mut(account_id) {
entry.account = account;
}
}
/// Iterates over all shared private accounts.
pub fn shared_private_accounts_iter(
&self,
) -> impl Iterator<Item = (&nssa::AccountId, &SharedAccountEntry)> {
self.shared_private_accounts.iter()
}
}
impl Default for NSSAUserData {
@ -260,6 +311,92 @@ mod tests {
fn group_key_holders_default_empty() {
let user_data = NSSAUserData::default();
assert!(user_data.group_key_holders.is_empty());
assert!(user_data.shared_private_accounts.is_empty());
}
#[test]
fn shared_account_entry_serde_round_trip() {
use nssa_core::program::PdaSeed;
let entry = SharedAccountEntry {
group_label: String::from("test-group"),
identifier: 42,
pda_seed: None,
pda_program_id: None,
account: nssa_core::account::Account::default(),
};
let encoded = bincode::serialize(&entry).expect("serialize");
let decoded: SharedAccountEntry = bincode::deserialize(&encoded).expect("deserialize");
assert_eq!(decoded.group_label, "test-group");
assert_eq!(decoded.identifier, 42);
assert!(decoded.pda_seed.is_none());
let pda_entry = SharedAccountEntry {
group_label: String::from("pda-group"),
identifier: u128::MAX,
pda_seed: Some(PdaSeed::new([7_u8; 32])),
pda_program_id: Some([9; 8]),
account: nssa_core::account::Account::default(),
};
let pda_encoded = bincode::serialize(&pda_entry).expect("serialize pda");
let pda_decoded: SharedAccountEntry =
bincode::deserialize(&pda_encoded).expect("deserialize pda");
assert_eq!(pda_decoded.group_label, "pda-group");
assert_eq!(pda_decoded.identifier, u128::MAX);
assert_eq!(pda_decoded.pda_seed.unwrap(), PdaSeed::new([7_u8; 32]));
}
#[test]
fn shared_account_entry_none_pda_seed_round_trips() {
// Verify that an entry with pda_seed=None serializes and deserializes correctly,
// confirming the #[serde(default)] attribute works for backward compatibility.
let entry = SharedAccountEntry {
group_label: String::from("old"),
identifier: 1,
pda_seed: None,
pda_program_id: None,
account: nssa_core::account::Account::default(),
};
let encoded = bincode::serialize(&entry).expect("serialize");
let decoded: SharedAccountEntry = bincode::deserialize(&encoded).expect("deserialize");
assert_eq!(decoded.group_label, "old");
assert_eq!(decoded.identifier, 1);
assert!(decoded.pda_seed.is_none());
}
#[test]
fn shared_account_derives_consistent_keys_from_group() {
use nssa_core::program::PdaSeed;
let mut user_data = NSSAUserData::default();
let gms_holder = GroupKeyHolder::from_gms([42_u8; 32]);
user_data.insert_group_key_holder(String::from("my-group"), gms_holder);
let holder = user_data.group_key_holder("my-group").unwrap();
// Regular shared account: derive via tag
let tag = [1_u8; 32];
let keys_a = holder.derive_keys_for_shared_account(&tag);
let keys_b = holder.derive_keys_for_shared_account(&tag);
assert_eq!(
keys_a.generate_nullifier_public_key(),
keys_b.generate_nullifier_public_key(),
);
// PDA shared account: derive via seed
let seed = PdaSeed::new([2_u8; 32]);
let pda_keys_a = holder.derive_keys_for_pda(&[9; 8], &seed);
let pda_keys_b = holder.derive_keys_for_pda(&[9; 8], &seed);
assert_eq!(
pda_keys_a.generate_nullifier_public_key(),
pda_keys_b.generate_nullifier_public_key(),
);
// PDA and shared derivations don't collide
assert_ne!(
keys_a.generate_nullifier_public_key(),
pda_keys_a.generate_nullifier_public_key(),
);
}
#[test]

View File

@ -418,105 +418,143 @@ mod tests {
assert!(matches!(result, Err(NssaError::CircuitProvingError(_))));
}
/// Group PDA deposit: creates a new PDA and transfers balance from the
/// counterparty. Both accounts owned by `private_pda_spender`.
/// PDA init: initializes a new PDA under `authenticated_transfer`'s ownership.
/// The `auth_transfer_proxy` program chains to `authenticated_transfer` with `pda_seeds`
/// to establish authorization and the private PDA binding.
#[test]
fn group_pda_deposit() {
let program = Program::private_pda_spender();
let noop = Program::noop();
fn private_pda_init() {
let program = Program::auth_transfer_proxy();
let auth_transfer = Program::authenticated_transfer_program();
let keys = test_private_account_keys_1();
let npk = keys.npk();
let seed = PdaSeed::new([42; 32]);
let shared_secret_pda = SharedSecretKey::new(&[55; 32], &keys.vpk());
// PDA (new, mask 3)
// PDA (new, private PDA) — AccountId derived from auth_transfer_proxy's program ID
let pda_id = AccountId::for_private_pda(&program.id(), &seed, &npk);
let pda_pre = AccountWithMetadata::new(Account::default(), false, pda_id);
// Sender (mask 0, public, owned by this program, has balance)
let auth_id = auth_transfer.id();
let program_with_deps =
ProgramWithDependencies::new(program, [(auth_id, auth_transfer)].into());
// is_withdraw=false triggers init path (1 pre-state)
let instruction = Program::serialize_instruction((seed, auth_id, 0_u128, false)).unwrap();
let result = execute_and_prove(
vec![pda_pre],
instruction,
vec![InputAccountIdentity::PrivatePdaInit {
npk,
ssk: shared_secret_pda,
}],
&program_with_deps,
);
let (output, _proof) = result.expect("PDA init should succeed");
assert_eq!(output.new_commitments.len(), 1);
}
/// PDA withdraw: chains to `authenticated_transfer` to move balance from PDA to recipient.
/// Uses a default PDA (amount=0) because testing with a pre-funded PDA requires a
/// two-tx sequence with membership proofs.
#[test]
fn private_pda_withdraw() {
let program = Program::auth_transfer_proxy();
let auth_transfer = Program::authenticated_transfer_program();
let keys = test_private_account_keys_1();
let npk = keys.npk();
let seed = PdaSeed::new([42; 32]);
let shared_secret_pda = SharedSecretKey::new(&[55; 32], &keys.vpk());
// PDA (new, private PDA)
let pda_id = AccountId::for_private_pda(&program.id(), &seed, &npk);
let pda_pre = AccountWithMetadata::new(Account::default(), false, pda_id);
// Recipient (public)
let recipient_id = AccountId::new([88; 32]);
let recipient_pre = AccountWithMetadata::new(
Account {
program_owner: auth_transfer.id(),
balance: 10000,
..Account::default()
},
true,
recipient_id,
);
let auth_id = auth_transfer.id();
let program_with_deps =
ProgramWithDependencies::new(program, [(auth_id, auth_transfer)].into());
// is_withdraw=true, amount=0 (PDA has no balance yet)
let instruction = Program::serialize_instruction((seed, auth_id, 0_u128, true)).unwrap();
let result = execute_and_prove(
vec![pda_pre, recipient_pre],
instruction,
vec![
InputAccountIdentity::PrivatePdaInit {
npk,
ssk: shared_secret_pda,
},
InputAccountIdentity::Public,
],
&program_with_deps,
);
let (output, _proof) = result.expect("PDA withdraw should succeed");
assert_eq!(output.new_commitments.len(), 1);
}
/// Shared regular private account: receives funds via `authenticated_transfer` directly,
/// no custom program needed. This demonstrates the non-PDA shared account flow where
/// keys are derived from GMS via `derive_keys_for_shared_account`. The shared account
/// uses the standard unauthorized private account path and works with auth-transfer's
/// transfer path like any other private account.
#[test]
fn shared_account_receives_via_auth_transfer() {
let program = Program::authenticated_transfer_program();
let shared_keys = test_private_account_keys_1();
let shared_npk = shared_keys.npk();
let shared_identifier: u128 = 42;
let shared_secret = SharedSecretKey::new(&[55; 32], &shared_keys.vpk());
// Sender: public account with balance, owned by auth-transfer
let sender_id = AccountId::new([99; 32]);
let sender_pre = AccountWithMetadata::new(
let sender = AccountWithMetadata::new(
Account {
program_owner: program.id(),
balance: 10000,
balance: 1000,
..Account::default()
},
true,
sender_id,
);
let noop_id = noop.id();
let program_with_deps = ProgramWithDependencies::new(program, [(noop_id, noop)].into());
// Recipient: shared private account (new, unauthorized)
let shared_account_id = AccountId::from((&shared_npk, shared_identifier));
let recipient = AccountWithMetadata::new(Account::default(), false, shared_account_id);
let instruction = Program::serialize_instruction((seed, noop_id, 500_u128, true)).unwrap();
// PDA is mask 3 (private PDA), sender is mask 0 (public).
// The noop chained call is required to establish the mask-3 (seed, npk) binding
// that the circuit enforces for private PDAs. Without a caller providing pda_seeds,
// the circuit's binding check rejects the account.
let result = execute_and_prove(
vec![pda_pre, sender_pre],
instruction,
vec![
InputAccountIdentity::PrivatePdaInit {
npk,
ssk: shared_secret_pda,
},
InputAccountIdentity::Public,
],
&program_with_deps,
);
let (output, _proof) = result.expect("group PDA deposit should succeed");
// Only PDA (mask 3) produces a commitment; sender (mask 0) is public.
assert_eq!(output.new_commitments.len(), 1);
}
/// Group PDA spend binding: the noop chained call with `pda_seeds` establishes
/// the mask-3 binding for an existing-but-default PDA. Uses amount=0 because
/// testing with a pre-funded PDA requires a two-tx sequence with membership proofs.
#[test]
fn group_pda_spend_binding() {
let program = Program::private_pda_spender();
let noop = Program::noop();
let keys = test_private_account_keys_1();
let npk = keys.npk();
let seed = PdaSeed::new([42; 32]);
let shared_secret_pda = SharedSecretKey::new(&[55; 32], &keys.vpk());
let pda_id = AccountId::for_private_pda(&program.id(), &seed, &npk);
let pda_pre = AccountWithMetadata::new(Account::default(), false, pda_id);
let bob_id = AccountId::new([88; 32]);
let bob_pre = AccountWithMetadata::new(
Account {
program_owner: program.id(),
balance: 10000,
..Account::default()
},
true,
bob_id,
);
let noop_id = noop.id();
let program_with_deps = ProgramWithDependencies::new(program, [(noop_id, noop)].into());
let instruction = Program::serialize_instruction((seed, noop_id, 0_u128, false)).unwrap();
let balance_to_move: u128 = 100;
let instruction = Program::serialize_instruction(balance_to_move).unwrap();
let result = execute_and_prove(
vec![pda_pre, bob_pre],
vec![sender, recipient],
instruction,
vec![
InputAccountIdentity::PrivatePdaInit {
npk,
ssk: shared_secret_pda,
},
InputAccountIdentity::Public,
InputAccountIdentity::PrivateUnauthorized {
npk: shared_npk,
ssk: shared_secret,
identifier: shared_identifier,
},
],
&program_with_deps,
&program.into(),
);
let (output, _proof) = result.expect("group PDA spend binding should succeed");
let (output, _proof) = result.expect("shared account receive should succeed");
// Sender is public (no commitment), recipient is private (1 commitment)
assert_eq!(output.new_commitments.len(), 1);
}
}

View File

@ -313,12 +313,12 @@ mod tests {
}
#[must_use]
pub fn private_pda_spender() -> Self {
use test_program_methods::{PRIVATE_PDA_SPENDER_ELF, PRIVATE_PDA_SPENDER_ID};
pub fn auth_transfer_proxy() -> Self {
use test_program_methods::{AUTH_TRANSFER_PROXY_ELF, AUTH_TRANSFER_PROXY_ID};
Self {
id: PRIVATE_PDA_SPENDER_ID,
elf: PRIVATE_PDA_SPENDER_ELF.to_vec(),
id: AUTH_TRANSFER_PROXY_ID,
elf: AUTH_TRANSFER_PROXY_ELF.to_vec(),
}
}

View File

@ -0,0 +1,97 @@
use nssa_core::program::{
AccountPostState, ChainedCall, PdaSeed, ProgramId, ProgramInput, ProgramOutput,
read_nssa_inputs,
};
/// PDA authorization program that delegates balance operations to `authenticated_transfer`.
///
/// The PDA is owned by `authenticated_transfer`, not by this program. This program's role
/// is solely to provide PDA authorization via `pda_seeds` in chained calls.
///
/// Instruction: `(pda_seed, auth_transfer_id, amount, is_withdraw)`.
///
/// **Init** (`is_withdraw = false`, 1 pre-state `[pda]`):
/// Chains to `authenticated_transfer` with `instruction=0` (init path) and `pda_seeds=[seed]`
/// to initialize the PDA under `authenticated_transfer`'s ownership.
///
/// **Withdraw** (`is_withdraw = true`, 2 pre-states `[pda, recipient]`):
/// Chains to `authenticated_transfer` with the amount and `pda_seeds=[seed]` to authorize
/// the PDA for a balance transfer. The actual balance modification happens in
/// `authenticated_transfer`, not here.
///
/// **Deposit**: done directly via `authenticated_transfer` (no need for this program).
type Instruction = (PdaSeed, ProgramId, u128, bool);
#[expect(
clippy::allow_attributes,
reason = "allow is needed because the clones are only redundant in test compilation"
)]
#[allow(
clippy::redundant_clone,
reason = "clones needed in non-test compilation"
)]
fn main() {
let (
ProgramInput {
self_program_id,
caller_program_id,
pre_states,
instruction: (pda_seed, auth_transfer_id, amount, is_withdraw),
},
instruction_words,
) = read_nssa_inputs::<Instruction>();
if is_withdraw {
let Ok([pda_pre, recipient_pre]) = <[_; 2]>::try_from(pre_states.clone()) else {
panic!("expected exactly 2 pre_states for withdraw: [pda, recipient]");
};
// Post-states stay unchanged in this program. The actual balance transfer
// happens in the chained call to authenticated_transfer.
let pda_post = AccountPostState::new(pda_pre.account.clone());
let recipient_post = AccountPostState::new(recipient_pre.account.clone());
// Chain to authenticated_transfer with pda_seeds to authorize the PDA.
// The circuit's resolve_authorization_and_record_bindings establishes the
// private PDA (seed, npk) binding when pda_seeds match the private PDA derivation.
let mut auth_pda_pre = pda_pre;
auth_pda_pre.is_authorized = true;
let auth_call =
ChainedCall::new(auth_transfer_id, vec![auth_pda_pre, recipient_pre], &amount)
.with_pda_seeds(vec![pda_seed]);
ProgramOutput::new(
self_program_id,
caller_program_id,
instruction_words,
pre_states,
vec![pda_post, recipient_post],
)
.with_chained_calls(vec![auth_call])
.write();
} else {
// Init: initialize the PDA under authenticated_transfer's ownership.
let Ok([pda_pre]) = <[_; 1]>::try_from(pre_states.clone()) else {
panic!("expected exactly 1 pre_state for init: [pda]");
};
let pda_post = AccountPostState::new(pda_pre.account.clone());
// Chain to authenticated_transfer with instruction=0 (init path) and pda_seeds
// to authorize the PDA. authenticated_transfer will claim it with Claim::Authorized.
let mut auth_pda_pre = pda_pre;
auth_pda_pre.is_authorized = true;
let auth_call = ChainedCall::new(auth_transfer_id, vec![auth_pda_pre], &0_u128)
.with_pda_seeds(vec![pda_seed]);
ProgramOutput::new(
self_program_id,
caller_program_id,
instruction_words,
pre_states,
vec![pda_post],
)
.with_chained_calls(vec![auth_call])
.write();
}
}

View File

@ -1,118 +0,0 @@
use nssa_core::program::{
AccountPostState, ChainedCall, Claim, PdaSeed, ProgramId, ProgramInput, ProgramOutput,
read_nssa_inputs,
};
/// Single program for group PDA operations. Owns and operates the PDA directly.
///
/// Instruction: `(pda_seed, noop_program_id, amount, is_deposit)`.
/// Pre-states: `[group_pda, counterparty]`.
///
/// **Deposit** (`is_deposit = true`, new PDA):
/// Claims PDA via `Claim::Pda(seed)`, increases PDA balance, decreases counterparty.
/// Counterparty must be authorized and owned by this program (or uninitialized).
///
/// **Spend** (`is_deposit = false`, existing PDA):
/// Decreases PDA balance (this program owns it), increases counterparty.
/// Chains to a noop callee with `pda_seeds` to establish the mask-3 binding
/// that the circuit requires for existing private PDAs.
type Instruction = (PdaSeed, ProgramId, u128, bool);
#[expect(
clippy::allow_attributes,
reason = "allow is needed because the clones are only redundant in test compilation"
)]
#[allow(
clippy::redundant_clone,
reason = "clones needed in non-test compilation"
)]
fn main() {
let (
ProgramInput {
self_program_id,
caller_program_id,
pre_states,
instruction: (pda_seed, noop_id, amount, is_deposit),
},
instruction_words,
) = read_nssa_inputs::<Instruction>();
let Ok([pda_pre, counterparty_pre]) = <[_; 2]>::try_from(pre_states.clone()) else {
panic!("expected exactly 2 pre_states: [group_pda, counterparty]");
};
if is_deposit {
// Deposit: claim PDA, transfer balance from counterparty to PDA.
// Both accounts must be owned by this program (or uninitialized) for
// validate_execution to allow balance changes.
assert!(
counterparty_pre.is_authorized,
"Counterparty must be authorized to deposit"
);
let mut pda_account = pda_pre.account;
let mut counterparty_account = counterparty_pre.account;
pda_account.balance = pda_account
.balance
.checked_add(amount)
.expect("PDA balance overflow");
counterparty_account.balance = counterparty_account
.balance
.checked_sub(amount)
.expect("Counterparty has insufficient balance");
let pda_post = AccountPostState::new_claimed_if_default(pda_account, Claim::Pda(pda_seed));
let counterparty_post = AccountPostState::new(counterparty_account);
ProgramOutput::new(
self_program_id,
caller_program_id,
instruction_words,
pre_states,
vec![pda_post, counterparty_post],
)
.write();
} else {
// Spend: decrease PDA balance (owned by this program), increase counterparty.
// Chain to noop with pda_seeds to establish the mask-3 binding for the
// existing PDA. The noop's pre_states must match our post_states.
// Authorization is enforced by the circuit's binding check, not here.
let mut pda_account = pda_pre.account.clone();
let mut counterparty_account = counterparty_pre.account.clone();
pda_account.balance = pda_account
.balance
.checked_sub(amount)
.expect("PDA has insufficient balance");
counterparty_account.balance = counterparty_account
.balance
.checked_add(amount)
.expect("Counterparty balance overflow");
let pda_post = AccountPostState::new(pda_account.clone());
let counterparty_post = AccountPostState::new(counterparty_account.clone());
// Chain to noop solely to establish the mask-3 binding via pda_seeds.
let mut noop_pda_pre = pda_pre;
noop_pda_pre.account = pda_account;
noop_pda_pre.is_authorized = true;
let mut noop_counterparty_pre = counterparty_pre;
noop_counterparty_pre.account = counterparty_account;
let noop_call = ChainedCall::new(noop_id, vec![noop_pda_pre, noop_counterparty_pre], &())
.with_pda_seeds(vec![pda_seed]);
ProgramOutput::new(
self_program_id,
caller_program_id,
instruction_words,
pre_states,
vec![pda_post, counterparty_post],
)
.with_chained_calls(vec![noop_call])
.write();
}
}

View File

@ -92,6 +92,23 @@ pub enum NewSubcommand {
/// Label to assign to the new account.
label: Option<String>,
},
/// Create a shared private account from a group's GMS.
PrivateGms {
/// Group name to derive keys from.
group: String,
#[arg(short, long)]
/// Label to assign to the new account.
label: Option<String>,
#[arg(long)]
/// Create a PDA account (requires --seed and --program-id).
pda: bool,
#[arg(long, requires = "pda")]
/// PDA seed as 64-character hex string.
seed: Option<String>,
#[arg(long, requires = "pda")]
/// Program ID as hex string.
program_id: Option<String>,
},
/// Recommended for receiving from multiple senders: creates a key node (npk + vpk) without
/// registering any account.
PrivateAccountsKey {
@ -183,9 +200,67 @@ impl WalletSubcommand for NewSubcommand {
);
wallet_core.store_persistent_data().await?;
Ok(SubcommandReturnValue::RegisterAccount { account_id })
}
Self::PrivateGms {
group,
label,
pda,
seed,
program_id,
} => {
if let Some(label) = &label
&& wallet_core
.storage
.labels
.values()
.any(|l| l.to_string() == *label)
{
anyhow::bail!("Label '{label}' is already in use by another account");
}
let info = if pda {
let seed_hex = seed.context("--seed is required for PDA accounts")?;
let pid_hex =
program_id.context("--program-id is required for PDA accounts")?;
let seed_bytes: [u8; 32] = hex::decode(&seed_hex)
.context("Invalid seed hex")?
.try_into()
.map_err(|_err| anyhow::anyhow!("Seed must be exactly 32 bytes"))?;
let pda_seed = nssa_core::program::PdaSeed::new(seed_bytes);
let pid_bytes = hex::decode(&pid_hex).context("Invalid program ID hex")?;
if pid_bytes.len() != 32 {
anyhow::bail!("Program ID must be exactly 32 bytes");
}
let mut pid: nssa_core::program::ProgramId = [0; 8];
for (i, chunk) in pid_bytes.chunks_exact(4).enumerate() {
pid[i] = u32::from_le_bytes(chunk.try_into().unwrap());
}
wallet_core.create_shared_pda_account(&group, pda_seed, pid)?
} else {
wallet_core.create_shared_regular_account(&group)?
};
if let Some(label) = label {
wallet_core
.storage
.labels
.insert(info.account_id.to_string(), Label::new(label));
}
println!("Shared account from group '{group}'");
println!("AccountId: Private/{}", info.account_id);
println!("NPK: {}", hex::encode(info.npk.0));
println!("VPK: {}", hex::encode(&info.vpk.0));
wallet_core.store_persistent_data().await?;
Ok(SubcommandReturnValue::RegisterAccount {
account_id: info.account_id,
})
}
Self::PrivateAccountsKey { cci } => {
let chain_index = wallet_core.create_private_accounts_key(cci);

View File

@ -1,15 +1,13 @@
use anyhow::{Context as _, Result};
use clap::Subcommand;
use key_protocol::key_management::group_key_holder::GroupKeyHolder;
use nssa::AccountId;
use nssa_core::program::PdaSeed;
use key_protocol::key_management::group_key_holder::{GroupKeyHolder, SealingPublicKey};
use crate::{
WalletCore,
cli::{SubcommandReturnValue, WalletSubcommand},
};
/// Group PDA management commands.
/// Group key management commands.
#[derive(Subcommand, Debug, Clone)]
pub enum GroupSubcommand {
/// Create a new group with a fresh random GMS.
@ -17,36 +15,9 @@ pub enum GroupSubcommand {
/// Human-readable name for the group.
name: String,
},
/// Import a group from raw GMS bytes.
Import {
/// Human-readable name for the group.
name: String,
/// Raw GMS as 64-character hex string.
#[arg(long)]
gms: String,
/// Epoch (defaults to 0).
#[arg(long, default_value = "0")]
epoch: u32,
},
/// Export the raw GMS hex for backup or manual distribution.
Export {
/// Group name.
name: String,
},
/// List all groups with their epochs.
/// List all groups.
#[command(visible_alias = "ls")]
List,
/// Derive keys for a PDA seed and show the resulting AccountId.
Derive {
/// Group name.
name: String,
/// PDA seed as 64-character hex string.
#[arg(long)]
seed: String,
/// Program ID as hex string (u32x8 little-endian).
#[arg(long)]
program_id: String,
},
/// Remove a group from the wallet.
Remove {
/// Group name.
@ -56,26 +27,22 @@ pub enum GroupSubcommand {
Invite {
/// Group name.
name: String,
/// Recipient's viewing public key as hex string.
/// Recipient's sealing public key as hex string.
#[arg(long)]
vpk: String,
key: String,
},
/// Unseal a received GMS and store it (join a group).
/// Uses the wallet's dedicated sealing key (generated via `new-sealing-key`).
Join {
/// Human-readable name to store the group under.
name: String,
/// Sealed GMS as hex string (from the inviter).
#[arg(long)]
sealed: String,
/// Account label or Private/<id> whose VSK to use for decryption.
#[arg(long)]
account: String,
},
/// Ratchet the GMS to exclude removed members.
Ratchet {
/// Group name.
name: String,
},
/// Generate a dedicated sealing key pair for GMS distribution.
/// Share the printed public key with group members so they can seal GMS for you.
NewSealingKey,
}
impl WalletSubcommand for GroupSubcommand {
@ -88,62 +55,17 @@ impl WalletSubcommand for GroupSubcommand {
if wallet_core
.storage()
.user_data
.get_group_key_holder(&name)
.group_key_holder(&name)
.is_some()
{
anyhow::bail!("Group '{name}' already exists");
}
let holder = GroupKeyHolder::new();
wallet_core
.storage_mut()
.user_data
.insert_group_key_holder(name.clone(), holder);
wallet_core.insert_group_key_holder(name.clone(), holder);
wallet_core.store_persistent_data().await?;
println!("Created group '{name}' at epoch 0");
Ok(SubcommandReturnValue::Empty)
}
Self::Import { name, gms, epoch } => {
if wallet_core
.storage()
.user_data
.get_group_key_holder(&name)
.is_some()
{
anyhow::bail!("Group '{name}' already exists");
}
let gms_bytes: [u8; 32] = hex::decode(&gms)
.context("Invalid GMS hex")?
.try_into()
.map_err(|_| anyhow::anyhow!("GMS must be exactly 32 bytes"))?;
let holder = GroupKeyHolder::from_gms_and_epoch(gms_bytes, epoch);
wallet_core
.storage_mut()
.user_data
.insert_group_key_holder(name.clone(), holder);
wallet_core.store_persistent_data().await?;
println!("Imported group '{name}' at epoch {epoch}");
Ok(SubcommandReturnValue::Empty)
}
Self::Export { name } => {
let holder = wallet_core
.storage()
.user_data
.get_group_key_holder(&name)
.context(format!("Group '{name}' not found"))?;
let gms_hex = hex::encode(holder.dangerous_raw_gms());
let epoch = holder.epoch();
println!("Group: {name}");
println!("Epoch: {epoch}");
println!("GMS: {gms_hex}");
println!("Created group '{name}'");
Ok(SubcommandReturnValue::Empty)
}
@ -152,60 +74,15 @@ impl WalletSubcommand for GroupSubcommand {
if holders.is_empty() {
println!("No groups found");
} else {
for (name, holder) in holders {
println!("{name} (epoch {})", holder.epoch());
for name in holders.keys() {
println!("{name}");
}
}
Ok(SubcommandReturnValue::Empty)
}
Self::Derive {
name,
seed,
program_id,
} => {
let holder = wallet_core
.storage()
.user_data
.get_group_key_holder(&name)
.context(format!("Group '{name}' not found"))?;
let seed_bytes: [u8; 32] = hex::decode(&seed)
.context("Invalid seed hex")?
.try_into()
.map_err(|_| anyhow::anyhow!("Seed must be exactly 32 bytes"))?;
let pda_seed = PdaSeed::new(seed_bytes);
let pid_bytes =
hex::decode(&program_id).context("Invalid program ID hex")?;
if pid_bytes.len() != 32 {
anyhow::bail!("Program ID must be exactly 32 bytes");
}
let mut pid: nssa_core::program::ProgramId = [0; 8];
for (i, chunk) in pid_bytes.chunks_exact(4).enumerate() {
pid[i] = u32::from_le_bytes(chunk.try_into().unwrap());
}
let keys = holder.derive_keys_for_pda(&pda_seed);
let npk = keys.generate_nullifier_public_key();
let vpk = keys.generate_viewing_public_key();
let account_id = AccountId::for_private_pda(&pid, &pda_seed, &npk);
println!("Group: {name}");
println!("NPK: {}", hex::encode(npk.0));
println!("VPK: {}", hex::encode(&vpk.0));
println!("AccountId: {account_id}");
Ok(SubcommandReturnValue::Empty)
}
Self::Remove { name } => {
if wallet_core
.storage_mut()
.user_data
.group_key_holders
.remove(&name)
.is_none()
{
if wallet_core.remove_group_key_holder(&name).is_none() {
anyhow::bail!("Group '{name}' not found");
}
@ -214,80 +91,66 @@ impl WalletSubcommand for GroupSubcommand {
Ok(SubcommandReturnValue::Empty)
}
Self::Invite { name, vpk } => {
Self::Invite { name, key } => {
let holder = wallet_core
.storage()
.user_data
.get_group_key_holder(&name)
.group_key_holder(&name)
.context(format!("Group '{name}' not found"))?;
let vpk_bytes = hex::decode(&vpk).context("Invalid VPK hex")?;
let recipient_vpk =
nssa_core::encryption::shared_key_derivation::Secp256k1Point(vpk_bytes);
let key_bytes = hex::decode(&key).context("Invalid key hex")?;
let recipient_key =
key_protocol::key_management::group_key_holder::SealingPublicKey::from_bytes(
key_bytes,
);
let sealed = holder.seal_for(&recipient_vpk);
let sealed = holder.seal_for(&recipient_key);
println!("{}", hex::encode(&sealed));
Ok(SubcommandReturnValue::Empty)
}
Self::Join {
name,
sealed,
account,
} => {
Self::Join { name, sealed } => {
if wallet_core
.storage()
.user_data
.get_group_key_holder(&name)
.group_key_holder(&name)
.is_some()
{
anyhow::bail!("Group '{name}' already exists");
}
let sealing_key =
wallet_core.storage().user_data.sealing_secret_key.context(
"No sealing key found. Run 'wallet group new-sealing-key' first.",
)?;
let sealed_bytes = hex::decode(&sealed).context("Invalid sealed hex")?;
// Resolve the account to get the VSK
let account_id: nssa::AccountId = account
.parse()
.context("Invalid account ID (use Private/<base58>)")?;
let (keychain, _) = wallet_core
.storage()
.user_data
.get_private_account(account_id)
.context("Private account not found")?;
let vsk = keychain.private_key_holder.viewing_secret_key;
let holder = GroupKeyHolder::unseal(&sealed_bytes, &vsk)
let holder = GroupKeyHolder::unseal(&sealed_bytes, &sealing_key)
.map_err(|e| anyhow::anyhow!("Failed to unseal: {e:?}"))?;
let epoch = holder.epoch();
wallet_core
.storage_mut()
.user_data
.insert_group_key_holder(name.clone(), holder);
wallet_core.insert_group_key_holder(name.clone(), holder);
wallet_core.store_persistent_data().await?;
println!("Joined group '{name}' at epoch {epoch}");
println!("Joined group '{name}'");
Ok(SubcommandReturnValue::Empty)
}
Self::Ratchet { name } => {
let holder = wallet_core
.storage_mut()
.user_data
.group_key_holders
.get_mut(&name)
.context(format!("Group '{name}' not found"))?;
Self::NewSealingKey => {
if wallet_core.storage().user_data.sealing_secret_key.is_some() {
anyhow::bail!("Sealing key already exists. Each wallet has one sealing key.");
}
let mut salt = [0_u8; 32];
rand::RngCore::fill_bytes(&mut rand::rngs::OsRng, &mut salt);
holder.ratchet(salt);
let mut secret: nssa_core::encryption::Scalar = [0_u8; 32];
rand::RngCore::fill_bytes(&mut rand::rngs::OsRng, &mut secret);
let public_key = SealingPublicKey::from_scalar(secret);
let epoch = holder.epoch();
wallet_core.set_sealing_secret_key(secret);
wallet_core.store_persistent_data().await?;
println!("Ratcheted group '{name}' to epoch {epoch}");
println!("Re-invite remaining members with 'group invite'");
println!("Sealing key generated.");
println!("Public key: {}", hex::encode(public_key.to_bytes()));
println!("Share this public key with group members so they can seal GMS for you.");
Ok(SubcommandReturnValue::Empty)
}
}

View File

@ -14,6 +14,7 @@ use crate::{
account::AccountSubcommand,
chain::ChainSubcommand,
config::ConfigSubcommand,
group::GroupSubcommand,
programs::{
amm::AmmProgramAgnosticSubcommand, ata::AtaSubcommand,
native_token_transfer::AuthTransferSubcommand, pinata::PinataProgramAgnosticSubcommand,
@ -25,6 +26,7 @@ use crate::{
pub mod account;
pub mod chain;
pub mod config;
pub mod group;
pub mod programs;
pub(crate) trait WalletSubcommand {
@ -57,6 +59,9 @@ pub enum Command {
/// Associated Token Account program interaction subcommand.
#[command(subcommand)]
Ata(AtaSubcommand),
/// Group key management (create, invite, join, derive keys).
#[command(subcommand)]
Group(GroupSubcommand),
/// Check the wallet can connect to the node and builtin local programs
/// match the remote versions.
CheckHealth,
@ -164,6 +169,7 @@ pub async fn execute_subcommand(
Command::Token(token_subcommand) => token_subcommand.handle_subcommand(wallet_core).await?,
Command::AMM(amm_subcommand) => amm_subcommand.handle_subcommand(wallet_core).await?,
Command::Ata(ata_subcommand) => ata_subcommand.handle_subcommand(wallet_core).await?,
Command::Group(group_subcommand) => group_subcommand.handle_subcommand(wallet_core).await?,
Command::Config(config_subcommand) => {
config_subcommand.handle_subcommand(wallet_core).await?
}

View File

@ -98,6 +98,21 @@ pub struct PersistentStorage {
/// "2rnKprXqWGWJTkDZKsQbFXa4ctKRbapsdoTKQFnaVGG8").
#[serde(default)]
pub labels: HashMap<String, Label>,
/// Group key holders for shared account management.
#[serde(default)]
pub group_key_holders: std::collections::BTreeMap<
String,
key_protocol::key_management::group_key_holder::GroupKeyHolder,
>,
/// Cached state of shared private accounts (PDA and regular).
#[serde(default)]
pub shared_private_accounts: std::collections::BTreeMap<
nssa::AccountId,
key_protocol::key_protocol_core::SharedAccountEntry,
>,
/// Dedicated sealing secret key for GMS distribution.
#[serde(default)]
pub sealing_secret_key: Option<nssa_core::encryption::Scalar>,
}
impl PersistentStorage {

View File

@ -204,6 +204,9 @@ pub fn produce_data_for_storage(
accounts: vec_for_storage,
last_synced_block,
labels,
group_key_holders: user_data.group_key_holders.clone(),
shared_private_accounts: user_data.shared_private_accounts.clone(),
sealing_secret_key: user_data.sealing_secret_key,
}
}

View File

@ -51,6 +51,13 @@ pub enum AccDecodeData {
Decode(nssa_core::SharedSecretKey, AccountId),
}
/// Info returned when creating a shared account.
pub struct SharedAccountInfo {
pub account_id: AccountId,
pub npk: nssa_core::NullifierPublicKey,
pub vpk: nssa_core::encryption::ViewingPublicKey,
}
#[derive(Debug, thiserror::Error)]
pub enum ExecutionFailureKind {
#[error("Failed to get data from sequencer")]
@ -98,6 +105,9 @@ impl WalletCore {
accounts: persistent_accounts,
last_synced_block,
labels,
group_key_holders,
shared_private_accounts,
sealing_secret_key,
} = PersistentStorage::from_path(&storage_path).with_context(|| {
format!(
"Failed to read persistent storage at {}",
@ -109,7 +119,13 @@ impl WalletCore {
config_path,
storage_path,
config_overrides,
|config| WalletChainStore::new(config, persistent_accounts, labels),
|config| {
let mut store = WalletChainStore::new(config, persistent_accounts, labels)?;
store.user_data.group_key_holders = group_key_holders;
store.user_data.shared_private_accounts = shared_private_accounts;
store.user_data.sealing_secret_key = sealing_secret_key;
Ok(store)
},
last_synced_block,
)
}
@ -287,6 +303,169 @@ impl WalletCore {
(account_id, cci)
}
/// Insert a group key holder into storage.
pub fn insert_group_key_holder(
&mut self,
name: String,
holder: key_protocol::key_management::group_key_holder::GroupKeyHolder,
) {
self.storage.user_data.insert_group_key_holder(name, holder);
}
/// Set the wallet's dedicated sealing secret key.
pub const fn set_sealing_secret_key(&mut self, key: nssa_core::encryption::Scalar) {
self.storage.user_data.sealing_secret_key = Some(key);
}
/// Resolve an `AccountId` to the appropriate `PrivacyPreservingAccount` variant.
/// Checks the key tree first, then shared private accounts.
#[must_use]
pub fn resolve_private_account(
&self,
account_id: nssa::AccountId,
) -> Option<PrivacyPreservingAccount> {
// Check key tree first
if self
.storage
.user_data
.get_private_account(account_id)
.is_some()
{
return Some(PrivacyPreservingAccount::PrivateOwned(account_id));
}
// Check shared private accounts
let entry = self.storage.user_data.shared_private_account(&account_id)?;
let holder = self
.storage
.user_data
.group_key_holder(&entry.group_label)?;
if let Some(pda_seed) = &entry.pda_seed {
let program_id = entry.pda_program_id?;
let keys = holder.derive_keys_for_pda(&program_id, pda_seed);
Some(PrivacyPreservingAccount::PrivatePda {
nsk: keys.nullifier_secret_key,
npk: keys.generate_nullifier_public_key(),
vpk: keys.generate_viewing_public_key(),
program_id,
seed: *pda_seed,
})
} else {
let derivation_seed = {
use sha2::Digest as _;
let mut hasher = sha2::Sha256::new();
hasher.update(b"/LEE/v0.3/SharedAccountTag/\x00\x00\x00\x00\x00");
hasher.update(entry.identifier.to_le_bytes());
let result: [u8; 32] = hasher.finalize().into();
result
};
let keys = holder.derive_keys_for_shared_account(&derivation_seed);
Some(PrivacyPreservingAccount::PrivateShared {
nsk: keys.nullifier_secret_key,
npk: keys.generate_nullifier_public_key(),
vpk: keys.generate_viewing_public_key(),
identifier: entry.identifier,
})
}
}
/// Remove a group key holder from storage. Returns the removed holder if it existed.
pub fn remove_group_key_holder(
&mut self,
name: &str,
) -> Option<key_protocol::key_management::group_key_holder::GroupKeyHolder> {
self.storage.user_data.group_key_holders.remove(name)
}
/// Register a shared account in storage for sync tracking.
fn register_shared_account(
&mut self,
account_id: AccountId,
group_label: String,
identifier: nssa_core::Identifier,
pda_seed: Option<nssa_core::program::PdaSeed>,
pda_program_id: Option<nssa_core::program::ProgramId>,
) {
use key_protocol::key_protocol_core::SharedAccountEntry;
self.storage.user_data.insert_shared_private_account(
account_id,
SharedAccountEntry {
group_label,
identifier,
pda_seed,
pda_program_id,
account: Account::default(),
},
);
}
/// Create a shared PDA account from a group's GMS. Returns the `AccountId` and derived keys.
pub fn create_shared_pda_account(
&mut self,
group_name: &str,
pda_seed: nssa_core::program::PdaSeed,
program_id: nssa_core::program::ProgramId,
) -> Result<SharedAccountInfo> {
let holder = self
.storage
.user_data
.group_key_holder(group_name)
.context(format!("Group '{group_name}' not found"))?;
let keys = holder.derive_keys_for_pda(&program_id, &pda_seed);
let npk = keys.generate_nullifier_public_key();
let vpk = keys.generate_viewing_public_key();
let account_id = AccountId::for_private_pda(&program_id, &pda_seed, &npk);
self.register_shared_account(
account_id,
String::from(group_name),
u128::MAX,
Some(pda_seed),
Some(program_id),
);
Ok(SharedAccountInfo {
account_id,
npk,
vpk,
})
}
/// 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(&mut self, group_name: &str) -> Result<SharedAccountInfo> {
let identifier: nssa_core::Identifier = rand::random();
let derivation_seed = {
use sha2::Digest as _;
let mut hasher = sha2::Sha256::new();
hasher.update(b"/LEE/v0.3/SharedAccountTag/\x00\x00\x00\x00\x00");
hasher.update(identifier.to_le_bytes());
let result: [u8; 32] = hasher.finalize().into();
result
};
let holder = self
.storage
.user_data
.group_key_holder(group_name)
.context(format!("Group '{group_name}' not found"))?;
let keys = holder.derive_keys_for_shared_account(&derivation_seed);
let npk = keys.generate_nullifier_public_key();
let vpk = keys.generate_viewing_public_key();
let account_id = AccountId::from((&npk, identifier));
self.register_shared_account(account_id, String::from(group_name), identifier, None, None);
Ok(SharedAccountInfo {
account_id,
npk,
vpk,
})
}
/// Get account balance.
pub async fn get_account_balance(&self, acc: AccountId) -> Result<u128> {
Ok(self.sequencer_client.get_account_balance(acc).await?)
@ -557,6 +736,77 @@ impl WalletCore {
self.storage
.insert_private_account_data(affected_account_id, identifier, new_acc);
}
// Scan for updates to shared accounts (GMS-derived).
self.sync_shared_private_accounts_with_tx(&tx);
}
fn sync_shared_private_accounts_with_tx(&mut self, tx: &PrivacyPreservingTransaction) {
let shared_keys: Vec<_> = self
.storage
.user_data
.shared_private_accounts_iter()
.filter_map(|(&account_id, entry)| {
let holder = self
.storage
.user_data
.group_key_holder(&entry.group_label)?;
let keys = match (&entry.pda_seed, &entry.pda_program_id) {
(Some(pda_seed), Some(program_id)) => {
holder.derive_keys_for_pda(program_id, pda_seed)
}
(Some(_), None) => return None, // PDA without program_id, skip
_ => {
let derivation_seed = {
use sha2::Digest as _;
let mut hasher = sha2::Sha256::new();
hasher.update(b"/LEE/v0.3/SharedAccountTag/\x00\x00\x00\x00\x00");
hasher.update(entry.identifier.to_le_bytes());
let result: [u8; 32] = hasher.finalize().into();
result
};
holder.derive_keys_for_shared_account(&derivation_seed)
}
};
let npk = keys.generate_nullifier_public_key();
let vpk = keys.generate_viewing_public_key();
let vsk = keys.viewing_secret_key;
Some((account_id, npk, vpk, vsk))
})
.collect();
for (account_id, npk, vpk, vsk) in shared_keys {
let view_tag = EncryptedAccountData::compute_view_tag(&npk, &vpk);
for (ciph_id, encrypted_data) in tx
.message()
.encrypted_private_post_states
.iter()
.enumerate()
{
if encrypted_data.view_tag != view_tag {
continue;
}
let shared_secret = SharedSecretKey::new(&vsk, &encrypted_data.epk);
let commitment = &tx.message.new_commitments[ciph_id];
if let Some((_decrypted_identifier, new_acc)) = nssa_core::EncryptionScheme::decrypt(
&encrypted_data.ciphertext,
&shared_secret,
commitment,
ciph_id
.try_into()
.expect("Ciphertext ID is expected to fit in u32"),
) {
info!("Synced shared account {account_id:#?} with new state {new_acc:#?}");
self.storage
.user_data
.update_shared_private_account_state(&account_id, new_acc);
}
}
}
}
#[must_use]

View File

@ -30,6 +30,15 @@ pub enum PrivacyPreservingAccount {
program_id: ProgramId,
seed: PdaSeed,
},
/// 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,
},
}
impl PrivacyPreservingAccount {
@ -49,6 +58,7 @@ impl PrivacyPreservingAccount {
identifier: _,
}
| Self::PrivatePda { .. }
| Self::PrivateShared { .. }
)
}
}
@ -111,6 +121,7 @@ impl AccountManager {
nsk: None,
npk,
identifier,
is_pda: false,
vpk,
pre_state: auth_acc,
proof: None,
@ -130,6 +141,16 @@ impl AccountManager {
let pre =
private_pda_preparation(wallet, nsk, npk, vpk, &program_id, &seed).await?;
State::Private(pre)
}
PrivacyPreservingAccount::PrivateShared {
nsk,
npk,
vpk,
identifier,
} => {
let pre = private_shared_preparation(wallet, nsk, npk, vpk, identifier).await?;
State::Private(pre)
}
};
@ -184,22 +205,17 @@ impl AccountManager {
.iter()
.map(|state| match state {
State::Public { .. } => InputAccountIdentity::Public,
State::Private(pre) if pre.identifier == u128::MAX => {
// Private PDA account
match (pre.nsk, pre.proof.clone()) {
(Some(nsk), Some(membership_proof)) => {
InputAccountIdentity::PrivatePdaUpdate {
ssk: pre.ssk,
nsk,
membership_proof,
}
}
_ => InputAccountIdentity::PrivatePdaInit {
npk: pre.npk,
ssk: pre.ssk,
},
}
}
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,
},
_ => InputAccountIdentity::PrivatePdaInit {
npk: pre.npk,
ssk: pre.ssk,
},
},
State::Private(pre) => match (pre.nsk, pre.proof.clone()) {
(Some(nsk), Some(membership_proof)) => {
InputAccountIdentity::PrivateAuthorizedUpdate {
@ -249,6 +265,7 @@ struct AccountPreparedData {
nsk: Option<NullifierSecretKey>,
npk: NullifierPublicKey,
identifier: Identifier,
is_pda: bool,
vpk: ViewingPublicKey,
pre_state: AccountWithMetadata,
proof: Option<MembershipProof>,
@ -294,6 +311,7 @@ async fn private_acc_preparation(
nsk: Some(nsk),
npk: from_npk,
identifier: from_identifier,
is_pda: false,
vpk: from_vpk,
pre_state: sender_pre,
proof,
@ -317,9 +335,8 @@ async fn private_pda_preparation(
let acc = wallet
.storage
.user_data
.pda_accounts
.get(&account_id)
.cloned()
.shared_private_account(&account_id)
.map(|e| e.account.clone())
.unwrap_or_default();
let exists = acc != nssa_core::account::Account::default();
@ -347,6 +364,7 @@ async fn private_pda_preparation(
nsk: exists.then_some(nsk),
npk,
identifier: u128::MAX,
is_pda: true,
vpk,
pre_state,
proof,
@ -354,3 +372,65 @@ async fn private_pda_preparation(
epk,
})
}
async fn private_shared_preparation(
wallet: &WalletCore,
nsk: NullifierSecretKey,
npk: NullifierPublicKey,
vpk: ViewingPublicKey,
identifier: Identifier,
) -> Result<AccountPreparedData, ExecutionFailureKind> {
let account_id = nssa::AccountId::from((&npk, identifier));
let acc = wallet
.storage
.user_data
.shared_private_account(&account_id)
.map(|e| e.account.clone())
.unwrap_or_default();
let exists = acc != nssa_core::account::Account::default();
let pre_state = AccountWithMetadata::new(acc, exists, (&npk, identifier));
let proof = if exists {
wallet
.check_private_account_initialized(account_id)
.await
.unwrap_or(None)
} else {
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: exists.then_some(nsk),
npk,
identifier,
is_pda: false,
vpk,
pre_state,
proof,
ssk,
epk,
})
}
#[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());
}
}

View File

@ -188,7 +188,9 @@ impl Ata<'_> {
Program::serialize_instruction(instruction).expect("Instruction should serialize");
let accounts = vec![
PrivacyPreservingAccount::PrivateOwned(owner_id),
self.0
.resolve_private_account(owner_id)
.ok_or(ExecutionFailureKind::KeyNotFoundError)?,
PrivacyPreservingAccount::Public(definition_id),
PrivacyPreservingAccount::Public(ata_id),
];
@ -223,7 +225,9 @@ impl Ata<'_> {
Program::serialize_instruction(instruction).expect("Instruction should serialize");
let accounts = vec![
PrivacyPreservingAccount::PrivateOwned(owner_id),
self.0
.resolve_private_account(owner_id)
.ok_or(ExecutionFailureKind::KeyNotFoundError)?,
PrivacyPreservingAccount::Public(sender_ata_id),
PrivacyPreservingAccount::Public(recipient_id),
];
@ -257,7 +261,9 @@ impl Ata<'_> {
Program::serialize_instruction(instruction).expect("Instruction should serialize");
let accounts = vec![
PrivacyPreservingAccount::PrivateOwned(owner_id),
self.0
.resolve_private_account(owner_id)
.ok_or(ExecutionFailureKind::KeyNotFoundError)?,
PrivacyPreservingAccount::Public(holder_ata_id),
PrivacyPreservingAccount::Public(definition_id),
];

View File

@ -16,7 +16,9 @@ impl NativeTokenTransfer<'_> {
self.0
.send_privacy_preserving_tx_with_pre_check(
vec![
PrivacyPreservingAccount::PrivateOwned(from),
self.0
.resolve_private_account(from)
.ok_or(ExecutionFailureKind::KeyNotFoundError)?,
PrivacyPreservingAccount::Public(to),
],
instruction_data,

View File

@ -14,9 +14,14 @@ impl NativeTokenTransfer<'_> {
) -> Result<(HashType, SharedSecretKey), ExecutionFailureKind> {
let instruction: u128 = 0;
let account = self
.0
.resolve_private_account(from)
.ok_or(ExecutionFailureKind::KeyNotFoundError)?;
self.0
.send_privacy_preserving_tx(
vec![PrivacyPreservingAccount::PrivateOwned(from)],
vec![account],
Program::serialize_instruction(instruction).unwrap(),
&Program::authenticated_transfer_program().into(),
)
@ -41,7 +46,9 @@ impl NativeTokenTransfer<'_> {
self.0
.send_privacy_preserving_tx_with_pre_check(
vec![
PrivacyPreservingAccount::PrivateOwned(from),
self.0
.resolve_private_account(from)
.ok_or(ExecutionFailureKind::KeyNotFoundError)?,
PrivacyPreservingAccount::PrivateForeign {
npk: to_npk,
vpk: to_vpk,
@ -69,12 +76,18 @@ impl NativeTokenTransfer<'_> {
) -> Result<(HashType, [SharedSecretKey; 2]), ExecutionFailureKind> {
let (instruction_data, program, tx_pre_check) = auth_transfer_preparation(balance_to_move);
let from_account = self
.0
.resolve_private_account(from)
.ok_or(ExecutionFailureKind::KeyNotFoundError)?;
let to_account = self
.0
.resolve_private_account(to)
.ok_or(ExecutionFailureKind::KeyNotFoundError)?;
self.0
.send_privacy_preserving_tx_with_pre_check(
vec![
PrivacyPreservingAccount::PrivateOwned(from),
PrivacyPreservingAccount::PrivateOwned(to),
],
vec![from_account, to_account],
instruction_data,
&program.into(),
tx_pre_check,

View File

@ -18,7 +18,9 @@ impl NativeTokenTransfer<'_> {
.send_privacy_preserving_tx_with_pre_check(
vec![
PrivacyPreservingAccount::Public(from),
PrivacyPreservingAccount::PrivateOwned(to),
self.0
.resolve_private_account(to)
.ok_or(ExecutionFailureKind::KeyNotFoundError)?,
],
instruction_data,
&program.into(),

View File

@ -56,7 +56,9 @@ impl Pinata<'_> {
.send_privacy_preserving_tx(
vec![
PrivacyPreservingAccount::Public(pinata_account_id),
PrivacyPreservingAccount::PrivateOwned(winner_account_id),
self.0
.resolve_private_account(winner_account_id)
.ok_or(ExecutionFailureKind::KeyNotFoundError)?,
],
nssa::program::Program::serialize_instruction(solution).unwrap(),
&nssa::program::Program::pinata().into(),

View File

@ -74,7 +74,9 @@ impl Token<'_> {
.send_privacy_preserving_tx(
vec![
PrivacyPreservingAccount::Public(definition_account_id),
PrivacyPreservingAccount::PrivateOwned(supply_account_id),
self.0
.resolve_private_account(supply_account_id)
.ok_or(ExecutionFailureKind::KeyNotFoundError)?,
],
instruction_data,
&Program::token().into(),
@ -103,7 +105,9 @@ impl Token<'_> {
self.0
.send_privacy_preserving_tx(
vec![
PrivacyPreservingAccount::PrivateOwned(definition_account_id),
self.0
.resolve_private_account(definition_account_id)
.ok_or(ExecutionFailureKind::KeyNotFoundError)?,
PrivacyPreservingAccount::Public(supply_account_id),
],
instruction_data,
@ -133,8 +137,12 @@ impl Token<'_> {
self.0
.send_privacy_preserving_tx(
vec![
PrivacyPreservingAccount::PrivateOwned(definition_account_id),
PrivacyPreservingAccount::PrivateOwned(supply_account_id),
self.0
.resolve_private_account(definition_account_id)
.ok_or(ExecutionFailureKind::KeyNotFoundError)?,
self.0
.resolve_private_account(supply_account_id)
.ok_or(ExecutionFailureKind::KeyNotFoundError)?,
],
instruction_data,
&Program::token().into(),
@ -227,8 +235,12 @@ impl Token<'_> {
self.0
.send_privacy_preserving_tx(
vec![
PrivacyPreservingAccount::PrivateOwned(sender_account_id),
PrivacyPreservingAccount::PrivateOwned(recipient_account_id),
self.0
.resolve_private_account(sender_account_id)
.ok_or(ExecutionFailureKind::KeyNotFoundError)?,
self.0
.resolve_private_account(recipient_account_id)
.ok_or(ExecutionFailureKind::KeyNotFoundError)?,
],
instruction_data,
&Program::token().into(),
@ -259,7 +271,9 @@ impl Token<'_> {
self.0
.send_privacy_preserving_tx(
vec![
PrivacyPreservingAccount::PrivateOwned(sender_account_id),
self.0
.resolve_private_account(sender_account_id)
.ok_or(ExecutionFailureKind::KeyNotFoundError)?,
PrivacyPreservingAccount::PrivateForeign {
npk: recipient_npk,
vpk: recipient_vpk,
@ -293,7 +307,9 @@ impl Token<'_> {
self.0
.send_privacy_preserving_tx(
vec![
PrivacyPreservingAccount::PrivateOwned(sender_account_id),
self.0
.resolve_private_account(sender_account_id)
.ok_or(ExecutionFailureKind::KeyNotFoundError)?,
PrivacyPreservingAccount::Public(recipient_account_id),
],
instruction_data,
@ -325,7 +341,9 @@ impl Token<'_> {
.send_privacy_preserving_tx(
vec![
PrivacyPreservingAccount::Public(sender_account_id),
PrivacyPreservingAccount::PrivateOwned(recipient_account_id),
self.0
.resolve_private_account(recipient_account_id)
.ok_or(ExecutionFailureKind::KeyNotFoundError)?,
],
instruction_data,
&Program::token().into(),
@ -434,8 +452,12 @@ impl Token<'_> {
self.0
.send_privacy_preserving_tx(
vec![
PrivacyPreservingAccount::PrivateOwned(definition_account_id),
PrivacyPreservingAccount::PrivateOwned(holder_account_id),
self.0
.resolve_private_account(definition_account_id)
.ok_or(ExecutionFailureKind::KeyNotFoundError)?,
self.0
.resolve_private_account(holder_account_id)
.ok_or(ExecutionFailureKind::KeyNotFoundError)?,
],
instruction_data,
&Program::token().into(),
@ -464,7 +486,9 @@ impl Token<'_> {
self.0
.send_privacy_preserving_tx(
vec![
PrivacyPreservingAccount::PrivateOwned(definition_account_id),
self.0
.resolve_private_account(definition_account_id)
.ok_or(ExecutionFailureKind::KeyNotFoundError)?,
PrivacyPreservingAccount::Public(holder_account_id),
],
instruction_data,
@ -496,7 +520,9 @@ impl Token<'_> {
.send_privacy_preserving_tx(
vec![
PrivacyPreservingAccount::Public(definition_account_id),
PrivacyPreservingAccount::PrivateOwned(holder_account_id),
self.0
.resolve_private_account(holder_account_id)
.ok_or(ExecutionFailureKind::KeyNotFoundError)?,
],
instruction_data,
&Program::token().into(),
@ -590,8 +616,12 @@ impl Token<'_> {
self.0
.send_privacy_preserving_tx(
vec![
PrivacyPreservingAccount::PrivateOwned(definition_account_id),
PrivacyPreservingAccount::PrivateOwned(holder_account_id),
self.0
.resolve_private_account(definition_account_id)
.ok_or(ExecutionFailureKind::KeyNotFoundError)?,
self.0
.resolve_private_account(holder_account_id)
.ok_or(ExecutionFailureKind::KeyNotFoundError)?,
],
instruction_data,
&Program::token().into(),
@ -622,7 +652,9 @@ impl Token<'_> {
self.0
.send_privacy_preserving_tx(
vec![
PrivacyPreservingAccount::PrivateOwned(definition_account_id),
self.0
.resolve_private_account(definition_account_id)
.ok_or(ExecutionFailureKind::KeyNotFoundError)?,
PrivacyPreservingAccount::PrivateForeign {
npk: holder_npk,
vpk: holder_vpk,
@ -656,7 +688,9 @@ impl Token<'_> {
self.0
.send_privacy_preserving_tx(
vec![
PrivacyPreservingAccount::PrivateOwned(definition_account_id),
self.0
.resolve_private_account(definition_account_id)
.ok_or(ExecutionFailureKind::KeyNotFoundError)?,
PrivacyPreservingAccount::Public(holder_account_id),
],
instruction_data,
@ -688,7 +722,9 @@ impl Token<'_> {
.send_privacy_preserving_tx(
vec![
PrivacyPreservingAccount::Public(definition_account_id),
PrivacyPreservingAccount::PrivateOwned(holder_account_id),
self.0
.resolve_private_account(holder_account_id)
.ok_or(ExecutionFailureKind::KeyNotFoundError)?,
],
instruction_data,
&Program::token().into(),