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