@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.
npm install @p15/complianceSelective disclosure
The preferred audit path: a signed, encrypted, scoped statement about chosen transactions, readable only by a named auditor. Revocable and non-live.
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.idA 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.
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.
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 + noteA 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.
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.