# Protocol15 documentation (full text) Generated from the docs site. Pages appear in reading order. ================================================================ URL: https://localhost:8080/ ================================================================ # Eyes closed. Details hidden. Every transfer you make on a public chain is visible forever. The amount, and who you paid, sit in plain sight for explorers, MEV bots, and anyone curious about your balance. Protocol15 hides both the amount and the recipient of a transfer, while staying selectively auditable: hand someone a viewing key and only they see the full detail. **Private by default. Auditable when needed.** ## Why "15" The eye's blind spot, the optic disc, sits about 15 degrees off the visual axis. It is a patch of retina with no photoreceptors, where you literally see nothing. The brain fills the gap so vision feels continuous. That is the whole idea: the details still exist, they just live where no one else can look. ## What it is, in one line A single Blind Mode toggle on an otherwise normal transfer. No shielded pool, no deposit and withdraw, no bridge. It hides the amount with confidential balances and hides the destination with stealth addresses, then proves both are valid with succinct zero-knowledge proofs. It confirms in seconds and composes with the wallets and apps people already use. On an explorer it reads only as a Protocol15 transfer. ## How to read these docs These docs serve two audiences and one machine. - **If you just want to understand it:** start with [How it works](/how-it-works) for the plain-language tour, then **Concepts** for the precise mechanics, what Blind Mode hides, the privacy model, stealth delivery and scanning, auditability, and the threat model. - **If you are integrating it** into a wallet or an app: go to **Guides**. The [Quickstart](/build/quickstart) gets you to a blind transfer fast, then the [wallet](/build/wallet-integration) and [app](/build/app-integration) integration pages and the [Recipes](/build/recipes) cover the full surface. The **SDK reference** documents every package ([@p15/stealth](/sdk/stealth), [@p15/blind-mode](/sdk/blind-mode), [@p15/compliance](/sdk/compliance)) and **Programs** is the on-chain ground truth ([note-vault](/programs/note-vault) and the rest). - **If you are an AI agent:** these docs are written to be implemented without a human. See [For AI agents](/for-ai), with a machine index at [/llms.txt](/llms.txt) and the entire docset as plain text at [/llms-full.txt](/llms-full.txt). ## A note on access Protocol15 is closed by design. The protocol runs on devnet, the dApp is invite-only, and there is no public app link. These docs explain how it works; they are not a way in. If you want access, join the waitlist from the landing page. ================================================================ URL: https://localhost:8080/how-it-works ================================================================ # How it works This page is for anyone, with no code and no jargon. If you want the precise mechanics, the [Concepts](/concepts/blind-mode) section goes deeper, and [Build](/build/quickstart) is for developers. ## The problem When you send money on most blockchains, two things become public forever: how much you sent, and who you sent it to. Anyone can look up your address and see your whole history. They can read your balance, watch your salary arrive, and follow every payment you make. Trading bots use this to get ahead of you. It is the equivalent of every bank transfer you ever make being printed in the newspaper, with your name on it. ## The idea Protocol15 closes that gap. You keep using a normal wallet and make a normal transfer, but you flip on **Blind Mode** first. With Blind Mode on, two things stay hidden: - **The amount.** Outsiders cannot see how much moved. - **The recipient.** Outsiders cannot see who received it. Everyone can still see that *a* Protocol15 transfer happened. They just cannot read the contents. The name comes from the eye's blind spot: a small patch where you genuinely see nothing, even though the thing is right there. ## What it feels like to use Inside the app there are two clearly labelled layers: - **Normal** is your ordinary public wallet, exactly as before. - **Private** is the hidden layer: one private balance you can send from, receive into, and cash back out. You cross between them on purpose. "Make private" moves some funds into the private layer. "Withdraw" moves them back to public. Those two crossings are the only public moments. Everything you do while inside the private layer is hidden. Sending privately takes two quick approvals in your wallet, and it confirms in seconds. There is no separate app to deposit into, no bridge, and no waiting period. ## How you receive You have a single shareable address, like a username for payments. When someone pays you, the money does not actually land on that address. Your wallet quietly works out a fresh, one-time location for each payment that only you can open. So two payments to you look completely unrelated to anyone watching, and none of them point back to your public identity. Your wallet finds these payments for you automatically. One important consequence: because only you can open a payment, payments are final. Nobody can reverse one, and sending to a wrong address means the funds are gone, the same as cash. Double-check the address. ## Private, but not a black box Hidden by default does not mean impossible to audit. If you ever need to prove what you did, to an accountant, an auditor, or a counterparty, you can hand that one person a key that reveals exactly what you choose, and nothing more. You stay in control of who sees what. Nobody can force their way in. ## In one sentence You send and receive normally, the amount and the recipient stay private, payments settle in seconds, and you can selectively prove the details later if you ever need to. ================================================================ URL: https://localhost:8080/for-ai ================================================================ # For AI agents These docs are built to be implemented by a coding agent with no human in the loop. Everything you need to integrate Protocol15 is here, with real function names and types. This page is the entry point: the machine-readable endpoints and a single self-contained brief you can follow top to bottom. ## Machine-readable endpoints - **[/llms.txt](/llms.txt)** an index of every page with a one-line description and a link, following the llms.txt convention. Fetch this first to map the docs. - **[/llms-full.txt](/llms-full.txt)** every page concatenated as plain text in reading order. Fetch this to load the entire documentation set into context in one request. ## Ground truth, in order Read these, in this order, and you have the whole picture: 1. [How it works](/how-it-works) the model in plain language. 2. [Blind Mode](/concepts/blind-mode), [Privacy model](/concepts/privacy-model), [Stealth & announce](/concepts/stealth-and-announce) what is hidden and how. 3. [Quickstart](/build/quickstart) the minimal end-to-end code, and [Recipes](/build/recipes) for the helpers it references (`executePlan`, `spendableNotes`, session keys). 4. [Wallet integration](/build/wallet-integration) or [App integration](/build/app-integration) the target surface. 5. [SDK reference](/sdk/stealth): [@p15/stealth](/sdk/stealth), [@p15/blind-mode](/sdk/blind-mode), [@p15/compliance](/sdk/compliance) every export and type. 6. [Programs](/programs/overview) the on-chain ground truth: accounts, instructions, PDAs. ## Self-contained integration brief Implement a Protocol15 integration by following this exactly. **Packages.** `@p15/stealth` (keys, meta-address, scanning), `@p15/blind-mode` (proof backend, transfer planning), `@p15/compliance` (disclosure, optional). Transactions target `@solana/kit`. Network is **devnet**; packages are local `file:` deps until publish. **Keys.** Derive everything from one signature. `keys = deriveKeys(await wallet.signMessage(keyDerivationMessage(walletAddress, epoch)), epoch)`. The signature must be the raw 64 bytes. `keys.spendScalar` is spend authority and must never be persisted or sent anywhere. `keys.scanSecret` is read-only. Publish `encodeMetaAddress({ scanPublic, spendPublic })` as the payment identity. **Backend.** `const backend = getBackend("noir-v1", { prover: new RemoteProver(url) })`. The prover endpoint builds Groth16 proofs; it never receives the spend scalar. **Flows.** Each returns a `BackendPlan` with `transactions[]` tagged `session` or `wallet`. Send them in order, each signed by the tagged payer (see `executePlan` in Recipes). Each proof must be its own transaction. - Make private: `backend.planFund({ owner, feePayer, mint, amount, scanSecret, spendScalar, rentFor })`. - Send: `backend.planSend({ feePayer, senderScanSecret, senderSpendScalar, senderScanPublic, mint, amount, recipientMetaAddress, rentFor }, inputNote)`. - Withdraw: `backend.planWithdraw({ feePayer, owner, scanSecret, spendScalar, mint, amount, destination, rentFor }, inputNote)`. - Balance: build the spendable-note set (below), then `backend.loadBalance(notes)`. **Scanning and spendable notes.** `scanAnnouncements(rpc, keys.scanSecret, { indexerUrl })` returns both directions. Do **not** trust the `direction` byte to decide what is spendable. For each announcement, re-derive the note with `deriveNoteForReceive(amount, assetField(mint), scanSecret, spendScalar, ephemeralPublic)` and keep it only if its on-chain marker at `findNotePda(commitmentBytes(commitment))` is alive (8-byte `p15note\0` discriminator). A payment you sent re-derives to a phantom commitment with no marker and drops out. See `spendableNotes` in [Recipes](/build/recipes). There is no claim step: an incoming note is immediately spendable. ## Hard invariants Do not violate these. They are correctness, not style. - **Amounts are `bigint` base units.** Never floats. SOL and pSOL use 9 decimals, so base units equal lamports. - **The spend scalar never leaves the client.** Not to the prover, not to a server, not to storage. - **Funding is a separate explicit step.** Never auto-wrap public funds to cover a short private send; block instead. Coupling the deposit amount to the send amount de-anonymizes both. - **Prompt counts are fixed:** make-private is 1 user signature, blind send is 2 prompts (fund session key, then sign spend), withdraw is 3 (the range proof is a solo transaction). Do not merge proof transactions. - **No claim step.** An incoming payment is already spendable. - **Payments are final.** No reversal, no sender reclaim. Validate the recipient meta-address with `isMetaAddress` before sending. - **Auditor selection must match the mint**, or on-chain proof verification fails. ## What to confirm with a human The packages are unpublished and devnet-only, mainnet is disabled by decision, and access to the live dApp is invite-gated. If a task implies mainnet, a public deployment, or publishing the packages, stop and confirm; those are out of scope by design. ================================================================ URL: https://localhost:8080/build/quickstart ================================================================ # Quickstart The shortest accurate path from zero to a blind transfer, using the real API and the real flow the dApp uses. Read [How it works](/how-it-works) for the mental model and [Concepts](/concepts/blind-mode) for why each step exists. The helpers referenced here (`executePlan`, `spendableNotes`, session keys) are in [Recipes](/build/recipes). > **Early access.** Protocol15 runs on **devnet** only, and the `@p15/*` packages > are not published to npm yet. Today you consume them from local checkouts with > `file:` paths. The import lines below are the published names so the code > matches the future release. The API does not change. ## 1. Install ```bash npm install @p15/stealth @p15/blind-mode @p15/compliance @solana/kit ``` ## 2. Derive keys Every key derives from one ed25519 signature. No seed phrases. The `spendScalar` is spend authority and never leaves the client. ```ts import { deriveKeys, keyDerivationMessage, encodeMetaAddress } from "@p15/stealth"; const epoch = 0; const keys = deriveKeys(await wallet.signMessage(keyDerivationMessage(walletAddress, epoch)), epoch); const myMetaAddress = encodeMetaAddress({ scanPublic: keys.scanPublic, spendPublic: keys.spendPublic, }); ``` ## 3. Build the backend ```ts import { getBackend, RemoteProver } from "@p15/blind-mode"; const backend = getBackend("noir-v1", { prover: new RemoteProver(process.env.PROVER_URL!) }); const rentFor = (space: bigint) => rpc.getMinimumBalanceForRentExemption(space).send(); ``` ## 4. Make funds private The one explicit funding step, and the one public moment on the way in. For SOL you wrap into a wSOL ATA and deposit in the **same transaction**, so make-private stays a single wallet prompt. ```ts import { getTransferSolInstruction } from "@solana-program/system"; import { getCreateAssociatedTokenIdempotentInstruction, getSyncNativeInstruction } from "@solana-program/token"; import { lamports } from "@solana/kit"; const plan = await backend.planFund({ owner: walletSigner, feePayer: walletSigner, mint, // wSOL mint for SOL tokenProgram, amount: 1_000_000_000n, // base units (lamports for SOL) scanSecret: keys.scanSecret, spendScalar: keys.spendScalar, rentFor, }); // Wrap SOL, then deposit, in one wallet-signed transaction: const ixs = [ getCreateAssociatedTokenIdempotentInstruction({ payer: walletSigner, ata: wsolAta, owner: walletAddress, mint }), getTransferSolInstruction({ source: walletSigner, destination: wsolAta, amount: lamports(1_000_000_000n) }), getSyncNativeInstruction({ account: wsolAta }), ...plan.transactions[0].instructions, ]; await sendWithWallet(ixs); ``` ## 5. Send a blind transfer Two wallet prompts total: one already spent funding the session key (step 4 of [Recipes](/build/recipes)), one is implicit in the session signing. Pick an input note from your scanned, marker-checked notes. ```ts const notes = await spendableNotes(keys); // Recipes const inputNote = pickInputNote(notes, 250_000_000n); // Recipes const plan = await backend.planSend( { feePayer: sessionSigner, senderScanSecret: keys.scanSecret, senderSpendScalar: keys.spendScalar, senderScanPublic: keys.scanPublic, mint, amount: 250_000_000n, recipientMetaAddress: theirMetaAddress, rentFor, }, inputNote, ); await executePlan(plan, { rpc, rpcSubscriptions, sessionSigner, walletSigner }); ``` There is **no claim step**: an incoming payment is already a spendable note for the recipient. ## 6. Receive The recipient scans one fixed anchor and re-derives their notes. `spendableNotes` already does this; the underlying call is: ```ts import { scanAnnouncements } from "@p15/stealth"; const found = await scanAnnouncements(rpc, keys.scanSecret, { indexerUrl }); // direction 0 = incoming, 1 = your own self-receipt ``` ## 7. Withdraw (optional) Move a private note back to public SOL. Three transactions (the range proof is a solo tx), and a public moment. ```ts const plan = await backend.planWithdraw( { feePayer: sessionSigner, owner: walletSigner, scanSecret: keys.scanSecret, spendScalar: keys.spendScalar, mint, tokenProgram, amount, destination, rentFor }, inputNote, ); await executePlan(plan, { rpc, rpcSubscriptions, sessionSigner, walletSigner }); ``` ## Next - [Integrate into a wallet](/build/wallet-integration) — the full surface. - [Integrate into an app](/build/app-integration) — accept payments. - [Auditing & disclosure](/build/auditing) — prove transactions selectively. - [SDK reference](/sdk/stealth) and [Programs](/programs/overview) — the ground truth. ================================================================ URL: https://localhost:8080/build/wallet-integration ================================================================ # Integrate into a wallet A wallet that adds Protocol15 gives its users a private balance alongside their public one. This is the full surface, mapped one-to-one to the dApp's own `lib/actions.ts`. It assumes the [Quickstart](/build/quickstart) and the helpers in [Recipes](/build/recipes) (`backend`, `executePlan`, `spendableNotes`, `pickInputNote`, session keys). ## The two layers - **Public** the wallet's existing balance and send. - **Private** one private balance from a wallet signature, with private send, receive, and withdraw. The only public moments are **make private** (deposit) and **withdraw** (cash out). Mark them as public in your UI. Everything else stays hidden. ## Session keys, and the prompt budget Most blind transactions only need a fee payer. Create a short-lived **session key**, fund it once from the wallet, let it pay and sign, then sweep the leftover back. That is what keeps the prompt counts fixed: - make private: **1** wallet prompt (wrap + deposit in one tx). - blind send: **2** prompts (fund the session, then the session signs the spend). - withdraw: **3** transactions (the range proof is a solo tx), wallet funds once. Persist the session key for the tab so a refresh mid-flow recovers it. ## Set up keys ```ts const keys = deriveKeys(await wallet.signMessage(keyDerivationMessage(walletAddress, epoch)), epoch); const metaAddress = encodeMetaAddress({ scanPublic: keys.scanPublic, spendPublic: keys.spendPublic }); ``` Cache `metaAddress` and the epoch per wallet. Never persist `spendScalar`; re-derive it from a fresh signature when needed. ## Show the balance Use `spendableNotes` ([Recipes](/build/recipes)), which scans, re-derives every note, and keeps only those with a live on-chain marker. Do **not** trust the announcement `direction` byte to decide what is spendable; a payment you sent re-derives to a phantom commitment with no marker and drops out on its own. ```ts const notes = await spendableNotes(keys); const balance = notes.reduce((s, n) => s + n.amount, 0n); // or backend.loadBalance(notes) ``` ## Make private (deposit) Screen first (an async pre-check, not a transaction), then wrap and deposit in one wallet-signed tx. ```ts import { getScreeningProvider } from "@p15/compliance"; const screen = await getScreeningProvider().screen({ wallet: walletAddress, mint, amount, direction: "deposit" }); if (screen.decision === "deny") throw new Error("This deposit can't be made private."); const plan = await backend.planFund({ owner: walletSigner, feePayer: walletSigner, mint, tokenProgram, amount, scanSecret: keys.scanSecret, spendScalar: keys.spendScalar, rentFor, }); const ixs = [...wrapSolIxs(walletSigner, wsolAta, amount), ...plan.transactions[0].instructions]; await sendWithWallet(ixs); // single prompt ``` (`wrapSolIxs` = create wSOL ATA idempotently, transfer SOL, `SyncNative`; see the Quickstart.) For an SPL token that is not SOL, skip the wrap and just send the plan's instructions. ## Private send ```ts const inputNote = pickInputNote(await spendableNotes(keys), amount); // Prompt 1: fund the session key. const budget = (await backend.estimateBudget("send")) + 5_000_000n; await sendWithWallet([getTransferSolInstruction({ source: walletSigner, destination: sessionSigner.address, amount: lamports(budget) })]); const plan = await backend.planSend( { feePayer: sessionSigner, senderScanSecret: keys.scanSecret, senderSpendScalar: keys.spendScalar, senderScanPublic: keys.scanPublic, mint, amount, recipientMetaAddress, note, rentFor }, inputNote, ); for (const tx of plan.transactions) await sendWithSession(sessionSigner, tx.instructions); // finally: refund + clear the session ``` If no single note covers the amount, `pickInputNote` throws. Surface that ("make more private or send less"); never auto-wrap public funds to cover the gap, which would link the deposit to the send. ## Receive There is no claim step. `spendableNotes` already surfaces incoming notes the moment they are scanned. For an inbox UI, page with `scanAnnouncementsPage` and show `direction === 0` as received, `1` as sent, both from the one scan. Redact amounts by default. ## Withdraw (cash out) ```ts const inputNote = pickInputNote(await spendableNotes(keys), amount); const budget = (await backend.estimateBudget("withdraw")) + 7_000_000n; await sendWithWallet([getTransferSolInstruction({ source: walletSigner, destination: sessionSigner.address, amount: lamports(budget) })]); const plan = await backend.planWithdraw( { feePayer: sessionSigner, owner: walletSigner, scanSecret: keys.scanSecret, spendScalar: keys.spendScalar, mint, tokenProgram, amount, destination, rentFor }, inputNote, ); for (const tx of plan.transactions) await sendWithSession(sessionSigner, tx.instructions); ``` For SOL specifically, the dApp withdraws wSOL to the session's own wSOL ATA, then closes it (`CloseAccount`) to get native SOL, and the session refund sweeps it to the wallet, keeping every leg session-signed. ## Key rotation Store the epoch per wallet. To rotate, bump it, re-derive with `keyDerivationMessage(wallet, epoch + 1)`, and surface the new meta-address. Old-epoch notes stay valid. See [Auditability](/concepts/auditability). ## Open the books Optional, from `@p15/compliance`: scoped disclosures or a per-epoch viewing key. See [Auditing & disclosure](/build/auditing). Neither can move funds. ## Invariants - Blind send is 2 wallet prompts; withdraw is 3 transactions. - Funding is its own explicit step, never auto-wrap on send. - Payments are final. Validate the recipient meta-address with `isMetaAddress`. - Amounts are `bigint` base units, never floats. ================================================================ URL: https://localhost:8080/build/app-integration ================================================================ # Integrate into an app An app (a storefront, payroll, a tipping widget) integrates Protocol15 to **accept private payments** and optionally to **let users pay privately**. This is a smaller surface than a wallet: mostly a receiving identity and a way to detect incoming payments. It builds on the [Quickstart](/build/quickstart) and [Recipes](/build/recipes). ## Accept private payments ### 1. A receiving identity The app derives its own key set the same way a user does, from a service wallet or a per-merchant wallet, and publishes the meta-address as its "pay me" target. ```ts const keys = deriveKeys(await merchantWallet.signMessage(keyDerivationMessage(merchantAddress, epoch)), epoch); const payTo = encodeMetaAddress({ scanPublic: keys.scanPublic, spendPublic: keys.spendPublic }); ``` Show `payTo` (text or QR) wherever you currently show an address. Every payment to it lands at a fresh one-time location, so your receipts are not linkable on chain. ### 2. Detect incoming payments Poll the announce anchor with your `scanSecret` and keep the incoming direction. Decrypting a memo with your scan secret is itself the proof a payment is yours. For a busy endpoint, run the [indexer](/guides/indexer-deploy) so each poll is one request. ```ts import { scanAnnouncements } from "@p15/stealth"; const found = await scanAnnouncements(rpc, keys.scanSecret, { indexerUrl }); for (const d of found.filter((a) => a.announcement.direction === 0)) { reconcile({ amount: d.announcement.amount, // exact base units signature: d.signature, // stable, use as idempotency key at: d.blockTime, memo: d.announcement.note, // optional payer-supplied memo }); } ``` The `signature` is a stable idempotency key, so re-scanning never double-counts. ### 3. Reconcile against an order Amounts are exact, so match an incoming payment to an invoice by amount plus a time window. For a stronger link, ask the payer to include a memo via `planSend({ note })` and read `d.announcement.note`. ## Spending what you received A received payment is a spendable note. To pay out or move funds, use `spendableNotes` ([Recipes](/build/recipes)) to list them, then `planSend` or `planWithdraw` exactly as a wallet does. To move takings to a public balance for accounting or off-ramp, `planWithdraw` into a public token account you control (a public moment). ## Let users pay your app privately If your app also sends, derive the paying user's keys, build the backend, and call `planSend` toward your merchant meta-address. Same flow as a wallet; see the [Quickstart](/build/quickstart) step 5. ## Compliance When you need to prove a payment to an auditor or counterparty, issue a scoped [disclosure](/build/auditing) for just those transactions. It is signed, chain-verifiable, and revocable, and never exposes your other activity. If your jurisdiction requires pre-deposit screening, wire `getScreeningProvider` as an async pre-check before accepting funds. ## Notes - One scan returns both received (`0`) and sent (`1`) history; you usually only need received. - Amounts are `bigint` base units. - Decryption is always client-side; the indexer only ever sees ciphertext. ================================================================ URL: https://localhost:8080/build/auditing ================================================================ # Auditing & disclosure Protocol15 is private by default and auditable when needed. This guide shows how to issue the two kinds of access from `@p15/compliance`, both scoped to what you choose and neither able to move funds. See [Auditability](/concepts/auditability) for the design and [@p15/compliance](/sdk/compliance) for the full API. ## Pick the tier - **Selective disclosure** (preferred): a signed statement about chosen transactions, encrypted to one named auditor. Scoped, revocable, non-live. Use this for "prove these five payments to my accountant." - **Viewing key**: a per-epoch read-only key that reveals everything in that epoch. Use this only for the full "open the books" case. ## Build records A disclosure reveals `DisclosureRecord`s. Build them from a scan of your inbox. ```ts import { scanInbox } from "./your-scan"; // wraps scanAnnouncementsPage const inbox = await scanInbox(keys); // your Incoming[] mapping const records = inbox .filter((i) => chosenSignatures.includes(i.signature)) .map((i) => ({ signature: i.signature, mint: i.mint, stealthAta: i.stealthAta, amount: i.amount.toString(), decimals: 9, symbol: "SOL", direction: i.direction, timestamp: i.timestamp, note: i.note, epoch: keys.epoch, })); ``` ## Issue a selective disclosure ```ts import { buildDisclosure } from "@p15/compliance"; import { decodeMetaAddress } from "@p15/stealth"; const auditor = decodeMetaAddress(auditorMetaAddress); const envelope = buildDisclosure({ records, auditorPublic: auditor.scanPublic, spendScalar: keys.spendScalar, // signs the statement scope: { purpose: "2026 tax", epochs: [keys.epoch] }, oneTime: false, }); // Hand `envelope` to the auditor (it is encrypted to them). ``` Always include `epochs: [keys.epoch]` in the scope so the statement cannot imply other epochs. The auditor verifies and reads it with their own scan secret: ```ts import { verifyDisclosure } from "@p15/compliance"; const result = verifyDisclosure(auditorScanSecret, envelope.trim()); // result.holder, result.scope, result.records ``` ## One-time disclosures Set `oneTime: true`. The statement carries an `id`; opening it burns an on-chain marker so it can be opened only once and the open is publicly recorded. ```ts import { isDisclosureOpened, buildBurnDisclosureMarkerIx, openCompressionRpc } from "@p15/compliance"; const verified = verifyDisclosure(auditorScanSecret, envelope.trim()); if (verified.oneTime && verified.id) { const cRpc = openCompressionRpc(process.env.COMPRESSION_RPC!); if (await isDisclosureOpened(cRpc, verified.id)) throw new Error("already opened once"); const burnIx = await buildBurnDisclosureMarkerIx({ rpc: cRpc, payer: auditorWallet, id: verified.id }); await sendWithAuditorWallet(burnIx); // web3.js instruction } ``` This needs a Photon-indexed compression RPC (set `COMPRESSION_RPC`). The marker is a ZK Compression nullifier at a deterministic address, so a second open reverts at the address tree. ## Export a viewing key ```ts import { exportViewingKey } from "@p15/compliance"; const json = exportViewingKey({ scanSecret: keys.scanSecret, epoch: keys.epoch, label: "auditor A" }); ``` The recipient imports it and decrypts memos with `importViewingKey` + `auditAnnouncement`. It reveals one epoch only; rotate the epoch to contain a leak. The spend scalar is never part of it, so a viewing key cannot move funds. ================================================================ URL: https://localhost:8080/build/recipes ================================================================ # Recipes The helpers every integration needs, taken from the dApp's own `lib/actions.ts`. The [Quickstart](/build/quickstart) and integration guides reference these by name. ## Shared setup ```ts import { getBackend, RemoteProver } from "@p15/blind-mode"; const backend = getBackend("noir-v1", { prover: new RemoteProver("/api/prove") }); const rentFor = (space: bigint) => rpc.getMinimumBalanceForRentExemption(space).send(); ``` ## Execute a BackendPlan Every `plan*` call returns a `BackendPlan` whose `transactions` are tagged `session` or `wallet`. Send each in order, signed by the right payer. This uses `@solana/kit`. ```ts import { appendTransactionMessageInstructions, createTransactionMessage, pipe, sendAndConfirmTransactionFactory, setTransactionMessageFeePayerSigner, setTransactionMessageLifetimeUsingBlockhash, signTransactionMessageWithSigners, } from "@solana/kit"; import type { BackendPlan } from "@p15/blind-mode"; export async function executePlan(plan: BackendPlan, ctx) { const confirm = sendAndConfirmTransactionFactory({ rpc: ctx.rpc, rpcSubscriptions: ctx.rpcSubscriptions }); for (const tx of plan.transactions) { const payer = tx.signer === "session" ? ctx.sessionSigner : ctx.walletSigner; const { value: latest } = await ctx.rpc.getLatestBlockhash().send(); const message = pipe( createTransactionMessage({ version: 0 }), (m) => setTransactionMessageFeePayerSigner(payer, m), (m) => setTransactionMessageLifetimeUsingBlockhash(latest, m), (m) => appendTransactionMessageInstructions(tx.instructions, m), ); await confirm(await signTransactionMessageWithSigners(message), { commitment: "confirmed" }); } } ``` ## List a wallet's spendable notes This is the single most important helper. A scanned announcement tells you a note's amount and ephemeral key; whether it is **spendable** is whether you can re-derive it AND its on-chain marker is still alive. A record of a payment you *sent* re-derives (with your keys) to a phantom commitment with no marker, so it is excluded automatically. ```ts import { scanAnnouncementsPage, type DiscoveredAnnouncement } from "@p15/stealth"; import { deriveNoteForReceive, assetField, findNotePda, commitmentBytes, type SpendableNote } from "@p15/blind-mode"; const MARKER = Buffer.from("p15note\0"); // note-vault marker discriminator async function markerAlive(commitment: bigint): Promise { const pda = await findNotePda(commitmentBytes(commitment)); const info = await rpc.getAccountInfo(pda, { encoding: "base64" }).send(); if (!info.value) return false; const data = Buffer.from(info.value.data[0], "base64"); return data.length >= 8 && data.subarray(0, 8).equals(MARKER); } export async function spendableNotes(keys): Promise { const { items } = await scanAnnouncementsPage(rpc, keys.scanSecret, { limit: 100, indexerUrl }); const out: SpendableNote[] = []; const seen = new Set(); for (const d of items) { const note = deriveNoteForReceive( d.announcement.amount, assetField(d.announcement.mint), keys.scanSecret, keys.spendScalar, d.announcement.ephemeralPublic, ); const k = note.commitment.toString(); if (seen.has(k)) continue; seen.add(k); if (await markerAlive(note.commitment)) { out.push({ commitment: commitmentBytes(note.commitment), amount: d.announcement.amount, ephemeralPublic: d.announcement.ephemeralPublic, }); } } return out; } ``` Sum them for the balance, or pass `backend.loadBalance(notes)`. ## Pick an input note ```ts function pickInputNote(notes, amount: bigint) { const covering = notes.filter((n) => n.amount >= amount).sort((a, b) => (a.amount < b.amount ? -1 : 1)); if (!covering.length) throw new Error("No single private note covers that amount. Make more private or send less."); return covering[0]; // smallest note that covers it, to keep change small } ``` The note model has no consolidation primitive in v1: a send spends exactly one note. If no single note covers the amount, the user makes more private or sends less. Do not auto-wrap public funds to cover the gap. ## Create and fund a session key ```ts import { generateKeyPairSigner } from "@solana/kit"; import { getTransferSolInstruction } from "@solana-program/system"; import { lamports } from "@solana/kit"; const sessionSigner = await generateKeyPairSigner(); const budget = (await backend.estimateBudget("send")) + 5_000_000n; // + a little slack // First wallet prompt: fund the session key. await sendWithWallet([getTransferSolInstruction({ source: walletSigner, destination: sessionSigner.address, amount: lamports(budget) })]); ``` Persist the session key for the tab and sweep leftovers back to the user when the flow ends. ## Page through history ```ts let before; do { const page = await scanAnnouncementsPage(rpc, keys.scanSecret, { before, limit: 50, indexerUrl }); render(page.items); // each has direction 0 (in) or 1 (out) before = page.nextBefore ?? undefined; } while (before); ``` Auditing helpers (`buildDisclosure`, `exportViewingKey`, screening) are in the [Auditing & disclosure](/build/auditing) guide. ================================================================ URL: https://localhost:8080/guides/private-swap ================================================================ # Private swap (v1) — tradeoffs Private swap is the wedge: a user holding a **private note of asset A** ends up with a **private note of asset B**, with the amounts and the graph hidden, and both legs recoverable by scanning the announce bus. The swap venue is abstracted behind the `SwapRouter` seam so the privacy orchestration is identical regardless of where liquidity comes from. ## Flow `planPrivateSwap` (`packages/blind-mode/src/private-swap.ts`) returns four session-signed tx groups (1 wallet popup to fund the session, like every blind flow): 1. **prepare swap accounts** — session in/out ATAs + the pool out ATA. 2. **unlock swap input (PUBLIC)** — withdraw the whole input note A to the session's ATA. 3. **swap** — router swaps A→B into the session's out ATA. 4. **make output private + record** — deposit B as a fresh private note + announce both legs (an `in` for the output note, an `out`-receipt for the consumed input). ## Routers `getSwapRouter(cluster, opts)` (`src/swap-router.ts`) picks the venue: | cluster | router | notes | |---------|--------|-------| | `devnet` | `DevnetStubRouter` | fixed-rate swap between two Token-2022 test mints, backed by operator reserves. No Jupiter liquidity on devnet. Requires `opts.stub`. | | `fork` / `mainnet` | `JupiterRouter` | real Jupiter v6 liquidity (`lite-api.jup.ag`), `wrapAndUnwrapSol: false` (the note legs already move wrapped tokens). | `SWAP_ROUTER=stub|jupiter` overrides the cluster default. ## v1 tradeoffs (be honest) - **Whole-note only.** v1 swaps an entire input note — no partial split, so no transfer proof on the input leg. To swap a different amount, first send yourself a note of that size. - **The swap input is PUBLIC.** Step 2 withdraws note A to the session's ATA before the swap, so the *input amount and asset* are visible on-chain for that moment (labeled PUBLIC in the plan). Privacy is restored when B is re-deposited as a note. What stays hidden across the whole operation: the link between *your wallet* and the swap (the session key is ephemeral), and the output note. - **The AMM sees the amount.** Jupiter/Orca necessarily see the swapped amount and the two assets — that's inherent to using public liquidity. p15 hides *who* and *the resulting balance*, not the trade size from the venue. - **Slippage.** `minOut` is the floor; the router rejects a quote below it. ## v2 (deferred) Integrating the `swap-auth` circuit (`circuits/swap-auth`) would let a ZK proof authorize the swap without revealing the amount on the private leg. Designed separately; not wired into `planPrivateSwap` yet. ## Verify - **Unit:** `npm test` → `tests/unit/private-swap.test.ts` (tx grouping, signer tags, both announce legs, `getSwapRouter` selection). Runs offline. - **Devnet e2e (stub):** `tsx scripts/12-private-swap-e2e.ts` — full private-in → swap → private-out on devnet. - **Mainnet-fork e2e (Jupiter):** the full flow against cloned mainnet liquidity, no real SOL — manual two-phase (needs the agave 4.1 validator + the built `note_vault.so`): ```bash tsx scripts/15-jupiter-private-swap-fork.ts collect bash /tmp/p15-pswap/validator.sh # in another shell tsx scripts/15-jupiter-private-swap-fork.ts swap ``` Asserts: input wSOL note consumed, output USDC note live, and scanning the announce bus recovers both legs (`in` + `out`-receipt). ================================================================ URL: https://localhost:8080/guides/prover ================================================================ # Prover (Groth16 via Noir + sunspot) Blind sends need a Groth16 proof of the transfer circuit. There is **no browser prover** (see `spike/noir/REPORT.md`): sunspot generates proofs via a Go CLI, so the dApp's `RemoteProver` POSTs the witness to a server route that shells out to `nargo` + `sunspot`. ## Topology ``` dApp (RemoteProver) --POST /api/prove--> CliProver --> nargo execute + sunspot prove ``` - **`RemoteProver`** (`packages/blind-mode/src/noir/prover.ts`) — dApp path. POSTs `{ circuit, inputs }`; the witness carries amounts/blindings but **NOT** the ed25519 spend scalar, so funds stay non-custodial (documented devnet trust assumption). - **`CliProver`** (same file) — Node path: writes `Prover.toml`, runs `nargo execute` then `sunspot prove`, reads `.proof` + `.pw`. - **`/api/prove`** (`dapp/app/api/prove/route.ts`) — `runtime: nodejs`, `maxDuration: 60`. Widens `PATH` with `~/.nargo/bin` and `~/sunspot/go`. Single-flight (shared `Prover.toml`); behind the access gate in production. ## Self-host vs dApp route By default the dApp uses its own `/api/prove`. To run the prover elsewhere (dedicated host with the toolchain + circuit artifacts), set **`PROVER_URL`** and point `RemoteProver` at it. The contract is the same `POST { circuit, inputs } -> { proof, publicWitness }` (both base64). ## Health `GET /api/prove/health` (no proving; cheap, pollable) reports: ```json { "ok": true, "toolchain": { "ok": true, "nargo": "nargo version ...", "sunspot": "..." }, "circuits": { "transfer": { "compiled": true, "mtime": "..." }, "swap-auth": { ... } } } ``` Returns `503` when the toolchain isn't on `PATH` or no circuit is compiled. Use it to verify a prover host is ready before routing blind sends to it. ## Toolchain See `docs/toolchain.md`. In short: `nargo` (via `noirup`, `~/.nargo/bin`) and `sunspot` (`~/sunspot/go`). Circuit artifacts live under `packages/blind-mode/circuits//target/.json`. ## Compute budget Every verifier-CPI tx raises its compute-unit limit above the 200k default. The dApp does this at one chokepoint — `withComputeBudget(instructions, units?)` in `dapp/lib/client.ts` (default `CU_LIMIT = 800_000`) — applied by both `sendWithSigner` and `sendWithKeypair`. A heavier route (e.g. a Jupiter swap group) passes a larger `units` rather than hand-rolling its own ComputeBudget ix. ## v2 (deferred) A browser WASM prover would remove the server round-trip for blind sends. Spike only; not built (`packages/blind-mode`). ================================================================ URL: https://localhost:8080/guides/indexer-deploy ================================================================ # Indexer: deploy & scanning in production The `@p15/indexer` is a dev/ops convenience: it polls the one fixed announce anchor, extracts every encrypted p15 memo, and serves them over HTTP so wallets scan their inbox in a single request instead of N `getTransaction` RPC calls. **It never sees plaintext** — ciphertext + the public 1-byte view tag only. Decryption stays client-side with the recipient's scan key. ## API contract Types live in `indexer/src/types.ts` (the source of truth, imported by the handlers): - `GET /announcements?before=&until=&limit=200` → `AnnouncementsPage` (`{ items: AnnouncementRow[], nextBefore: string | null }`), newest-first. - `GET /health` → `HealthResponse` (`{ ok, rows, rpc }`). The dapp/SDK scanner consumes this via `ScanOptions.indexerUrl` (`NEXT_PUBLIC_INDEXER_URL`). ## Env | var | default | meaning | |--------------|---------------------------------|----------------------| | `SOLANA_RPC` | `https://api.devnet.solana.com` | cluster to poll | | `PORT` | `8787` | HTTP port | | `DATA_FILE` | `./announcements.json` | JSON persistence path| | `POLL_MS` | `10000` | poll interval (ms) | ## Docker `@p15/stealth` is a sibling consumed via `file:`, which Docker can't see outside the build context — so **build from the outer repo root**, and build the stealth dist first: ```bash cd protocol-15 (cd packages/stealth && npm ci && npm run build) docker build -f indexer/Dockerfile -t p15-indexer . docker run -p 8787:8787 -v p15-index-data:/data \ -e SOLANA_RPC=https://api.devnet.solana.com p15-indexer curl localhost:8787/health ``` The JSON store persists on the `/data` volume, so restarts resume the backfill instead of starting over. ## Deploy (Railway / Fly) - **Railway:** point at the repo, set the Dockerfile path to `indexer/Dockerfile` and the build context to the repo root. Add a persistent volume mounted at `/data`. Set `SOLANA_RPC` (a paid RPC is strongly recommended — the public devnet RPC 429s hard during the initial backfill; the poller already retries with exponential backoff but a real endpoint is far faster). - **Fly:** `fly launch --dockerfile indexer/Dockerfile` from the repo root, add a volume for `/data`, set the same env. Healthcheck `GET /health`. ## RPC fallback (no indexer) The scanner works **without** an indexer: when `indexerUrl` is unset, `scanAnnouncementsPage` RPC-scans the anchor directly (`getSignaturesForAddress` + per-tx `getTransaction`). Tradeoff: - **With indexer:** one HTTP request per page; view tags pre-extracted. Best for wallets with shared infra. - **Without:** N `getTransaction` calls per page, rate-limit sensitive on public RPC. Fine for low-volume or self-hosted single-user scanning. Use the indexer when many clients scan the same anchor or when on a public RPC; skip it for one-off local scans. ================================================================ URL: https://localhost:8080/sdk/stealth ================================================================ # @p15/stealth The base layer: key derivation, the public meta-address identity, stealth delivery, and the announce-and-scan discovery system. No internal dependencies. Everything else builds on it. Import from the package root. ```bash npm install @p15/stealth ``` ## Keys Every key derives from one ed25519 signature over a fixed message. No seed phrases. ```ts import { keyDerivationMessage, deriveKeys } from "@p15/stealth"; const message = keyDerivationMessage(walletAddress, epoch); // Uint8Array const signature = await wallet.signMessage(message); // raw 64 bytes const keys = deriveKeys(signature, epoch); // P15Keys ``` `deriveKeys(signature, epoch = 0)` throws if the signature is not 64 bytes. The `epoch` is also bound into the message, so different epochs cannot collide. The returned `P15Keys`: | Field | Type | Meaning | | --- | --- | --- | | `scanSecret` | `Uint8Array` | X25519 secret that detects incoming payments and decrypts memos. Shareable with auditors. | | `scanPublic` | `Uint8Array` | X25519 public half; part of the meta-address. | | `spendPublic` | `Uint8Array` | ed25519 spend public; part of the meta-address. | | `spendScalar` | `Uint8Array` | ed25519 spend scalar (clamped). **Spend authority; never leaves the client.** | | `elgamalSeed` | `Uint8Array` | Seed for the CT ElGamal keypair (reserved). | | `aesSeed` | `Uint8Array` | Seed for the decryptable-balance AeKey (reserved). | | `epoch` | `number` | The rotation epoch this set belongs to. | Helpers: `clampScalar`, `bytesToScalar`, `scalarToBytes` for raw scalar work. ## Meta-address The public payment identity: a bech32m string encoding the scan and spend public keys. Share it once. ```ts import { encodeMetaAddress, decodeMetaAddress, isMetaAddress } from "@p15/stealth"; const meta = encodeMetaAddress({ scanPublic: keys.scanPublic, spendPublic: keys.spendPublic }); isMetaAddress(meta); // true const { scanPublic, spendPublic } = decodeMetaAddress(meta); ``` Always validate an incoming address with `isMetaAddress` before sending. Two payments to the same meta-address produce unrelated on-chain destinations. ## Stealth derivation The sender turns a meta-address into a one-time destination; the recipient re-derives ownership. The owner key is `R = spend_pub + t*G` where `t` comes from the ECDH shared secret. The sender can compute `R` but not the scalar behind it. | Export | Purpose | | --- | --- | | `deriveStealthForSend` | Sender: one-time `StealthAccount` from a meta-address. | | `deriveStealthForReceive` | Recipient: detect + reconstruct a payment. | | `deriveStealthOwnerScalarForReceive` | Recipient: the spendable owner scalar. | | `matchesViewTag(scanSecret, ephemeralPublic, viewTag)` | Cheap "is this mine" pre-filter. | | `scalarTransactionSigner(scalarBytes)` | A `TransactionSigner` from a raw scalar (the release key). | For the note model, the equivalent note-aware derivations live in [@p15/blind-mode](/sdk/blind-mode) (`deriveNoteForSend` / `deriveNoteForReceive`), which reuse the same `R = spend_pub + t*G` tweak. ## Announce and scan Every blind transfer pings one fixed anchor address with an encrypted memo. Recipients scan that one address. ```ts import { P15_ANNOUNCE_ADDRESS, scanAnnouncements, scanAnnouncementsPage } from "@p15/stealth"; // All announcements addressed to this key (both directions): const found = await scanAnnouncements(rpc, keys.scanSecret, { indexerUrl }); // One page, with a cursor for older history: const page = await scanAnnouncementsPage(rpc, keys.scanSecret, { before, limit: 50, indexerUrl, }); // page.items: DiscoveredAnnouncement[], page.nextBefore: string | null ``` `ScanOptions`: `before`, `until`, `limit`, `indexerUrl`. When `indexerUrl` is set the scanner fetches pre-collected announcements in one request and decrypts locally; otherwise it scans the anchor over RPC. The indexer only ever sees ciphertext. A `DiscoveredAnnouncement` carries `signature`, `slot`, `blockTime`, and a decoded `announcement` with the `amount`, `mint`, `ephemeralPublic`, `direction` (`0` = incoming, `1` = the sender's self-receipt), optional `note`, and `scheme`. ```ts import { encodeAnnouncement, decodeAnnouncement } from "@p15/stealth"; // Low-level memo codec, used by the planners; you rarely call these directly. ``` ## Putting it together: a recipient's balance ```ts import { scanAnnouncements } from "@p15/stealth"; import { deriveNoteForReceive, assetField } from "@p15/blind-mode"; const found = await scanAnnouncements(rpc, keys.scanSecret, { indexerUrl }); const notes = found.map((d) => deriveNoteForReceive( d.announcement.amount, assetField(d.announcement.mint), keys.scanSecret, keys.spendScalar, d.announcement.ephemeralPublic, ), ); // Keep only notes whose on-chain marker is still alive (see note-vault program). ``` ================================================================ URL: https://localhost:8080/sdk/blind-mode ================================================================ # @p15/blind-mode Amount privacy: the pluggable proof backend, transfer planning, the note primitives, the note-vault and pSOL instruction builders, and private swap. Depends on `@p15/stealth`. ```bash npm install @p15/blind-mode ``` ## The backend seam You never instantiate a concrete engine by hand. `getBackend` returns a `TransferProofBackend`, and the active engine is `noir-v1`, which needs a prover. ```ts import { getBackend, RemoteProver } from "@p15/blind-mode"; const prover = new RemoteProver("https://your-prover/api/prove"); const backend = getBackend("noir-v1", { prover }); ``` `getBackend("noir-v1")` throws without `opts.prover`. `native-ct` is reserved for the Token-2022 Confidential Transfer path and takes no prover. ### TransferProofBackend Every method that builds transactions returns a `BackendPlan`. You do not assemble instructions yourself for the common flows; you execute the plan. | Method | Returns | Purpose | | --- | --- | --- | | `estimateBudget(kind)` | `Promise` | Lamports to pre-fund the session key. `kind` is `'send'` or `'withdraw'`. | | `planFund(input)` | `Promise` | Make private: public tokens to a private self-note. | | `planSend(input, inputNote)` | `Promise` | Spend one note to a recipient note + change. | | `planWithdraw(input, inputNote)` | `Promise` | Private note back to a public token account. | | `loadBalance(notes)` | `bigint` | Sum the user's spendable notes. | A `BackendPlan` is `{ transactions: PlanTx[], announceExtra?: Uint8Array }`. Each `PlanTx` is `{ label, signer: 'session' | 'wallet', instructions }`. Send them in order, each signed by the tagged payer. See the [Recipes](/build/recipes) `executePlan` helper. Each ~1.5 KB proof must be its own transaction, so a send is two and a withdraw is three. ### Inputs `FundInput`: `{ owner, feePayer, mint, tokenProgram?, amount, scanSecret, spendScalar, rentFor }`. `SendInput`: `{ feePayer, senderScanSecret, senderSpendScalar, senderScanPublic, mint, amount, recipientMetaAddress, note?, rentFor }`. `WithdrawInput`: `{ feePayer, owner, scanSecret, spendScalar, mint, tokenProgram?, amount, destination, rentFor }`. `rentFor` is `(space: bigint) => Promise`, normally `(s) => rpc.getMinimumBalanceForRentExemption(s).send()`. `SpendableNote` (what the backend spends): `{ commitment: Uint8Array, amount: bigint, ephemeralPublic: Uint8Array }`. ## Prover The backend delegates proof generation to a `Prover`. | Export | Use | | --- | --- | | `RemoteProver(url)` | POSTs `{ circuit, inputs }` to a prover endpoint; returns `{ proof, publicWitness }`. The dApp path. The witness never contains the spend scalar, so proving stays non-custodial. | | `CliProver` | Runs the local Noir toolchain (`nargo` + `sunspot`). The Node path. | | `Prover`, `ProofResult`, `CircuitInputs` | The seam types. `toToml(inputs)` serializes inputs for the CLI. | See [Self-host the prover](/guides/prover) for running one. ## Note primitives The math behind a note. The commitment matches the note-vault program exactly. ```ts import { noteCommitment, assetField, pubkeyToField, noteNullifier, deriveNoteForSend, deriveNoteForReceive, } from "@p15/blind-mode"; const asset = assetField(mintBytes); // Poseidon(mint) as a field // Sender, from a recipient meta-address: const out = deriveNoteForSend(amount, asset, recipientScanPublic, recipientSpendPublic); // out.commitment, out.ownerPublic (= R), out.ephemeralPublic, out.secret, out.blinding // Recipient, from a scanned announcement: const note = deriveNoteForReceive(amount, asset, keys.scanSecret, keys.spendScalar, ephemeralPublic); // note.ownerScalar (spendable), note.nullifier, note.commitment ``` A `Note` carries `amount`, `asset`, `ownerHash` (`Poseidon(R)`), `blinding`, `secret`, `commitment`, and `ownerPublic` (the 32-byte `R`, used as the note-vault PDA seed). `noteNullifier(secret) = Poseidon(secret)`. ## note-vault builders Low-level instruction builders for the [note-vault program](/programs/note-vault). The `plan*` backend methods use these; reach for them directly only when you need custom transaction shapes. `findPoolAuthority`, `findPoolAta(mint, tokenProgram?)`, `findNotePda(commitment)`, `commitmentBytes(commitment)`, `buildCreatePoolAtaInstruction`, `buildDepositInstruction`, `buildSpendInstruction`, `buildWithdrawInstruction`. Constants `NOTE_VAULT_PROGRAM_ID`, `TRANSFER_VERIFIER_ID`. The `tokenProgram?` parameter defaults to Token-2022, but the active mints (SOL, USDC) are on the **legacy SPL Token program**, so pass the mint's actual program (`TOKEN_PROGRAM_ADDRESS` from `@solana-program/token`). The pool moves plain SPL tokens with `transfer_checked`; there are no Confidential Transfers involved. ## pSOL wrapper (legacy) Builders for the [psol-wrapper program](/programs/psol-wrapper), the earlier program-minted Token-2022 wrapper. The note-model dApp wraps SOL **natively** instead, so reach for these only if you specifically want pSOL: `buildInitializeInstruction`, `buildWrapInstruction`, `buildUnwrapInstruction`, `findVaultPda`, `findMintAuthPda`, `findConfigPda`, `fetchWrapFeeLamports`, `fetchFeeTreasury`. Constants `PSOL_PROGRAM_ID`, `PSOL_MINT`, `PSOL_DECIMALS = 9`, `DEFAULT_WRAP_FEE_LAMPORTS`. ## Private swap Turn a private note of one asset into a private note of another. ```ts import { planPrivateSwap, getSwapRouter, JupiterRouter, DevnetStubRouter } from "@p15/blind-mode"; const router = getSwapRouter("devnet", {}); // or new JupiterRouter() on mainnet const { amountOut } = await router.quote(mintIn, mintOut, inputNote.amount); const plan = await planPrivateSwap( { feePayer, scanSecret, spendScalar, scanPublic, mintIn, tokenProgramIn, mintOut, tokenProgramOut, minOut: (amountOut * 99n) / 100n, router, rentFor }, inputNote, ); ``` `SwapRouter` is a seam (`quote`, plus instruction building). `DevnetStubRouter` uses a fixed rate for devnet; `JupiterRouter` hits Jupiter on mainnet. See the [Private swap](/guides/private-swap) guide. v1 swaps a whole input note. ## Verifier helpers `readTransferPublicInputs(witness)` and `verifierInstructionData(proof, witness)` build the CPI payload note-vault sends to the Groth16 verifier; `fieldToBytesBE` / `bytesBEToField` convert field elements. ================================================================ URL: https://localhost:8080/sdk/compliance ================================================================ # @p15/compliance Selective disclosure, viewing keys, one-time on-chain markers, and pre-deposit screening. Depends on `@p15/stealth`. This is the "auditable when needed" half of the protocol; none of it can move funds. ```bash npm install @p15/compliance ``` ## Selective disclosure The preferred audit path: a signed, encrypted, scoped statement about chosen transactions, readable only by a named auditor. Revocable and non-live. ```ts import { buildDisclosure, verifyDisclosure } from "@p15/compliance"; import { decodeMetaAddress } from "@p15/stealth"; const auditor = decodeMetaAddress(auditorMetaAddress); const envelope = buildDisclosure({ records, // DisclosureRecord[] you choose to reveal auditorPublic: auditor.scanPublic, spendScalar: keys.spendScalar, // signs the statement; proves you are the holder scope: { purpose: "2026 audit", epochs: [keys.epoch] }, oneTime: false, }); // The auditor, with their own scan secret: const result = verifyDisclosure(auditorScanSecret, envelope); // result.holder, result.scope, result.records, result.oneTime, result.id ``` A `DisclosureRecord` describes one transaction: `signature`, `mint`, `stealthAta`, `amount` (string), and optional `decimals`, `symbol`, `direction`, `counterparty`, `note`, `timestamp`, `epoch`. A `DisclosureScope` is `{ purpose?, fromTs?, toTs?, epochs? }`. Always scope to the current epoch so the statement cannot imply other epochs. ### One-time disclosures Set `oneTime: true` and the document carries an `id`. Opening it burns a one-time on-chain marker, so a second open is blocked and the open leaves a public record, with no server or custom program. ```ts import { isDisclosureOpened, buildBurnDisclosureMarkerIx, openCompressionRpc } from "@p15/compliance"; const cRpc = openCompressionRpc(process.env.COMPRESSION_RPC!); if (await isDisclosureOpened(cRpc, result.id)) throw new Error("already opened"); const burnIx = await buildBurnDisclosureMarkerIx({ rpc: cRpc, payer, id: result.id }); // burnIx is a web3.js instruction; sign it with the auditor's wallet. ``` The marker is a ZK Compression nullifier (a compressed account at a deterministic address), so it carries no full-account rent. `disclosureMarkerAddress(id)` gives the address. The one irreducible limit is human: you cannot unsee what the first opener decrypted. ## Viewing keys The open-the-books escape hatch, scoped to one epoch. Read-only; cannot move funds. ```ts import { exportViewingKey, importViewingKey, auditAnnouncement } from "@p15/compliance"; const json = exportViewingKey({ scanSecret: keys.scanSecret, epoch: keys.epoch, label: "auditor A" }); // The auditor: const vk = importViewingKey(json); const announcement = auditAnnouncement(vk.scanSecret, memo); // decrypts amount + note ``` A viewing key is the `scanSecret` plus its `epoch`. It reveals everything in that epoch and nothing in any other. To contain a leak, rotate the epoch (see [Auditability](/concepts/auditability)). The spend scalar is never part of it. ## Screening A pre-deposit screening seam, mirroring the backend selector. Wire it as an async pre-check before accepting funds; it is not a transaction, so it does not change the wallet-prompt contract. ```ts import { getScreeningProvider } from "@p15/compliance"; const provider = getScreeningProvider("mock"); // or "range"; env: SCREENING_PROVIDER const result = await provider.screen({ wallet, mint, amount, direction: "deposit" }); if (result.decision === "deny") throw new Error("blocked"); if (result.decision === "review") log("flagged, continuing"); // result: { decision: 'allow' | 'review' | 'deny', risk: number, reasons: string[], provider } ``` `MockScreeningProvider` is offline and deterministic (`{ denylist?, reviewThreshold? }`), the default. `RangeScreeningProvider` adapts the Range Risk API and is inert until `RANGE_API_KEY` / `RANGE_API_URL` are set. For unskippable on-chain enforcement instead, see the [policy program](/programs/policy). ================================================================ URL: https://localhost:8080/programs/overview ================================================================ # On-chain overview Protocol15's on-chain surface is four Anchor programs on **devnet**. Mainnet is not enabled. This section documents each one as it is deployed: its purpose, PDAs, accounts, instructions, and how to call it. The SDK builds these instructions for you (see [@p15/blind-mode](/sdk/blind-mode)); this section is the ground truth underneath. ## The programs | Program | Id | Role | | --- | --- | --- | | [note-vault](/programs/note-vault) | `7gtXhpwEaYHKJ9XpAX7Z4BNjXU3cFCq4CNyzMQpFaQZ2` | The shielded pool. Holds deposits, verifies private transfers, releases withdrawals. | | transfer verifier | `14C9wbz3Eg6nx6A4GNNE2DqkgdZGKnxhp4TLMJkD7Pw3` | Groth16 verifier for the 1-in/2-out transfer circuit. Called by note-vault via CPI. | | [psol-wrapper](/programs/psol-wrapper) | `5LQUmd355dcXkETDheBXavpaBzJq4JFWLb9mfkku31ZR` | Legacy 1:1 SOL to pSOL wrapper. **Not on the active path** (the dApp uses native wSOL). | | [stealth-vault](/programs/stealth-vault) | `7oFAWawZCxDivMSa73hGh5V4XnhukLbrZQTA9Sq2r2pk` | Non-revocable custody for one-time Token-2022 CT accounts. Tied to the disabled CT path. | | [policy](/programs/policy) | `3B8sqPfgKYvxffwCvPx76Syu4R6MzQr7wkiTmvfjEzLR` | Optional on-chain allowlist and per-transaction limit. Not wired into the dApp. | ## A note on the token program Protocol15 does **not** use Token-2022 Confidential Transfers or ZK ElGamal. That path is disabled. Privacy comes entirely from the Noir/Groth16 note model in note-vault, on top of **plain SPL `transfer_checked`**. The active token program is the **legacy SPL Token program**: SOL is handled as native wrapped SOL (`So111…112`), and USDC is legacy SPL too. note-vault uses Anchor's `TokenInterface`, so it can hold a Token-2022 mint as well, but nothing on the active path requires Token-2022. The two programs that do use Token-2022 (psol-wrapper and stealth-vault) belong to the disabled CT design. ## How they fit together The active amount-privacy engine is **note-vault**: a shielded pool where every balance is a hidden note. A blind transfer is one `spend` on note-vault, gated by a Groth16 proof the **transfer verifier** checks. SOL enters and leaves the pool as **native wrapped SOL** on the legacy Token program (wrap = create the wSOL ATA, transfer lamports, `SyncNative`; unwrap = `CloseAccount`). ``` make private: SOL --wrap (native wSOL, legacy Token)--> wSOL --note-vault.deposit--> private note blind send: private note --note-vault.spend (proof)--> recipient note + change withdraw: private note --note-vault.withdraw--> wSOL --unwrap (CloseAccount)--> SOL ``` **psol-wrapper** is a separate, program-minted SOL wrapper (pSOL, a Token-2022 CT mint) from the earlier CT design. The current note-model dApp wraps SOL natively instead, so psol-wrapper is documented but not on the active flow. **stealth-vault** is the alternative custody design for the Token-2022 Confidential Transfer path. That path is disabled in favour of the note model, so the program is deployed but not on the active flow. It is documented because the recipient-only ownership gate it pioneered (`R = spend_pub + t*G` as a required signer) is the same gate note-vault uses. **policy** is an optional escalation a deployer can prepend or CPI into the make-private boundary. The active screening path is the off-chain [screening provider](/sdk/compliance); policy is for unskippable on-chain enforcement. ## Conventions used across these pages - **PDAs** are derived with `Pubkey.findProgramAddress(seeds, programId)`. Each program page lists its seeds. - **Amounts** are `u64` base units. pSOL and wrapped SOL use 9 decimals, so base units equal lamports. - **Recipient-only ownership.** note-vault and stealth-vault both require the recipient-only key `R` as a transaction signer. The sender can compute `R` but cannot sign as it, which is what makes payments final. - **Field encoding.** Commitments and owners are BN254 field elements, big-endian, to match the gnark public witness. The SDK's `noteCommitment`, `assetField`, and `pubkeyToField` mirror the program's Poseidon encoding exactly. ## Trust assumptions - The programs' upgrade authorities (flagged for a multisig and burn before any real mainnet use). - The per-circuit Groth16 trusted setup behind the transfer verifier. - Everything else is structural: there is no admin or operator key that can move pooled funds. Tokens leave note-vault only through `withdraw`, gated on a recipient signature, a recomputed commitment, and a live note marker. ================================================================ URL: https://localhost:8080/programs/note-vault ================================================================ # 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. | ```ts 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 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`](/sdk/blind-mode) 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 | | ```ts 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` (388 bytes), `witness: Vec` (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. ```ts 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. ```ts 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: ```ts 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`](/build/wallet-integration) helper does exactly this across every scanned announcement. ================================================================ URL: https://localhost:8080/programs/psol-wrapper ================================================================ # psol-wrapper A trustless 1:1 wrapper between native SOL and **pSOL**, a program-minted Token-2022 wrapper. Program id `5LQUmd355dcXkETDheBXavpaBzJq4JFWLb9mfkku31ZR` (devnet). > **Not on the active path.** The current note-model dApp wraps SOL as **native > wrapped SOL** on the legacy Token program (create wSOL ATA, transfer, > `SyncNative`, `CloseAccount`), not as pSOL. psol-wrapper is from the earlier > Token-2022 Confidential Transfer design and is documented for completeness. If > you are integrating today, you wrap SOL natively; see > [Quickstart](/build/quickstart) step 4. ## What pSOL is pSOL is a Token-2022 mint with 9 decimals (so base units equal lamports) whose **mint authority is this program's `mint-auth` PDA**. That means this program is the only thing that can ever mint pSOL, and every pSOL in existence is backed 1:1 by lamports locked in the program's `vault` PDA. Mint id `Bfp9F8zcfQWvm3jWsBq7BYR6WYXr21FgJR1qzhfksKR4`. ## PDAs and config | PDA | Seeds | Purpose | | --- | --- | --- | | vault | `["vault"]` | A plain system account holding the wrapped lamports. | | mint authority | `["mint-auth"]` | Signs `MintTo`; is the pSOL mint authority. | | config | `["config"]` | Stores `psol_mint`, `wrap_fee_lamports`, `fee_treasury`. | ```ts import { findVaultPda, findMintAuthPda, findConfigPda, fetchWrapFeeLamports } from "@p15/blind-mode"; const fee = await fetchWrapFeeLamports(rpc); // current per-wrap fee ``` ## Instructions ### initialize One-time setup. Registers the pSOL mint in the config PDA and records the fee treasury. It asserts the mint's authority is the `mint-auth` PDA, so the program cannot be pointed at a mint it does not control. Run once after deploy; the SDK's `psol:init` script does this. ### wrap Locks `amount` lamports in the vault and mints `amount` pSOL to the user's token account. A flat protocol fee (`config.wrap_fee_lamports`, default 0.01 SOL) is debited **separately** from principal and paid straight to `config.fee_treasury`. The user is debited `amount + fee` and receives the full `amount` of pSOL. Emits a `WrapEvent`. **Args:** `amount: u64`. **Accounts (in order):** user (signer, writable), vault (writable), fee_treasury (writable, pinned to config), config, psol_mint (writable), user_token (writable), mint_auth, token program (Token-2022), system program. ```ts import { buildWrapInstruction } from "@p15/blind-mode"; const wrap = await buildWrapInstruction({ user, amount, userToken }); ``` ### unwrap Burns `amount` pSOL from the user's token account and releases the same `amount` of lamports from the vault to a destination. No fee, and the 1:1 invariant is preserved. **Args:** `amount: u64`. **Accounts (in order):** user (signer, writable), vault (writable), config, psol_mint (writable), user_token (writable), destination (writable), token program, system program. ```ts import { buildUnwrapInstruction } from "@p15/blind-mode"; const unwrap = await buildUnwrapInstruction({ user, amount, userToken, destination }); ``` ## In the wallet flow You usually do not call these directly. "Make private" wraps SOL into the wallet's wSOL ATA and deposits it into note-vault in a single transaction; withdraw releases wSOL and unwraps it back to native SOL. The dApp uses the native `@solana-program/token` wSOL path (create ATA, transfer, `SyncNative`, `CloseAccount`) for SOL specifically; the psol-wrapper builders above are for the pSOL mint when you want the program-minted wrapper instead of native wSOL. ## Errors `ZeroAmount`, `WrongMint` (mint does not match the registered pSOL mint), `BadMintAuthority` (mint authority is not the `mint-auth` PDA), `WrongTreasury` (fee treasury does not match config). ================================================================ URL: https://localhost:8080/programs/stealth-vault ================================================================ # stealth-vault Non-revocable custody for one-time Token-2022 Confidential Transfer accounts. Program id `7oFAWawZCxDivMSa73hGh5V4XnhukLbrZQTA9Sq2r2pk` (devnet). > **Not on the active path.** stealth-vault is the custody design for the > Token-2022 Confidential Transfer flow, which is disabled in favour of the > [note model](/programs/note-vault). The program is deployed and documented > because it pioneered the recipient-only ownership gate that note-vault now uses. > If you are integrating today, you want note-vault, not this. ## The problem it solves In a naive stealth scheme the one-time account owner is derived from the ECDH shared secret, which the **sender also knows**, so the sender could sign as that owner and reclaim an unclaimed payment. Token-2022 forbids changing a CT account's owner (`SetAuthority` returns `0x22`), so the fix is structural: the one-time CT account is an ATA owned by a per-output PDA of this program, and every release instruction requires a signature from the recipient-only owner `R = spend_pub + t*G`. The sender can compute `R` but cannot sign as it (that needs the recipient's spend scalar), so once funds land, only the recipient can move them. Payments are final; there is deliberately no sender reclaim path. ## PDA | PDA | Seeds | Purpose | | --- | --- | --- | | vault | `["stealth", R]` | Owns the one-time CT account. Derived from the recipient-only key `R`. | The program is **stateless**: release instructions take `r` as a signer and derive the PDA from `r.key()` in the seeds, so a wrong signer simply fails the seeds constraint. There is no stored state and nothing to migrate. ## Instructions ### init_stealth Sender side. Creates, reallocates, and configures the one-time CT account owned by the vault PDA for recipient-only owner `r` (passed as a non-signer here, since the sender initializes). The caller **must** place the `VerifyPubkeyValidity` instruction immediately after this one in the same transaction: Token-2022 resolves the configure proof at offset +1 against the top-level instructions via the sysvar. **Args:** `decryptable_zero_balance: [u8;36]`, `maximum_pending_balance_credit_counter: u64`. Internally it does three hand-encoded Token-2022 CPIs: create the ATA (idempotent), `Reallocate` for the ConfidentialTransferAccount extension, and `ConfigureAccount` with `proof_instruction_offset = +1`. ### apply_pending Recipient side. Applies the pending balance on the stealth account. Only `r` can sign; the PDA seeds bind to it. **Args:** `expected_pending_balance_credit_counter: u64`, `new_decryptable_available_balance: [u8;36]`. ### release_transfer Recipient side. A confidential transfer from the stealth account to a destination. The three proof context-state accounts (equality, validity, range) are created and verified client-side exactly as a normal CT transfer; the program supplies them and signs the transfer with the vault PDA seeds. **Args:** `new_source_decryptable_available_balance: [u8;36]`, `auditor_ciphertext_lo: [u8;64]`, `auditor_ciphertext_hi: [u8;64]`. ## Why it is documented The same recipient-only gate (require `r: Signer`, derive the PDA from `r.key()`) is what makes [note-vault](/programs/note-vault) payments final. If the CT path is ever re-enabled, this program is how live stealth outputs get non-revocable custody without a SetAuthority shortcut. ================================================================ URL: https://localhost:8080/programs/policy ================================================================ # policy Optional on-chain enforcement for the make-private boundary. Program id `3B8sqPfgKYvxffwCvPx76Syu4R6MzQr7wkiTmvfjEzLR` (devnet). > **Optional and not wired in.** The active screening path is the off-chain > [screening provider](/sdk/compliance) in `@p15/compliance`. This program is the > escalation for a deployer who wants allowlist or limit enforcement that no > client can skip. It stands alone behind a feature flag; the dApp does not call > it by default. ## Shape - A `config` PDA at `["policy"]` holding the admin and a per-transaction `max_amount`. - A per-wallet denial marker at `["denied", wallet]` whose mere **existence** means "blocked". (An allowlist is the dual; v1 ships the denylist.) - A stateless `check` assertion that fails the transaction if the amount is over the limit or the wallet has a denial marker. ## Instructions | Instruction | Args | Who | Effect | | --- | --- | --- | --- | | `init_policy` | `max_amount: u64` | anyone (becomes admin) | Create the config; signer is recorded as admin. | | `set_limit` | `max_amount: u64` | admin | Update the per-transaction limit. | | `deny` | `wallet: Pubkey` | admin | Create the `["denied", wallet]` marker. | | `allow` | `wallet: Pubkey` | admin | Close the marker, rent back to admin. | | `check` | `amount: u64` | anyone | Pass only if `amount <= max_amount` and the wallet has no denial marker. | `check` is **stateless and CPI-friendly**: it derives everything from seeds and stores nothing, so a deployer can prepend it as a top-level instruction before make-private, or CPI it from a wrapping program. ``` check accounts: config ["policy"] read-only denied ["denied", wallet] read-only (empty == not denied) ``` ## Errors `AmountOverLimit` (amount exceeds the policy limit), `WalletDenied` (the wallet has a denial marker). ## When to use it Reach for policy only when off-chain screening is not enough, for example when a regulated deployer must guarantee that no modified client can bypass an allowlist or a per-transfer cap. For everything else, the off-chain [`ScreeningProvider`](/sdk/compliance) is simpler and is the path the dApp uses. ================================================================ URL: https://localhost:8080/concepts/blind-mode ================================================================ # Blind Mode Most privacy tools hide one thing. They either obscure how much you sent or who you sent it to, rarely both, and almost never in a way that still looks like a normal payment. Blind Mode hides both at once. A blind transfer answers two questions for an outside observer, and refuses to answer either: - **How much?** The amount is carried as a confidential balance. What lands on chain is a ciphertext and a set of proofs, not a number. - **To whom?** The funds go to a fresh one-time address that only the recipient can derive. Nothing links it back to their public identity. Everyone watching sees that a Protocol15 transfer happened. They do not see the value and they cannot follow the destination to a known wallet. ## The product contract Blind Mode is one toggle on an ordinary send. The dApp keeps two layers and is explicit about which one you are in: - **Normal** is the public wallet. Balances and sends are visible to everyone, the way they always were. - **Private** is the hidden layer. One private balance, private send and receive, accept incoming, and withdraw. The only public moments are the two crossings between the layers. Making funds private is a deliberate step, and withdrawing back to public is another. Both are marked as public in the interface. Everything that happens inside the private layer stays hidden. ## What a send looks like A blind send is two wallet approvals and nothing more. The first funds a short-lived session key that pays fees; the second is the transfer itself. If your private balance is short, the send stops and tells you, instead of quietly wrapping more public funds. That keeps the public amount you wrapped decoupled from the private amount you sent, which matters: if the two always matched, an observer could line them up. ## Why it composes There is no separate pool to enter and exit, and no bridge to trust. A blind transfer is a normal transaction with confidential amounts and a stealth destination. That is what lets it sit inside existing wallets and apps, and read on an explorer as just another Protocol15 transfer. See [Privacy model](/concepts/privacy-model) for what actually proves the amount is valid, and [Stealth & announce](/concepts/stealth-and-announce) for how the recipient finds a payment nobody else can see. ================================================================ URL: https://localhost:8080/concepts/privacy-model ================================================================ # Privacy model Blind Mode hides the amount with a note model, not with on-chain confidential balances. The tokens themselves are plain SPL tokens moved with ordinary `transfer_checked`. The privacy comes from what sits on top: Noir circuits, Groth16 proofs, and a shared pool. This is a deliberate choice. An earlier design used Token-2022 Confidential Transfers and ZK ElGamal. That path is disabled. Plain SPL plus notes is simpler to reason about, works with legacy mints like wrapped SOL and USDC, and keeps the proving story in one place. ## Notes and the shared pool A note is a private claim on a balance held inside a shared pool. The pool is a program that owns one token account per mint. Depositing moves real tokens into the pool and records a commitment; spending proves you own a note without revealing which one; withdrawing moves tokens back out. Each note commits to five fields with a Poseidon hash: ``` commitment = Poseidon5(amount, asset, owner, blind, secret) ``` - `amount` is the value of the note. - `asset` is the mint, folded into a field element, so a note is tied to one token. - `owner` binds the note to a key only the holder controls. - `blind` and `secret` are randomness that make the commitment hiding and keep notes unlinkable. Because every note carries an `asset`, the same pool holds many tokens at once. A transfer circuit asserts that the input and both outputs share one asset, so a transfer never silently changes which token you hold. ## What the proof establishes Spending a note produces a Groth16 proof, verified on chain by a dedicated verifier program, that asserts the spend is well formed: the input note exists, the spender owns it, the output commitments are correctly constructed, and value is conserved. The verifier checks the proof; it never sees the amount, the owner, or which note moved. The amount stays private because it only ever appears inside the proof. Two new output notes come out of a spend, which is what lets value split and recombine without exposing the figures. ## No custody, gated withdrawal The pool is non-custodial. Funds leave only through `withdraw`, and only when the caller signs as the note's owner, the commitment recomputes, and a live spend marker is present. The `asset` is computed on chain from the mint, not trusted from the client, so a withdrawal cannot lie about which token it is taking. This same note machinery powers private swaps: withdraw a note of asset A, route the swap, and deposit the output as a private note of asset B, with both legs recoverable by scanning. See the [Private swap](/guides/private-swap) guide for the flow, and [Stealth & announce](/concepts/stealth-and-announce) for how recipients discover their notes. ================================================================ URL: https://localhost:8080/concepts/stealth-and-announce ================================================================ # Stealth & announce Hiding the amount is half the problem. If a payment still lands at a recipient's known address, the graph gives them away. Protocol15 sends every payment to a fresh one-time address that only the recipient can derive, and uses a single shared anchor to let them find it. ## Keys from one signature Everything derives from one ed25519 signature. The wallet signs a fixed message and that signature seeds a small hierarchy of keys: a scan key, a spend key, and the secrets that protect notes. There are no seed phrases to manage, and any wallet that can sign a message works. These keys are organized into **epochs**. Bumping the epoch produces a fully independent set of keys and a new public address, while everything from earlier epochs stays valid. Epochs are the containment primitive: if a key is exposed, you rotate to a new epoch instead of moving funds. ## Meta-address Your public identity in the protocol is a **meta-address**: a bech32m string that encodes your scan and spend public keys. You share it once. Senders use it to pay you, but a meta-address is not where funds land. It is the input from which a sender computes a unique one-time address for each payment. Two payments to the same meta-address produce two unrelated on-chain addresses. Nobody can tell they went to the same person. ## The announce anchor A one-time address is unlinkable, which creates a discovery problem: how does the recipient know a payment exists? Every blind transfer also sends one lamport to a single fixed anchor address, carrying an encrypted memo. Recipients scan the history of that one address, decrypt the memos addressed to them, and recover their payments. A short public view tag lets a scanner skip memos that are not theirs cheaply, so misses are fast. ## Both sides from one scan The sender posts two memos in the same transaction. One is encrypted to the recipient's scan key; the other is a self-receipt encrypted to the sender's own scan key. So both sides of every transfer are recoverable purely by scanning, with no device-local history to lose. A single scan of the anchor returns both the payments you received and the ones you sent. For wallets that do not want to make many RPC calls, an optional indexer pre-collects the anchor's encrypted memos and serves them in one request. Decryption always stays on the client; the indexer only ever sees ciphertext and the public view tag. See the [Indexer deploy](/guides/indexer-deploy) guide. ## Finality Live outputs are non-revocable. The one-time account is owned by a key the recipient alone can produce, and release requires that key as a signer, which the sender cannot generate. Payments are final. A sender cannot claw a payment back, and funds sent to a dead address are lost. That is the cost of making delivery unlinkable and trustless at the same time. ================================================================ URL: https://localhost:8080/concepts/auditability ================================================================ # Auditability Privacy that cannot be opened on purpose is a liability. Protocol15 is private by default and auditable when needed: the holder can give a specific party a specific view, without weakening privacy for anyone else. There are two tiers. ## Selective disclosure This is the preferred path. A disclosure is a signed, chain-verifiable statement about a chosen set of transactions, encrypted to a named auditor's meta-address. It is scoped to exactly the transactions you pick, it is not a live key, and it can be revoked. The auditor verifies the statement is genuine and decrypts only what you handed over. They cannot wander beyond the chosen set, and they cannot follow your future activity. An optional **one-time** mode adds a public, single-open guarantee. The statement carries an id, and opening it burns a one-time on-chain marker. A second open is blocked, and the open leaves a public record, with no server or custom program in the loop. The marker is implemented as a compression nullifier, a compressed account at a deterministic address, so it does not carry the rent cost of a full account. The one irreducible limit is human: you cannot unsee what the first opener already decrypted. ## Viewing keys The second tier is the open-the-books escape hatch. A viewing key bundles the secrets needed to read activity: the decryption key and the scan secret. It is a read-only god key for one epoch. It reveals everything in that epoch and nothing in any other. A viewing key never risks funds. The spend authority is a separate secret that never leaves the client. Handing over a viewing key lets someone watch; it does not let them move anything. ## Compromise containment Because both tiers are scoped to an epoch, exposure is contained by rotation. If a viewing key leaks, you bump to a new epoch. The leaked key keeps working for the epoch it belonged to, which is already in the past, and the new epoch is untouched. You do not have to move funds to recover privacy. This is the same epoch mechanism described in [Stealth & announce](/concepts/stealth-and-announce): one rotation primitive serves both delivery and audit. ================================================================ URL: https://localhost:8080/concepts/threat-model ================================================================ # Threat model Honest privacy means being clear about its edges. This page states what a blind transfer hides, what it does not, and the design choices that follow. ## What is hidden - **The amount.** Values live inside notes and proofs. An observer sees a ciphertext and a verified proof, never a number. - **The recipient.** Funds land at a fresh one-time address derived from the recipient's meta-address. Two payments to the same person look unrelated, and neither links back to a known wallet. - **The link between sends.** Notes split and recombine through a shared pool, so amounts in do not line up with amounts out. ## What is not hidden - **That a transfer happened.** A blind transfer is a real transaction. On an explorer it reads as a Protocol15 transfer. The fact of activity, its timing, and its fees are public. - **The two crossings.** Making funds private and withdrawing back to public are public moments by construction, and the interface marks them as such. Amounts and timing at these boundaries are visible, so treat them as the points where metadata can leak. - **Network-level metadata.** Protocol15 does not hide your IP address or which RPC you talk to. Use the usual transport-level protections if that is part of your model. ## Design consequences - **Payments are final.** Delivery is non-revocable: only the recipient can release a one-time output, and the sender cannot produce the key to claw it back. A payment to a dead or wrong meta-address is lost. Unlinkable and trustless delivery costs reversibility, and the protocol pays that cost on purpose. - **No privileged shortcut.** The release path cannot be bypassed by a re-authorization trick; ownership is structural, derived from the recipient's key. A forged signer fails by construction rather than by a check that could be skipped. - **Auditability is opt-in, not a backdoor.** Nobody can open your activity without a disclosure or viewing key you choose to issue, and both are scoped to one epoch. See [Auditability](/concepts/auditability). ## Anonymity set Unlinkability is only as strong as the crowd you hide in. A shared pool with few participants, or a transfer with an unusual amount or timing, narrows the set an observer has to consider. Privacy here is a protocol property and a usage discipline at the same time.