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.

TypeScript
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

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

TypeScript
import { verifyDisclosure } from "@p15/compliance";
const result = verifyDisclosure(auditorScanSecret, envelope.trim());
// result.holder, result.scope, result.records

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

TypeScript
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

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