This repo contains:
- A short write-up of the PCA model:
paper/pca.md. - Python validation helpers + property tests:
pca_validation/andtests/. - A Pinocchio-ready Rust library for on-chain verification:
pinocchio-pca/. - A demo Solana program showcasing private withdrawals:
programs/private_payments/. - A demo Solana program showcasing private allowlist access:
programs/private_allowlist/.
PCA turns authorization into a proof about a statement rather than an identity.
You prove there exists a witness w such that a policy predicate holds for
(action, context, public_inputs), and the verifier checks the proof and a
nullifier for replay resistance.
Key encodings in this repo:
Statement encoding (conceptual):
stmt = enc(action) || enc(context) || enc(public_inputs)
Scope hash (Rust on-chain, pinocchio-pca/src/hash.rs):
scope = SHA256(DST_SCOPE || action || context || SHA256(p_pieces))
Domain hash (Rust on-chain, pinocchio-pca/src/hash.rs):
domain = SHA256(DST_DOMAIN || protocol_id || policy_id || verifier_id)
Nullifier (defined in the paper and Python helpers, computed off-chain):
nf = SHA256(DST_NF || domain || spend_key || scope)
Notes:
- The Rust library does not compute
nf; it expects the proof to expose it as a public input and enforces uniqueness via a PDA. The nullifier derivation lives in your circuit and off-chain tooling. contextin this implementation is a slot window (issued_at_slot,valid_until_slot) serialized to 16 bytes and included in the scope hash. If you need a stable/fresh split, ensure your circuit uses the same bytes thatPcaContext::to_le_bytes()produces.- On-chain SHA-256 for scope/domain/action hashing uses the
sol_sha256syscall to reduce compute; host builds still usesha2for deterministic tests.
Pca::authorize expects at least 6 public inputs. The first 6 elements are
reserved and must be ordered as:
0: scope_hi
1: scope_lo
2: domain_hi
3: domain_lo
4: nf_hi
5: nf_lo
Each *_hi/*_lo is produced by splitting a 32-byte digest into two 16-byte
limbs, then left-padding to 32 bytes (big-endian 128-bit encoding):
hi = 0x0000..00 || digest[0..16]
lo = 0x0000..00 || digest[16..32]
Your circuit must compute scope, domain, and nf with the same hash
functions and pack them into public inputs using this encoding.
Library entrypoint:
Pca::authorizevalidates the domain, slot window, public input prefix, proof verification, and consumes the nullifier PDA.- You pass a verifier that implements
ProofVerifier(for example, Groth16).
Typical flow inside a program:
use pinocchio_pca::{Pca, PcaContext, PcaDomain};
let pca = Pca::new(PcaDomain::for_program(policy_id, program_id));
let context = PcaContext { issued_at_slot, valid_until_slot };
// p_for_scope is the exact set of bytes your circuit hashed for `scope`.
let p_for_scope = [&public_inputs[0][..], &public_inputs[1][..], ...];
pca.authorize::<N, _>(
program_id,
payer,
nullifier_record,
rent_sysvar,
action_bytes,
context,
&p_for_scope,
&nf,
&proof_a,
&proof_b,
&proof_c,
&public_inputs,
&verifier,
)?;
On-chain nullifier PDA:
- Seed prefix:
b"pca_nf"plus the 32-bytenf. - Record layout (112 bytes): discriminator(8) | nf(32) | scope(32) | domain(32) | created_slot(8).
Example programs:
pinocchio-pca/examples/pca_program.rsshows how to parse an instruction and callPca::authorize. Replace the placeholder verifier with a real one.programs/pca_multisigshows a minimal 2-of-2 multisig that callsPca::authorizeonce per signer and enforces shared scope inputs.
Measured via solana-program-test transaction metadata:
private_paymentsWithdrawPrivate: ~198,512 CU (includes proof verification + nullifier PDA + token transfer)private_allowlistAccessPrivate: ~179,535 CU (includes proof verification + nullifier PDA)
Notes:
- Numbers vary with toolchain/runtime and compute budget settings.
- Capture commands:
cargo test -p private-payments --tests -- --nocapturecargo test -p private-allowlist --tests -- --nocapture
Anchor can call syscalls, but its default account validation, deserialization, and larger dependency surface add compute and program size. These flows already consume ~180-200k CU for Groth16 verification, hashing, and PDA creation, so framework overhead can push you over budget or force compute budget increases.
To stay in this CU range you still need:
- syscall-based hashing (
sol_sha256) and Groth16 verification (alt-bn128 syscalls) - minimal account handling and manual checks
At that point you are effectively outside "Anchor alone", which is why this repo uses Pinocchio for the on-chain core.
Python (paper-level invariants and crypto games):
uv run pytest
Tests live in tests/ and rely on helpers in pca_validation/.
Rust (library invariants and encodings):
cd pinocchio-pca
cargo test
Optional:
cd pinocchio-pca
cargo check --examples
If you enable Groth16 verification:
cd pinocchio-pca
cargo test --features groth16