Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 44 additions & 0 deletions scripts/smoke-test-privacy.sh
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,18 @@ mod privacy_test {

Ok(SpelOutput::states_only(vec![post]))
}

/// Initialize a private PDA — address is unique per (seed, npk) pair.
#[instruction]
pub fn init_private_pda(
#[account(init, private_pda, pda = literal("private_vault"), npk = arg("user_npk"))]
vault: AccountWithMetadata,
#[account(signer)]
authority: AccountWithMetadata,
user_npk: nssa_core::NullifierPublicKey,
) -> SpelResult {
Ok(SpelOutput::execute(vec![vault, authority], vec![]))
}
}
RUSTEOF

Expand Down Expand Up @@ -319,6 +331,37 @@ SEQUENCER_URL="$SEQUENCER_URL" "$SPEL_BIN" --idl "$IDL_ABS" -p "$GUEST_BIN_ABS"

log " ✓ Privacy-preserving TX submitted and confirmed"

# ─── Step 11: Get npk for private account ────────────────────────────────

log "Step 11: Retrieving NullifierPublicKey for private account..."
NPK_HEX=$(echo "$WALLET_PASSWORD" | $WALLET_BIN account get --keys --account-id "$PRIVATE_ACCOUNT" \
2>&1 | grep "^npk " | awk '{print $2}')
[ -n "$NPK_HEX" ] || fail "Could not retrieve npk for '$PRIVATE_ACCOUNT'"
log " npk: ${NPK_HEX:0:20}..."

# ─── Step 12: Compute private PDA address ────────────────────────────────

log "Step 12: Computing private PDA address..."
PRIVATE_PDA=$("$SPEL_BIN" --idl "$IDL_ABS" -p "$GUEST_BIN_ABS" pda vault --npk "$NPK_HEX" \
2>"$LOG_DIR/pda.log") || fail "spel pda failed (see $LOG_DIR/pda.log)"
[ -n "$PRIVATE_PDA" ] || fail "spel pda returned empty address"
log " Private PDA: ${PRIVATE_PDA:0:40}..."

# ─── Step 13: Initialize private PDA account ─────────────────────────────
#
# NOTE: Skipped — private PDA initialization via privacy-preserving transaction
# requires LEZ to expose a mask=3 account variant (PrivatePdaInit) in the
# PrivacyPreservingAccount enum. The wallet API in v0.2.0-rc3 only supports
# mask 0/1/2; mask=3 is required by the circuit to validate Claim::Pda for
# private PDAs via AccountId::for_private_pda. All other parts of the private
# PDA feature (IDL generation, framework macros, PDA address computation) are
# correct and verified by unit tests. Re-enable this step once LEZ adds wallet
# support for mask-3 accounts.

log "Step 13: SKIPPED — private PDA TX init requires LEZ mask-3 wallet support"
warn " Private PDA address was computed correctly at step 12: $PRIVATE_PDA"
warn " Submission requires LEZ PrivacyPreservingAccount::PrivatePdaInit (mask=3)"

# ─── Done ─────────────────────────────────────────────────────────────────

