zkSVM

App

Litepaper

Documentation

Links

DocsBuild

Programs that hold notes

PDAs as owners: treasuries, escrows, gating.

3 min read

A note can belong to any Solana address, and a program-derived address is an address. So a program can hold shielded value — a treasury, an escrow, a payroll — and pay out of it without showing whom, or how much.

The idea

An address-owned note is spent with claim, and claim asks for one thing: that the owning address signed. A wallet signs with its key. A PDA signs through its program's invoke_signed. The pool cannot tell the difference, and does not try.

note.pk = Poseidon2(pda_hi, pda_lo)        the PDA's 32 bytes, split in two

No key material enters the circuit, so there is nothing for a program to lack.

Funding a PDA

Pay it like any other address. From the SDK:

TypeScript
import { addressRecipient, buildTransfer } from "@zksvm/sdk";

const toTreasury = await addressRecipient(treasuryPda, operatorEncPk);
await buildTransfer({ /* … */ outputs: [{ amount, ...toTreasury }, change] }, PROGRAM_ID);

The second argument matters. A PDA has no private key, so it cannot decrypt a note encrypted to itself. Pass an X25519 key that whoever operates the program holds, and they will find the note by scanning. Without it, deliver the opening (amount, blinding) to the operator yourself.

Spending by CPI

The proof is built off-chain, by whoever operates the program, exactly as for a wallet:

TypeScript
const { instruction } = await buildClaim(
  { payer, owner: treasuryPda, input: note, outputs, fee: 0n, tree, artifacts },
  PROGRAM_ID,
);

instruction names the PDA as a signer, which no transaction can satisfy directly. Route it through your program instead: pass the same accounts and data, and have the program forward them with its seeds.

Rust
let ix = Instruction {
    program_id: *pool_program.key,
    data: data.to_vec(),
    accounts: forwarded.iter().map(|a| AccountMeta {
        pubkey: *a.key,
        is_signer: a.is_signer || *a.key == treasury,
        is_writable: a.is_writable,
    }).collect(),
};
invoke_signed(&ix, forwarded, &[&[b"treasury", &[bump]]])?;

That is the whole of programs/tests/treasury, the test fixture the on-chain suite uses. A real treasury decides whether to sign — a vote passed, a schedule came due — and that policy is ordinary program logic. A claim by CPI measures about 173,000 compute units.

Gating on shielded funds

A program can require proof of funds by calling attest through CPI in the same transaction as the action it gates. If the call returns, some note held at least threshold at that moment; the tag in the arguments is the holder's pseudonym under your scope, stable across calls.

Choose a scope per purpose — your program id plus a round or epoch — and record tags if one holder should act only once per scope. Remember what a tag is: a pseudonym of a key, not a count of funds. Attestations and receipts explains the difference.

Conditional ownership

Returnable notes make the claimant a Solana address — which may also be a PDA. A note that a program may take, or that returns to its sender after a date is an escrow with a timeout, with no escrow account and no public amounts.

Wire format and accounts: Protocol.