note-vault
The shielded pool. note-vault is the active amount-privacy engine: it holds every
deposit, verifies private transfers with a Groth16 proof, and releases
withdrawals. Program id 7gtXhpwEaYHKJ9XpAX7Z4BNjXU3cFCq4CNyzMQpFaQZ2 (devnet).
Model
Token-account balances are public, so a per-note account would leak amounts.
Instead the program owns one pool token account per mint (the ATA of the
pool-authority PDA) holding all deposits of that token, and every unspent note is
a rent-only marker account at ["note", commitment] that stores no value. An
internal spend moves no tokens, so amounts stay hidden; a double-spend is
prevented structurally by closing the spent note's marker.
It is multi-asset: every commitment binds an asset field computed on chain
as Poseidon(mint), and withdraw releases tokens from the pool ATA of the very
mint the note commits to. A SOL note and a USDC note are indistinguishable yet can
never be confused.
It is non-custodial: there is no admin key. Tokens leave only through
withdraw, gated on the recipient signature, a recomputed commitment, and a live
marker.
PDAs
| PDA | Seeds | Purpose |
|---|---|---|
| pool authority | ["poolv1"] | Owns every per-mint pool ATA; signs withdraw transfers. |
| pool ATA | ATA(pool authority, mint) | Holds all deposits of one mint. |
| note marker | ["note", commitment] | Existence = the note is unspent. 8-byte marker. |
import { findPoolAuthority, findPoolAta, findNotePda, commitmentBytes } from "@p15/blind-mode";
const authority = await findPoolAuthority();
const poolAta = await findPoolAta(mint, tokenProgram);
const marker = await findNotePda(commitmentBytes(commitment));Commitment
A note commitment is a BN254 field element, big-endian, computed with a Poseidon hash of five fields. The program and the SDK compute it identically.
commitment = Poseidon(amount, asset, owner, blind, secret)
asset = Poseidon(mint) // pubkey folded to a field
owner = Poseidon(R) // R = spend_pub + t*G, the recipient-only keyThe amount is a 64-bit value in the low bytes of a field; blind and secret
are random 32-byte field elements that make the commitment hiding. The SDK's
noteCommitment, assetField, deriveNoteForSend/deriveNoteForReceive
produce these.
Instructions
deposit
The public on-ramp ("make private"). Moves amount of mint into the pool ATA
and registers a self-note. The program recomputes the commitment from the
revealed opening, binding asset to the mint account, so a marker cannot claim
more value, or a different token, than was deposited.
Args: amount: u64, owner: [u8;32], blind: [u8;32], secret: [u8;32].
Accounts (in order):
| # | Account | Notes |
|---|---|---|
| 0 | depositor | signer, writable. Signs the token transfer into the pool. |
| 1 | pool authority | ["poolv1"] PDA. |
| 2 | mint | the token being deposited. |
| 3 | pool ATA | writable. ATA(pool authority, mint). Create idempotently first. |
| 4 | depositor ATA | writable. Source of the tokens. |
| 5 | note | writable. The ["note", commitment] marker PDA, created here. |
| 6 | token program | The mint's SPL token program. Legacy Token for SOL/USDC; Token-2022 is also supported via the interface. |
| 7 | system program |
import { buildCreatePoolAtaInstruction, buildDepositInstruction } from "@p15/blind-mode";
// Once per mint:
const createPool = await buildCreatePoolAtaInstruction({ payer, mint, tokenProgram });
const deposit = await buildDepositInstruction({
depositor,
amount,
mint,
depositorAta,
owner: note.ownerHash, // 32-byte BE
blind: note.blind,
secret: note.secret,
commitment: commitmentBytes(note.commitment),
tokenProgram,
});spend
The private 1-in / 2-out transfer. Consumes the input note (closing its marker) and registers two output notes, after the Groth16 proof verifies the commitments open, value is conserved, and the asset is preserved. It moves no tokens, so it needs no mint or token accounts and is asset-agnostic.
Args: proof: Vec<u8> (388 bytes), witness: Vec<u8> (140 bytes: a 12-byte
header plus four 32-byte public inputs in_commitment, in_owner, out1, out2).
Accounts (in order):
| # | Account | Notes |
|---|---|---|
| 0 | r | signer, read-only. The recipient-only owner; the gate. Spend fails unless Poseidon(R) == in_owner. |
| 1 | payer | signer, writable. Ephemeral session key; pays output-marker rent. |
| 2 | in_note | writable. Input marker, consumed. |
| 3 | out1_note | writable. Created. |
| 4 | out2_note | writable. Created. |
| 5 | verifier | the transfer verifier program (address-checked). |
| 6 | system program |
The program CPIs the verifier with data = [proof || witness] and no accounts.
r is provided as a scalar signer the sender cannot produce, which is why
payments are final.
import { buildSpendInstruction } from "@p15/blind-mode";
const spend = await buildSpendInstruction({
r, // scalarTransactionSigner over (spend_scalar + t)
payer, // session key
inCommitment: commitmentBytes(input.commitment),
out1Commitment: commitmentBytes(out1.commitment),
out2Commitment: commitmentBytes(out2.commitment),
proof, // from the prover
witness,
});Each ~1.5 KB proof must be its own transaction (the 1232-byte limit), which is why
the higher-level planSend returns transactions tagged for separate submission.
withdraw
The public exit. Reveals a whole note and releases its amount of mint from the
pool ATA to a destination. Partial withdrawals are done by spend-ing to a
smaller note first. The pool-authority PDA signs the release.
Args: amount: u64, blind: [u8;32], secret: [u8;32] (the owner and
asset are derived on chain from r and the mint).
Accounts (in order): r (signer), payer (signer, writable), pool authority,
mint, pool ATA (writable), destination ATA (writable), note (writable), token
program, system program.
import { buildWithdrawInstruction } from "@p15/blind-mode";
const withdraw = await buildWithdrawInstruction({
r,
payer,
amount,
mint,
blind: note.blind,
secret: note.secret,
commitment: commitmentBytes(note.commitment),
destinationAta,
tokenProgram,
});Errors
BadNotePda (marker PDA does not match the commitment), NoteNotFound (marker
missing or already spent), OwnerMismatch (Poseidon(R) is not the note owner),
BadProof / BadWitness (wrong length), BadVerifier (wrong verifier program),
Poseidon (syscall failed).
Checking whether a note is spendable
A scanned announcement tells you a note's amount and ephemeral key; whether it is still spendable is whether its marker is alive. Re-derive the commitment and read the marker account:
const pda = await findNotePda(commitmentBytes(note.commitment));
const info = await rpc.getAccountInfo(pda, { encoding: "base64" }).send();
const data = info.value ? Buffer.from(info.value.data[0], "base64") : null;
const MARKER = Buffer.from("p15note\0"); // 8-byte discriminator
const spendable = !!data && data.length >= 8 && data.subarray(0, 8).equals(MARKER);The high-level spendableNotes helper does exactly
this across every scanned announcement.