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
for the design and @p15/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 DisclosureRecords. Build them from a scan of your inbox.
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
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:
import { verifyDisclosure } from "@p15/compliance";
const result = verifyDisclosure(auditorScanSecret, envelope.trim());
// result.holder, result.scope, result.recordsOne-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.
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
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.