@p15/compliance

Selective disclosure, viewing keys, one-time on-chain markers, and pre-deposit screening. Depends on @p15/stealth. This is the "auditable when needed" half of the protocol; none of it can move funds.

Shell
npm install @p15/compliance

Selective disclosure

The preferred audit path: a signed, encrypted, scoped statement about chosen transactions, readable only by a named auditor. Revocable and non-live.

TypeScript
import { buildDisclosure, verifyDisclosure } from "@p15/compliance";
import { decodeMetaAddress } from "@p15/stealth";
 
const auditor = decodeMetaAddress(auditorMetaAddress);
 
const envelope = buildDisclosure({
  records,                       // DisclosureRecord[] you choose to reveal
  auditorPublic: auditor.scanPublic,
  spendScalar: keys.spendScalar, // signs the statement; proves you are the holder
  scope: { purpose: "2026 audit", epochs: [keys.epoch] },
  oneTime: false,
});
 
// The auditor, with their own scan secret:
const result = verifyDisclosure(auditorScanSecret, envelope);
// result.holder, result.scope, result.records, result.oneTime, result.id

A DisclosureRecord describes one transaction: signature, mint, stealthAta, amount (string), and optional decimals, symbol, direction, counterparty, note, timestamp, epoch. A DisclosureScope is { purpose?, fromTs?, toTs?, epochs? }. Always scope to the current epoch so the statement cannot imply other epochs.

One-time disclosures

Set oneTime: true and the document carries an id. Opening it burns a one-time on-chain marker, so a second open is blocked and the open leaves a public record, with no server or custom program.

TypeScript
import { isDisclosureOpened, buildBurnDisclosureMarkerIx, openCompressionRpc } from "@p15/compliance";
 
const cRpc = openCompressionRpc(process.env.COMPRESSION_RPC!);
if (await isDisclosureOpened(cRpc, result.id)) throw new Error("already opened");
 
const burnIx = await buildBurnDisclosureMarkerIx({ rpc: cRpc, payer, id: result.id });
// burnIx is a web3.js instruction; sign it with the auditor's wallet.

The marker is a ZK Compression nullifier (a compressed account at a deterministic address), so it carries no full-account rent. disclosureMarkerAddress(id) gives the address. The one irreducible limit is human: you cannot unsee what the first opener decrypted.

Viewing keys

The open-the-books escape hatch, scoped to one epoch. Read-only; cannot move funds.

TypeScript
import { exportViewingKey, importViewingKey, auditAnnouncement } from "@p15/compliance";
 
const json = exportViewingKey({ scanSecret: keys.scanSecret, epoch: keys.epoch, label: "auditor A" });
 
// The auditor:
const vk = importViewingKey(json);
const announcement = auditAnnouncement(vk.scanSecret, memo); // decrypts amount + note

A viewing key is the scanSecret plus its epoch. It reveals everything in that epoch and nothing in any other. To contain a leak, rotate the epoch (see Auditability). The spend scalar is never part of it.

Screening

A pre-deposit screening seam, mirroring the backend selector. Wire it as an async pre-check before accepting funds; it is not a transaction, so it does not change the wallet-prompt contract.

TypeScript
import { getScreeningProvider } from "@p15/compliance";
 
const provider = getScreeningProvider("mock");   // or "range"; env: SCREENING_PROVIDER
const result = await provider.screen({ wallet, mint, amount, direction: "deposit" });
 
if (result.decision === "deny") throw new Error("blocked");
if (result.decision === "review") log("flagged, continuing");
// result: { decision: 'allow' | 'review' | 'deny', risk: number, reasons: string[], provider }

MockScreeningProvider is offline and deterministic ({ denylist?, reviewThreshold? }), the default. RangeScreeningProvider adapts the Range Risk API and is inert until RANGE_API_KEY / RANGE_API_URL are set. For unskippable on-chain enforcement instead, see the policy program.