log ""
Expand All @@ -327,3 +370,4 @@ log " Public TX: $LOG_DIR/public-tx.log"
log " Auth-transfer: $LOG_DIR/auth-transfer.log"
log " Private TX: $LOG_DIR/private-tx.log"
log " Sequencer: $LOG_DIR/sequencer.log"
warn " Step 13 (private PDA TX) was SKIPPED — needs LEZ mask-3 wallet support"
15 changes: 11 additions & 4 deletions spel-cli/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -527,12 +527,19 @@ fn compute_pda_command(idl: &SpelIdl, program_path: Option<&str>, program_id_hex
let npk: Option<NullifierPublicKey> = if pda_def.private {
match npk_hex {
Some(ref hex) => {
use crate::hex::decode_bytes_32;
let bytes = decode_bytes_32(hex).unwrap_or_else(|e| {
eprintln!("❌ Invalid --npk '{}': {}", hex, e);
use crate::hex::hex_decode;
let hex_clean = hex.strip_prefix("0x").or_else(|| hex.strip_prefix("0X")).unwrap_or(hex);
if hex_clean.len() != 64 {
eprintln!("❌ --npk must be a 64-char hex string (32 bytes), got {} chars", hex_clean.len());
std::process::exit(1);
}
let bytes = hex_decode(hex_clean).unwrap_or_else(|e| {
eprintln!("❌ Invalid --npk hex '{}': {}", hex, e);
std::process::exit(1);
});
Some(NullifierPublicKey(bytes))
let mut arr = [0u8; 32];
arr.copy_from_slice(&bytes);
Some(NullifierPublicKey(arr))
}
None => {
eprintln!("❌ '{}' is a private PDA — pass --npk <64-char-hex>", account_name);
Expand Down
8 changes: 8 additions & 0 deletions spel-cli/src/parse.rs
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,14 @@ pub fn parse_value(raw: &str, ty: &IdlType) -> Result<ParsedValue, String> {
Ok(ParsedValue::Some(Box::new(parse_value(raw, option)?)))
}
}
IdlType::Defined { defined } if defined == "NullifierPublicKey" => {
let bytes = hex_decode(raw)
.map_err(|e| format!("Invalid NullifierPublicKey hex '{}': {}", raw, e))?;
if bytes.len() != 32 {
return Err(format!("NullifierPublicKey must be 32 bytes (64 hex chars), got {}", bytes.len()));
}
Ok(ParsedValue::ByteArray(bytes))
}
IdlType::Defined { defined } => Ok(ParsedValue::Raw(format!("{}({})", defined, raw))),
}
}
Expand Down
4 changes: 4 additions & 0 deletions spel-cli/src/serialize.rs
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,10 @@ fn to_dynamic_value(ty: &IdlType, val: &ParsedValue) -> Result<DynamicValue, Ser
.collect();
Ok(DynamicValue::Seq(vals.iter().map(|v| DynamicValue::U32(*v)).collect()))
}
// NullifierPublicKey([u8; 32]) — serializes as a tuple of 32 u8 values
(IdlType::Defined { defined }, ParsedValue::ByteArray(bytes)) if defined == "NullifierPublicKey" => {
Ok(DynamicValue::Tuple(bytes.iter().map(|b| DynamicValue::U8(*b)).collect()))
}
(IdlType::Option { option: _ }, ParsedValue::None) => Ok(DynamicValue::None),
(IdlType::Option { option }, ParsedValue::Some(inner)) => {
Ok(DynamicValue::Some(Box::new(to_dynamic_value(option, inner)?)))
Expand Down
33 changes: 31 additions & 2 deletions spel-cli/src/tx.rs
Original file line number Diff line number Diff line change
Expand Up @@ -243,9 +243,33 @@ pub async fn execute_instruction(
}

// Compute PDAs
use nssa_core::NullifierPublicKey;
for acc in &ix.accounts {
if let Some(pda) = &acc.pda {
match compute_pda_from_seeds(&pda.seeds, &program_id, &account_map, &parsed_arg_map, None) {
let npk: Option<NullifierPublicKey> = if pda.private {
match &pda.npk_arg {
Some(npk_arg_name) => {
match parsed_arg_map.get(npk_arg_name.as_str()) {
Some(ParsedValue::ByteArray(bytes)) if bytes.len() == 32 => {
let mut arr = [0u8; 32];
arr.copy_from_slice(bytes);
Some(NullifierPublicKey(arr))
}
_ => {
eprintln!("❌ Private PDA '{}' requires arg '{}' of type NullifierPublicKey", acc.name, npk_arg_name);
process::exit(1);
}
}
}
None => {
eprintln!("❌ Private PDA '{}' has no npk_arg in IDL", acc.name);
process::exit(1);
}
}
} else {
None
};
match compute_pda_from_seeds(&pda.seeds, &program_id, &account_map, &parsed_arg_map, npk.as_ref()) {
Ok(id) => {
account_map.insert(acc.name.clone(), id);
}
Expand Down Expand Up @@ -387,7 +411,12 @@ pub async fn execute_instruction(
pp_accounts.push(PrivacyPreservingAccount::Public(id));
}
} else {
// PDA account — always public
// PDA account.
// TODO: private PDAs (pda.private == true) need mask=3 in the LEZ circuit,
// which requires PrivacyPreservingAccount::PrivatePdaInit (not yet in
// v0.2.0-rc3). Until LEZ exposes that variant, private PDAs are submitted
// as Public (mask=0) and the circuit will reject them. Once LEZ adds
// mask-3 wallet support, use a dedicated variant here.
let id = *account_map.get(&acc.name).unwrap_or_else(|| {
eprintln!("❌ Account '{}' not resolved", acc.name);
process::exit(1);
Expand Down
55 changes: 41 additions & 14 deletions spel-client-gen/src/codegen.rs
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,13 @@ pub fn generate_client(idl: &SpelIdl) -> Result<String, String> {
writeln!(out, "use borsh::BorshDeserialize;").unwrap();
writeln!(out, "use serde::{{Deserialize, Serialize}};").unwrap();
writeln!(out, "use wallet::WalletCore;").unwrap();
// Import NullifierPublicKey if any instruction uses private PDAs or has an npk arg
let needs_npk = idl.instructions.iter().any(|ix|
ix.accounts.iter().any(|a| a.pda.as_ref().map_or(false, |p| p.private))
);
if needs_npk {
writeln!(out, "use nssa_core::NullifierPublicKey;").unwrap();
}
writeln!(out).unwrap();

// Parse helpers
Expand All @@ -46,21 +53,39 @@ pub fn generate_client(idl: &SpelIdl) -> Result<String, String> {
// --- Standalone PDA computation helpers ---
let pda_helpers = collect_pda_helpers(idl);
for helper in &pda_helpers {
writeln!(out, "/// Compute PDA for the `{}` account.", helper.account_name).unwrap();
write!(out, "pub fn compute_{}_pda(program_id: &ProgramId", helper.account_name).unwrap();
for (pname, pty) in &helper.params {
write!(out, ", {}: {}", pname, pty).unwrap();
}
writeln!(out, ") -> AccountId {{").unwrap();
for binding in &helper.let_bindings {
writeln!(out, " {}", binding).unwrap();
}
writeln!(out, " spel_framework_core::pda::compute_pda(program_id, &[").unwrap();
for expr in &helper.seed_exprs {
writeln!(out, " {},", expr).unwrap();
if helper.private {
writeln!(out, "/// Compute private PDA for the `{}` account (address is unique per npk).", helper.account_name).unwrap();
write!(out, "pub fn compute_{}_pda(program_id: &ProgramId", helper.account_name).unwrap();
for (pname, pty) in &helper.params {
write!(out, ", {}: {}", pname, pty).unwrap();
}
writeln!(out, ", npk: &NullifierPublicKey) -> AccountId {{").unwrap();
for binding in &helper.let_bindings {
writeln!(out, " {}", binding).unwrap();
}
writeln!(out, " spel_framework_core::pda::compute_private_pda(program_id, &[").unwrap();
for expr in &helper.seed_exprs {
writeln!(out, " {},", expr).unwrap();
}
writeln!(out, " ], npk)").unwrap();
writeln!(out, "}}").unwrap();
} else {
writeln!(out, "/// Compute PDA for the `{}` account.", helper.account_name).unwrap();
write!(out, "pub fn compute_{}_pda(program_id: &ProgramId", helper.account_name).unwrap();
for (pname, pty) in &helper.params {
write!(out, ", {}: {}", pname, pty).unwrap();
}
writeln!(out, ") -> AccountId {{").unwrap();
for binding in &helper.let_bindings {
writeln!(out, " {}", binding).unwrap();
}
writeln!(out, " spel_framework_core::pda::compute_pda(program_id, &[").unwrap();
for expr in &helper.seed_exprs {
writeln!(out, " {},", expr).unwrap();
}
writeln!(out, " ])").unwrap();
writeln!(out, "}}").unwrap();
}
writeln!(out, " ])").unwrap();
writeln!(out, "}}").unwrap();
writeln!(out).unwrap();
}

Expand Down Expand Up @@ -200,6 +225,7 @@ struct PdaHelper {
params: Vec<(String, String)>, // (param_name, param_type) for non-const seeds
let_bindings: Vec<String>, // let bindings needed before compute_pda call
seed_exprs: Vec<String>, // seed slice expressions for compute_pda
private: bool, // true → use compute_private_pda with npk param
}

/// Collect unique PDA accounts across all instructions, deduplicating by account name.
Expand Down Expand Up @@ -256,6 +282,7 @@ fn collect_pda_helpers(idl: &SpelIdl) -> Vec<PdaHelper> {
params,
let_bindings,
seed_exprs,
private: pda.private,
});
}
}
Expand Down
47 changes: 41 additions & 6 deletions spel-client-gen/src/ffi_codegen.rs
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,12 @@ pub fn generate_ffi(idl: &SpelIdl) -> Result<String, String> {
writeln!(out, "use nssa_core::program::PdaSeed;").unwrap();
writeln!(out, "use sequencer_service_rpc::RpcClient as _;").unwrap();
writeln!(out, "use wallet::WalletCore;").unwrap();
let needs_npk = idl.instructions.iter().any(|ix|
ix.accounts.iter().any(|a| a.pda.as_ref().map_or(false, |p| p.private))
);
if needs_npk {
writeln!(out, "use nssa_core::NullifierPublicKey;").unwrap();
}

// Import or generate instruction type
if let Some(ref itype) = idl.instruction_type {
Expand Down Expand Up @@ -158,6 +164,12 @@ pub fn generate_ffi(idl: &SpelIdl) -> Result<String, String> {
writeln!(out, "fn compute_pda_with_program(program_id: &ProgramId, seeds: &[&[u8]]) -> Result<AccountId, String> {{").unwrap();
writeln!(out, " Ok(AccountId::for_public_pda(program_id, &PdaSeed::new(pda_seed_bytes(seeds)?)))").unwrap();
writeln!(out, "}}").unwrap();
if needs_npk {
writeln!(out, "#[allow(dead_code)]").unwrap();
writeln!(out, "fn compute_private_pda_with_program(program_id: &ProgramId, seeds: &[&[u8]], npk: &NullifierPublicKey) -> Result<AccountId, String> {{").unwrap();
writeln!(out, " Ok(AccountId::for_private_pda(program_id, &PdaSeed::new(pda_seed_bytes(seeds)?), npk))").unwrap();
writeln!(out, "}}").unwrap();
}
writeln!(out).unwrap();

// parse_program_id_hex
Expand Down Expand Up @@ -304,11 +316,22 @@ pub fn generate_ffi(idl: &SpelIdl) -> Result<String, String> {
for binding in &seed_pre_bindings {
writeln!(out, "{binding}").unwrap();
}
writeln!(out, " let {name} = compute_pda_with_program(&program_id, &[").unwrap();
for entry in &seed_entries {
writeln!(out, "{entry}").unwrap();
if pda.private {
let npk_arg = pda.npk_arg.as_deref()
.expect("private PDA must have npk_arg — should have been caught earlier");
let npk_var = rust_ident(npk_arg);
writeln!(out, " let {name} = compute_private_pda_with_program(&program_id, &[").unwrap();
for entry in &seed_entries {
writeln!(out, "{entry}").unwrap();
}
writeln!(out, " ], &{npk_var})?;").unwrap();
} else {
writeln!(out, " let {name} = compute_pda_with_program(&program_id, &[").unwrap();
for entry in &seed_entries {
writeln!(out, "{entry}").unwrap();
}
writeln!(out, " ])?;").unwrap();
}
writeln!(out, " ])?;").unwrap();
}
}
writeln!(out).unwrap();
Expand Down Expand Up @@ -451,6 +474,7 @@ pub fn generate_pda_helpers(idl: &SpelIdl) -> String {
params.iter().cloned().collect();

// Function signature
let is_private = pda.private;
write!(out, "pub fn compute_{}_pda(", acc_name).unwrap();
write!(out, "program_id: &ProgramId").unwrap();
for (name, ty) in &params {
Expand All @@ -462,6 +486,9 @@ pub fn generate_pda_helpers(idl: &SpelIdl) -> String {
write!(out, ", {}: &{}", name, ty).unwrap();
}
}
if is_private {
write!(out, ", npk: &NullifierPublicKey").unwrap();
}
writeln!(out, ") -> AccountId {{").unwrap();

let n_seeds = pda.seeds.len();
Expand Down Expand Up @@ -491,7 +518,11 @@ pub fn generate_pda_helpers(idl: &SpelIdl) -> String {
}
}
writeln!(out, " let pda_seed = nssa_core::program::PdaSeed::new(seed_bytes);").unwrap();
writeln!(out, " AccountId::for_public_pda(program_id, &pda_seed)").unwrap();
if is_private {
writeln!(out, " AccountId::for_private_pda(program_id, &pda_seed, npk)").unwrap();
} else {
writeln!(out, " AccountId::for_public_pda(program_id, &pda_seed)").unwrap();
}
} else {
// Multi-seed: SHA-256(seed1 || seed2 || ...) — matches spel-cli/src/pda.rs
writeln!(out, " use sha2::{{Sha256, Digest}};").unwrap();
Expand Down Expand Up @@ -525,7 +556,11 @@ pub fn generate_pda_helpers(idl: &SpelIdl) -> String {
}
writeln!(out, " let combined: [u8; 32] = hasher.finalize().into();").unwrap();
writeln!(out, " let pda_seed = nssa_core::program::PdaSeed::new(combined);").unwrap();
writeln!(out, " AccountId::for_public_pda(program_id, &pda_seed)").unwrap();
if is_private {
writeln!(out, " AccountId::for_private_pda(program_id, &pda_seed, npk)").unwrap();
} else {
writeln!(out, " AccountId::for_public_pda(program_id, &pda_seed)").unwrap();
}
}
writeln!(out, "}}").unwrap();
}
Expand Down
Loading