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

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

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

TypeScript
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

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

TypeScript
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 bigint base units, never floats.