Skip to main content
Confiroll pays with an OpenZeppelin-style confidential token on Soroban. A balance has two internal buckets: receiving (incoming, not yet merged) and spendable (merged, ready to send/withdraw). Amounts live in commitments and encrypted event fields, not in plaintext arguments. The client generates proofs with Noir (witness solving) and bb.js 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

Two amounts are public by design. The total you deposit is visible on-chain (deposit takes a plaintext amount), and the specific amount you withdraw is re-revealed on exit. So Confiroll hides the per-recipient split of a payroll and each contractor’s ongoing balance, not the aggregate you funded or the amount someone cashes out. State this precisely; never imply the totals are 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

Every confidential_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 k and 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

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