Recipes

The helpers every integration needs, taken from the dApp's own lib/actions.ts. The Quickstart and integration guides reference these by name.

Shared setup

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

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

TypeScript
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<boolean> {
  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<SpendableNote[]> {
  const { items } = await scanAnnouncementsPage(rpc, keys.scanSecret, { limit: 100, indexerUrl });
  const out: SpendableNote[] = [];
  const seen = new Set<string>();
  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

TypeScript
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

TypeScript
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

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