Skip to content

feat: extend private PDA support to CLI dispatch and smoke test#174

Draft
vpavlin wants to merge 4 commits into
mainfrom
feat/private-pda-smoke-test
Draft

feat: extend private PDA support to CLI dispatch and smoke test#174
vpavlin wants to merge 4 commits into
mainfrom
feat/private-pda-smoke-test

Conversation

@vpavlin

@vpavlin vpavlin commented May 5, 2026

Copy link
Copy Markdown
Collaborator

Summary

Follow-up to #171 (private PDA macro support). Completes the end-to-end story for private PDAs:

  • IdlPda.npk_arg: adds npk_arg: Option<String> to the IDL PDA struct so the CLI knows which instruction arg supplies the NullifierPublicKey — without it, the CLI couldn't compute private PDA addresses at transaction time
  • CLI dispatch fix: tx.rs was passing None for npk when computing PDAs, always producing a public address; now reads the correct arg from the IDL
  • NullifierPublicKey in CLI args: parse.rs and serialize.rs now handle IdlType::Defined{"NullifierPublicKey"} (hex → 32-byte tuple) so instructions that take an npk arg work end-to-end
  • Smoke test: adds init_private_pda instruction to the test program and steps 11–13: retrieve npk from wallet, compute private PDA address via spel pda, submit the instruction

Test plan

  • All workspace unit tests pass (cargo test --workspace)
  • Fixture program tests pass including idl_init_private_account_marks_pda_as_private (now also asserts npk_arg == "user_npk")
  • Smoke test: LEZ_TAG=v0.2.0-rc3 LSSA_DIR=<path> ./scripts/smoke-test-privacy.sh completes all 13 steps including the new private PDA TX

🤖 Generated with Claude Code

vpavlin and others added 3 commits May 5, 2026 12:18
- IdlPda: add `npk_arg: Option<String>` so CLI knows which instruction
  arg holds the NullifierPublicKey for PDA derivation
- Macro: populate npk_arg in both generate_idl_fn and generate_idl_json
- tx.rs: resolve npk from parsed args when computing private PDAs,
  replacing the hardcoded None that always produced public addresses
- parse.rs: handle IdlType::Defined{"NullifierPublicKey"} as 32-byte hex
- serialize.rs: serialize NullifierPublicKey ByteArray as tuple of u8s
- smoke-test-privacy.sh: add init_private_pda instruction to test
  program plus steps 11–13 (get npk, compute PDA, init PDA account)
- Fixture test: assert npk_arg == "user_npk" in IDL

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- codegen.rs: compute_{account}_pda helpers add `npk: &NullifierPublicKey`
  param and call compute_private_pda when pda.private == true; import
  NullifierPublicKey conditionally
- ffi_codegen.rs: add compute_private_pda_with_program helper; inline PDA
  derivation in per-instruction FFI functions branches on pda.private to
  call the private variant with the npk arg already parsed from JSON;
  standalone compute_{account}_pda helpers updated likewise; import
  NullifierPublicKey conditionally
- util.rs: idl_type_to_json_parse handles Defined{"NullifierPublicKey"}
  by parsing hex string → [u8; 32] → NullifierPublicKey(arr)
- tests.rs: PRIVATE_PDA_IDL fixture + 6 tests covering npk param presence,
  correct derivation function, JSON arg ordering, import inclusion, and
  syntax validity for both client and FFI output

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Fix 1 (E2E compile): compute_private_pda_with_program was always emitted
in FFI codegen even when the IDL has no private PDAs, but the
NullifierPublicKey import is guarded by needs_npk — causing E0425 in the
treasury fixture compile-check. Gate the helper emission on needs_npk.

Fix 2 (Privacy smoke test): --npk hex parsing in the pda command used
decode_bytes_32 which tries base58 first. A 64-char hex string without
any '0' digit passes base58 decode (yielding ~46 bytes), triggering
"Base58 decoded to N bytes, expected 32" instead of falling through to
hex. Parse --npk directly as hex to avoid the ambiguity.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
vpavlin added a commit that referenced this pull request May 5, 2026
Fix 1 (E2E compile): compute_private_pda_with_program was always emitted
in FFI codegen even when the IDL has no private PDAs, but the
NullifierPublicKey import is guarded by needs_npk — causing E0425 in the
treasury fixture compile-check. Gate the helper emission on needs_npk.

Fix 2 (Privacy smoke test): --npk hex parsing in the pda command used
decode_bytes_32 which tries base58 first. A 64-char hex string without
any '0' digit passes base58 decode (yielding ~46 bytes), triggering
"Base58 decoded to N bytes, expected 32" instead of falling through to
hex. Parse --npk directly as hex to avoid the ambiguity.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…upport

The privacy-preserving circuit validates private PDA claims via mask=3 using
AccountId::for_private_pda.  The LEZ v0.2.0-rc3 wallet API only exposes
PrivacyPreservingAccount::{Public, PrivateOwned, PrivateForeign} (masks 0-2);
there is no PrivatePdaInit variant that sets mask=3 and provides the NPK to
the circuit.  Submitting the vault PDA as Public (mask=0) causes the circuit
to check for_public_pda, which doesn't match the for_private_pda address,
producing "Invalid PDA claim".

Skip step 13 with a clear warning; all other parts of the private PDA feature
(IDL generation, framework macros, PDA address computation, CLI pda subcommand)
are correct and covered by unit tests.  Add a TODO comment in tx.rs pointing
at the exact mask-3 gap so it's easy to find when LEZ adds the missing variant.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@vpavlin

vpavlin commented May 5, 2026

Copy link
Copy Markdown
Collaborator Author

Step 13 (private PDA TX) blocked on LEZ

The smoke test passes through step 12 (PDA address computation) but step 13 (init-private-pda as a privacy-preserving transaction) is currently skipped because it requires a LEZ change that is not in v0.2.0-rc3.

Root cause: The privacy-preserving circuit validates private PDA claims using visibility mask=3, which calls AccountId::for_private_pda(program_id, seed, npk) to verify the account address. The wallet's PrivacyPreservingAccount enum in rc3 has no variant that produces mask=3 — it only has Public (mask=0), PrivateOwned (mask=1/2), and PrivateForeign (mask=2). There is no way to reach mask=3 from outside the LEZ crate since key_protocol (needed to compute the SSK/EPK) is an internal dependency not exposed to downstream crates.

Where it's missing in rc3: wallet/src/privacy_preserving_tx.rs@v0.2.0-rc3#L13 — the PrivacyPreservingAccount enum has three variants, no PrivatePda.

Where it exists in main: wallet/src/privacy_preserving_tx.rs@main#L26PrivatePda { nsk, npk, vpk, program_id, seed } is present, along with private_pda_preparation() that computes the correct for_private_pda account ID and account_identities() that emits InputAccountIdentity::PrivatePdaInit.

Unblocking: bump the LEZ dependency from v0.2.0-rc3 to a tag/commit that includes the PrivatePda variant. Once that lands, the fix in the SPEL CLI is a one-liner in tx.rs where the TODO comment is — replace Public(id) with PrivatePda { nsk, npk, vpk, program_id, seed } for private PDAs, and re-enable step 13 in the smoke test.

@vpavlin vpavlin marked this pull request as draft May 5, 2026 12:45
@vpavlin vpavlin marked this pull request as draft May 5, 2026 12:45
@vpavlin vpavlin marked this pull request as draft May 5, 2026 12:45
@vpavlin vpavlin force-pushed the feat/private-pda-smoke-test branch from 29887fa to 2eb5b8c Compare May 18, 2026 10:04
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant