UltraHonk with a
keccak Fiat-Shamir transcript, which is mandatory because the on-chain verifier expects
keccak. Only register, confidential_transfer, and withdraw carry proofs; deposit and
merge do not.
The lifecycle at a glance
What’s public vs hidden
Step by step
1
register(account, auditor_id, data)
Proves knowledge of
sk such that Y = sk * H and PVK = vk * H, and publishes the
account’s public keys plus the auditor_id it registers under. A recipient must
register before they can be paid. The sender needs the recipient’s public PVK to
encrypt to. Opening balance is the identity point (zero).2
deposit(from, to, amount)
Moves public USDC into the confidential token. No proof; the
amount is a plaintext
argument, so the deposited total is visible. Credits the receiving bucket. In a payroll
run the employer deposits the batch total (plus a little headroom) up front.3
confidential_transfer(from, to, data)
The payout. There is no amount argument. The value lives only inside the proof and the
encrypted event. The proof enforces sender balance conservation, encrypts the amount to the
recipient via ECDH against their
PVK, and writes two auditor ciphertext channels (a
recipient channel and a sender channel) that the circuit forces to agree. The recipient
needs nothing at pay time; they decrypt later from the event with their viewing key.4
merge(account)
Folds the receiving bucket into spendable by point addition. No proof, no amounts in
the event, just the account. Required before freshly received funds can be spent or
withdrawn.
5
withdraw(from, to, amount, data)
Converts a confidential balance back to public USDC. The withdrawn
amount is a plaintext
argument (re-revealed by design, so the USDC is verifiable); the remaining balance is
re-blinded and stays confidential. The recipient ends holding ordinary USDC. There is no
in-app fiat off-ramp.Proving
Three of the five operations carry a zero-knowledge proof. The proving pipeline is the same for each, and the transcript choice is not optional:1
Noir solves the witness
The circuit is written in Noir. Given your private inputs (
sk, the amount, blindings) and
the public inputs, Noir solves the witness: it computes every intermediate value the circuit
constrains.2
bb.js generates the UltraHonk proof
The
bb.js prover (WASM, running in the browser) takes the solved witness and produces an
UltraHonk proof. This is the step that needs sk, and it runs entirely on your device.3
Keccak transcript is mandatory
The Fiat-Shamir transcript uses keccak. This is required, not a preference: the
Nethermind Soroban verifier that checks the proof on-chain expects a keccak transcript, so a
proof built with any other hash is rejected.
Only
register, confidential_transfer, and withdraw carry proofs. deposit and merge
carry no proof: deposit takes a plaintext amount and needs no secret knowledge, and merge
is a public point addition of your own buckets. This is why the two proof-free operations sit
outside the proving pipeline entirely.Auditor / escrow disclosure
Everyconfidential_transfer bakes in an encrypted copy readable only by the holder of a
single escrow scalar k (registered on-chain as K_aud = k * H under an auditor_id).
Disclosure is a pure read + decrypt: it signs nothing and submits no transaction:
- ECDH between
kand the event’s public ephemeral point recovers the masking value, then subtracts it from the ciphertext to reveal the amount. - The sender channel yields the amount plus the sender’s post-transfer balance; the recipient channel yields the amount plus its blinding. The two channels cross-check to the same value, and because the circuit constrains both, the ciphertexts can’t be omitted or forged.
Two auditor channels, cross-checked
The dual channels are what make disclosure trustworthy. A single ciphertext could be wrong or missing without anyone noticing; two constrained channels cannot:
The circuit forces both channels to encode the same amount. Because both are constrained
inside the proof, the sender cannot omit a channel, cannot write two channels that disagree,
and cannot fake a value. When the escrow holder decrypts, the sender channel and the recipient
channel resolve to one number. If they did not agree, the proof would not have verified, so a
verified transfer is one whose auditor channels are present and consistent.
Who holds
k decides who can audit. In the custody model the employer holds the escrow
key for their own payroll, so Confiroll stays blind while the employer can still disclose to an
auditor. Disclosure runs on the employer’s device against public event data. On the testnet
preview this decryption runs in the CLI tooling (pnpm auditor), and the API route
POST /auditor/disclose returns 501. See
The non-custodial model.What each operation reveals
A one-line recap you can hold in your head:
The two rows that reveal an amount are
deposit and withdraw, and both do so by design so the
public USDC is verifiable. Everything in between hides the per-recipient split.
FAQ
Can I skip register and just receive a payment?
Can I skip register and just receive a payment?
No. A recipient must
register first. Registration publishes the recipient’s public viewing
key PVK, and the sender needs that PVK to encrypt the amount to the recipient inside the
proof. Without it there is no key to encrypt to, so the transfer cannot be built. This is the
onboarding step a contractor completes before a first payout.Why do deposit and withdraw expose an amount but transfer does not?
Why do deposit and withdraw expose an amount but transfer does not?
deposit and withdraw are the boundary between public USDC and the confidential balance,
and the public USDC leg has to be verifiable, so those amounts are plaintext by design.
confidential_transfer moves value entirely inside the confidential token, so it carries no
amount argument at all: the value lives in the proof and encrypted event fields. Hiding the
per-recipient split is exactly the transfer step.What stops a sender from writing a fake or missing auditor channel?
What stops a sender from writing a fake or missing auditor channel?
The circuit. Both the recipient channel and the sender channel are constrained inside the
proof to encode the same amount, so a transfer that omits a channel or writes disagreeing
channels does not produce a valid proof and never verifies on-chain. A verified transfer is
one whose two auditor channels are present and cross-check to a single value.