diff --git a/scripts/smoke-test-privacy.sh b/scripts/smoke-test-privacy.sh index afe4a019..3e525887 100755 --- a/scripts/smoke-test-privacy.sh +++ b/scripts/smoke-test-privacy.sh @@ -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 @@ -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 "" @@ -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" diff --git a/spel-cli/src/lib.rs b/spel-cli/src/lib.rs index 89a932a0..ebb5195e 100644 --- a/spel-cli/src/lib.rs +++ b/spel-cli/src/lib.rs @@ -527,12 +527,19 @@ fn compute_pda_command(idl: &SpelIdl, program_path: Option<&str>, program_id_hex let npk: Option = 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); diff --git a/spel-cli/src/parse.rs b/spel-cli/src/parse.rs index 0bb593a8..0984332b 100644 --- a/spel-cli/src/parse.rs +++ b/spel-cli/src/parse.rs @@ -66,6 +66,14 @@ pub fn parse_value(raw: &str, ty: &IdlType) -> Result { 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))), } } diff --git a/spel-cli/src/serialize.rs b/spel-cli/src/serialize.rs index b2cd6932..dbbcef49 100644 --- a/spel-cli/src/serialize.rs +++ b/spel-cli/src/serialize.rs @@ -96,6 +96,10 @@ fn to_dynamic_value(ty: &IdlType, val: &ParsedValue) -> Result { + 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)?))) diff --git a/spel-cli/src/tx.rs b/spel-cli/src/tx.rs index 9c82f497..6d4f9896 100644 --- a/spel-cli/src/tx.rs +++ b/spel-cli/src/tx.rs @@ -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 = 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); } @@ -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); diff --git a/spel-client-gen/src/codegen.rs b/spel-client-gen/src/codegen.rs index 168d6b3c..39578762 100644 --- a/spel-client-gen/src/codegen.rs +++ b/spel-client-gen/src/codegen.rs @@ -25,6 +25,13 @@ pub fn generate_client(idl: &SpelIdl) -> Result { 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 @@ -46,21 +53,39 @@ pub fn generate_client(idl: &SpelIdl) -> Result { // --- 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(); } @@ -200,6 +225,7 @@ struct PdaHelper { params: Vec<(String, String)>, // (param_name, param_type) for non-const seeds let_bindings: Vec, // let bindings needed before compute_pda call seed_exprs: Vec, // 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. @@ -256,6 +282,7 @@ fn collect_pda_helpers(idl: &SpelIdl) -> Vec { params, let_bindings, seed_exprs, + private: pda.private, }); } } diff --git a/spel-client-gen/src/ffi_codegen.rs b/spel-client-gen/src/ffi_codegen.rs index 83344349..df0d1ba1 100644 --- a/spel-client-gen/src/ffi_codegen.rs +++ b/spel-client-gen/src/ffi_codegen.rs @@ -49,6 +49,12 @@ pub fn generate_ffi(idl: &SpelIdl) -> Result { 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 { @@ -158,6 +164,12 @@ pub fn generate_ffi(idl: &SpelIdl) -> Result { writeln!(out, "fn compute_pda_with_program(program_id: &ProgramId, seeds: &[&[u8]]) -> Result {{").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 {{").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 @@ -304,11 +316,22 @@ pub fn generate_ffi(idl: &SpelIdl) -> Result { 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(); @@ -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 ¶ms { @@ -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(); @@ -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(); @@ -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(); } diff --git a/spel-client-gen/src/tests.rs b/spel-client-gen/src/tests.rs index 2bb50df9..36516421 100644 --- a/spel-client-gen/src/tests.rs +++ b/spel-client-gen/src/tests.rs @@ -217,7 +217,7 @@ fn test_pda_helpers_single_arg_seed() { signer: false, init: true, owner: None, - pda: Some(IdlPda { private: false, + pda: Some(IdlPda { private: false, npk_arg: None, seeds: vec![IdlSeed::Arg { path: "create_key".to_string() }], }), rest: false, @@ -272,7 +272,7 @@ fn test_pda_helpers_multi_seed() { signer: false, init: true, owner: None, - pda: Some(IdlPda { private: false, + pda: Some(IdlPda { private: false, npk_arg: None, seeds: vec![ IdlSeed::Const { value: "multisig_state__".to_string() }, IdlSeed::Arg { path: "create_key".to_string() }, @@ -328,7 +328,7 @@ fn test_pda_helpers_deduplication() { signer: false, init: false, owner: None, - pda: Some(IdlPda { private: false, + pda: Some(IdlPda { private: false, npk_arg: None, seeds: vec![IdlSeed::Arg { path: "my_key".to_string() }], }), rest: false, @@ -399,7 +399,7 @@ fn test_pda_helpers_u64_single_seed() { signer: false, init: true, owner: None, - pda: Some(IdlPda { private: false, + pda: Some(IdlPda { private: false, npk_arg: None, seeds: vec![IdlSeed::Arg { path: "proposal_index".to_string() }], }), rest: false, @@ -452,7 +452,7 @@ fn test_pda_helpers_u64_multi_seed() { signer: false, init: true, owner: None, - pda: Some(IdlPda { private: false, + pda: Some(IdlPda { private: false, npk_arg: None, seeds: vec![ IdlSeed::Arg { path: "create_key".to_string() }, IdlSeed::Arg { path: "proposal_index".to_string() }, @@ -1229,3 +1229,144 @@ fn test_ffi_code_is_valid_rust_syntax_sample_idl() { let output = generate_from_idl_json(SAMPLE_IDL).expect("codegen should succeed"); assert_parses_as_rust("SAMPLE_IDL ffi_code", &output.ffi_code); } + +// ── Private PDA codegen tests ───────────────────────────────────────────────── + +const PRIVATE_PDA_IDL: &str = r#"{ + "version": "0.1.0", + "name": "private_vault", + "instructions": [ + { + "name": "init_vault", + "accounts": [ + { + "name": "vault", + "writable": false, + "signer": false, + "init": true, + "pda": { + "seeds": [{"kind": "const", "value": "vault"}], + "private": true, + "npk_arg": "user_npk" + }, + "visibility": ["private"] + }, + { + "name": "authority", + "writable": false, + "signer": true, + "init": false + } + ], + "args": [ + {"name": "user_npk", "type": {"defined": "NullifierPublicKey"}} + ] + } + ], + "accounts": [], + "types": [], + "errors": [] +}"#; + +#[test] +fn test_private_pda_client_helper_has_npk_param() { + let output = generate_from_idl_json(PRIVATE_PDA_IDL).expect("codegen should succeed"); + let client = &output.client_code; + assert!( + client.contains("compute_vault_pda("), + "client should emit compute_vault_pda helper: {client}" + ); + assert!( + client.contains("npk: &NullifierPublicKey"), + "private PDA helper must have npk parameter: {client}" + ); + assert!( + client.contains("compute_private_pda("), + "private PDA helper must call compute_private_pda: {client}" + ); + assert!( + !client.contains("compute_pda(program_id"), + "private PDA helper must NOT call compute_pda (public): {client}" + ); +} + +#[test] +fn test_private_pda_ffi_helper_has_npk_param() { + let output = generate_from_idl_json(PRIVATE_PDA_IDL).expect("codegen should succeed"); + let ffi = &output.ffi_code; + assert!( + ffi.contains("compute_vault_pda("), + "FFI should emit compute_vault_pda helper: {ffi}" + ); + assert!( + ffi.contains("npk: &NullifierPublicKey"), + "FFI private PDA helper must have npk parameter: {ffi}" + ); + assert!( + ffi.contains("for_private_pda("), + "FFI private PDA helper must call for_private_pda: {ffi}" + ); + assert!( + ffi.contains("compute_private_pda_with_program("), + "FFI must call compute_private_pda_with_program for inline PDA derivation: {ffi}" + ); + assert!( + !ffi.contains("compute_pda_with_program(&program_id"), + "FFI must NOT use public compute_pda_with_program for private PDA: {ffi}" + ); +} + +#[test] +fn test_private_pda_ffi_uses_npk_arg_from_json() { + let output = generate_from_idl_json(PRIVATE_PDA_IDL).expect("codegen should succeed"); + let ffi = &output.ffi_code; + // The npk arg must be parsed from JSON before being passed to the PDA derivation. + // Search for the call site (contains '&program_id') rather than the function definition. + let npk_parse_pos = ffi.find("let user_npk =") + .expect("must bind user_npk from JSON args"); + let pda_call_pos = ffi.find("compute_private_pda_with_program(&program_id") + .expect("must call compute_private_pda_with_program with &program_id"); + assert!( + npk_parse_pos < pda_call_pos, + "user_npk binding must appear before private PDA derivation call" + ); +} + +#[test] +fn test_private_pda_ffi_imports_nullifier_public_key() { + let output = generate_from_idl_json(PRIVATE_PDA_IDL).expect("codegen should succeed"); + assert!( + output.ffi_code.contains("use nssa_core::NullifierPublicKey;"), + "FFI with private PDA must import NullifierPublicKey" + ); + assert!( + output.client_code.contains("use nssa_core::NullifierPublicKey;"), + "client with private PDA must import NullifierPublicKey" + ); +} + +#[test] +fn test_private_pda_ffi_is_valid_rust_syntax() { + let output = generate_from_idl_json(PRIVATE_PDA_IDL).expect("codegen should succeed"); + assert_parses_as_rust("PRIVATE_PDA_IDL ffi_code", &output.ffi_code); + assert_parses_as_rust("PRIVATE_PDA_IDL client_code", &output.client_code); +} + +#[test] +fn test_public_pda_does_not_get_npk_import() { + // A program with only public PDAs should NOT import NullifierPublicKey + // and must NOT emit compute_private_pda_with_program (which references it) + let output = generate_from_idl_json(WHISPER_WALL_IDL).expect("codegen should succeed"); + assert!( + !output.ffi_code.contains("use nssa_core::NullifierPublicKey;"), + "public-PDA program must NOT import NullifierPublicKey in FFI" + ); + assert!( + !output.client_code.contains("use nssa_core::NullifierPublicKey;"), + "public-PDA program must NOT import NullifierPublicKey in client" + ); + assert!( + !output.ffi_code.contains("compute_private_pda_with_program"), + "public-PDA program must NOT emit compute_private_pda_with_program in FFI" + ); +} diff --git a/spel-client-gen/src/util.rs b/spel-client-gen/src/util.rs index 9daf11c0..287e7230 100644 --- a/spel-client-gen/src/util.rs +++ b/spel-client-gen/src/util.rs @@ -105,6 +105,15 @@ pub fn idl_type_to_json_parse(ty: &spel_framework_core::idl::IdlType, var: &str) "{var}.as_array().ok_or(\"expected array\")?.iter().map(|item| Ok({inner})).collect::, String>>()?" ) } + IdlType::Defined { defined } if defined == "NullifierPublicKey" => { + format!( + "{{ let _hex = {var}.as_str().ok_or(\"expected hex string for NullifierPublicKey\")?; \ + let _bytes = hex::decode(_hex.trim_start_matches(\"0x\")).map_err(|e| format!(\"invalid NullifierPublicKey hex: {{}}\", e))?; \ + if _bytes.len() != 32 {{ return Err(format!(\"NullifierPublicKey must be 32 bytes, got {{}}\", _bytes.len())); }} \ + let mut _arr = [0u8; 32]; _arr.copy_from_slice(&_bytes); \ + NullifierPublicKey(_arr) }}" + ) + } _ => format!("serde_json::from_value({var}.clone()).map_err(|e| format!(\"parse error: {{}}\", e))?"), } } diff --git a/spel-framework-core/src/idl.rs b/spel-framework-core/src/idl.rs index 479b8800..9bc8e8a2 100644 --- a/spel-framework-core/src/idl.rs +++ b/spel-framework-core/src/idl.rs @@ -105,6 +105,10 @@ pub struct IdlPda { /// Callers must supply `--npk ` to derive the address. #[serde(default, skip_serializing_if = "is_false")] pub private: bool, + /// Name of the instruction argument that holds the NullifierPublicKey for this private PDA. + /// Only set when `private == true`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub npk_arg: Option, } /// A seed component for PDA derivation. diff --git a/spel-framework-core/src/idl_gen.rs b/spel-framework-core/src/idl_gen.rs index 94bc06f3..7315ee17 100644 --- a/spel-framework-core/src/idl_gen.rs +++ b/spel-framework-core/src/idl_gen.rs @@ -178,7 +178,7 @@ fn generate_idl_inner( PdaSeedDef::Arg(p) => IdlSeed::Arg { path: p.clone() }, }) .collect(); - Some(IdlPda { seeds, private: false }) + Some(IdlPda { seeds, private: false, npk_arg: None }) }; IdlAccountItem { diff --git a/spel-framework-macros/src/lib.rs b/spel-framework-macros/src/lib.rs index 57486793..ce782363 100644 --- a/spel-framework-macros/src/lib.rs +++ b/spel-framework-macros/src/lib.rs @@ -1659,10 +1659,15 @@ fn generate_idl_fn(mod_name: &Ident, instructions: &[InstructionInfo], external_ .collect(); let is_private = acc.constraints.private_pda; + let npk_arg_expr: proc_macro2::TokenStream = match &acc.constraints.npk_arg { + Some(name) => quote! { Some(#name.to_string()) }, + None => quote! { None }, + }; quote! { Some(spel_framework::idl::IdlPda { seeds: vec![#(#seed_literals),*], private: #is_private, + npk_arg: #npk_arg_expr, }) } }; @@ -1809,7 +1814,10 @@ fn generate_idl_json(mod_name: &Ident, instructions: &[InstructionInfo], externa }) .collect(); if acc.constraints.private_pda { - format!(",\"pda\":{{\"seeds\":[{}],\"private\":true}}", seeds.join(",")) + let npk_json = acc.constraints.npk_arg.as_deref() + .map(|n| format!(",\"npk_arg\":\"{}\"", n)) + .unwrap_or_default(); + format!(",\"pda\":{{\"seeds\":[{}],\"private\":true{}}}", seeds.join(","), npk_json) } else { format!(",\"pda\":{{\"seeds\":[{}]}}", seeds.join(",")) } diff --git a/tests/e2e/fixture_program/src/lib.rs b/tests/e2e/fixture_program/src/lib.rs index 0008ffaa..bc7bc857 100644 --- a/tests/e2e/fixture_program/src/lib.rs +++ b/tests/e2e/fixture_program/src/lib.rs @@ -752,6 +752,7 @@ mod tests { let acc = &ix.accounts[0]; let pda = acc.pda.as_ref().expect("account must have PDA definition"); assert!(pda.private, "IDL PDA must be marked private"); + assert_eq!(pda.npk_arg.as_deref(), Some("user_npk"), "IDL PDA must record npk_arg name"); assert!(acc.visibility.iter().any(|v| v == "private"), "visibility must include 'private'"); }