feat(wallet)!: add group CLI commands with --for-gms account creation

BREAKING CHANGE: `NewSubcommand::Private` has new required fields (`for_gms`, `pda`, `seed`, `program_id`). Code constructing this variant must include them (use `None`/`false` for defaults). `shared_accounts` value type changed from `Account` to `SharedAccountEntry`.
This commit is contained in:
Moudy 2026-05-05 18:55:51 +02:00
parent 7be0ed926c
commit cd545819e7
6 changed files with 232 additions and 166 deletions

View File

@ -21,6 +21,15 @@ 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 are needed to re-derive keys during sync.
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct SharedAccountEntry {
pub group_label: String,
pub identifier: Identifier,
pub account: Account,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct NSSAUserData {
/// Default public accounts.
@ -37,11 +46,12 @@ pub struct NSSAUserData {
#[serde(default)]
pub group_key_holders: BTreeMap<String, GroupKeyHolder>,
/// Cached plaintext state of shared accounts (PDAs and regular shared accounts),
/// keyed by `AccountId`. Updated after each transaction by decrypting the circuit output.
/// The sequencer only stores encrypted commitments, so this local cache is the
/// only source of plaintext state for these accounts.
#[serde(default, alias = "group_pda_accounts", alias = "pda_accounts")]
pub shared_accounts: BTreeMap<nssa::AccountId, nssa_core::account::Account>,
/// keyed by `AccountId`. Each entry stores the group label and identifier needed
/// to re-derive keys during sync.
/// Old wallet files with `pda_accounts` (plain Account values) are incompatible with
/// this type. The `default` attribute ensures they deserialize as empty rather than failing.
#[serde(default)]
pub shared_accounts: BTreeMap<nssa::AccountId, SharedAccountEntry>,
}
impl NSSAUserData {

View File

@ -83,14 +83,27 @@ pub enum NewSubcommand {
label: Option<String>,
},
/// Single-account convenience: creates a key node and auto-registers one account with a random
/// identifier.
/// identifier. When `--for-gms` is provided, derives keys from the named group instead of
/// the wallet's key tree.
Private {
#[arg(long)]
/// Chain index of a parent node.
/// Chain index of a parent node (ignored when --for-gms is used).
cci: Option<ChainIndex>,
#[arg(short, long)]
/// Label to assign to the new account.
label: Option<String>,
#[arg(long)]
/// Derive keys from a group's GMS instead of the wallet tree.
for_gms: Option<String>,
#[arg(long, requires = "for_gms")]
/// 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.
@ -144,7 +157,14 @@ impl WalletSubcommand for NewSubcommand {
Ok(SubcommandReturnValue::RegisterAccount { account_id })
}
Self::Private { cci, label } => {
Self::Private {
cci,
label,
for_gms,
pda,
seed,
program_id,
} => {
if let Some(label) = &label
&& wallet_core
.storage
@ -155,36 +175,132 @@ impl WalletSubcommand for NewSubcommand {
anyhow::bail!("Label '{label}' is already in use by another account");
}
let (account_id, chain_index) = wallet_core.create_new_account_private(cci);
if let Some(group_name) = for_gms {
// GMS-derived account
let holder = wallet_core
.storage()
.user_data
.group_key_holder(&group_name)
.context(format!("Group '{group_name}' not found"))?;
let node = wallet_core
.storage
.user_data
.private_key_tree
.key_map
.get(&chain_index)
.expect("Node was just inserted");
let key = &node.value.0;
if pda {
// PDA shared account
let seed_hex = seed.context("--seed is required for PDA accounts")?;
let pid_hex =
program_id.context("--program-id is required for PDA accounts")?;
if let Some(label) = label {
wallet_core
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());
}
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 = nssa::AccountId::for_private_pda(&pid, &pda_seed, &npk);
if let Some(label) = label {
wallet_core
.storage
.labels
.insert(account_id.to_string(), Label::new(label));
}
wallet_core.register_shared_account(
account_id,
group_name.clone(),
u128::MAX,
);
println!("PDA shared account from group '{group_name}'");
println!("AccountId: {account_id}");
println!("NPK: {}", hex::encode(npk.0));
println!("VPK: {}", hex::encode(&vpk.0));
wallet_core.store_persistent_data().await?;
Ok(SubcommandReturnValue::RegisterAccount { account_id })
} else {
// Regular shared account. The tag is derived deterministically
// from the identifier so that keys can be re-derived without
// storing the tag separately.
let identifier: nssa_core::Identifier = rand::random();
let tag = {
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 keys = holder.derive_keys_for_shared_account(&tag);
let npk = keys.generate_nullifier_public_key();
let vpk = keys.generate_viewing_public_key();
let account_id = nssa::AccountId::from((&npk, identifier));
if let Some(label) = label {
wallet_core
.storage
.labels
.insert(account_id.to_string(), Label::new(label));
}
wallet_core.register_shared_account(
account_id,
group_name.clone(),
identifier,
);
println!("Shared account from group '{group_name}'");
println!("AccountId: Private/{account_id}");
println!("NPK: {}", hex::encode(npk.0));
println!("VPK: {}", hex::encode(&vpk.0));
wallet_core.store_persistent_data().await?;
Ok(SubcommandReturnValue::RegisterAccount { account_id })
}
} else {
// Standard wallet-tree-derived account
let (account_id, chain_index) = wallet_core.create_new_account_private(cci);
let node = wallet_core
.storage
.labels
.insert(account_id.to_string(), Label::new(label));
.user_data
.private_key_tree
.key_map
.get(&chain_index)
.expect("Node was just inserted");
let key = &node.value.0;
if let Some(label) = label {
wallet_core
.storage
.labels
.insert(account_id.to_string(), Label::new(label));
}
println!(
"Generated new account with account_id Private/{account_id} at path {chain_index}"
);
println!("With npk {}", hex::encode(key.nullifier_public_key.0));
println!(
"With vpk {}",
hex::encode(key.viewing_public_key.to_bytes())
);
wallet_core.store_persistent_data().await?;
Ok(SubcommandReturnValue::RegisterAccount { account_id })
}
println!(
"Generated new account with account_id Private/{account_id} at path {chain_index}"
);
println!("With npk {}", hex::encode(key.nullifier_public_key.0));
println!(
"With vpk {}",
hex::encode(key.viewing_public_key.to_bytes())
);
wallet_core.store_persistent_data().await?;
Ok(SubcommandReturnValue::RegisterAccount { 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 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.
@ -24,29 +22,15 @@ pub enum GroupSubcommand {
/// 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,9 +40,9 @@ 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).
Join {
@ -67,15 +51,10 @@ pub enum GroupSubcommand {
/// Sealed GMS as hex string (from the inviter).
#[arg(long)]
sealed: String,
/// Account label or Private/<id> whose VSK to use for decryption.
/// Account ID whose viewing secret key to use for decryption.
#[arg(long)]
account: String,
},
/// Ratchet the GMS to exclude removed members.
Ratchet {
/// Group name.
name: String,
},
}
impl WalletSubcommand for GroupSubcommand {
@ -88,28 +67,25 @@ 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");
println!("Created group '{name}'");
Ok(SubcommandReturnValue::Empty)
}
Self::Import { name, gms, epoch } => {
Self::Import { name, gms } => {
if wallet_core
.storage()
.user_data
.get_group_key_holder(&name)
.group_key_holder(&name)
.is_some()
{
anyhow::bail!("Group '{name}' already exists");
@ -118,16 +94,13 @@ impl WalletSubcommand for GroupSubcommand {
let gms_bytes: [u8; 32] = hex::decode(&gms)
.context("Invalid GMS hex")?
.try_into()
.map_err(|_| anyhow::anyhow!("GMS must be exactly 32 bytes"))?;
.map_err(|_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);
let holder = GroupKeyHolder::from_gms(gms_bytes);
wallet_core.insert_group_key_holder(name.clone(), holder);
wallet_core.store_persistent_data().await?;
println!("Imported group '{name}' at epoch {epoch}");
println!("Imported group '{name}'");
Ok(SubcommandReturnValue::Empty)
}
@ -135,14 +108,12 @@ impl WalletSubcommand for GroupSubcommand {
let holder = wallet_core
.storage()
.user_data
.get_group_key_holder(&name)
.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}");
Ok(SubcommandReturnValue::Empty)
}
@ -152,60 +123,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,18 +140,18 @@ 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 =
nssa_core::encryption::shared_key_derivation::Secp256k1Point(key_bytes);
let sealed = holder.seal_for(&recipient_vpk);
let sealed = holder.seal_for(&recipient_key);
println!("{}", hex::encode(&sealed));
Ok(SubcommandReturnValue::Empty)
}
@ -238,7 +164,7 @@ 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");
@ -246,11 +172,8 @@ impl WalletSubcommand for GroupSubcommand {
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
let account_id: nssa::AccountId = account.parse().context("Invalid account ID")?;
let (keychain, _, _) = wallet_core
.storage()
.user_data
.get_private_account(account_id)
@ -260,34 +183,10 @@ impl WalletSubcommand for GroupSubcommand {
let holder = GroupKeyHolder::unseal(&sealed_bytes, &vsk)
.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}");
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"))?;
let mut salt = [0_u8; 32];
rand::RngCore::fill_bytes(&mut rand::rngs::OsRng, &mut salt);
holder.ratchet(salt);
let epoch = holder.epoch();
wallet_core.store_persistent_data().await?;
println!("Ratcheted group '{name}' to epoch {epoch}");
println!("Re-invite remaining members with 'group invite'");
println!("Joined group '{name}'");
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

@ -287,6 +287,41 @@ 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);
}
/// 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.
pub fn register_shared_account(
&mut self,
account_id: AccountId,
group_label: String,
identifier: nssa_core::Identifier,
) {
use key_protocol::key_protocol_core::SharedAccountEntry;
self.storage.user_data.shared_accounts.insert(
account_id,
SharedAccountEntry {
group_label,
identifier,
account: Account::default(),
},
);
}
/// Get account balance.
pub async fn get_account_balance(&self, acc: AccountId) -> Result<u128> {
Ok(self.sequencer_client.get_account_balance(acc).await?)

View File

@ -337,7 +337,7 @@ async fn private_pda_preparation(
.user_data
.shared_accounts
.get(&account_id)
.cloned()
.map(|e| e.account.clone())
.unwrap_or_default();
let exists = acc != nssa_core::account::Account::default();
@ -388,7 +388,7 @@ async fn private_shared_preparation(
.user_data
.shared_accounts
.get(&account_id)
.cloned()
.map(|e| e.account.clone())
.unwrap_or_default();
let exists = acc != nssa_core::account::Account::default();