Skip to content

feat: support NSK-derived PDA seeds for private vote receipts #199

@vpavlin

Description

@vpavlin

Problem

The current pda = [literal(...), account(...)] macro syntax only accepts public-derivable seed components — byte literals and account IDs. Both are known to the caller before the transaction is built, so PDA addresses can be computed outside the zkVM.

This means any PDA used as a double-spend nullifier (e.g. a vote receipt) leaks a deterministic one-way hash of the voter's private AccountId:

receipt_pda = SHA-256(pad("vote_receipt_v1") || voter_account_id || admin_account_id)

Because admin_account_id is public and voter_account_id is fixed per voter, anyone who already knows a voter's AccountId can check on-chain whether they voted. This is acceptable for passive observers (preimage-resistant), but it fails coercion-resistance: a targeted adversary with knowledge of the voter's AccountId can confirm participation.

Proposed solution

Add a new seed component type — nullifier_of("account") — that computes a session-scoped nullifier from the private account's NSK inside the zkVM:

#[account(init, pda = [literal("vote_receipt_v1"), nullifier_of("voter"), account("admin")])]
vote_receipt: AccountWithMetadata,

Where nullifier_of("voter") expands to something like:

H(NSK_voter || program_id || "vote_receipt_v1" || admin_account_id)

computed from the private account's Nullifier Secret Key inside the zkVM circuit. The resulting value is unlinkable to the voter's AccountId even by an adversary who knows that ID.

Required framework changes

  1. New seed type in the macronullifier_of("account_param") alongside the existing literal(...) and account(...) variants.
  2. NSK access inside program execution — the program context needs to expose the NSK for private accounts (already available to the circuit for nullifier computation; needs surfacing to #[instruction] bodies or the PDA derivation step).
  3. Output the computed PDA address — because the seed is computed inside the zkVM, the caller (FFI / client) cannot pre-compute the PDA. The framework must include the initialised account's address in the program output so the caller can reference it in subsequent transactions.

Motivation / use cases

  • Private voting — vote receipt PDA is unlinkable to the voter's identity even under targeted surveillance.
  • Private reputation / badges — claim a credential PDA without revealing which account claimed it.
  • Anonymous rate-limiting — prove you haven't performed an action without revealing who you are.

Current workaround

Use the voter's public AccountId as a seed component (as done in joke-wall). This hides votes from passive observers but leaks participation to anyone who already knows the voter's AccountId.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions