Conditional disclosure lets an organization prove facts about one private
transaction to an authorized party — only when an M-of-N custodian quorum
approves, and only when the transaction qualifies under an on-chain policy
(amount above a threshold) — without ever exposing the viewing key, which would
reveal the entire payment history. Specified in
conditional-disclosure.md.
Testnet only, unaudited. Same gates as the privacy pool: circuit + contract
audits and a production trusted setup are mainnet prerequisites.
Who is this for
| Scenario | How it maps |
|---|
| KYC / travel-rule provider | Policy threshold = the reporting bound. The provider opens a case, the customer proves the qualifying pool transaction; the provider gets (amount, deposit provenance) on the record — nothing else. |
| DAO treasury oversight | The board’s custodians hold FROST shares. Treasury payments above the policy threshold are disclosable to the board case-by-case; below-threshold operational spend stays private. |
| Audit firm | Each engagement is a caseId. Every disclosure consumes a one-time nullifier, so the audit trail is on-chain and replay-proof. |
The two surfaces — and which one to use
Active disclosure (default). Custodians run FROST threshold signatures: the
group’s secret key is created by distributed key generation and never exists in
one place — not even during signing. A disclosure is a zero-knowledge proof
gated by the quorum’s BIP-340 signature. Use this for everything routine.
Escrow backstop (exceptional). Shamir shares of the viewing key, for
key-loss recovery or lawful non-cooperative access. Combining M shares
reconstructs the full viewing key and reveals all incoming payments to the
combiner. It is a visible, deliberate escalation — rotate keys afterwards.
import { splitViewingKey, recoverViewingKey } from "@opaquecash/disclosure";
const shares = await splitViewingKey(viewingKey, 2, 3); // 2-of-3
// hand shares[i] to custodian i+1; recovery (exceptional!):
const recovered = await recoverViewingKey([shares[0], shares[2]]);
1. Custodian ceremony (once)
Each of the N custodians runs the frost-custodian CLI (in sdk/tools/),
exchanging the round files through any shared channel. No dealer — the key is
born distributed:
# every custodian i ∈ 1..3, in lockstep:
frost-custodian dkg-part1 --id $i --min 2 --max 3 --dir ceremony/
frost-custodian dkg-part2 --id $i --dir ceremony/
frost-custodian dkg-finalize --id $i --dir ceremony/
# → ceremony/keys/$i.key.secret.json stays private to custodian i
# → ceremony/group.json contains the x-only group key for the policy
Register the policy on the chain(s) you operate on — it binds the group key to
a pool and a qualification threshold, immutably (rotation = new ceremony + new
policy):
import { buildRegisterPolicyTx, buildRegisterPolicyIx } from "@opaquecash/disclosure";
// Ethereum (OpaqueDisclosureRegistry)
const tx = buildRegisterPolicyTx(registryAddress, {
pool: poolAddress,
groupKeyX: BigInt("0x" + group.group_key_x),
threshold: 1_000_000_000_000_000_000n, // 1 ETH reporting bound
m: 2, n: 3,
});
// Solana (conditional-disclosure program)
const ix = buildRegisterPolicyIx(programId, {
pool: poolPda, groupKeyX, threshold: 1_000_000_000n, m: 2, n: 3, payer,
});
2. A disclosure, end to end
The requester opens a case. A case is (policyId, caseId, requester);
caseId is an opaque 32-byte engagement identifier. The context commits to
all three and is what custodians sign — it contains no transaction data, so
custodians can authorize without learning anything; the circuit enforces that
only a qualifying note can satisfy the proof.
import { computeContextEvm, contextToMessage } from "@opaquecash/disclosure";
const context = computeContextEvm(policyId, caseId, requesterAddress);
const message = contextToMessage(context); // the 32 bytes custodians sign
M custodians co-sign (any M of the N):
frost-custodian sign-round1 --id $i --key ceremony/keys/$i.key.secret.json --dir signing/
frost-custodian sign-round2 --id $i --key ceremony/keys/$i.key.secret.json \
--message <context-hex> --dir signing/
frost-custodian aggregate --group ceremony/group.json --message <context-hex> --dir signing/
# → signing/signature.json: a standard BIP-340 signature, ready for the registry
The note owner proves. The proof shows the note is in the pool’s state tree
and value > threshold, and discloses (value, label) — the label links to the
Deposit event, giving provenance:
import {
buildDisclosureWitness, generateDisclosureProof,
parseFrostSignature, verifyQuorumSignature, toSolidityProof, buildDiscloseTx,
} from "@opaquecash/disclosure";
import { buildPoolCrypto } from "@opaquecash/privacy-pool";
const crypto = await buildPoolCrypto();
const witness = buildDisclosureWitness(crypto, {
note, // { value, label, nullifier, secret }
threshold: policy.threshold,
stateLeaves, stateIndex, // from the pool's Deposit events
context,
});
const { proof } = await generateDisclosureProof(witness, {
wasmPath: "conditional_disclosure.wasm",
zkeyPath: "conditional_disclosure_final.zkey",
});
The requester submits (the registry binds the request to msg.sender, so a
proof + signature cannot be hijacked):
const sig = parseFrostSignature(signatureJson);
verifyQuorumSignature(groupKeyX, message, sig); // pre-flight: mirrors the on-chain check
const tx = buildDiscloseTx(registryAddress, {
proof: toSolidityProof(proof),
signals: [w.value, w.label, w.threshold, w.stateRoot, w.disclosureNullifier, w.context],
policyId, caseId, sig,
});
The registry verifies the policy threshold, the context, the BIP-340 quorum
signature, the pool root, and the Groth16 proof, then consumes the disclosure
nullifier and emits Disclosure(policyId, caseId, requester, label, value, …)
— the on-record result.
What each party learns
| Party | Learns | Never learns |
|---|
| Custodians | that case (policyId, caseId, requester) was authorized | any note data, the viewing key |
| Requester | (value, label → deposit provenance), qualification, case binding | other notes, withdrawal destinations, the viewing key |
| Chain | the Disclosure event + a one-time nullifier | which other notes the owner holds |
A second disclosure of the same note under a different case needs a fresh
quorum signature (different context → different nullifier). Replaying the
same disclosure is rejected by the nullifier registry.
Failure modes to plan for
- Below-threshold note: the witness builder throws and the circuit is
unsatisfiable — a quorum cannot be tricked into over-disclosing, and an owner
cannot disclose what does not qualify.
- Quorum loss: fewer than M live custodians means no disclosures (and no
recovery if escrow shares are also lost). Choose N comfortably above M.
- Owner non-cooperation: the proof path needs the note openings. The
escrow backstop is the documented escalation, with its total-read-access cost.