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

PDASeedsPurpose
pool authority["poolv1"]Owns every per-mint pool ATA; signs withdraw transfers.
pool ATAATA(pool authority, mint)Holds all deposits of one mint.
note marker["note", commitment]Existence = the note is unspent. 8-byte marker.
TypeScript
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.

code
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 key

The 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):

#AccountNotes
0depositorsigner, writable. Signs the token transfer into the pool.
1pool authority["poolv1"] PDA.
2mintthe token being deposited.
3pool ATAwritable. ATA(pool authority, mint). Create idempotently first.
4depositor ATAwritable. Source of the tokens.
5notewritable. The ["note", commitment] marker PDA, created here.
6token programThe mint's SPL token program. Legacy Token for SOL/USDC; Token-2022 is also supported via the interface.
7system program
TypeScript
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):

#AccountNotes
0rsigner, read-only. The recipient-only owner; the gate. Spend fails unless Poseidon(R) == in_owner.
1payersigner, writable. Ephemeral session key; pays output-marker rent.
2in_notewritable. Input marker, consumed.
3out1_notewritable. Created.
4out2_notewritable. Created.
5verifierthe transfer verifier program (address-checked).
6system 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.

TypeScript
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.

TypeScript
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:

TypeScript
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.