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 and the helpers
in 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
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), 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.
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.
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
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 sessionIf 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)
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.
Open the books
Optional, from @p15/compliance: scoped disclosures or a per-epoch viewing key.
See Auditing & disclosure. 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
bigintbase units, never floats.